@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
|
@@ -27,6 +27,8 @@ import { CoverageMap } from "../encode/CoverageMap.js";
|
|
|
27
27
|
import { firstUnmetWant, planEncoders } from "../encode/EncodePlan.js";
|
|
28
28
|
import { endOfRun } from "../encode/EncodeRun.js";
|
|
29
29
|
import { ENCODE_EXIT } from "../encode/encode-exit.js";
|
|
30
|
+
import { affordableRuns } from "../encode/run-budget.js";
|
|
31
|
+
import { RunCosts } from "../encode/run-costs.js";
|
|
30
32
|
import { SegmentDemand } from "../encode/SegmentDemand.js";
|
|
31
33
|
|
|
32
34
|
export class EncodeOrchestrator {
|
|
@@ -36,9 +38,19 @@ export class EncodeOrchestrator {
|
|
|
36
38
|
/** Output address to the runs on it. @type {Map<string, import("../encode/EncodeRun.js").EncodeRun[]>} */
|
|
37
39
|
#runs = new Map();
|
|
38
40
|
|
|
41
|
+
|
|
39
42
|
/** How runs have ended, by cause. @type {Map<string, number>} */
|
|
40
43
|
#endings = new Map();
|
|
41
44
|
|
|
45
|
+
/** The last state said out loud, so an unchanged state is not repeated. */
|
|
46
|
+
#lastDescribed = "";
|
|
47
|
+
|
|
48
|
+
/** What a stop and a start have cost on this host. */
|
|
49
|
+
#costs = new RunCosts();
|
|
50
|
+
|
|
51
|
+
/** The last reason a budget was cut, so the same one is not said twice. */
|
|
52
|
+
#lastBudgetReason = new Map();
|
|
53
|
+
|
|
42
54
|
/**
|
|
43
55
|
* @param {object} params
|
|
44
56
|
* @param {(address: string) => number} params.maxRunsFor - How many encoders
|
|
@@ -53,17 +65,31 @@ export class EncodeOrchestrator {
|
|
|
53
65
|
* @param {{ info: (line: string) => void, warn: (line: string) => void }} params.logger
|
|
54
66
|
* @param {() => number} [params.now]
|
|
55
67
|
*/
|
|
56
|
-
constructor({
|
|
68
|
+
constructor({
|
|
69
|
+
maxRunsFor,
|
|
70
|
+
makeRun,
|
|
71
|
+
segmentSeconds,
|
|
72
|
+
restartCostSec,
|
|
73
|
+
refetchSecPerFilmSecond = () => 0,
|
|
74
|
+
segmentStore = null,
|
|
75
|
+
logger,
|
|
76
|
+
now
|
|
77
|
+
}) {
|
|
78
|
+
// The store of produced segments — the layer below this one. It is asked to
|
|
79
|
+
// clean up after a run that ended other than by reaching the end of its
|
|
80
|
+
// stretch, which is the one thing an ending must not leave behind: a file
|
|
81
|
+
// under a name that promises a whole segment.
|
|
82
|
+
this.segmentStore = segmentStore;
|
|
57
83
|
this.demand = new SegmentDemand();
|
|
58
84
|
this.maxRunsFor = maxRunsFor;
|
|
85
|
+
// Seconds of swarm time per second of film: what re-encoding material that
|
|
86
|
+
// already exists costs the download, over and above the encoder's own time.
|
|
87
|
+
// Injected, because the film's byte rate and the swarm's are measured
|
|
88
|
+
// elsewhere and this class must not reach for them.
|
|
89
|
+
this.refetchSecPerFilmSecond = refetchSecPerFilmSecond;
|
|
59
90
|
this.makeRun = makeRun;
|
|
60
91
|
this.segmentSeconds = segmentSeconds;
|
|
61
92
|
this.restartCostSec = restartCostSec;
|
|
62
|
-
// How far in front of its viewer a run is allowed to get. It is what bounds
|
|
63
|
-
// the claim of a run that was given no end — see #claimFor.
|
|
64
|
-
this.lookaheadSegments = Number.isFinite(lookaheadSegments) && lookaheadSegments > 0
|
|
65
|
-
? Math.ceil(lookaheadSegments)
|
|
66
|
-
: 0;
|
|
67
93
|
this.logger = logger;
|
|
68
94
|
this.now = typeof now === "function" ? now : Date.now;
|
|
69
95
|
}
|
|
@@ -121,9 +147,14 @@ export class EncodeOrchestrator {
|
|
|
121
147
|
* @param {string} params.address
|
|
122
148
|
* @param {number} params.from
|
|
123
149
|
* @param {number} params.to
|
|
150
|
+
* @param {number} [params.priority] - Higher is sooner. One viewer states
|
|
151
|
+
* several stretches at once — what must be ready before they set off, what
|
|
152
|
+
* is reachable while they watch it, the rest of the track — and the filling
|
|
153
|
+
* takes them in this order. Absent means one undifferentiated want, which
|
|
154
|
+
* is what a caller that knows only a position states.
|
|
124
155
|
*/
|
|
125
|
-
want({ claimant, address, from, to }) {
|
|
126
|
-
this.demand.state({ claimant, address, from, to, statedAt: this.now() });
|
|
156
|
+
want({ claimant, address, from, to, priority = 0 }) {
|
|
157
|
+
this.demand.state({ claimant, address, from, to, priority, statedAt: this.now() });
|
|
127
158
|
}
|
|
128
159
|
|
|
129
160
|
/**
|
|
@@ -173,6 +204,19 @@ export class EncodeOrchestrator {
|
|
|
173
204
|
for (const address of addresses) {
|
|
174
205
|
this.#reconcileOne(address);
|
|
175
206
|
}
|
|
207
|
+
// WHAT THIS CLASS BELIEVES, said by this class. `describe()` was written
|
|
208
|
+
// and called from nowhere, so on 2026-09-05 the question "why did the plan
|
|
209
|
+
// not see the gap the viewer was stopped at" had to be answered by
|
|
210
|
+
// inference from start and stop lines, and was not answered at all.
|
|
211
|
+
//
|
|
212
|
+
// Printed on CHANGE rather than on a timer: a quiet session says nothing, a
|
|
213
|
+
// session that is deciding something says what it decided, and there is no
|
|
214
|
+
// interval to choose.
|
|
215
|
+
const state = this.describe();
|
|
216
|
+
if (state !== this.#lastDescribed) {
|
|
217
|
+
this.#lastDescribed = state;
|
|
218
|
+
this.logger.info(state);
|
|
219
|
+
}
|
|
176
220
|
}
|
|
177
221
|
|
|
178
222
|
/**
|
|
@@ -196,9 +240,14 @@ export class EncodeOrchestrator {
|
|
|
196
240
|
});
|
|
197
241
|
}
|
|
198
242
|
}
|
|
243
|
+
// Carrying the priority through, because the filling takes the work in that
|
|
244
|
+
// order: what a viewer must have before they set off comes before what is
|
|
245
|
+
// merely in front of them, which comes before the rest of the track. Passed
|
|
246
|
+
// as a plain number so the plan stays arithmetic.
|
|
199
247
|
const windows = this.demand.windowsOn(address).map((window) => ({
|
|
200
248
|
from: window.from,
|
|
201
|
-
to: window.to
|
|
249
|
+
to: window.to,
|
|
250
|
+
priority: Number(window.priority) || 0
|
|
202
251
|
}));
|
|
203
252
|
const live = this.runsOn(address).filter((run) => run.isAlive);
|
|
204
253
|
const actions = planEncoders({
|
|
@@ -208,9 +257,17 @@ export class EncodeOrchestrator {
|
|
|
208
257
|
// each; what it hands back names the run by BEING it, so nothing has to
|
|
209
258
|
// invent a token to refer to one by.
|
|
210
259
|
runs: live,
|
|
211
|
-
maxRuns:
|
|
260
|
+
maxRuns: this.#affordableOn(address, live),
|
|
212
261
|
segmentSeconds: this.segmentSeconds,
|
|
213
|
-
restartCostSec: this.restartCostSec
|
|
262
|
+
restartCostSec: this.restartCostSec,
|
|
263
|
+
// Measured from this host's own runs, rather than written into the code
|
|
264
|
+
// from one machine's reading.
|
|
265
|
+
...this.#costs.seconds(),
|
|
266
|
+
// What a second of film costs to fetch again, in seconds of swarm time.
|
|
267
|
+
// Answered by whoever measures the film's own byte rate and the swarm's;
|
|
268
|
+
// zero until they have, which makes driving through look cheaper than it
|
|
269
|
+
// is and is stated here so the bias is known.
|
|
270
|
+
refetchSecPerFilmSecond: this.refetchSecPerFilmSecond(address)
|
|
214
271
|
});
|
|
215
272
|
|
|
216
273
|
for (const action of actions) {
|
|
@@ -244,24 +301,68 @@ export class EncodeOrchestrator {
|
|
|
244
301
|
* @param {string} because
|
|
245
302
|
*/
|
|
246
303
|
#start(address, from, to, because) {
|
|
304
|
+
// The encoder is built here and now: whoever builds one waits for nothing,
|
|
305
|
+
// so it exists by the time this line returns. That is what makes the
|
|
306
|
+
// stretch held from this instant — this class knows what it is making
|
|
307
|
+
// because it has just made it, and no second encoder can be started for the
|
|
308
|
+
// same stretch on the next pass.
|
|
309
|
+
//
|
|
310
|
+
// It was not always so. The builder used to answer with nothing and start
|
|
311
|
+
// the encoder behind the answer, so the stretch stayed FREE for as long as
|
|
312
|
+
// that took, and every pass in between started another one: 684 starts in
|
|
313
|
+
// 482 seconds of field 2026-09-05, of which 973 answers said the encoder
|
|
314
|
+
// was not there yet — every start without exception.
|
|
315
|
+
//
|
|
247
316
|
// The run names itself: identity is a property of the thing, and two
|
|
248
317
|
// places minting names is how one stops being unique.
|
|
249
318
|
const run = this.makeRun({ address, from, to });
|
|
250
319
|
if (!run) {
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
|
|
254
|
-
// and the next pass sees it.
|
|
255
|
-
this.logger.info(`encode: an encoder for #${from}..#${to} of ${address} is not there yet`);
|
|
320
|
+
// A refusal, not a wait: no session serves this output, or this position
|
|
321
|
+
// has failed to start too many times running.
|
|
322
|
+
this.logger.warn(`encode: no encoder could be made for #${from}..#${to} of ${address}`);
|
|
256
323
|
return;
|
|
257
324
|
}
|
|
258
325
|
const onThisOutput = this.#runs.get(address) ?? [];
|
|
259
326
|
onThisOutput.push(run);
|
|
260
327
|
this.#runs.set(address, onThisOutput);
|
|
261
|
-
this.coverageOf(address).claim(run, from, to);
|
|
328
|
+
this.coverageOf(address).claim(run, from, endOfRun({ from, to }));
|
|
262
329
|
run.start(because);
|
|
263
330
|
}
|
|
264
331
|
|
|
332
|
+
/**
|
|
333
|
+
* How many encoders may run on this output, from every limit at once.
|
|
334
|
+
*
|
|
335
|
+
* The processor is one of them and is answered from outside, where the
|
|
336
|
+
* machine is measured. The other two are known here: what the swarm delivers,
|
|
337
|
+
* through the seconds of swarm time a second of film costs, and — once it is
|
|
338
|
+
* supplied — the memory the piece store may hold against what one encoder's
|
|
339
|
+
* reader keeps.
|
|
340
|
+
*
|
|
341
|
+
* Said out loud when it is not the processor that decided, because "why is
|
|
342
|
+
* there only one encoder" is otherwise a question no log can answer.
|
|
343
|
+
*
|
|
344
|
+
* @param {string} address
|
|
345
|
+
* @param {{ speedX: number }[]} live
|
|
346
|
+
* @returns {number}
|
|
347
|
+
*/
|
|
348
|
+
#affordableOn(address, live) {
|
|
349
|
+
const byProcessor = Math.max(0, this.maxRunsFor(address));
|
|
350
|
+
const fastest = live.reduce((best, run) => Math.max(best, run.speedX || 0), 0);
|
|
351
|
+
const budget = affordableRuns({
|
|
352
|
+
byProcessor,
|
|
353
|
+
speedX: fastest,
|
|
354
|
+
refetchSecPerFilmSecond: this.refetchSecPerFilmSecond(address)
|
|
355
|
+
});
|
|
356
|
+
if (budget.runs !== byProcessor && budget.because !== this.#lastBudgetReason.get(address)) {
|
|
357
|
+
this.#lastBudgetReason.set(address, budget.because);
|
|
358
|
+
this.logger.info(
|
|
359
|
+
`encode: ${budget.runs} encoder(s) on ${address.slice(0, 60)} — ${budget.because} ` +
|
|
360
|
+
`(the processor alone would allow ${byProcessor})`
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
return budget.runs;
|
|
364
|
+
}
|
|
365
|
+
|
|
265
366
|
/**
|
|
266
367
|
* Take charge of a run this class did not start.
|
|
267
368
|
*
|
|
@@ -316,8 +417,18 @@ export class EncodeOrchestrator {
|
|
|
316
417
|
coverage.claim(run, from, end);
|
|
317
418
|
return;
|
|
318
419
|
}
|
|
420
|
+
// A RUN WITH NO END HOLDS WHAT IT HAS MADE, NOT WHAT IT MIGHT MAKE.
|
|
421
|
+
//
|
|
422
|
+
// "No end" means the film's length is not known, so there is no last number
|
|
423
|
+
// to claim towards. Claiming the rest of the film would leave a viewer who
|
|
424
|
+
// opens the same film further in with every number taken and no encoder at
|
|
425
|
+
// all. Claiming a fixed distance in front of the head — which is what this
|
|
426
|
+
// did — needs a number nobody measured, and the number it used was the
|
|
427
|
+
// suspended-encoder threshold that no longer exists.
|
|
428
|
+
//
|
|
429
|
+
// What it has made is a fact, and it is the only one available here.
|
|
319
430
|
const head = Number.isFinite(run?.head) ? run.head : from;
|
|
320
|
-
coverage.claim(run, from, Math.max(from, head
|
|
431
|
+
coverage.claim(run, from, Math.max(from, head));
|
|
321
432
|
}
|
|
322
433
|
|
|
323
434
|
/**
|
|
@@ -338,6 +449,15 @@ export class EncodeOrchestrator {
|
|
|
338
449
|
* @param {import("../encode/EncodeRun.js").RunEnded} ended
|
|
339
450
|
*/
|
|
340
451
|
noteEnded(ended) {
|
|
452
|
+
this.#costs.note(ended);
|
|
453
|
+
// Exactly one ending is normal — the run reached the end of the stretch it
|
|
454
|
+
// was given and closed its last file. Every other leaves a piece open, and
|
|
455
|
+
// that file's name is indistinguishable from a finished one's.
|
|
456
|
+
if (ended.ending !== ENCODE_EXIT.COMPLETE && this.segmentStore) {
|
|
457
|
+
void this.segmentStore
|
|
458
|
+
.discardOpenPieceOf(ended.address, { from: ended.from, to: ended.to })
|
|
459
|
+
.catch(() => {});
|
|
460
|
+
}
|
|
341
461
|
this.coverageOf(ended.address).release(ended.run);
|
|
342
462
|
const remaining = this.runsOn(ended.address).filter((run) => run !== ended.run);
|
|
343
463
|
if (remaining.length === 0) {
|
|
@@ -378,14 +498,21 @@ export class EncodeOrchestrator {
|
|
|
378
498
|
const parts = [];
|
|
379
499
|
for (const address of new Set([...this.demand.addresses(), ...this.#runs.keys()])) {
|
|
380
500
|
const coverage = this.coverageOf(address);
|
|
381
|
-
const
|
|
501
|
+
const stated = this.demand.windowsOn(address);
|
|
502
|
+
const windows = stated.map((w) => ({ from: w.from, to: w.to }));
|
|
382
503
|
const waiting = firstUnmetWant(coverage, windows);
|
|
383
504
|
const runs = this.runsOn(address)
|
|
384
505
|
.map((run) => `#${run.head}..#${run.to}@${run.speedX.toFixed(1)}x`)
|
|
385
506
|
.join(" ");
|
|
507
|
+
// The zones as they were stated, with their order, so a plan that is
|
|
508
|
+
// working at the wrong end of the film is visible rather than inferred.
|
|
509
|
+
const zones = [...stated]
|
|
510
|
+
.sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0) || left.from - right.from)
|
|
511
|
+
.map((w) => `p${w.priority ?? 0}:#${w.from}..#${w.to}`)
|
|
512
|
+
.join(" ");
|
|
386
513
|
parts.push(
|
|
387
514
|
`${address.slice(0, 60)} ready=${coverage.stats().ready} ` +
|
|
388
|
-
`
|
|
515
|
+
`zones=[${zones}] runs=[${runs}] ` +
|
|
389
516
|
`waiting=${waiting === null ? "nobody" : `#${waiting}`}`
|
|
390
517
|
);
|
|
391
518
|
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file THE PRIORITY MAP — what one viewer needs, what all of them need
|
|
3
|
+
* together, and in what order the work should be taken.
|
|
4
|
+
*
|
|
5
|
+
* A layer of its own, below both orchestrators and depending on nothing. It
|
|
6
|
+
* knows ONLY priorities: what is downloaded is the download orchestrator's own
|
|
7
|
+
* knowledge, what is encoded is the encoding orchestrator's, and neither is
|
|
8
|
+
* visible from here. Both read this and recompute their own on every change.
|
|
9
|
+
*
|
|
10
|
+
* The shape, stated by the user 2026-09-05:
|
|
11
|
+
*
|
|
12
|
+
* > Usually you make a map for each viewer, then merge the maps, then decide
|
|
13
|
+
* > the best way of filling it given the encoders available, where they are now
|
|
14
|
+
* > and how many there are.
|
|
15
|
+
*
|
|
16
|
+
* Three questions, and this file answers the first two. The third — the filling
|
|
17
|
+
* — belongs to whoever holds the encoders, and it is handed the merged map
|
|
18
|
+
* instead of a list of windows.
|
|
19
|
+
*
|
|
20
|
+
* **ONE PRIORITISATION, TWO CONSUMERS.** Downloading and encoding keep
|
|
21
|
+
* different STATES — made / being made / free for one; downloaded / arriving,
|
|
22
|
+
* and which peers hold it at what speed, for the other — but they must agree
|
|
23
|
+
* about what matters first, or the swarm fetches what the encoder will not
|
|
24
|
+
* reach for another twenty minutes. That agreement is this map.
|
|
25
|
+
*
|
|
26
|
+
* **THE UNIT IS SECONDS OF FILM.** A map is a set of stretches with sizes and a
|
|
27
|
+
* length of its own, so it needs a unit, and seconds are the only one every
|
|
28
|
+
* term of the arithmetic is already stated in: encode speed is a ratio of
|
|
29
|
+
* seconds to seconds, the measured allowance below which an interruption
|
|
30
|
+
* reaches a viewer is seconds, the viewer's position is seconds, the film's
|
|
31
|
+
* length is seconds. Bytes cannot serve — how many a second costs is not known
|
|
32
|
+
* when a file is opened and is not constant across it, and a soundtrack in a
|
|
33
|
+
* file of its own has bytes of its own. Segment numbers cannot serve either:
|
|
34
|
+
* they exist only once a cut grid is read, and two outputs of one film number
|
|
35
|
+
* differently.
|
|
36
|
+
*
|
|
37
|
+
* **What this file must NOT know**, and the boundary is the point: nothing about
|
|
38
|
+
* containers, cut grids, pieces or bytes. Turning a stretch of seconds into the
|
|
39
|
+
* bytes of one track is the container's and the track's business, by whatever
|
|
40
|
+
* means suit that file — a Cues table, a sample table, or a walk when the file
|
|
41
|
+
* carries neither, which is the same answer they already give in order to play
|
|
42
|
+
* it at all. Getting those bytes is the downloader's business; making segments
|
|
43
|
+
* out of them is the encoder's.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* How urgently a stretch of film is wanted. Higher is sooner.
|
|
48
|
+
*
|
|
49
|
+
* @typedef {object} DemandZone
|
|
50
|
+
* @property {number} from - First second of film, inclusive.
|
|
51
|
+
* @property {number} to - Last second of film, inclusive.
|
|
52
|
+
* @property {number} priority - Higher is more urgent. Only the ORDER between
|
|
53
|
+
* zones is meaningful; the numbers themselves are not a scale.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/** Where the viewer stands. Nothing outranks it. */
|
|
57
|
+
const AT_THE_VIEWER = 33;
|
|
58
|
+
|
|
59
|
+
/** In front of them, within reach while they watch what is already made. */
|
|
60
|
+
const IN_FRONT = 32;
|
|
61
|
+
|
|
62
|
+
/** The rest of the track: wanted, because the file is encoded whole. */
|
|
63
|
+
const THE_REST = 1;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* How many zones one viewer's map may hold.
|
|
67
|
+
*
|
|
68
|
+
* Below realtime the zones grow geometrically, so their number is the logarithm
|
|
69
|
+
* of the film left over what the viewer holds — five on the addon host's worst
|
|
70
|
+
* measured case, and it grows by one each time the speed halves. The bound is
|
|
71
|
+
* not a policy about how many encoders may run (the machine's budget answers
|
|
72
|
+
* that, and it is far smaller); it stops a speed measured at almost zero from
|
|
73
|
+
* turning a film into thousands of slivers before anything reads the map.
|
|
74
|
+
*/
|
|
75
|
+
const MOST_ZONES = 32;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* One viewer's map, and every boundary in it is measured rather than chosen.
|
|
79
|
+
*
|
|
80
|
+
* The shape depends on one measured number — how fast this machine encodes this
|
|
81
|
+
* track against realtime:
|
|
82
|
+
*
|
|
83
|
+
* - **at or above realtime** the encoder gains on the viewer everywhere, so one
|
|
84
|
+
* of them holds the whole film. Three zones: the measured allowance in front
|
|
85
|
+
* of the viewer, what the encoder reaches while they watch it, and the rest;
|
|
86
|
+
* - **below realtime** the encoder loses `1 - speed` of a second for every
|
|
87
|
+
* second played, so one cannot hold the film and the map says how many can.
|
|
88
|
+
* An encoder starting at `q` stays ahead of a viewer at `p` for
|
|
89
|
+
* `(q - p) * s / (1 - s)`, which GROWS with its distance from them — so the
|
|
90
|
+
* zones grow, each is one encoder's share, and their number is the smallest
|
|
91
|
+
* that holds this viewer.
|
|
92
|
+
*
|
|
93
|
+
* The last zone is always the rest of the track, wanted because the file is
|
|
94
|
+
* encoded whole and last because nobody is waiting on it.
|
|
95
|
+
*
|
|
96
|
+
* @param {object} params
|
|
97
|
+
* @param {number} params.atSeconds - Where they are watching from.
|
|
98
|
+
* @param {number} params.durationSeconds - How long the film is.
|
|
99
|
+
* @param {number} params.allowanceSeconds - The measured depth below which an
|
|
100
|
+
* interruption reaches this viewer (`minimumBufferSeconds`).
|
|
101
|
+
* @param {number} params.encodeSpeedX - Measured encode speed against realtime
|
|
102
|
+
* for this track on this machine. Zero or less means nothing has measured it
|
|
103
|
+
* yet, and then zone 2 is left out rather than invented.
|
|
104
|
+
* @returns {DemandZone[]} Ascending, without gaps or overlaps, covering
|
|
105
|
+
* everything from where they are to the end of the film.
|
|
106
|
+
*/
|
|
107
|
+
export function mapForViewer({ atSeconds, durationSeconds, allowanceSeconds, encodeSpeedX }) {
|
|
108
|
+
const from = Number.isFinite(atSeconds) && atSeconds > 0 ? atSeconds : 0;
|
|
109
|
+
const end = Number.isFinite(durationSeconds) ? durationSeconds : 0;
|
|
110
|
+
if (!(end > from)) {
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
const allowance = Number.isFinite(allowanceSeconds) && allowanceSeconds > 0 ? allowanceSeconds : 0;
|
|
114
|
+
const speed = Number.isFinite(encodeSpeedX) && encodeSpeedX > 0 ? encodeSpeedX : 0;
|
|
115
|
+
|
|
116
|
+
/** @type {DemandZone[]} */
|
|
117
|
+
const zones = [];
|
|
118
|
+
|
|
119
|
+
// AT OR ABOVE REALTIME ONE ENCODER SUFFICES, whatever the film's length.
|
|
120
|
+
//
|
|
121
|
+
// From the condition below with `s >= 1`: the encoder gains on the viewer at
|
|
122
|
+
// every point, so there is no distance at which they catch it. All that has
|
|
123
|
+
// to exist in front of them is the allowance this file's own interruptions
|
|
124
|
+
// have shown to be necessary.
|
|
125
|
+
if (speed === 0 || speed >= 1) {
|
|
126
|
+
const readyBy = Math.min(end, from + allowance);
|
|
127
|
+
zones.push({ from, to: readyBy, priority: AT_THE_VIEWER });
|
|
128
|
+
if (readyBy < end && speed > 0) {
|
|
129
|
+
// While they watch what the first zone holds, the encoder makes `speed`
|
|
130
|
+
// times as much again. Beyond that nobody is waiting yet.
|
|
131
|
+
const reach = Math.min(end, readyBy + (readyBy - from) * speed);
|
|
132
|
+
if (reach > readyBy) {
|
|
133
|
+
zones.push({ from: readyBy, to: reach, priority: IN_FRONT });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const covered = zones[zones.length - 1].to;
|
|
137
|
+
if (covered < end) {
|
|
138
|
+
zones.push({ from: covered, to: end, priority: THE_REST });
|
|
139
|
+
}
|
|
140
|
+
return zones;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// BELOW REALTIME THE ZONES GROW, AND EACH IS ONE ENCODER'S SHARE.
|
|
144
|
+
//
|
|
145
|
+
// An encoder starting at `q` produces the point `q + y` after `y / s`, and the
|
|
146
|
+
// viewer reaches it after `q + y - p`. It stays ahead while
|
|
147
|
+
//
|
|
148
|
+
// y <= (q - p) * s / (1 - s)
|
|
149
|
+
//
|
|
150
|
+
// so the length one encoder can hold GROWS with its distance from the viewer:
|
|
151
|
+
// the further off it starts, the later the viewer arrives. Equal shares are
|
|
152
|
+
// therefore the wrong division, and by a wide margin — on the addon host with
|
|
153
|
+
// 2400 s of film left, 120 s held and 0.5x, equal shares need twenty encoders
|
|
154
|
+
// and growing ones need five (120, 240, 480, 960, 1920).
|
|
155
|
+
//
|
|
156
|
+
// Each zone is exactly as long as its bound allows, which makes the count the
|
|
157
|
+
// smallest that can hold this viewer: any zone longer stalls them, and any
|
|
158
|
+
// shorter leaves the next one starting nearer, where its own bound is tighter.
|
|
159
|
+
// With nothing held, no partition holds this viewer: the first encoder can
|
|
160
|
+
// stay ahead for `b * s / (1 - s)`, and that is zero. Saying so plainly beats
|
|
161
|
+
// slicing the film into equal slivers that pretend otherwise — the whole of
|
|
162
|
+
// what is left is urgent, and it will still not be enough.
|
|
163
|
+
if (allowance <= 0) {
|
|
164
|
+
return [{ from, to: end, priority: AT_THE_VIEWER }];
|
|
165
|
+
}
|
|
166
|
+
const growth = speed / (1 - speed);
|
|
167
|
+
let at = from;
|
|
168
|
+
let held = allowance;
|
|
169
|
+
let priority = AT_THE_VIEWER;
|
|
170
|
+
while (at < end && zones.length < MOST_ZONES) {
|
|
171
|
+
const share = held * growth;
|
|
172
|
+
const to = Math.min(end, at + share);
|
|
173
|
+
zones.push({ from: at, to, priority });
|
|
174
|
+
held += to - at;
|
|
175
|
+
at = to;
|
|
176
|
+
// The next zone is one step less urgent: the viewer meets the one before it
|
|
177
|
+
// first. One scale for every viewer, or merging two maps would compare
|
|
178
|
+
// numbers that mean different things.
|
|
179
|
+
priority = Math.max(THE_REST + 1, priority - 1);
|
|
180
|
+
}
|
|
181
|
+
if (at < end) {
|
|
182
|
+
zones.push({ from: at, to: end, priority: THE_REST });
|
|
183
|
+
}
|
|
184
|
+
return zones;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Every viewer's map as one.
|
|
189
|
+
*
|
|
190
|
+
* The highest priority per second wins: film two people want is as urgent as
|
|
191
|
+
* the more urgent of them, and making it once serves both. What comes back has
|
|
192
|
+
* no overlaps, so the filling can walk it without asking about any individual
|
|
193
|
+
* viewer — which is the rule this layer exists to keep, that which viewer asked
|
|
194
|
+
* never reaches the encoders.
|
|
195
|
+
*
|
|
196
|
+
* @param {DemandZone[][]} maps
|
|
197
|
+
* @returns {DemandZone[]} Ascending by position.
|
|
198
|
+
*/
|
|
199
|
+
export function mergeMaps(maps) {
|
|
200
|
+
/** @type {DemandZone[]} */
|
|
201
|
+
const all = [];
|
|
202
|
+
for (const map of maps ?? []) {
|
|
203
|
+
for (const zone of map ?? []) {
|
|
204
|
+
if (Number.isFinite(zone?.from) && Number.isFinite(zone?.to) && zone.to > zone.from) {
|
|
205
|
+
all.push(zone);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (all.length === 0) {
|
|
210
|
+
return [];
|
|
211
|
+
}
|
|
212
|
+
// Walked by BOUNDARIES rather than by second: a film is thousands of them and
|
|
213
|
+
// this is asked again on every change.
|
|
214
|
+
const points = [...new Set(all.flatMap((zone) => [zone.from, zone.to]))].sort(
|
|
215
|
+
(left, right) => left - right
|
|
216
|
+
);
|
|
217
|
+
/** @type {DemandZone[]} */
|
|
218
|
+
const merged = [];
|
|
219
|
+
for (let index = 0; index < points.length - 1; index += 1) {
|
|
220
|
+
const from = points[index];
|
|
221
|
+
const to = points[index + 1];
|
|
222
|
+
let priority = 0;
|
|
223
|
+
for (const zone of all) {
|
|
224
|
+
if (zone.from <= from && to <= zone.to && zone.priority > priority) {
|
|
225
|
+
priority = zone.priority;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (priority <= 0) {
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const previous = merged[merged.length - 1];
|
|
232
|
+
if (previous && previous.priority === priority && previous.to === from) {
|
|
233
|
+
previous.to = to;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
merged.push({ from, to, priority });
|
|
237
|
+
}
|
|
238
|
+
return merged;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The merged map in the order the work is taken: most urgent first, and within
|
|
243
|
+
* one priority the earliest film first — that is where somebody is stopped.
|
|
244
|
+
*
|
|
245
|
+
* @param {DemandZone[]} merged
|
|
246
|
+
* @returns {DemandZone[]}
|
|
247
|
+
*/
|
|
248
|
+
export function inWorkingOrder(merged) {
|
|
249
|
+
return [...(merged ?? [])].sort(
|
|
250
|
+
(left, right) => right.priority - left.priority || left.from - right.from
|
|
251
|
+
);
|
|
252
|
+
}
|
|
@@ -125,6 +125,89 @@ export class Viewer {
|
|
|
125
125
|
* @type {Set<string>}
|
|
126
126
|
*/
|
|
127
127
|
this.outputs = new Set();
|
|
128
|
+
// Whether the picture is moving. A viewer who has stopped it consumes
|
|
129
|
+
// nothing, so nothing in front of them ever becomes due — they have no
|
|
130
|
+
// deadline at all, and the work goes to whoever is watching. The page knows
|
|
131
|
+
// this exactly and says it outright; inferring it from a position that has
|
|
132
|
+
// not moved takes two reports and lies whenever a browser holding a full
|
|
133
|
+
// cushion goes quiet between segments, which it does.
|
|
134
|
+
this.playing = true;
|
|
135
|
+
// Seconds of film held ahead of the picture, as the page last said.
|
|
136
|
+
this.bufferedSeconds = null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Where they are, in SECONDS of film, and nothing else.
|
|
141
|
+
*
|
|
142
|
+
* A segment number cannot live here: the picture and the soundtrack of one
|
|
143
|
+
* film are cut independently and into different numbers of pieces — 454
|
|
144
|
+
* against 401 on the field file of 2026-09-05 — so piece 48 of one is not the
|
|
145
|
+
* same moment as piece 48 of the other. Whoever holds a cut grid turns these
|
|
146
|
+
* seconds into their own numbers.
|
|
147
|
+
*
|
|
148
|
+
* @param {number} seconds
|
|
149
|
+
* @param {number} [now]
|
|
150
|
+
*/
|
|
151
|
+
moveTo(seconds, now = Date.now()) {
|
|
152
|
+
if (!Number.isFinite(seconds) || seconds < 0) {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
this.position = { seconds, at: now, seeked: seconds };
|
|
156
|
+
this.lastSeenAt = now;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Everything a viewer says about itself, in one statement.
|
|
161
|
+
*
|
|
162
|
+
* The page sends four things together — how fast its link measured, how much
|
|
163
|
+
* film it holds, where the picture is, and whether the picture is moving —
|
|
164
|
+
* and all four are facts about this viewer. Taking them apart and assigning
|
|
165
|
+
* them one by one somewhere else is how they came to be spread over five
|
|
166
|
+
* places, two of them on a session shared with other people.
|
|
167
|
+
*
|
|
168
|
+
* @param {object} report
|
|
169
|
+
* @param {number} report.linkMbps
|
|
170
|
+
* @param {number} report.bufferedAheadSec
|
|
171
|
+
* @param {number | null} [report.positionSeconds] - Null from a page that
|
|
172
|
+
* does not say; then the position stands as it was.
|
|
173
|
+
* @param {boolean} [report.playing] - Absent from a page that does not say;
|
|
174
|
+
* then the viewer counts as playing, which is what every page meant before
|
|
175
|
+
* it could say otherwise.
|
|
176
|
+
* @param {number} [now]
|
|
177
|
+
*/
|
|
178
|
+
report({ linkMbps, bufferedAheadSec, positionSeconds = null, playing }, now = Date.now()) {
|
|
179
|
+
this.netReport = {
|
|
180
|
+
linkMbps,
|
|
181
|
+
bufferedAheadSec,
|
|
182
|
+
positionSeconds:
|
|
183
|
+
Number.isFinite(positionSeconds) && positionSeconds >= 0 ? positionSeconds : null,
|
|
184
|
+
at: now
|
|
185
|
+
};
|
|
186
|
+
this.bufferedSeconds = bufferedAheadSec;
|
|
187
|
+
this.playing = playing === undefined ? true : Boolean(playing);
|
|
188
|
+
if (Number.isFinite(positionSeconds) && positionSeconds >= 0) {
|
|
189
|
+
this.moveTo(/** @type {number} */ (positionSeconds), now);
|
|
190
|
+
}
|
|
191
|
+
this.seen(now);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* When this viewer runs out of what they hold, in milliseconds.
|
|
196
|
+
*
|
|
197
|
+
* Film is consumed at one second per second while the picture moves, so the
|
|
198
|
+
* moment they run dry is now plus what they hold. Stopped, they consume
|
|
199
|
+
* nothing and there is no such moment — which is why a pause needs no rule of
|
|
200
|
+
* its own anywhere: it falls out of this as an absent deadline.
|
|
201
|
+
*
|
|
202
|
+
* @param {number} [now]
|
|
203
|
+
* @returns {number | null}
|
|
204
|
+
*/
|
|
205
|
+
deadlineAt(now = Date.now()) {
|
|
206
|
+
if (!this.playing) {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
const held = Number.isFinite(this.bufferedSeconds) ? Math.max(0, this.bufferedSeconds) : 0;
|
|
210
|
+
return now + held * 1000;
|
|
128
211
|
}
|
|
129
212
|
|
|
130
213
|
/**
|