@torrent-tv/proxy 2.80.4 → 2.80.6
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 +36 -0
- package/package.json +1 -1
- package/routes/api/delivery-sink/get.js +8 -4
- package/server.js +415 -403
- package/services/data-channel-handler.js +87 -25
- package/services/delivery-probe.js +38 -5
- package/services/encode/CoverageMap.js +77 -4
- package/services/encode/EncodePlan.js +1025 -358
- package/services/encode/EncodeRun.js +42 -22
- package/services/encode/SegmentDemand.js +0 -0
- package/services/encode/SegmentStore.js +55 -3
- package/services/encode/open-piece.js +47 -24
- package/services/encode/run-command.js +12 -2
- package/services/hls-session-manager.js +38 -158
- package/services/hwaccel.js +182 -54
- package/services/orchestrators/EncodeOrchestrator.js +123 -94
- package/services/output/LiveOutputs.js +233 -213
- package/services/output/Timeline.js +333 -256
- package/services/priority/PriorityMap.js +262 -108
- package/services/priority/PriorityOrchestrator.js +31 -6
- package/services/quality/EncodeCost.js +555 -500
- package/services/torrent-pool.js +9 -4
- package/test/encode-orchestrator.test.js +195 -65
- package/test/encode-plan-viewers.test.js +719 -0
- package/test/encode-plan.test.js +174 -81
- package/test/open-piece.test.js +152 -0
- package/test/output-speed.test.js +86 -0
- package/test/priority-map-download.test.js +25 -7
- package/test/priority-map.test.js +134 -83
- package/test/seek-landing.test.js +109 -76
- package/test/segment-demand.test.js +54 -56
- package/test/wedge-certainty.test.js +3 -3
- package/test/flushed-piece.test.js +0 -108
|
@@ -61,11 +61,10 @@ const MICROSECONDS_PER_SECOND = 1_000_000;
|
|
|
61
61
|
* @property {number} livedMs
|
|
62
62
|
* @property {boolean} normal - Whether this ending is the expected one.
|
|
63
63
|
* @property {string} lastError - The last thing ffmpeg said on stderr.
|
|
64
|
-
* @property {string | null}
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
* was never told to stop.
|
|
64
|
+
* @property {string | null} provenName - The last piece this run named while it
|
|
65
|
+
* was still running normally, and therefore the last one it is known to have
|
|
66
|
+
* finished. Anything on disk beyond it was open when the run ended, whatever
|
|
67
|
+
* ended it. Null where the run named nothing.
|
|
69
68
|
*/
|
|
70
69
|
|
|
71
70
|
/**
|
|
@@ -105,24 +104,27 @@ export class EncodeRun {
|
|
|
105
104
|
#closedTail = "";
|
|
106
105
|
|
|
107
106
|
/**
|
|
108
|
-
* The last piece named on the ready channel
|
|
107
|
+
* The last piece named on the ready channel while the run was still running
|
|
108
|
+
* normally — the last one it is KNOWN to have finished.
|
|
109
109
|
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
110
|
+
* "The encoder named it" was taken to mean "it is whole". That is true of the
|
|
111
|
+
* file and false of the span. On SIGTERM ffmpeg writes out the piece it had
|
|
112
|
+
* open and names it like any other; killed harder, or dying on its own, it
|
|
113
|
+
* leaves that piece unnamed and half-written. Both are readable, and neither
|
|
114
|
+
* covers the span its number promises. Field 2026-09-06: a run stopped
|
|
115
|
+
* mid-piece left `segment-00010.mp4` holding 3.92 s of the 5.589 s the
|
|
116
|
+
* playlist gives #10, and the viewer's picture jumped 1.5 s at 1:02; the
|
|
117
|
+
* soundtrack did the same at 17.5 s, 2.8 s wide, in the same session.
|
|
116
118
|
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
* A piece genuinely closed
|
|
120
|
-
*
|
|
121
|
-
* other
|
|
119
|
+
* Recorded by WHEN the name arrives, so nothing is read and no span is
|
|
120
|
+
* measured: a name that arrives after the stop was ordered is the flush and
|
|
121
|
+
* does not count as proof. A piece genuinely closed in the moment between the
|
|
122
|
+
* last normal name and the stop is then made a second time — the cheaper of
|
|
123
|
+
* the two errors, since the other is a hole the viewer sees.
|
|
122
124
|
*
|
|
123
125
|
* @type {string | null}
|
|
124
126
|
*/
|
|
125
|
-
#
|
|
127
|
+
#provenName = null;
|
|
126
128
|
|
|
127
129
|
/**
|
|
128
130
|
* When this run was told to stop, so the death itself can be priced.
|
|
@@ -197,6 +199,10 @@ export class EncodeRun {
|
|
|
197
199
|
* kept so a failure can quote what produced it.
|
|
198
200
|
* @param {boolean} [params.usesExplicitCuts] - Whether this run cuts at times
|
|
199
201
|
* it was given, which decides how a segment is judged finished.
|
|
202
|
+
* @param {(name: string) => number | null} [params.indexOfName] - The number
|
|
203
|
+
* a closed piece's name carries. How a piece is named belongs to the format
|
|
204
|
+
* that writes it, so it arrives as a plain function rather than this class
|
|
205
|
+
* knowing any naming.
|
|
200
206
|
*/
|
|
201
207
|
constructor({
|
|
202
208
|
address,
|
|
@@ -213,7 +219,8 @@ export class EncodeRun {
|
|
|
213
219
|
lastSegmentIndex,
|
|
214
220
|
inputUnavailable,
|
|
215
221
|
argsDescribed = "",
|
|
216
|
-
usesExplicitCuts = false
|
|
222
|
+
usesExplicitCuts = false,
|
|
223
|
+
indexOfName
|
|
217
224
|
}) {
|
|
218
225
|
this.address = address;
|
|
219
226
|
this.encoder = encoder;
|
|
@@ -232,6 +239,7 @@ export class EncodeRun {
|
|
|
232
239
|
this.inputUnavailable = typeof inputUnavailable === "function" ? inputUnavailable : () => false;
|
|
233
240
|
this.argsDescribed = argsDescribed;
|
|
234
241
|
this.usesExplicitCuts = usesExplicitCuts === true;
|
|
242
|
+
this.indexOfName = typeof indexOfName === "function" ? indexOfName : () => null;
|
|
235
243
|
/** The last thing ffmpeg said on stderr, which is what a failure is explained by. */
|
|
236
244
|
this.lastError = "";
|
|
237
245
|
}
|
|
@@ -451,8 +459,20 @@ export class EncodeRun {
|
|
|
451
459
|
if (name.length === 0) {
|
|
452
460
|
continue;
|
|
453
461
|
}
|
|
454
|
-
if (this.#stopping) {
|
|
455
|
-
this.#
|
|
462
|
+
if (!this.#stopping) {
|
|
463
|
+
this.#provenName = name;
|
|
464
|
+
}
|
|
465
|
+
// WHAT THIS RUN HAS MADE IS THIS RUN'S OWN FACT, and this channel is where
|
|
466
|
+
// it learns it. It used to be told from outside, by whoever listed the
|
|
467
|
+
// output directory — which every run of an output shares — so a run
|
|
468
|
+
// inherited every number any other run had ever left there. Field
|
|
469
|
+
// 2026-09-06: a run beginning at the piece for 3:39 reported "reached #20
|
|
470
|
+
// (483 segment(s))", having produced none of them, and its head therefore
|
|
471
|
+
// described somebody else's work. Both the claim it holds and the cleanup
|
|
472
|
+
// after it read that head.
|
|
473
|
+
const index = this.indexOfName(name);
|
|
474
|
+
if (Number.isInteger(index)) {
|
|
475
|
+
this.noteProduced(index);
|
|
456
476
|
}
|
|
457
477
|
this.onClosed(name);
|
|
458
478
|
}
|
|
@@ -643,7 +663,7 @@ export class EncodeRun {
|
|
|
643
663
|
from: this.from,
|
|
644
664
|
to: this.to,
|
|
645
665
|
reached: this.reached,
|
|
646
|
-
|
|
666
|
+
provenName: this.#provenName,
|
|
647
667
|
livedMs,
|
|
648
668
|
// How long dying took, and how long the first output took to appear.
|
|
649
669
|
// Null where the run was never told to stop, or never produced anything:
|
|
Binary file
|
|
@@ -98,6 +98,10 @@ export class SegmentStore {
|
|
|
98
98
|
*/
|
|
99
99
|
#closed = new Map();
|
|
100
100
|
|
|
101
|
+
/** Pieces already reported as taken on the successor rule, so one is said
|
|
102
|
+
* once. @type {Map<string, Set<number>>} */
|
|
103
|
+
#unreportedSaid = new Map();
|
|
104
|
+
|
|
101
105
|
/** @type {{ info: Function, warn: Function }} */
|
|
102
106
|
#logger;
|
|
103
107
|
|
|
@@ -308,7 +312,55 @@ export class SegmentStore {
|
|
|
308
312
|
if (this.#closed.get(key)?.has(index)) {
|
|
309
313
|
return true;
|
|
310
314
|
}
|
|
311
|
-
|
|
315
|
+
const bySuccessor = this.refresh(key).byNumber.has(index + 1);
|
|
316
|
+
if (bySuccessor) {
|
|
317
|
+
this.#noteUnreported(key, index);
|
|
318
|
+
}
|
|
319
|
+
return bySuccessor;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* A piece taken as finished because the NEXT one exists, with nothing from a
|
|
324
|
+
* run to say so.
|
|
325
|
+
*
|
|
326
|
+
* The successor rule is for what this process did not watch being written —
|
|
327
|
+
* pieces from a previous life of it. It is also the one way an unfinished
|
|
328
|
+
* piece can be served: a file that stops short still has a successor if
|
|
329
|
+
* anything wrote one, and then its name promises a whole span while it holds
|
|
330
|
+
* a fraction. Field 2026-09-06: 110 698 bytes served under a name whose
|
|
331
|
+
* neighbours are 12 MB, 40 ms of film where the playlist declared 10.4 s, and
|
|
332
|
+
* the player jumped the hole it left.
|
|
333
|
+
*
|
|
334
|
+
* That cannot arise from two encoders any more — their stretches no longer
|
|
335
|
+
* overlap — so what is left is a piece from a process that died without
|
|
336
|
+
* clearing up. Said once per piece, with its size, so a return of it is
|
|
337
|
+
* visible rather than inferred.
|
|
338
|
+
*
|
|
339
|
+
* @param {string} key
|
|
340
|
+
* @param {number} index
|
|
341
|
+
*/
|
|
342
|
+
#noteUnreported(key, index) {
|
|
343
|
+
let said = this.#unreportedSaid.get(key);
|
|
344
|
+
if (!said) {
|
|
345
|
+
said = new Set();
|
|
346
|
+
this.#unreportedSaid.set(key, said);
|
|
347
|
+
}
|
|
348
|
+
if (said.has(index)) {
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
said.add(index);
|
|
352
|
+
let bytes = -1;
|
|
353
|
+
try {
|
|
354
|
+
bytes = statSync(this.pathOf(key, index)).size;
|
|
355
|
+
} catch {
|
|
356
|
+
// Gone between the listing and this: nothing to report about it.
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
this.#logger?.info?.(
|
|
360
|
+
`segment store: #${index} of ${key.slice(0, 60)} is taken as finished because ` +
|
|
361
|
+
`#${index + 1} exists — no run reported it (${bytes} bytes). Expected only for ` +
|
|
362
|
+
"pieces left by a previous life of this process."
|
|
363
|
+
);
|
|
312
364
|
}
|
|
313
365
|
|
|
314
366
|
/**
|
|
@@ -350,12 +402,12 @@ export class SegmentStore {
|
|
|
350
402
|
* @param {((raw: Buffer) => boolean) | null} [judgeUsable]
|
|
351
403
|
* @returns {Promise<number | null>} The segment number removed, or null.
|
|
352
404
|
*/
|
|
353
|
-
async discardOpenPieceOf(key, within, judgeUsable = null,
|
|
405
|
+
async discardOpenPieceOf(key, within, judgeUsable = null, provenName = null) {
|
|
354
406
|
const format = this.#formats.get(key);
|
|
355
407
|
if (!format) {
|
|
356
408
|
return null;
|
|
357
409
|
}
|
|
358
|
-
const removed = await discardOpenPiece(this.directoryFor(key), format, within, judgeUsable,
|
|
410
|
+
const removed = await discardOpenPiece(this.directoryFor(key), format, within, judgeUsable, provenName);
|
|
359
411
|
if (removed !== null) {
|
|
360
412
|
this.#held.delete(key);
|
|
361
413
|
this.#logger?.info?.(
|
|
@@ -23,37 +23,39 @@ import path from "node:path";
|
|
|
23
23
|
* numbering and convinced the look-ahead to keep the encoder stopped for having
|
|
24
24
|
* "produced" it.
|
|
25
25
|
*
|
|
26
|
-
*
|
|
26
|
+
* **A piece is whole only if the run PROVED it, and reading it proves nothing.**
|
|
27
27
|
*
|
|
28
|
-
* The
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
28
|
+
* The highest-numbered file in a run's stretch is the one it had open. How a
|
|
29
|
+
* run ends decides what became of that file, and all three outcomes leave it
|
|
30
|
+
* readable-looking: stopped with SIGTERM, ffmpeg writes it out and names it on
|
|
31
|
+
* the ready channel exactly as it names a finished one; killed harder, or dying
|
|
32
|
+
* on its own, it leaves the bytes it had written with no name at all. In every
|
|
33
|
+
* case the file decodes and holds film only up to the instant the run ended,
|
|
34
|
+
* under a number whose playlist entry promises a whole span. Field 2026-09-06:
|
|
33
35
|
* `segment-00010.mp4` held 3.92 s of its declared 5.589 s — 96 frames — and the
|
|
34
36
|
* picture jumped 1.5 s at 1:02; the soundtrack did the same at 17.5 s, 2.8 s
|
|
35
|
-
* wide, in the same session.
|
|
36
|
-
* yes, which is how both survived.
|
|
37
|
+
* wide, in the same session. Both decoded, which is how both reached the viewer.
|
|
37
38
|
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* measure and no tolerance to choose.
|
|
39
|
+
* So the question asked here is not what the file contains but whether the run
|
|
40
|
+
* named it while it was still running normally. That is a fact the run holds,
|
|
41
|
+
* so there is no span to measure and no tolerance to choose.
|
|
41
42
|
*
|
|
42
|
-
* A piece
|
|
43
|
-
*
|
|
44
|
-
*
|
|
43
|
+
* A piece finished in the moment between the last such name and the end is
|
|
44
|
+
* then made a second time. That is the cheaper error: the other is a hole the
|
|
45
|
+
* viewer sees.
|
|
45
46
|
*
|
|
46
47
|
* @param {string | null | undefined} runDirPath
|
|
47
48
|
* @param {{ isSegmentFileName: (name: string) => boolean, segmentIndexFromName: (name: string) => number }} segmentFormat
|
|
48
49
|
* @param {((raw: Buffer) => boolean) | null} judgeUsable - Whether a non-empty
|
|
49
50
|
* piece carries what it should. Null where nothing can say, and then only an
|
|
50
51
|
* empty file is removed.
|
|
51
|
-
* @param {string | null} [
|
|
52
|
-
*
|
|
53
|
-
*
|
|
52
|
+
* @param {string | null} [provenName] - The last piece the run named while it
|
|
53
|
+
* was running normally. A file beyond it was open when the run ended and goes
|
|
54
|
+
* whether or not it reads. Null where the run proved nothing, and then every
|
|
55
|
+
* piece it left is unproven.
|
|
54
56
|
* @returns {Promise<number | null>} The segment number removed, or null.
|
|
55
57
|
*/
|
|
56
|
-
export async function discardOpenPiece(runDirPath, segmentFormat, within, judgeUsable,
|
|
58
|
+
export async function discardOpenPiece(runDirPath, segmentFormat, within, judgeUsable, provenName = null) {
|
|
57
59
|
if (!runDirPath || typeof segmentFormat?.isSegmentFileName !== "function") {
|
|
58
60
|
return null;
|
|
59
61
|
}
|
|
@@ -63,7 +65,28 @@ export async function discardOpenPiece(runDirPath, segmentFormat, within, judgeU
|
|
|
63
65
|
// belong to a run that is still going, and removing it would take away a
|
|
64
66
|
// piece somebody is producing.
|
|
65
67
|
const from = Number.isInteger(within?.from) ? within.from : 0;
|
|
66
|
-
|
|
68
|
+
// THE RUN'S OWN REACH, which is a fact it holds and needs nothing measured.
|
|
69
|
+
//
|
|
70
|
+
// A run cannot have opened a file above the one just past the last it named:
|
|
71
|
+
// ffmpeg names a piece when it closes it and opens the next, so the open piece
|
|
72
|
+
// is at most `proven + 1`, and where it named nothing at all the open one is
|
|
73
|
+
// the first it was given.
|
|
74
|
+
//
|
|
75
|
+
// Used only where no end was declared — `to` below `from`, which is how "to
|
|
76
|
+
// the end of the track" is written everywhere here. Such a run had no bound at
|
|
77
|
+
// all: the search covered the whole directory and took the highest-numbered
|
|
78
|
+
// file in it. Every run of an output writes into that one
|
|
79
|
+
// directory, so what it took was a piece a LIVE run had just finished. Field
|
|
80
|
+
// 2026-09-06: the piece holding 2:47-2:57 went that way, its number is spent
|
|
81
|
+
// for good because names only grow, and the picture stood still for 647 s.
|
|
82
|
+
const provenIndex = typeof provenName === "string" && segmentFormat.isSegmentFileName(provenName)
|
|
83
|
+
? segmentFormat.segmentIndexFromName(provenName)
|
|
84
|
+
: null;
|
|
85
|
+
const reach = Number.isInteger(provenIndex) && provenIndex >= from ? provenIndex + 1 : from;
|
|
86
|
+
// The declared end is the bound wherever there is one. The run's own reach is
|
|
87
|
+
// the bound of LAST RESORT, for a run given none: before it, such a run had no
|
|
88
|
+
// bound at all and the search covered the whole directory.
|
|
89
|
+
const to = Number.isInteger(within?.to) && within.to >= from ? within.to : reach;
|
|
67
90
|
let highest = null;
|
|
68
91
|
try {
|
|
69
92
|
for (const name of await readdir(runDirPath)) {
|
|
@@ -85,11 +108,11 @@ export async function discardOpenPiece(runDirPath, segmentFormat, within, judgeU
|
|
|
85
108
|
return null;
|
|
86
109
|
}
|
|
87
110
|
const filePath = path.join(runDirPath, highest.name);
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
const
|
|
92
|
-
let unusable =
|
|
111
|
+
// Proven finished only if the run said so while it was running. Anything
|
|
112
|
+
// beyond that name was open when the run ended, and its contents cannot say
|
|
113
|
+
// so — it decodes.
|
|
114
|
+
const proven = typeof provenName === "string" && provenName === highest.name;
|
|
115
|
+
let unusable = !proven;
|
|
93
116
|
try {
|
|
94
117
|
const info = await stat(filePath);
|
|
95
118
|
if (info.size === 0) {
|
|
@@ -208,7 +208,17 @@ export function nearestKeyframeAtOrBefore(keyframeTimes, target) {
|
|
|
208
208
|
export function seekLandingOffsetFor(material, keyframe) {
|
|
209
209
|
// A re-encode trims to the requested time itself, so it needs no help and
|
|
210
210
|
// must not be pushed past what it was asked for.
|
|
211
|
-
|
|
211
|
+
//
|
|
212
|
+
// AN OUTPUT CARRYING ONLY SOUND IS SUCH A RE-ENCODE, and asking about the
|
|
213
|
+
// picture missed it: the flag says whether the PICTURE is re-encoded, and an
|
|
214
|
+
// output with no picture answers no. So the whole offset was added to the
|
|
215
|
+
// soundtrack's own seek, and the sound then played 130 ms ahead of the picture
|
|
216
|
+
// from every restart onward — reported by the viewer 2026-09-06 as lips out of
|
|
217
|
+
// step with the voice from two minutes in, and measured: both outputs were
|
|
218
|
+
// repositioned to the same boundary and both given `-ss 166.963435`, after
|
|
219
|
+
// which the copied picture landed on the keyframe at 166.833 while the
|
|
220
|
+
// re-encoded sound began where it was asked.
|
|
221
|
+
if (material?.transcodeVideo === true || material?.audioOnly === true) {
|
|
212
222
|
return 0;
|
|
213
223
|
}
|
|
214
224
|
// A grid whose times are approximate needs that error added on top, or a name
|
|
@@ -504,7 +514,7 @@ export function buildRunCommand({
|
|
|
504
514
|
if (snappedKeyframe !== null) {
|
|
505
515
|
const residualSeconds = Math.max(0, seekSeconds - snappedKeyframe);
|
|
506
516
|
if (snappedKeyframe > 0) {
|
|
507
|
-
args.push("-ss", ffmpegSeconds(snappedKeyframe + seekLandingOffsetFor({ transcodeVideo, file }, snappedKeyframe)));
|
|
517
|
+
args.push("-ss", ffmpegSeconds(snappedKeyframe + seekLandingOffsetFor({ audioOnly, transcodeVideo, file }, snappedKeyframe)));
|
|
508
518
|
}
|
|
509
519
|
args.push("-i", inputUrl);
|
|
510
520
|
// The coarse landing, not the exact target: the residual below is discarded
|
|
@@ -23,7 +23,6 @@ import { speedFromReadings } from "./encoder-readings.js";
|
|
|
23
23
|
import { availableShareFrom } from "./available-share.js";
|
|
24
24
|
import { contentionPenalty } from "./contention.js";
|
|
25
25
|
import { minimumBufferFrom } from "./supply-margin.js";
|
|
26
|
-
import { mapForViewer } from "./priority/PriorityMap.js";
|
|
27
26
|
import { PriorityOrchestrator } from "./priority/PriorityOrchestrator.js";
|
|
28
27
|
import { baseDrawFrom, costPerMegabyteFrom } from "./torrent-cost.js";
|
|
29
28
|
import { medianOf, movedBeyondScatter, scatterOf } from "./learned-median.js";
|
|
@@ -556,12 +555,6 @@ const SEGMENT_STORE_IDLE_MS = 6 * 60 * 60 * 1000;
|
|
|
556
555
|
const SEGMENT_STORE_FREE_SHARE = 0.25;
|
|
557
556
|
/** What the store may hold where the free space cannot be read at all. */
|
|
558
557
|
const SEGMENT_STORE_FALLBACK_BYTES = 2 * 1024 * 1024 * 1024;
|
|
559
|
-
/**
|
|
560
|
-
* What it costs to stop an encoder and start another where the material is
|
|
561
|
-
* missing. Measured on the addon host 2026-09-04: a spawn with its input open
|
|
562
|
-
* is 0.12 s there, 0.5-0.6 s on a developer's desktop.
|
|
563
|
-
*/
|
|
564
|
-
const RUN_RESTART_COST_SEC = 0.12;
|
|
565
558
|
const DEFAULT_STARTUP_WAIT_MS = 5_000;
|
|
566
559
|
// Realtime budget — runtime downswitch (software encoder only). Periodically
|
|
567
560
|
// check each active software-transcode session's ffmpeg `speed`; when it stays
|
|
@@ -1475,6 +1468,7 @@ export class HlsSessionManager {
|
|
|
1475
1468
|
// could not be measured, and then nothing is corrected — the alternative
|
|
1476
1469
|
// is inventing a penalty, which is the same fault as inventing a fill rate.
|
|
1477
1470
|
contentionPenalties = null,
|
|
1471
|
+
copySpeedX = null,
|
|
1478
1472
|
tonemapSupported = false,
|
|
1479
1473
|
getCachedMediaInfo = null,
|
|
1480
1474
|
getCachedAudioTracks = null,
|
|
@@ -1529,6 +1523,10 @@ export class HlsSessionManager {
|
|
|
1529
1523
|
this.getSourceStats = typeof getSourceStats === "function" ? getSourceStats : null;
|
|
1530
1524
|
this.setPriorityMap = typeof setPriorityMap === "function" ? setPriorityMap : null;
|
|
1531
1525
|
this.contentionPenalties = contentionPenalties instanceof Map ? contentionPenalties : null;
|
|
1526
|
+
// Seconds of film per second when the picture is COPIED, measured at
|
|
1527
|
+
// startup. Nothing else prices that branch: the other benchmarks measure
|
|
1528
|
+
// encoding and decoding, and a copy does neither.
|
|
1529
|
+
this.copySpeedX = Number.isFinite(copySpeedX) && copySpeedX > 0 ? copySpeedX : null;
|
|
1532
1530
|
// Totals across every torrent this proxy holds, used to price what the
|
|
1533
1531
|
// torrent itself costs the machine (item 7). Optional: a proxy wired
|
|
1534
1532
|
// without it simply never learns that figure.
|
|
@@ -1612,6 +1610,7 @@ export class HlsSessionManager {
|
|
|
1612
1610
|
benchmark: this.softwarePresetBenchmark,
|
|
1613
1611
|
decodeModel: this.decodeCostModel,
|
|
1614
1612
|
contentionPenalties: this.contentionPenalties,
|
|
1613
|
+
copySpeedX: this.copySpeedX,
|
|
1615
1614
|
availability: this.hostAvailability
|
|
1616
1615
|
}),
|
|
1617
1616
|
audioCostKey: (session) => this.#audioCostKey(session),
|
|
@@ -1638,7 +1637,8 @@ export class HlsSessionManager {
|
|
|
1638
1637
|
maxRunsFor: (address) => this.maxRunsForOutput(address),
|
|
1639
1638
|
makeRun: ({ address, from, to }) => this.#makeRunAt(address, from, to),
|
|
1640
1639
|
segmentSeconds: this.segmentDurationSec,
|
|
1641
|
-
|
|
1640
|
+
contentionPenalties: this.contentionPenalties,
|
|
1641
|
+
startingSpeedFor: (address) => this.encodeCost.speedForOutput(address),
|
|
1642
1642
|
segmentStore: this.segmentStore,
|
|
1643
1643
|
logger
|
|
1644
1644
|
});
|
|
@@ -3659,63 +3659,6 @@ export class HlsSessionManager {
|
|
|
3659
3659
|
return readers;
|
|
3660
3660
|
}
|
|
3661
3661
|
|
|
3662
|
-
/**
|
|
3663
|
-
* One viewer's demand map, translated into this output's segment numbers.
|
|
3664
|
-
*
|
|
3665
|
-
* The map itself is seconds of film and knows nothing about cut grids
|
|
3666
|
-
* (`services/priority/PriorityMap.js`). The translation is this output's own
|
|
3667
|
-
* business, and it is exact: the timeline holds the boundaries.
|
|
3668
|
-
*
|
|
3669
|
-
* Two measurements feed it, and neither is chosen here:
|
|
3670
|
-
*
|
|
3671
|
-
* 1. the allowance below which an interruption reaches the viewer, from this
|
|
3672
|
-
* file's own recent interruptions (`minimumBufferFrom`). Null until the
|
|
3673
|
-
* reader has seen two of them, and then only the segment itself counts;
|
|
3674
|
-
* 2. how fast this machine encodes THIS track, from ffmpeg's own progress.
|
|
3675
|
-
* Below realtime it decides how much has to exist before playback starts;
|
|
3676
|
-
* above it, nothing beyond the allowance is needed.
|
|
3677
|
-
*
|
|
3678
|
-
* @param {object} session
|
|
3679
|
-
* @param {number} atSegment - Where the viewer is.
|
|
3680
|
-
* @returns {{from: number, to: number, priority: number}[]} In segment
|
|
3681
|
-
* numbers, both ends inclusive.
|
|
3682
|
-
*/
|
|
3683
|
-
#demandZonesFor(session, atSeconds, playing = true) {
|
|
3684
|
-
const boundaries = session.timeline?.boundaries ?? [];
|
|
3685
|
-
const segmentCount = Number(session.timeline?.segmentCount) || 0;
|
|
3686
|
-
// Where they are, in this output's own numbering. The viewer holds seconds;
|
|
3687
|
-
// every cut grid turns them into its own numbers, and two grids of one film
|
|
3688
|
-
// give different numbers for the same second.
|
|
3689
|
-
const atSegment = segmentCount > 0 ? this.#segmentIndexForTime(session, atSeconds) : 0;
|
|
3690
|
-
if (segmentCount <= 0) {
|
|
3691
|
-
// No playlist yet: the only thing that can be said is that they want
|
|
3692
|
-
// where they are.
|
|
3693
|
-
return [{ from: atSegment, to: atSegment, priority: 3 }];
|
|
3694
|
-
}
|
|
3695
|
-
const durationSeconds = Number(boundaries[boundaries.length - 1]) ||
|
|
3696
|
-
segmentCount * this.segmentDurationSec;
|
|
3697
|
-
const zones = mapForViewer({
|
|
3698
|
-
atSeconds,
|
|
3699
|
-
durationSeconds,
|
|
3700
|
-
allowanceSeconds: minimumBufferFrom({
|
|
3701
|
-
segmentSeconds: this.segmentDurationSec,
|
|
3702
|
-
worstSupplyWaitSec: session.supplyFigures?.worstWaitSec
|
|
3703
|
-
})?.seconds ?? this.segmentDurationSec,
|
|
3704
|
-
playing
|
|
3705
|
-
});
|
|
3706
|
-
/** @type {{from: number, to: number, priority: number}[]} */
|
|
3707
|
-
const inSegments = [];
|
|
3708
|
-
for (const zone of zones) {
|
|
3709
|
-
const from = Math.max(atSegment, this.#segmentIndexForTime(session, zone.from));
|
|
3710
|
-
const to = Math.min(segmentCount - 1, this.#segmentIndexForTime(session, zone.to));
|
|
3711
|
-
if (to >= from) {
|
|
3712
|
-
inSegments.push({ from, to, priority: zone.priority });
|
|
3713
|
-
}
|
|
3714
|
-
}
|
|
3715
|
-
return inSegments.length > 0
|
|
3716
|
-
? inSegments
|
|
3717
|
-
: [{ from: atSegment, to: atSegment, priority: 3 }];
|
|
3718
|
-
}
|
|
3719
3662
|
|
|
3720
3663
|
/**
|
|
3721
3664
|
* Say what the cushion is, for every session.
|
|
@@ -3871,62 +3814,35 @@ export class HlsSessionManager {
|
|
|
3871
3814
|
}
|
|
3872
3815
|
coverage.markReadyAll(this.segmentStore.provenNumbers(address));
|
|
3873
3816
|
for (const session of sessions) {
|
|
3874
|
-
for (const run of
|
|
3817
|
+
for (const run of liveRunsOf(session)) {
|
|
3875
3818
|
this.encodeOrchestrator.adopt(address, run);
|
|
3876
3819
|
}
|
|
3877
|
-
// What the viewers of this session are waiting for, as spans. A viewer
|
|
3878
|
-
// wants the segment they are at and the cushion in front of it, which
|
|
3879
|
-
// is what the encoder is steered by everywhere else in this class.
|
|
3880
|
-
//
|
|
3881
|
-
// PRESENCE AND POSITION ARE ASKED SEPARATELY, and that is the whole of
|
|
3882
|
-
// the 2026-09-05 fix. Presence decides whether this viewer states a
|
|
3883
|
-
// want at all; position decides what the want is. Asked as one question
|
|
3884
|
-
// — which is what a single field written only by segment requests
|
|
3885
|
-
// amounted to — a viewer who had just arrived answered "absent", every
|
|
3886
|
-
// encoder on their output was stopped for having nobody, and the
|
|
3887
|
-
// `init.mp4` they were waiting for in order to request their first
|
|
3888
|
-
// segment was therefore never made.
|
|
3889
|
-
for (const [consumerId, viewer] of viewersOf(session)) {
|
|
3890
|
-
if (!viewer.isPresent(now, staleAfterMs)) {
|
|
3891
|
-
this.encodeOrchestrator.release(`${session.id}:${consumerId}`);
|
|
3892
|
-
continue;
|
|
3893
|
-
}
|
|
3894
|
-
// Placed when they arrived, from the position their own request
|
|
3895
|
-
// named. A viewer with no position is one assembled by hand outside
|
|
3896
|
-
// this class; they want the beginning, which is where an output
|
|
3897
|
-
// starts when nobody says otherwise.
|
|
3898
|
-
// SECONDS, and turned into this output's own numbering below. A
|
|
3899
|
-
// segment number taken off the viewer would mean two different
|
|
3900
|
-
// moments of film on the picture and on the soundtrack, which are cut
|
|
3901
|
-
// independently: 454 pieces against 401 on the field file.
|
|
3902
|
-
const at = viewer.positionSeconds() ?? 0;
|
|
3903
|
-
// Their own map, in seconds of film, from measurements: how much must
|
|
3904
|
-
// be ready before they set off so that they never stop — the observed
|
|
3905
|
-
// allowance for this file plus what an encoder at THIS machine's
|
|
3906
|
-
// measured speed will fail to deliver in time — then what it reaches
|
|
3907
|
-
// while they watch that, then the rest of the track. No constant is
|
|
3908
|
-
// consulted: the 120 seconds that used to size this window were the
|
|
3909
|
-
// suspended encoder's threshold, one chosen number answering seven
|
|
3910
|
-
// different questions.
|
|
3911
|
-
for (const zone of this.#demandZonesFor(session, at, viewer.playing !== false)) {
|
|
3912
|
-
this.encodeOrchestrator.want({
|
|
3913
|
-
// The claimant is the PERSON, without the priority in it: their
|
|
3914
|
-
// zones are separate windows, but they leave together, and
|
|
3915
|
-
// `release` matches on this name.
|
|
3916
|
-
claimant: `${session.id}:${consumerId}`,
|
|
3917
|
-
address,
|
|
3918
|
-
from: zone.from,
|
|
3919
|
-
to: zone.to,
|
|
3920
|
-
priority: zone.priority
|
|
3921
|
-
});
|
|
3922
|
-
}
|
|
3923
|
-
}
|
|
3924
3820
|
}
|
|
3925
3821
|
}
|
|
3822
|
+
// THE MAP IS BUILT ONCE, IN ITS OWN LAYER, AND BOTH SIDES READ THAT ONE.
|
|
3823
|
+
//
|
|
3824
|
+
// It used to be built twice: once here, per viewer, converted and stated to
|
|
3825
|
+
// the encoding as a window each, and once again inside `publishFor` for the
|
|
3826
|
+
// downloading. Two answers to one question, and the encoding's copy carried
|
|
3827
|
+
// the viewer's NAME as the key of a claim — against the rule that the
|
|
3828
|
+
// encoding and the viewer are not connected at all.
|
|
3926
3829
|
this.priority.publishFor({
|
|
3927
3830
|
sessionGroups: byOutput.values(),
|
|
3928
3831
|
staleAfterMs: this.presenceStaleAfterMs()
|
|
3929
3832
|
});
|
|
3833
|
+
// Converted into each output's own numbering, because two outputs of one
|
|
3834
|
+
// film are cut independently and the same second is a different number in
|
|
3835
|
+
// each: 454 pieces against 401 on the field file.
|
|
3836
|
+
for (const [address, sessions] of byOutput) {
|
|
3837
|
+
const timeline = sessions[0].timeline;
|
|
3838
|
+
this.encodeOrchestrator.notePriorityMap(
|
|
3839
|
+
address,
|
|
3840
|
+
timeline?.inSegments?.(
|
|
3841
|
+
this.priority.mapFor(sessions[0].sourceKey, sessions[0].fileIndex),
|
|
3842
|
+
Number(timeline?.segmentCount) || 0
|
|
3843
|
+
) ?? []
|
|
3844
|
+
);
|
|
3845
|
+
}
|
|
3930
3846
|
this.encodeOrchestrator.reconcile();
|
|
3931
3847
|
}
|
|
3932
3848
|
|
|
@@ -3988,34 +3904,6 @@ export class HlsSessionManager {
|
|
|
3988
3904
|
}
|
|
3989
3905
|
}
|
|
3990
3906
|
|
|
3991
|
-
/**
|
|
3992
|
-
* This session's run, told what the disk holds before it is asked where it
|
|
3993
|
-
* has got to.
|
|
3994
|
-
*
|
|
3995
|
-
* A run learns of its own segments as they are SERVED, which is not when they
|
|
3996
|
-
* are made — a viewer two minutes behind the encoder has asked for none of
|
|
3997
|
-
* what is in front of them. The store knows, so the run is told, and its head
|
|
3998
|
-
* is then the same figure the look-ahead and the plan have always used.
|
|
3999
|
-
*
|
|
4000
|
-
* @param {HlsSession} session
|
|
4001
|
-
* @returns {import("./encode/EncodeRun.js").EncodeRun[]}
|
|
4002
|
-
*/
|
|
4003
|
-
#runsOfSession(session) {
|
|
4004
|
-
const runs = liveRunsOf(session);
|
|
4005
|
-
if (runs.length === 0) {
|
|
4006
|
-
return runs;
|
|
4007
|
-
}
|
|
4008
|
-
const produced = this.producedSegmentNumbers(session);
|
|
4009
|
-
for (const run of runs) {
|
|
4010
|
-
for (const index of produced) {
|
|
4011
|
-
if (index >= run.from) {
|
|
4012
|
-
run.noteProduced(index);
|
|
4013
|
-
}
|
|
4014
|
-
}
|
|
4015
|
-
}
|
|
4016
|
-
return runs;
|
|
4017
|
-
}
|
|
4018
|
-
|
|
4019
3907
|
/**
|
|
4020
3908
|
* How many encoders this machine can afford on one output at once.
|
|
4021
3909
|
*
|
|
@@ -4031,19 +3919,10 @@ export class HlsSessionManager {
|
|
|
4031
3919
|
* @returns {number}
|
|
4032
3920
|
*/
|
|
4033
3921
|
maxRunsForOutput(address) {
|
|
4034
|
-
|
|
4035
|
-
for (const session of this.sessionsById.values()) {
|
|
4036
|
-
if (session.outputKey !== address || session.state === "disposed") {
|
|
4037
|
-
continue;
|
|
4038
|
-
}
|
|
4039
|
-
const measured = Number(session.recentSpeed?.speed);
|
|
4040
|
-
if (Number.isFinite(measured) && measured > alone) {
|
|
4041
|
-
alone = measured;
|
|
4042
|
-
}
|
|
4043
|
-
}
|
|
3922
|
+
const alone = this.encodeCost.speedForOutput(address);
|
|
4044
3923
|
if (!(alone > 0)) {
|
|
4045
|
-
//
|
|
4046
|
-
//
|
|
3924
|
+
// The host measured nothing at all, which is a broken startup rather than
|
|
3925
|
+
// a state to plan around. One encoder is what it keeps.
|
|
4047
3926
|
return 1;
|
|
4048
3927
|
}
|
|
4049
3928
|
let affordable = 1;
|
|
@@ -5716,6 +5595,7 @@ export class HlsSessionManager {
|
|
|
5716
5595
|
session.timeline?.segmentCount > 0 ? session.timeline.segmentCount - 1 : null,
|
|
5717
5596
|
inputUnavailable: (message) => isInputUnavailable(message),
|
|
5718
5597
|
onProgress: (report) => this.#noteRunProgress(session, run, report),
|
|
5598
|
+
indexOfName: (name) => session.segmentFormat.segmentIndexFromName(name),
|
|
5719
5599
|
onClosed: (name) => this.segmentStore.markClosed(session.outputKey ?? "", session.segmentFormat.segmentIndexFromName(name)),
|
|
5720
5600
|
onEnded: (ended) => this.noteRunEnded(session, run, ended)
|
|
5721
5601
|
});
|
|
@@ -7070,11 +6950,11 @@ export class HlsSessionManager {
|
|
|
7070
6950
|
if (!output) {
|
|
7071
6951
|
return false;
|
|
7072
6952
|
}
|
|
7073
|
-
|
|
7074
|
-
|
|
7075
|
-
|
|
7076
|
-
|
|
7077
|
-
return
|
|
6953
|
+
// Nothing to release in the encoding: it holds one map per output, built
|
|
6954
|
+
// from where the viewers are, and the map that arrives next simply does not
|
|
6955
|
+
// have this one in it. A name to release was the last place a viewer
|
|
6956
|
+
// appeared inside the encoding at all.
|
|
6957
|
+
return this.viewers.leaves(output, consumerId);
|
|
7078
6958
|
}
|
|
7079
6959
|
|
|
7080
6960
|
/**
|