@torrent-tv/proxy 2.78.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 +19 -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 +38 -12
- package/services/encode/EncodeRun.js +70 -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 +99 -344
- package/services/orchestrators/EncodeOrchestrator.js +134 -17
- package/services/{encode/DemandMap.js → priority/PriorityMap.js} +99 -34
- package/services/viewer/Viewer.js +83 -0
- package/test/encode-orchestrator.test.js +36 -8
- package/test/encode-plan.test.js +67 -5
- package/test/one-authority.test.js +105 -0
- package/test/{demand-map.test.js → priority-map.test.js} +49 -12
|
@@ -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
|
}
|
|
@@ -178,6 +204,19 @@ export class EncodeOrchestrator {
|
|
|
178
204
|
for (const address of addresses) {
|
|
179
205
|
this.#reconcileOne(address);
|
|
180
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
|
+
}
|
|
181
220
|
}
|
|
182
221
|
|
|
183
222
|
/**
|
|
@@ -218,9 +257,17 @@ export class EncodeOrchestrator {
|
|
|
218
257
|
// each; what it hands back names the run by BEING it, so nothing has to
|
|
219
258
|
// invent a token to refer to one by.
|
|
220
259
|
runs: live,
|
|
221
|
-
maxRuns:
|
|
260
|
+
maxRuns: this.#affordableOn(address, live),
|
|
222
261
|
segmentSeconds: this.segmentSeconds,
|
|
223
|
-
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)
|
|
224
271
|
});
|
|
225
272
|
|
|
226
273
|
for (const action of actions) {
|
|
@@ -254,24 +301,68 @@ export class EncodeOrchestrator {
|
|
|
254
301
|
* @param {string} because
|
|
255
302
|
*/
|
|
256
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
|
+
//
|
|
257
316
|
// The run names itself: identity is a property of the thing, and two
|
|
258
317
|
// places minting names is how one stops being unique.
|
|
259
318
|
const run = this.makeRun({ address, from, to });
|
|
260
319
|
if (!run) {
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
|
|
264
|
-
// and the next pass sees it.
|
|
265
|
-
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}`);
|
|
266
323
|
return;
|
|
267
324
|
}
|
|
268
325
|
const onThisOutput = this.#runs.get(address) ?? [];
|
|
269
326
|
onThisOutput.push(run);
|
|
270
327
|
this.#runs.set(address, onThisOutput);
|
|
271
|
-
this.coverageOf(address).claim(run, from, to);
|
|
328
|
+
this.coverageOf(address).claim(run, from, endOfRun({ from, to }));
|
|
272
329
|
run.start(because);
|
|
273
330
|
}
|
|
274
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
|
+
|
|
275
366
|
/**
|
|
276
367
|
* Take charge of a run this class did not start.
|
|
277
368
|
*
|
|
@@ -326,8 +417,18 @@ export class EncodeOrchestrator {
|
|
|
326
417
|
coverage.claim(run, from, end);
|
|
327
418
|
return;
|
|
328
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.
|
|
329
430
|
const head = Number.isFinite(run?.head) ? run.head : from;
|
|
330
|
-
coverage.claim(run, from, Math.max(from, head
|
|
431
|
+
coverage.claim(run, from, Math.max(from, head));
|
|
331
432
|
}
|
|
332
433
|
|
|
333
434
|
/**
|
|
@@ -348,6 +449,15 @@ export class EncodeOrchestrator {
|
|
|
348
449
|
* @param {import("../encode/EncodeRun.js").RunEnded} ended
|
|
349
450
|
*/
|
|
350
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
|
+
}
|
|
351
461
|
this.coverageOf(ended.address).release(ended.run);
|
|
352
462
|
const remaining = this.runsOn(ended.address).filter((run) => run !== ended.run);
|
|
353
463
|
if (remaining.length === 0) {
|
|
@@ -388,14 +498,21 @@ export class EncodeOrchestrator {
|
|
|
388
498
|
const parts = [];
|
|
389
499
|
for (const address of new Set([...this.demand.addresses(), ...this.#runs.keys()])) {
|
|
390
500
|
const coverage = this.coverageOf(address);
|
|
391
|
-
const
|
|
501
|
+
const stated = this.demand.windowsOn(address);
|
|
502
|
+
const windows = stated.map((w) => ({ from: w.from, to: w.to }));
|
|
392
503
|
const waiting = firstUnmetWant(coverage, windows);
|
|
393
504
|
const runs = this.runsOn(address)
|
|
394
505
|
.map((run) => `#${run.head}..#${run.to}@${run.speedX.toFixed(1)}x`)
|
|
395
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(" ");
|
|
396
513
|
parts.push(
|
|
397
514
|
`${address.slice(0, 60)} ready=${coverage.stats().ready} ` +
|
|
398
|
-
`
|
|
515
|
+
`zones=[${zones}] runs=[${runs}] ` +
|
|
399
516
|
`waiting=${waiting === null ? "nobody" : `#${waiting}`}`
|
|
400
517
|
);
|
|
401
518
|
}
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @file
|
|
3
|
-
* order the work should be taken.
|
|
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.
|
|
4
9
|
*
|
|
5
10
|
* The shape, stated by the user 2026-09-05:
|
|
6
11
|
*
|
|
@@ -49,32 +54,44 @@
|
|
|
49
54
|
*/
|
|
50
55
|
|
|
51
56
|
/** Where the viewer stands. Nothing outranks it. */
|
|
52
|
-
const AT_THE_VIEWER =
|
|
57
|
+
const AT_THE_VIEWER = 33;
|
|
53
58
|
|
|
54
59
|
/** In front of them, within reach while they watch what is already made. */
|
|
55
|
-
const IN_FRONT =
|
|
60
|
+
const IN_FRONT = 32;
|
|
56
61
|
|
|
57
62
|
/** The rest of the track: wanted, because the file is encoded whole. */
|
|
58
63
|
const THE_REST = 1;
|
|
59
64
|
|
|
60
65
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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.
|
|
78
95
|
*
|
|
79
96
|
* @param {object} params
|
|
80
97
|
* @param {number} params.atSeconds - Where they are watching from.
|
|
@@ -93,28 +110,76 @@ export function mapForViewer({ atSeconds, durationSeconds, allowanceSeconds, enc
|
|
|
93
110
|
if (!(end > from)) {
|
|
94
111
|
return [];
|
|
95
112
|
}
|
|
96
|
-
const remaining = end - from;
|
|
97
113
|
const allowance = Number.isFinite(allowanceSeconds) && allowanceSeconds > 0 ? allowanceSeconds : 0;
|
|
98
114
|
const speed = Number.isFinite(encodeSpeedX) && encodeSpeedX > 0 ? encodeSpeedX : 0;
|
|
99
|
-
const shortfall = speed > 0 && speed < 1 ? remaining * (1 - speed) : 0;
|
|
100
115
|
|
|
101
116
|
/** @type {DemandZone[]} */
|
|
102
117
|
const zones = [];
|
|
103
|
-
const readyBy = Math.min(end, from + allowance + shortfall);
|
|
104
|
-
zones.push({ from, to: readyBy, priority: AT_THE_VIEWER });
|
|
105
118
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
+
}
|
|
112
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;
|
|
113
141
|
}
|
|
114
142
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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 });
|
|
118
183
|
}
|
|
119
184
|
return zones;
|
|
120
185
|
}
|
|
@@ -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
|
/**
|
|
@@ -45,6 +45,12 @@ function orchestrator({ maxRuns = 2 } = {}) {
|
|
|
45
45
|
maxRunsFor: () => maxRuns,
|
|
46
46
|
segmentSeconds: 4,
|
|
47
47
|
restartCostSec: 0.12,
|
|
48
|
+
// A host that has measured what the swarm charges to fetch a second of film
|
|
49
|
+
// again. Without it the drive-or-move comparison has only one side and the
|
|
50
|
+
// plan keeps the encoder rather than paying an unknown price — which is its
|
|
51
|
+
// own check in `encode-plan.test.js` rather than the shape every check here
|
|
52
|
+
// is written against.
|
|
53
|
+
refetchSecPerFilmSecond: () => 0.25,
|
|
48
54
|
now: () => 1000,
|
|
49
55
|
logger: { info: (line) => lines.push(line), warn: (line) => lines.push(line) },
|
|
50
56
|
makeRun: ({ address, from, to }) => {
|
|
@@ -195,22 +201,25 @@ test("nothing wanted anywhere is said plainly", () => {
|
|
|
195
201
|
assert.match(made.describe(), /nothing wanted/);
|
|
196
202
|
});
|
|
197
203
|
|
|
198
|
-
test("a run adopted with no end holds
|
|
204
|
+
test("a run adopted with no end holds what it has made, not the rest of the film", () => {
|
|
199
205
|
// A session's own encoder is handed to the plan rather than built by it, and
|
|
200
|
-
// it carries `to = -1` — no end
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
206
|
+
// it carries `to = -1` — no end, which means the film's length is not known.
|
|
207
|
+
// Claiming the film from there would leave a viewer further in with no
|
|
208
|
+
// encoder at all: they would wait for this run to encode its way to them,
|
|
209
|
+
// which on a long film is an hour. What it holds is what it has produced,
|
|
210
|
+
// which is a fact rather than a distance nobody measured.
|
|
205
211
|
const { made } = orchestrator({ maxRuns: 2 });
|
|
206
|
-
made.lookaheadSegments = 30;
|
|
207
212
|
const adopted = {
|
|
208
213
|
from: 0,
|
|
209
214
|
to: -1,
|
|
210
215
|
head: 3,
|
|
211
216
|
isAlive: true,
|
|
212
217
|
isStopping: false,
|
|
213
|
-
|
|
218
|
+
// Slow enough that the swarm is not what limits the count here: at 0.25
|
|
219
|
+
// seconds of swarm time per second of film, one encoder at 1x takes a
|
|
220
|
+
// quarter of what is delivered and four may run. The swarm's own limit has
|
|
221
|
+
// its own check below.
|
|
222
|
+
speedX: 1,
|
|
214
223
|
stop() {
|
|
215
224
|
this.isAlive = false;
|
|
216
225
|
}
|
|
@@ -225,3 +234,22 @@ test("a run adopted with no end holds the look-ahead, not the rest of the film",
|
|
|
225
234
|
assert.ok(adopted.isAlive, "and the adopted run was not stopped to make room");
|
|
226
235
|
assert.equal(runs.some((run) => run.from === 200), true, "started where that viewer is waiting");
|
|
227
236
|
});
|
|
237
|
+
|
|
238
|
+
test("the swarm limits the encoders, whatever the processor allows", () => {
|
|
239
|
+
// Every encoder reads the same torrent, so together they cannot consume
|
|
240
|
+
// faster than it is delivered. At 0.25 seconds of swarm time per second of
|
|
241
|
+
// film, one encoder running at 8x takes twice everything there is — so a
|
|
242
|
+
// machine whose processor would allow two gets one.
|
|
243
|
+
const { made, lines } = orchestrator({ maxRuns: 2 });
|
|
244
|
+
made.want({ claimant: "one", address: PICTURE, from: 100, to: 130 });
|
|
245
|
+
made.reconcile();
|
|
246
|
+
made.runsOn(PICTURE)[0].noteSpeed(8);
|
|
247
|
+
made.want({ claimant: "two", address: PICTURE, from: 500, to: 530 });
|
|
248
|
+
made.reconcile();
|
|
249
|
+
|
|
250
|
+
assert.equal(made.runsOn(PICTURE).length, 1);
|
|
251
|
+
assert.ok(
|
|
252
|
+
lines.some((line) => line.includes("what the swarm delivers")),
|
|
253
|
+
"and the line says which limit decided it"
|
|
254
|
+
);
|
|
255
|
+
});
|
package/test/encode-plan.test.js
CHANGED
|
@@ -14,7 +14,19 @@ import { endOfRun } from "../services/encode/EncodeRun.js";
|
|
|
14
14
|
import { firstUnmetWant, planEncoders } from "../services/encode/EncodePlan.js";
|
|
15
15
|
|
|
16
16
|
/** A host that can afford two encoders, four-second segments, a cheap restart. */
|
|
17
|
-
|
|
17
|
+
// A host that has measured itself: the start and the death from its own runs,
|
|
18
|
+
// and what the swarm charges to fetch a second of film again. All four terms
|
|
19
|
+
// have to be present for the drive-or-move comparison to mean anything, and a
|
|
20
|
+
// host missing any of them keeps its encoders instead — which is its own check
|
|
21
|
+
// below rather than the shape every other check is written against.
|
|
22
|
+
const HOST = {
|
|
23
|
+
maxRuns: 2,
|
|
24
|
+
segmentSeconds: 4,
|
|
25
|
+
restartCostSec: 0.12,
|
|
26
|
+
killCostSec: 0.5,
|
|
27
|
+
firstByteWaitSec: 1,
|
|
28
|
+
refetchSecPerFilmSecond: 0.25
|
|
29
|
+
};
|
|
18
30
|
|
|
19
31
|
/**
|
|
20
32
|
* @param {Partial<import("../services/encode/EncodePlan.js").LiveRun>} run
|
|
@@ -121,9 +133,13 @@ test("a covered stretch shorter than a restart is driven through instead", () =>
|
|
|
121
133
|
assert.ok(actions.some((action) => action.type === "keep" && action.run === runA));
|
|
122
134
|
});
|
|
123
135
|
|
|
124
|
-
test("a run whose speed nothing has measured is
|
|
125
|
-
//
|
|
126
|
-
//
|
|
136
|
+
test("a run whose speed nothing has measured is kept, not taken away", () => {
|
|
137
|
+
// Moving costs a known amount for an unknown gain, and a run nothing has
|
|
138
|
+
// measured has produced nothing yet — so taking its work away is certainly a
|
|
139
|
+
// loss and the comparison cannot be made. It used to answer the other way,
|
|
140
|
+
// and then every just-started run was moved the moment anything ahead of it
|
|
141
|
+
// was covered, which, once the film ahead had been made, was always: 684
|
|
142
|
+
// starts in 482 seconds in the field on 2026-09-05.
|
|
127
143
|
const coverage = new CoverageMap({ segmentCount: 100 });
|
|
128
144
|
const runA = run({ head: 10, speedX: 0 });
|
|
129
145
|
coverage.claim(runA, 0, 100);
|
|
@@ -134,9 +150,55 @@ test("a run whose speed nothing has measured is moved rather than left driving t
|
|
|
134
150
|
runs: [runA],
|
|
135
151
|
...HOST
|
|
136
152
|
});
|
|
153
|
+
assert.equal(actions.some((action) => action.type === "move"), false);
|
|
154
|
+
assert.ok(actions.some((action) => action.type === "keep" && action.run === runA));
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("both sides of the move are counted, not just the encoder's own time", () => {
|
|
158
|
+
// Driving through costs this run's encode time AND the swarm the same bytes a
|
|
159
|
+
// second time; moving costs the death, the start and the wait for the first
|
|
160
|
+
// bytes. Here driving is dear enough to lose: 20 covered segments of 4 s at
|
|
161
|
+
// 1x is 80 s of encoding, against a move priced at 0.12 + 0.5 + 3 seconds.
|
|
162
|
+
const coverage = new CoverageMap({ segmentCount: 200 });
|
|
163
|
+
const runA = run({ head: 10, speedX: 1 });
|
|
164
|
+
coverage.claim(runA, 0, 200);
|
|
165
|
+
for (let at = 10; at < 30; at += 1) {
|
|
166
|
+
coverage.markReady(at);
|
|
167
|
+
}
|
|
168
|
+
const actions = planEncoders({
|
|
169
|
+
coverage,
|
|
170
|
+
windows: [{ from: 0, to: 190 }],
|
|
171
|
+
runs: [runA],
|
|
172
|
+
...HOST,
|
|
173
|
+
killCostSec: 0.5,
|
|
174
|
+
firstByteWaitSec: 3,
|
|
175
|
+
refetchSecPerFilmSecond: 0.25
|
|
176
|
+
});
|
|
137
177
|
const move = actions.find((action) => action.type === "move");
|
|
138
178
|
assert.ok(move);
|
|
139
|
-
assert.match(move.because, /
|
|
179
|
+
assert.match(move.because, /refetch 20\.00s/);
|
|
180
|
+
assert.match(move.because, /against 3\.62s to move/);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("a short covered stretch is driven through rather than paid a restart for", () => {
|
|
184
|
+
// One covered segment at 1x is 4 s of encoding plus 1 s of refetch, against a
|
|
185
|
+
// move priced at 0.12 + 0.5 + 30 seconds on a host where the first bytes are
|
|
186
|
+
// slow to come. The comparison, not a rule, decides it.
|
|
187
|
+
const coverage = new CoverageMap({ segmentCount: 200 });
|
|
188
|
+
const runA = run({ head: 10, speedX: 1 });
|
|
189
|
+
coverage.claim(runA, 0, 200);
|
|
190
|
+
coverage.markReady(10);
|
|
191
|
+
const actions = planEncoders({
|
|
192
|
+
coverage,
|
|
193
|
+
windows: [{ from: 0, to: 190 }],
|
|
194
|
+
runs: [runA],
|
|
195
|
+
...HOST,
|
|
196
|
+
killCostSec: 0.5,
|
|
197
|
+
firstByteWaitSec: 30,
|
|
198
|
+
refetchSecPerFilmSecond: 0.25
|
|
199
|
+
});
|
|
200
|
+
assert.equal(actions.some((action) => action.type === "move"), false);
|
|
201
|
+
assert.ok(actions.some((action) => action.type === "keep" && action.run === runA));
|
|
140
202
|
});
|
|
141
203
|
|
|
142
204
|
test("a run with nothing left to make ahead of it is stopped", () => {
|