@torrent-tv/proxy 2.77.0 → 2.79.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/biome.json +1 -0
- package/package.json +1 -1
- package/routes/api/transcode-sessions/net-report/post.js +5 -0
- package/services/encode/EncodePlan.js +309 -272
- package/services/encode/EncodeRun.js +70 -0
- package/services/encode/SegmentDemand.js +0 -0
- package/services/encode/SegmentStore.js +59 -0
- package/services/encode/open-piece.js +88 -0
- package/services/encode/run-budget.js +89 -0
- package/services/encode/run-command.js +15 -0
- package/services/encode/run-costs.js +94 -0
- package/services/hls-session-manager.js +10657 -10846
- package/services/orchestrators/EncodeOrchestrator.js +147 -20
- package/services/priority/PriorityMap.js +252 -0
- package/services/viewer/Viewer.js +83 -0
- package/test/encode-orchestrator.test.js +36 -8
- package/test/encode-plan.test.js +111 -7
- package/test/one-authority.test.js +105 -0
- package/test/priority-map.test.js +139 -0
- package/test/run-intervals.test.js +67 -303
|
@@ -96,6 +96,28 @@ export class EncodeRun {
|
|
|
96
96
|
/** @type {number} */
|
|
97
97
|
#startedAt = 0;
|
|
98
98
|
|
|
99
|
+
/** Half a name left over from the last chunk of the encoder's own channel. */
|
|
100
|
+
#closedTail = "";
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* When this run was told to stop, so the death itself can be priced.
|
|
104
|
+
*
|
|
105
|
+
* The plan weighs moving an encoder against letting it drive on, and dying is
|
|
106
|
+
* one of the terms. It was measured in the field at 430-729 ms — larger than
|
|
107
|
+
* the start it is added to — by a log line that lived in the one place that
|
|
108
|
+
* killed a run. That place is gone, so the run times its own death.
|
|
109
|
+
*/
|
|
110
|
+
#stopOrderedAt = 0;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* When the first thing this run ever produced appeared.
|
|
114
|
+
*
|
|
115
|
+
* The other term the plan needs: a run started where nothing is downloaded
|
|
116
|
+
* waits for the swarm before it can encode a frame, and that wait is the
|
|
117
|
+
* largest part of what a restart costs. Nothing measured it before.
|
|
118
|
+
*/
|
|
119
|
+
#firstOutputAt = 0;
|
|
120
|
+
|
|
99
121
|
/** @type {number} */
|
|
100
122
|
#speedX = 0;
|
|
101
123
|
|
|
@@ -132,6 +154,9 @@ export class EncodeRun {
|
|
|
132
154
|
* @param {{ info: (line: string) => void, warn: (line: string) => void, error?: (line: string) => void }} params.logger
|
|
133
155
|
* @param {() => number} [params.now]
|
|
134
156
|
* @param {(ended: RunEnded) => void} [params.onEnded]
|
|
157
|
+
* @param {(name: string) => void} [params.onClosed] - Called with the file name
|
|
158
|
+
* of every piece the encoder has FINISHED writing, as the encoder itself
|
|
159
|
+
* names it on its own channel.
|
|
135
160
|
* @param {(progress: { processedSeconds: number | null, speed: string | null }) => void} [params.onProgress]
|
|
136
161
|
* Called for every `-progress` report. Seconds count from the START OF THIS
|
|
137
162
|
* RUN on both branches — neither `-output_ts_offset` nor `-copyts` changes
|
|
@@ -158,6 +183,7 @@ export class EncodeRun {
|
|
|
158
183
|
logger,
|
|
159
184
|
now,
|
|
160
185
|
onEnded,
|
|
186
|
+
onClosed,
|
|
161
187
|
onProgress,
|
|
162
188
|
lastSegmentIndex,
|
|
163
189
|
inputUnavailable,
|
|
@@ -174,6 +200,9 @@ export class EncodeRun {
|
|
|
174
200
|
this.now = typeof now === "function" ? now : Date.now;
|
|
175
201
|
this.onEnded = typeof onEnded === "function" ? onEnded : () => {};
|
|
176
202
|
this.onProgress = typeof onProgress === "function" ? onProgress : () => {};
|
|
203
|
+
// Told the NAME of every piece the encoder has closed. The name is the
|
|
204
|
+
// proof it is whole; nothing else here can prove that.
|
|
205
|
+
this.onClosed = typeof onClosed === "function" ? onClosed : () => {};
|
|
177
206
|
this.lastSegmentIndex = typeof lastSegmentIndex === "function" ? lastSegmentIndex : () => null;
|
|
178
207
|
this.inputUnavailable = typeof inputUnavailable === "function" ? inputUnavailable : () => false;
|
|
179
208
|
this.argsDescribed = argsDescribed;
|
|
@@ -304,6 +333,16 @@ export class EncodeRun {
|
|
|
304
333
|
*/
|
|
305
334
|
#wire(process) {
|
|
306
335
|
process.stdout?.on("data", (chunk) => this.#readProgress(String(chunk)));
|
|
336
|
+
// THE CHANNEL THE ENCODER NAMES ITS FINISHED PIECES ON.
|
|
337
|
+
//
|
|
338
|
+
// A name arrives here when ffmpeg CLOSES the file, so the name is proof the
|
|
339
|
+
// piece is whole — measured on the addon host 2026-09-05. Nothing else can
|
|
340
|
+
// prove it: a file on disk may still be being written, and the only other
|
|
341
|
+
// evidence available was the existence of the NEXT file, which never comes
|
|
342
|
+
// for the last piece of a run.
|
|
343
|
+
//
|
|
344
|
+
// Lines can arrive split, so what is left over is kept for the next chunk.
|
|
345
|
+
process.stdio?.[3]?.on("data", (chunk) => this.#readClosedPieces(String(chunk)));
|
|
307
346
|
process.stderr?.on("data", (chunk) => {
|
|
308
347
|
const line = String(chunk).trim();
|
|
309
348
|
if (line.length > 0) {
|
|
@@ -358,6 +397,9 @@ export class EncodeRun {
|
|
|
358
397
|
noteProduced(index) {
|
|
359
398
|
if (Number.isInteger(index) && index >= this.from) {
|
|
360
399
|
this.#produced.add(index);
|
|
400
|
+
if (this.#firstOutputAt === 0) {
|
|
401
|
+
this.#firstOutputAt = this.now();
|
|
402
|
+
}
|
|
361
403
|
if (this.#state === ENCODE_RUN_STATE.STARTING) {
|
|
362
404
|
this.#transition(ENCODE_RUN_EVENT.FIRST_SEGMENT);
|
|
363
405
|
}
|
|
@@ -369,6 +411,25 @@ export class EncodeRun {
|
|
|
369
411
|
*
|
|
370
412
|
* @param {number} speedX - Times realtime.
|
|
371
413
|
*/
|
|
414
|
+
/**
|
|
415
|
+
* Names of finished pieces, as the encoder writes them.
|
|
416
|
+
*
|
|
417
|
+
* @param {string} text
|
|
418
|
+
*/
|
|
419
|
+
#readClosedPieces(text) {
|
|
420
|
+
this.#closedTail += text;
|
|
421
|
+
const lines = this.#closedTail.split(/\r?\n/);
|
|
422
|
+
// The last piece of the chunk may be half a name; it waits for the rest.
|
|
423
|
+
this.#closedTail = lines.pop() ?? "";
|
|
424
|
+
for (const line of lines) {
|
|
425
|
+
const name = line.trim();
|
|
426
|
+
if (name.length === 0) {
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
this.onClosed(name);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
372
433
|
noteSpeed(speedX) {
|
|
373
434
|
if (Number.isFinite(speedX) && speedX > 0) {
|
|
374
435
|
this.#speedX = speedX;
|
|
@@ -389,6 +450,7 @@ export class EncodeRun {
|
|
|
389
450
|
}
|
|
390
451
|
this.#stopping = true;
|
|
391
452
|
this.#stopReason = because;
|
|
453
|
+
this.#stopOrderedAt = this.now();
|
|
392
454
|
this.#transition(ENCODE_RUN_EVENT.STOP_ORDERED);
|
|
393
455
|
// A suspended process does not act on SIGTERM until it is continued, so the
|
|
394
456
|
// wait for its exit would never end. Let it run before asking it to stop.
|
|
@@ -554,6 +616,14 @@ export class EncodeRun {
|
|
|
554
616
|
to: this.to,
|
|
555
617
|
reached: this.reached,
|
|
556
618
|
livedMs,
|
|
619
|
+
// How long dying took, and how long the first output took to appear.
|
|
620
|
+
// Null where the run was never told to stop, or never produced anything:
|
|
621
|
+
// an absent measurement says so rather than reading as zero.
|
|
622
|
+
dyingMs: this.#stopOrderedAt > 0 ? this.now() - this.#stopOrderedAt : null,
|
|
623
|
+
firstOutputMs:
|
|
624
|
+
this.#firstOutputAt > 0 && this.#startedAt > 0
|
|
625
|
+
? this.#firstOutputAt - this.#startedAt
|
|
626
|
+
: null,
|
|
557
627
|
normal: ending === ENCODE_EXIT.COMPLETE,
|
|
558
628
|
lastError: this.lastError
|
|
559
629
|
};
|
|
Binary file
|
|
@@ -39,6 +39,8 @@ import { rmSync } from "node:fs";
|
|
|
39
39
|
import os from "node:os";
|
|
40
40
|
import path from "node:path";
|
|
41
41
|
|
|
42
|
+
import { discardOpenPiece } from "./open-piece.js";
|
|
43
|
+
|
|
42
44
|
/** Where every output's segments live. One root for the process. */
|
|
43
45
|
export const DEFAULT_STORE_ROOT = path.join(os.tmpdir(), "torrent-tv-hls");
|
|
44
46
|
|
|
@@ -256,6 +258,30 @@ export class SegmentStore {
|
|
|
256
258
|
return proven.sort((left, right) => left - right);
|
|
257
259
|
}
|
|
258
260
|
|
|
261
|
+
/**
|
|
262
|
+
* Whether this piece is finished, and may therefore be served.
|
|
263
|
+
*
|
|
264
|
+
* Two proofs, and the first is the good one:
|
|
265
|
+
*
|
|
266
|
+
* 1. **the encoder said so** — it names each file on a channel of its own the
|
|
267
|
+
* moment it closes it, so the name is the writer's own statement that the
|
|
268
|
+
* piece is whole;
|
|
269
|
+
* 2. **the next file exists** — which only proves it for pieces this process
|
|
270
|
+
* did not watch being written, left by an earlier life of it. It is not
|
|
271
|
+
* true of the last piece of any run, and that is what used to hold the
|
|
272
|
+
* first segment of every run from the viewer.
|
|
273
|
+
*
|
|
274
|
+
* @param {string} key
|
|
275
|
+
* @param {number} index
|
|
276
|
+
* @returns {boolean}
|
|
277
|
+
*/
|
|
278
|
+
isClosed(key, index) {
|
|
279
|
+
if (this.#closed.get(key)?.has(index)) {
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
282
|
+
return this.refresh(key).byNumber.has(index + 1);
|
|
283
|
+
}
|
|
284
|
+
|
|
259
285
|
/**
|
|
260
286
|
* Say that a segment is closed for a reason the disk cannot show.
|
|
261
287
|
*
|
|
@@ -277,6 +303,39 @@ export class SegmentStore {
|
|
|
277
303
|
known.add(index);
|
|
278
304
|
}
|
|
279
305
|
|
|
306
|
+
/**
|
|
307
|
+
* Throw away the piece a run had open when it ended, if it is unusable.
|
|
308
|
+
*
|
|
309
|
+
* The store owns this output's directory and knows how its files are named,
|
|
310
|
+
* so it is the one place that can answer which file a run left open. The
|
|
311
|
+
* judging of a NON-EMPTY file — does it carry every track it should — needs
|
|
312
|
+
* the output's init bytes and belongs to whoever holds them; passed in, and
|
|
313
|
+
* absent it only an empty file is removed, which is the case that caused this
|
|
314
|
+
* to be written (a run stopped 548 ms after starting left a zero-byte file
|
|
315
|
+
* whose name then read as a segment made).
|
|
316
|
+
*
|
|
317
|
+
* @param {string} key
|
|
318
|
+
* @param {{ from: number, to: number } | null} within - The run's own
|
|
319
|
+
* numbers: several runs write into one directory, so the piece to discard
|
|
320
|
+
* has to be looked for inside the stretch the ended run was given.
|
|
321
|
+
* @param {((raw: Buffer) => boolean) | null} [judgeUsable]
|
|
322
|
+
* @returns {Promise<number | null>} The segment number removed, or null.
|
|
323
|
+
*/
|
|
324
|
+
async discardOpenPieceOf(key, within, judgeUsable = null) {
|
|
325
|
+
const format = this.#formats.get(key);
|
|
326
|
+
if (!format) {
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
const removed = await discardOpenPiece(this.directoryFor(key), format, within, judgeUsable);
|
|
330
|
+
if (removed !== null) {
|
|
331
|
+
this.#held.delete(key);
|
|
332
|
+
this.#logger?.info?.(
|
|
333
|
+
`segment store: discarded the open piece #${removed} of ${key.slice(0, 60)}`
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
return removed;
|
|
337
|
+
}
|
|
338
|
+
|
|
280
339
|
/**
|
|
281
340
|
* The one number in this output whose closure nothing on disk proves.
|
|
282
341
|
*
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The piece a run had open when it ended.
|
|
3
|
+
*
|
|
4
|
+
* A fact of a run's output directory, and therefore of the encoding layer. It
|
|
5
|
+
* lived in the eleven-thousand-line file that is being taken apart, where it was
|
|
6
|
+
* called by the one place that killed a run; stopping is decided in one place
|
|
7
|
+
* now and carried out in another, so the cleanup belongs to the layer that owns
|
|
8
|
+
* the directories rather than to whoever happened to do the killing.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readdir, readFile, stat, unlink } from "node:fs/promises";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Remove the piece a run had open when it ended, if that piece is unusable.
|
|
16
|
+
*
|
|
17
|
+
* The `segment` muxer creates its output file when it OPENS it and writes into
|
|
18
|
+
* it until the next cut, so at any instant exactly one file in a run's
|
|
19
|
+
* directory is unfinished: the highest-numbered one. A run that reaches the end
|
|
20
|
+
* of its work closes that file and it is a good piece; a run killed for a seek
|
|
21
|
+
* does not — measured 2026-09-03, ffmpeg exited 19 ms after SIGTERM and left
|
|
22
|
+
* `segment-00025.mp4` at zero bytes, which then closed the only hole in the
|
|
23
|
+
* numbering and convinced the look-ahead to keep the encoder stopped for having
|
|
24
|
+
* "produced" it.
|
|
25
|
+
*
|
|
26
|
+
* Only an unusable piece goes. A run stopped between two cuts leaves a finished
|
|
27
|
+
* file behind, and deleting good output would mean making it a second time.
|
|
28
|
+
*
|
|
29
|
+
* @param {string | null | undefined} runDirPath
|
|
30
|
+
* @param {{ isSegmentFileName: (name: string) => boolean, segmentIndexFromName: (name: string) => number }} segmentFormat
|
|
31
|
+
* @param {((raw: Buffer) => boolean) | null} judgeUsable - Whether a non-empty
|
|
32
|
+
* piece carries what it should. Null where nothing can say, and then only an
|
|
33
|
+
* empty file is removed.
|
|
34
|
+
* @returns {Promise<number | null>} The segment number removed, or null.
|
|
35
|
+
*/
|
|
36
|
+
export async function discardOpenPiece(runDirPath, segmentFormat, within, judgeUsable) {
|
|
37
|
+
if (!runDirPath || typeof segmentFormat?.isSegmentFileName !== "function") {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
// Only inside the stretch the ended run was given. Every run of an output
|
|
41
|
+
// writes into one directory now — they are kept apart by their intervals
|
|
42
|
+
// rather than by a directory each — so the highest-numbered file in there may
|
|
43
|
+
// belong to a run that is still going, and removing it would take away a
|
|
44
|
+
// piece somebody is producing.
|
|
45
|
+
const from = Number.isInteger(within?.from) ? within.from : 0;
|
|
46
|
+
const to = Number.isInteger(within?.to) && within.to >= from ? within.to : Number.MAX_SAFE_INTEGER;
|
|
47
|
+
let highest = null;
|
|
48
|
+
try {
|
|
49
|
+
for (const name of await readdir(runDirPath)) {
|
|
50
|
+
if (!segmentFormat.isSegmentFileName(name)) {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const index = segmentFormat.segmentIndexFromName(name);
|
|
54
|
+
if (index < from || index > to) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (index >= 0 && (highest === null || index > highest.index)) {
|
|
58
|
+
highest = { index, name };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
return null; // The run wrote nothing, or its directory is already gone.
|
|
63
|
+
}
|
|
64
|
+
if (highest === null) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
const filePath = path.join(runDirPath, highest.name);
|
|
68
|
+
let unusable = false;
|
|
69
|
+
try {
|
|
70
|
+
const info = await stat(filePath);
|
|
71
|
+
if (info.size === 0) {
|
|
72
|
+
unusable = true;
|
|
73
|
+
} else if (typeof judgeUsable === "function") {
|
|
74
|
+
unusable = !judgeUsable(await readFile(filePath));
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
return null; // Gone between the listing and the question.
|
|
78
|
+
}
|
|
79
|
+
if (!unusable) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
await unlink(filePath);
|
|
84
|
+
return highest.index;
|
|
85
|
+
} catch {
|
|
86
|
+
return null; // Already removed.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file How many encoders this machine can afford on one output.
|
|
3
|
+
*
|
|
4
|
+
* Not how many are wanted — that is the demand map's answer — but how many can
|
|
5
|
+
* run at once without making things worse. It is the SMALLEST of several
|
|
6
|
+
* limits, and every one of them is measured:
|
|
7
|
+
*
|
|
8
|
+
* 1. **the processor.** A second encoder slows the first: measured on the addon
|
|
9
|
+
* host at 1.70x for two at 854x480 and 1.98x at 1920x1080. The ladder is
|
|
10
|
+
* walked while the measured penalty still leaves every encoder above
|
|
11
|
+
* realtime, and it stops where the measurements stop rather than continuing
|
|
12
|
+
* a curve two points cannot describe;
|
|
13
|
+
* 2. **the swarm.** Every encoder reads the same torrent, so together they
|
|
14
|
+
* cannot consume faster than it is delivered. On the field file of
|
|
15
|
+
* 2026-09-05 a second of film weighs 842 KB and the swarm gave 2119-3347
|
|
16
|
+
* KB/s, so the encoders' speeds together may reach 2.5-4x — which is why a
|
|
17
|
+
* copy the processor would run at 8x runs at four, and why a third encoder
|
|
18
|
+
* makes all three slower rather than adding anything;
|
|
19
|
+
* 3. **memory for the torrent's pieces.** Encoders placed far apart hold
|
|
20
|
+
* windows that do not overlap, so the store must hold their SUM. On the
|
|
21
|
+
* addon host the store was already full on the readers of one viewer — "6
|
|
22
|
+
* readers want 14 pieces of 14 the store may hold" — so this is the limit
|
|
23
|
+
* that binds first there, not the processor.
|
|
24
|
+
*
|
|
25
|
+
* Which of them bound the answer is returned beside it, because "why is there
|
|
26
|
+
* only one encoder" is otherwise a question no log can answer.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {object} RunBudget
|
|
31
|
+
* @property {number} runs - How many encoders may run on this output.
|
|
32
|
+
* @property {string} because - Which limit decided it.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {object} params
|
|
37
|
+
* @param {number} params.byProcessor - What the processor allows, from the
|
|
38
|
+
* measured speed and the measured penalty for concurrency. At least one.
|
|
39
|
+
* @param {number} [params.speedX] - The measured speed of one encoder here.
|
|
40
|
+
* @param {number} [params.refetchSecPerFilmSecond] - Seconds of swarm time per
|
|
41
|
+
* second of film: the film's own byte rate over what the swarm delivers. One
|
|
42
|
+
* encoder at speed `s` therefore consumes `s * refetch` of the swarm, and
|
|
43
|
+
* what they may consume together is all of it. Absent where neither rate has
|
|
44
|
+
* been measured, and then the swarm does not bound the answer.
|
|
45
|
+
* @param {number} [params.storeBytes] - What the piece store may hold.
|
|
46
|
+
* @param {number} [params.readerWindowBytes] - What one encoder's reader keeps.
|
|
47
|
+
* @returns {RunBudget}
|
|
48
|
+
*/
|
|
49
|
+
export function affordableRuns({
|
|
50
|
+
byProcessor,
|
|
51
|
+
speedX,
|
|
52
|
+
refetchSecPerFilmSecond,
|
|
53
|
+
storeBytes,
|
|
54
|
+
readerWindowBytes
|
|
55
|
+
}) {
|
|
56
|
+
let runs = Number.isFinite(byProcessor) && byProcessor > 0 ? Math.floor(byProcessor) : 1;
|
|
57
|
+
let because = "the processor";
|
|
58
|
+
|
|
59
|
+
// The swarm. One encoder at speed `s` takes `s * refetch` of what is
|
|
60
|
+
// delivered, and everything running takes the sum, which cannot pass one.
|
|
61
|
+
if (
|
|
62
|
+
Number.isFinite(refetchSecPerFilmSecond) &&
|
|
63
|
+
refetchSecPerFilmSecond > 0 &&
|
|
64
|
+
Number.isFinite(speedX) &&
|
|
65
|
+
speedX > 0
|
|
66
|
+
) {
|
|
67
|
+
const bySwarm = Math.max(1, Math.floor(1 / (speedX * refetchSecPerFilmSecond)));
|
|
68
|
+
if (bySwarm < runs) {
|
|
69
|
+
runs = bySwarm;
|
|
70
|
+
because = "what the swarm delivers";
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Memory: encoders far apart hold windows that do not overlap.
|
|
75
|
+
if (
|
|
76
|
+
Number.isFinite(storeBytes) &&
|
|
77
|
+
storeBytes > 0 &&
|
|
78
|
+
Number.isFinite(readerWindowBytes) &&
|
|
79
|
+
readerWindowBytes > 0
|
|
80
|
+
) {
|
|
81
|
+
const byMemory = Math.max(1, Math.floor(storeBytes / readerWindowBytes));
|
|
82
|
+
if (byMemory < runs) {
|
|
83
|
+
runs = byMemory;
|
|
84
|
+
because = "memory for the torrent's pieces";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { runs: Math.max(1, runs), because };
|
|
89
|
+
}
|
|
@@ -620,6 +620,21 @@ export function buildRunCommand({
|
|
|
620
620
|
"0.05",
|
|
621
621
|
"-segment_start_number",
|
|
622
622
|
String(safeIndex),
|
|
623
|
+
// THE ENCODER SAYS WHEN A PIECE IS FINISHED, on a channel of its own.
|
|
624
|
+
//
|
|
625
|
+
// Measured on the addon host 2026-09-05: a name appears in this list when
|
|
626
|
+
// the file is CLOSED, not when it is created — at the third sample
|
|
627
|
+
// `seg-000.mp4` was on disk and absent from the list, and it appeared at
|
|
628
|
+
// the fourth, in the same moment `seg-001.mp4` came into being. So a name
|
|
629
|
+
// here is the writer's own statement that the piece is whole.
|
|
630
|
+
//
|
|
631
|
+
// Without it, a finished file is indistinguishable from one still being
|
|
632
|
+
// written, and the only proof available was the existence of the NEXT
|
|
633
|
+
// one — which never comes for the last piece of every run.
|
|
634
|
+
"-segment_list",
|
|
635
|
+
"pipe:3",
|
|
636
|
+
"-segment_list_flags",
|
|
637
|
+
"+live",
|
|
623
638
|
...explicitTimes,
|
|
624
639
|
segmentFormat.segmentFileNameTemplate()
|
|
625
640
|
);
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file What starting and stopping an encoder costs on THIS host.
|
|
3
|
+
*
|
|
4
|
+
* Two figures, and both are terms in the one decision anybody makes about a
|
|
5
|
+
* running encoder: let it drive on through material that already exists, or
|
|
6
|
+
* stop it and start another where the material is missing.
|
|
7
|
+
*
|
|
8
|
+
* Before this file neither was measured here. The start was a single reading
|
|
9
|
+
* taken once on one machine and written into the code as a constant; the stop
|
|
10
|
+
* and the wait for the first output were not counted at all, so the comparison
|
|
11
|
+
* priced only one side of itself and always answered the same way.
|
|
12
|
+
*
|
|
13
|
+
* Readings are kept and read as their median: one starved start must not decide
|
|
14
|
+
* the rule, and a host that has since become busy must be able to change the
|
|
15
|
+
* answer.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* How many recent readings a figure is taken from. The same reasoning as the
|
|
20
|
+
* other learned figures in this proxy: long enough that one reading does not
|
|
21
|
+
* move the answer, short enough that the answer still follows the host.
|
|
22
|
+
*/
|
|
23
|
+
const RECENT_READINGS = 20;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The middle of a set of readings, or null when there are none.
|
|
27
|
+
*
|
|
28
|
+
* Written here rather than borrowed from the general helper one level up: this
|
|
29
|
+
* layer states facts and imports nothing above itself, and four lines of
|
|
30
|
+
* arithmetic are not worth breaking that for.
|
|
31
|
+
*
|
|
32
|
+
* @param {number[]} values
|
|
33
|
+
* @returns {number | null}
|
|
34
|
+
*/
|
|
35
|
+
function middleOf(values) {
|
|
36
|
+
if (values.length === 0) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
const sorted = [...values].sort((left, right) => left - right);
|
|
40
|
+
const middle = Math.floor(sorted.length / 2);
|
|
41
|
+
return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class RunCosts {
|
|
45
|
+
/** How long dying took, in milliseconds. @type {number[]} */
|
|
46
|
+
#dying = [];
|
|
47
|
+
|
|
48
|
+
/** How long the first output took to appear, in milliseconds. @type {number[]} */
|
|
49
|
+
#firstOutput = [];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Take the two readings a finished run carries. Either may be absent — a run
|
|
53
|
+
* that was never told to stop did not die on command, and one that produced
|
|
54
|
+
* nothing has no first output — and an absent reading is not a zero.
|
|
55
|
+
*
|
|
56
|
+
* @param {{ dyingMs?: number | null, firstOutputMs?: number | null }} ended
|
|
57
|
+
*/
|
|
58
|
+
note(ended) {
|
|
59
|
+
if (Number.isFinite(ended?.dyingMs)) {
|
|
60
|
+
RunCosts.#keep(this.#dying, /** @type {number} */ (ended.dyingMs));
|
|
61
|
+
}
|
|
62
|
+
if (Number.isFinite(ended?.firstOutputMs)) {
|
|
63
|
+
RunCosts.#keep(this.#firstOutput, /** @type {number} */ (ended.firstOutputMs));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {number[]} readings
|
|
69
|
+
* @param {number} value
|
|
70
|
+
*/
|
|
71
|
+
static #keep(readings, value) {
|
|
72
|
+
readings.push(value);
|
|
73
|
+
while (readings.length > RECENT_READINGS) {
|
|
74
|
+
readings.shift();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The two costs in seconds, from this host's own readings.
|
|
80
|
+
*
|
|
81
|
+
* Zero where nothing has been measured yet. Zero understates both, so a plan
|
|
82
|
+
* that has no readings prices moving an encoder as cheaper than it is — which
|
|
83
|
+
* is why the plan keeps a run it cannot compare rather than moving it.
|
|
84
|
+
*
|
|
85
|
+
* @returns {{ killCostSec: number, firstByteWaitSec: number, samples: number }}
|
|
86
|
+
*/
|
|
87
|
+
seconds() {
|
|
88
|
+
return {
|
|
89
|
+
killCostSec: (middleOf(this.#dying) ?? 0) / 1000,
|
|
90
|
+
firstByteWaitSec: (middleOf(this.#firstOutput) ?? 0) / 1000,
|
|
91
|
+
samples: Math.min(this.#dying.length, this.#firstOutput.length)
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
}
|