@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.
@@ -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({ maxRunsFor, makeRun, segmentSeconds, restartCostSec, lookaheadSegments = 0, logger, now }) {
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: Math.max(0, this.maxRunsFor(address)),
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
- // Not necessarily a failure: making an encoder can take a probe and a
252
- // keyframe read, and this call is arithmetic that must not wait on
253
- // either. Whoever builds one answers with nothing while it is on its way,
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 + this.lookaheadSegments));
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 windows = this.demand.windowsOn(address).map((w) => ({ from: w.from, to: w.to }));
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
- `viewers=${windows.length} runs=[${runs}] ` +
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
  /**