@torrent-tv/proxy 2.80.4 → 2.80.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/package.json +1 -1
  3. package/routes/api/delivery-sink/get.js +8 -4
  4. package/server.js +415 -403
  5. package/services/data-channel-handler.js +87 -25
  6. package/services/delivery-probe.js +38 -5
  7. package/services/encode/CoverageMap.js +77 -4
  8. package/services/encode/EncodePlan.js +1025 -358
  9. package/services/encode/EncodeRun.js +42 -22
  10. package/services/encode/SegmentDemand.js +0 -0
  11. package/services/encode/SegmentStore.js +55 -3
  12. package/services/encode/open-piece.js +47 -24
  13. package/services/encode/run-command.js +12 -2
  14. package/services/hls-session-manager.js +38 -158
  15. package/services/hwaccel.js +182 -54
  16. package/services/orchestrators/EncodeOrchestrator.js +123 -94
  17. package/services/output/LiveOutputs.js +233 -213
  18. package/services/output/Timeline.js +333 -256
  19. package/services/priority/PriorityMap.js +262 -108
  20. package/services/priority/PriorityOrchestrator.js +31 -6
  21. package/services/quality/EncodeCost.js +555 -500
  22. package/services/torrent-pool.js +9 -4
  23. package/test/encode-orchestrator.test.js +195 -65
  24. package/test/encode-plan-viewers.test.js +719 -0
  25. package/test/encode-plan.test.js +174 -81
  26. package/test/open-piece.test.js +152 -0
  27. package/test/output-speed.test.js +86 -0
  28. package/test/priority-map-download.test.js +25 -7
  29. package/test/priority-map.test.js +134 -83
  30. package/test/seek-landing.test.js +109 -76
  31. package/test/segment-demand.test.js +54 -56
  32. package/test/wedge-certainty.test.js +3 -3
  33. package/test/flushed-piece.test.js +0 -108
@@ -21,7 +21,7 @@
21
21
  */
22
22
 
23
23
  import { spawn } from "node:child_process";
24
- import { mkdtempSync, readdirSync, rmSync, statSync } from "node:fs";
24
+ import { mkdtempSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
25
25
  import { mkdtemp, readFile, rm } from "node:fs/promises";
26
26
  import os from "node:os";
27
27
  import path from "node:path";
@@ -35,25 +35,25 @@ import {
35
35
  parseFfmpegVideoFps
36
36
  } from "./ffmpeg-banner.js";
37
37
 
38
- import { keyFrameArgs, SOFTWARE_CRF, TRANSCODE_FPS } from "./encode/args.js";
39
- // The five kinds, one class each. Detection and benchmarking stay in this file;
40
- // how a kind is driven belongs to the kind.
41
- import {
42
- NvencEncoder,
43
- QsvEncoder,
44
- SoftwareEncoder,
45
- V4l2m2mEncoder,
46
- VaapiEncoder
47
- } from "./encode/index.js";
48
- // Re-exported so every caller goes on importing these figures from here:
49
- // the same calculation, moved to sit beside the encoder kinds built from it.
50
- export {
51
- chooseOutputFps,
52
- maxrateKbpsFor,
53
- nominalKbpsForHeight,
54
- nominalKbpsForMaxrate,
55
- TRANSCODE_FPS
56
- } from "./encode/args.js";
38
+ import { keyFrameArgs, SOFTWARE_CRF, TRANSCODE_FPS } from "./encode/args.js";
39
+ // The five kinds, one class each. Detection and benchmarking stay in this file;
40
+ // how a kind is driven belongs to the kind.
41
+ import {
42
+ NvencEncoder,
43
+ QsvEncoder,
44
+ SoftwareEncoder,
45
+ V4l2m2mEncoder,
46
+ VaapiEncoder
47
+ } from "./encode/index.js";
48
+ // Re-exported so every caller goes on importing these figures from here:
49
+ // the same calculation, moved to sit beside the encoder kinds built from it.
50
+ export {
51
+ chooseOutputFps,
52
+ maxrateKbpsFor,
53
+ nominalKbpsForHeight,
54
+ nominalKbpsForMaxrate,
55
+ TRANSCODE_FPS
56
+ } from "./encode/args.js";
57
57
 
58
58
  // libx264 presets to benchmark, ordered slowest/highest-quality → fastest.
59
59
  const BENCHMARK_PRESETS = ["fast", "faster", "veryfast", "superfast", "ultrafast"];
@@ -78,6 +78,15 @@ const ENCODE_BENCHMARK_MAX_PLAUSIBLE_SPEED = 1000;
78
78
  * arrive twice a second whatever the encoding speed.
79
79
  */
80
80
  const ENCODE_BENCHMARK_TIMEOUT_MS = 10_000;
81
+ /**
82
+ * How many times the calibration clip is joined to itself to measure copying.
83
+ *
84
+ * Not a figure about the machine: it is how much film the reading needs to have
85
+ * in front of it. A copy runs at hundreds of times realtime, and the slope is
86
+ * taken over a window of one second, so the input has to hold more film than the
87
+ * fastest plausible host gets through in that second. Forty laps of a five-second
88
+ * clip is 200 s of film, which covers the ceiling `slopeOf` will accept.
89
+ */
81
90
  /** Progress reports arrive line by line. */
82
91
  const NEWLINE = String.fromCharCode(10);
83
92
  // Producing one second of video per second of clock. Not a margin and not a
@@ -94,40 +103,40 @@ const REALTIME = 1;
94
103
  const UNPRICED_DECODE_BAR = 1.8;
95
104
 
96
105
 
97
- // The five kinds live in `encode/`, one class each, and these keep the names
98
- // every caller already uses. A kind states its own arguments and its own
99
- // ladder of speed settings; detection and benchmarking stay here.
100
- /** @returns {import("./hwaccel.js").VideoEncoderDescriptor} */
101
- export function softwareDescriptor() {
102
- return new SoftwareEncoder();
103
- }
104
-
105
- /**
106
- * @param {string} device
107
- * @returns {import("./hwaccel.js").VideoEncoderDescriptor}
108
- */
109
- function vaapiDescriptor(device) {
110
- return new VaapiEncoder(device);
111
- }
112
-
113
- /**
114
- * @param {string} device
115
- * @returns {import("./hwaccel.js").VideoEncoderDescriptor}
116
- */
117
- function qsvDescriptor(device) {
118
- return new QsvEncoder(device);
119
- }
120
-
121
- /** @returns {import("./hwaccel.js").VideoEncoderDescriptor} */
122
- function nvencDescriptor() {
123
- return new NvencEncoder();
124
- }
125
-
126
- /** @returns {import("./hwaccel.js").VideoEncoderDescriptor} */
127
- function v4l2m2mDescriptor() {
128
- return new V4l2m2mEncoder();
129
- }
130
-
106
+ // The five kinds live in `encode/`, one class each, and these keep the names
107
+ // every caller already uses. A kind states its own arguments and its own
108
+ // ladder of speed settings; detection and benchmarking stay here.
109
+ /** @returns {import("./hwaccel.js").VideoEncoderDescriptor} */
110
+ export function softwareDescriptor() {
111
+ return new SoftwareEncoder();
112
+ }
113
+
114
+ /**
115
+ * @param {string} device
116
+ * @returns {import("./hwaccel.js").VideoEncoderDescriptor}
117
+ */
118
+ function vaapiDescriptor(device) {
119
+ return new VaapiEncoder(device);
120
+ }
121
+
122
+ /**
123
+ * @param {string} device
124
+ * @returns {import("./hwaccel.js").VideoEncoderDescriptor}
125
+ */
126
+ function qsvDescriptor(device) {
127
+ return new QsvEncoder(device);
128
+ }
129
+
130
+ /** @returns {import("./hwaccel.js").VideoEncoderDescriptor} */
131
+ function nvencDescriptor() {
132
+ return new NvencEncoder();
133
+ }
134
+
135
+ /** @returns {import("./hwaccel.js").VideoEncoderDescriptor} */
136
+ function v4l2m2mDescriptor() {
137
+ return new V4l2m2mEncoder();
138
+ }
139
+
131
140
 
132
141
 
133
142
  /**
@@ -1713,3 +1722,122 @@ export function chooseSoftwareEncodeSettings(benchmark, ceiling, outputFps, cost
1713
1722
  const preset = pickSoftwarePreset(benchmark, chosen.width * chosen.height * fps, cost);
1714
1723
  return { width: chosen.width, height: chosen.height, preset, ladder, rungIndex: chosenIndex };
1715
1724
  }
1725
+
1726
+ /**
1727
+ * How fast this machine COPIES a picture, in seconds of film per second.
1728
+ *
1729
+ * The startup measurements price encoding and decoding, and a copied picture
1730
+ * does neither: it reads packets and writes them out again. That left one whole
1731
+ * branch of what this proxy does with no figure at all, and a figure is what
1732
+ * every decision in the encoding layer is made from — where to put an encoder,
1733
+ * how many to run, whether anybody will be left waiting. Without it a copied
1734
+ * output was planned with no speed until its own run had been running long
1735
+ * enough to report one, which is exactly the moment the plan matters most.
1736
+ *
1737
+ * Measured the same way as the others: ffmpeg's own progress, read as a slope
1738
+ * over a window, so the process starting is outside the figure.
1739
+ *
1740
+ * The clip is joined to itself first rather than looped with `-stream_loop`.
1741
+ * Looping charges a re-initialisation per lap — measured on the addon host at
1742
+ * 0.03 s for 480p and 0.12 s for 1080p — and a copy of a five-second clip laps
1743
+ * many times a second, so the reading would have been mostly re-initialisation.
1744
+ *
1745
+ * @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void }, clipsDir?: string }} params
1746
+ * @returns {Promise<number | null>} Seconds of film per second, or null where
1747
+ * the reading could not be taken. Null means unmeasured and is never a
1748
+ * substitute for a number.
1749
+ */
1750
+ export async function benchmarkCopySpeed({ ffmpegBin, logger, clipsDir = CALIBRATION_DIR }) {
1751
+ const log = logger ?? { info: () => {}, warn: () => {} };
1752
+ const startedAt = Date.now();
1753
+ // The largest clip in the set. A copy moves BYTES, so what it can do is a
1754
+ // statement about the biggest pictures this host will be asked to pass
1755
+ // through, and the small ones are covered by the same figure.
1756
+ const clip = path.join(clipsDir, "cal-h264-1080-hi.mp4");
1757
+ const speed = await measureCopySlope(ffmpegBin, clip);
1758
+ if (!(speed > 0)) {
1759
+ log.warn("hwaccel: copying could not be measured; a copied picture will be planned from its own run instead");
1760
+ return null;
1761
+ }
1762
+ log.info(
1763
+ `hwaccel: this host copies a picture at ${speed.toFixed(0)}x realtime ` +
1764
+ `(measured in ${((Date.now() - startedAt) / 1000).toFixed(1)}s)`
1765
+ );
1766
+ return speed;
1767
+ }
1768
+
1769
+ /**
1770
+ * Seconds of film per second, copying one file, read from ffmpeg's progress.
1771
+ *
1772
+ * @param {string} ffmpegBin
1773
+ * @param {string} filePath
1774
+ * @returns {Promise<number | null>}
1775
+ */
1776
+ function measureCopySlope(ffmpegBin, filePath) {
1777
+ return new Promise((resolve) => {
1778
+ const args = [
1779
+ "-hide_banner", "-loglevel", "error", "-nostats",
1780
+ // Played over and over, because a copy gets through a five-second clip in
1781
+ // milliseconds and a slope needs a window to be taken over. Looping
1782
+ // charges the demuxer being re-opened once a lap, so what comes out is a
1783
+ // FLOOR on what this host can copy — the safe direction, since a plan made
1784
+ // from it expects copying to be slower than it is.
1785
+ "-stream_loop", "-1", "-i", filePath,
1786
+ // What a copied output does: packets in, packets out, nothing decoded and
1787
+ // nothing encoded. Written nowhere, so the figure is this machine's own
1788
+ // handling and not the disk under a temp directory.
1789
+ "-c", "copy", "-f", "null", "-",
1790
+ // Progress is reported every half second by default, which over a window
1791
+ // of one second is two readings. This asks for twenty.
1792
+ "-stats_period", "0.05",
1793
+ "-progress", "pipe:1"
1794
+ ];
1795
+ /** @type {Array<{ wallSec: number, outSec: number }>} */
1796
+ const samples = [];
1797
+ let settled = false;
1798
+ let buffered = "";
1799
+ let child;
1800
+ const startedAt = Date.now();
1801
+ const finish = (value) => {
1802
+ if (settled) {
1803
+ return;
1804
+ }
1805
+ settled = true;
1806
+ clearTimeout(timer);
1807
+ try {
1808
+ child?.kill("SIGKILL");
1809
+ } catch {
1810
+ // already gone
1811
+ }
1812
+ resolve(value);
1813
+ };
1814
+ const timer = setTimeout(() => finish(null), ENCODE_BENCHMARK_TIMEOUT_MS);
1815
+ try {
1816
+ child = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "ignore"], windowsHide: true });
1817
+ } catch {
1818
+ finish(null);
1819
+ return;
1820
+ }
1821
+ child.stdout.on("data", (chunk) => {
1822
+ buffered += String(chunk);
1823
+ let newline = buffered.indexOf(NEWLINE);
1824
+ while (newline >= 0) {
1825
+ const line = buffered.slice(0, newline).trim();
1826
+ buffered = buffered.slice(newline + 1);
1827
+ if (line.startsWith("out_time_ms=")) {
1828
+ const outSec = Number(line.slice("out_time_ms=".length)) / 1e6;
1829
+ if (Number.isFinite(outSec) && outSec >= 0) {
1830
+ samples.push({ wallSec: (Date.now() - startedAt) / 1000, outSec });
1831
+ }
1832
+ }
1833
+ newline = buffered.indexOf(NEWLINE);
1834
+ }
1835
+ const slope = slopeOf(samples);
1836
+ if (slope !== null) {
1837
+ finish(slope);
1838
+ }
1839
+ });
1840
+ child.on("error", () => finish(null));
1841
+ child.on("exit", () => finish(slopeOf(samples, ENCODE_BENCHMARK_MIN_WINDOW_SEC)));
1842
+ });
1843
+ }
@@ -27,9 +27,9 @@ 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 { mergeMaps } from "../priority/PriorityMap.js";
31
30
  import { affordableRuns } from "../encode/run-budget.js";
32
31
  import { RunCosts } from "../encode/run-costs.js";
32
+ import { contentionPenalty } from "../contention.js";
33
33
  import { SegmentDemand } from "../encode/SegmentDemand.js";
34
34
 
35
35
  export class EncodeOrchestrator {
@@ -40,6 +40,9 @@ export class EncodeOrchestrator {
40
40
  #runs = new Map();
41
41
 
42
42
 
43
+ /** The fastest speed measured on one output, kept across restarts. @type {Map<string, number>} */
44
+ #lastSpeed = new Map();
45
+
43
46
  /** How runs have ended, by cause. @type {Map<string, number>} */
44
47
  #endings = new Map();
45
48
 
@@ -62,7 +65,12 @@ export class EncodeOrchestrator {
62
65
  * for a stretch. What to read, what to map and how to cut belong to whoever
63
66
  * knows the source.
64
67
  * @param {number} params.segmentSeconds
65
- * @param {number} params.restartCostSec - Measured: 0.12 s on the addon host.
68
+ * @param {import("../contention.js").ContentionPenalties | null}
69
+ * [params.contentionPenalties] - How much slower one encoder runs beside
70
+ * others, MEASURED on this host at startup and keyed by how many others
71
+ * there are. Null until something has measured it, and then the penalty is
72
+ * 1 — a number invented here would be the same mistake as an invented
73
+ * encoding speed.
66
74
  * @param {{ info: (line: string) => void, warn: (line: string) => void }} params.logger
67
75
  * @param {() => number} [params.now]
68
76
  */
@@ -70,8 +78,9 @@ export class EncodeOrchestrator {
70
78
  maxRunsFor,
71
79
  makeRun,
72
80
  segmentSeconds,
73
- restartCostSec,
81
+ contentionPenalties = null,
74
82
  refetchSecPerFilmSecond = () => 0,
83
+ startingSpeedFor = () => 0,
75
84
  segmentStore = null,
76
85
  logger,
77
86
  now
@@ -88,9 +97,16 @@ export class EncodeOrchestrator {
88
97
  // Injected, because the film's byte rate and the swarm's are measured
89
98
  // elsewhere and this class must not reach for them.
90
99
  this.refetchSecPerFilmSecond = refetchSecPerFilmSecond;
100
+ // Measured per host: what a second encoder costs the first. Unmeasured is 1,
101
+ // and then only the budget bounds how many there are.
102
+ this.contentionPenalties = contentionPenalties instanceof Map ? contentionPenalties : null;
103
+ // WHAT THIS HOST ENCODES AT BEFORE ANY RUN HAS REPORTED. The startup
104
+ // benchmark measures it — a real pipeline over real clips, before a viewer
105
+ // exists — so the plan is never asked to compare arrivals with no speed to
106
+ // compute them from. Every run that then works refines it.
107
+ this.startingSpeedFor = startingSpeedFor;
91
108
  this.makeRun = makeRun;
92
109
  this.segmentSeconds = segmentSeconds;
93
- this.restartCostSec = restartCostSec;
94
110
  this.logger = logger;
95
111
  this.now = typeof now === "function" ? now : Date.now;
96
112
  }
@@ -154,17 +170,27 @@ export class EncodeOrchestrator {
154
170
  * takes them in this order. Absent means one undifferentiated want, which
155
171
  * is what a caller that knows only a position states.
156
172
  */
157
- want({ claimant, address, from, to, priority = 0 }) {
158
- this.demand.state({ claimant, address, from, to, priority, statedAt: this.now() });
159
- }
160
-
161
173
  /**
162
- * A viewer has gone.
174
+ * What is wanted of one output, in its own segment numbers.
175
+ *
176
+ * ONE MAP, ALREADY MERGED, AND WITH NOBODY'S NAME ON IT. It is built once per
177
+ * film by the layer that knows where the viewers are; this layer receives it
178
+ * converted into an output's own numbering and never asks who is in it.
179
+ *
180
+ * That replaced a window per viewer per band stated here and merged here,
181
+ * which was the same work done twice in two layers, with the viewer's name as
182
+ * the key of a claim — against the rule that the encoding and the viewer are
183
+ * not connected at all.
184
+ *
185
+ * An empty map says nobody is coming anywhere in this output, and the plan
186
+ * stops its encoders for it. Nothing has to be released when somebody leaves:
187
+ * the map that arrives next simply does not have them in it.
163
188
  *
164
- * @param {string} claimant
189
+ * @param {string} address
190
+ * @param {{ from: number, to: number, priority: number, withinSeconds: number }[]} zones
165
191
  */
166
- release(claimant) {
167
- this.demand.forget(claimant);
192
+ notePriorityMap(address, zones) {
193
+ this.demand.state(address, zones);
168
194
  }
169
195
 
170
196
  /**
@@ -191,6 +217,14 @@ export class EncodeOrchestrator {
191
217
  run.noteSpeed(speedX);
192
218
  }
193
219
  }
220
+ // HOW FAST THIS MACHINE ENCODES THIS OUTPUT is a property of the machine and
221
+ // the material, not of one process. Read off `run.speedX` alone it was lost
222
+ // at every restart: a moved encoder is a new object that has measured
223
+ // nothing, so the plan fell back to "nothing is known" and stopped comparing
224
+ // arrivals at all — which is every decision in this layer.
225
+ if (speedX > 0 && speedX > (this.#lastSpeed.get(address) ?? 0)) {
226
+ this.#lastSpeed.set(address, speedX);
227
+ }
194
228
  }
195
229
 
196
230
  /**
@@ -241,32 +275,20 @@ export class EncodeOrchestrator {
241
275
  });
242
276
  }
243
277
  }
244
- // Carrying the priority through, because the filling takes the work in that
245
- // order: what a viewer must have before they set off comes before what is
246
- // merely in front of them, which comes before the rest of the track. Passed
247
- // as a plain number so the plan stays arithmetic.
248
278
  // ONE MAP, NOT ONE WINDOW PER VIEWER PER ZONE.
249
279
  //
250
- // Two viewers a few seconds apart state stretches that overlap, and the
251
- // plan puts one encoder on each stretch it is given — so unmerged windows
252
- // buy an encoder per viewer for film they both want, which is the opposite
253
- // of what sharing the output is for. Merged, the highest number per segment
254
- // wins and the stretches do not overlap, so one encoder serves everyone
255
- // standing in front of it.
256
- const windows = mergeMaps([
257
- this.demand.windowsOn(address).map((window) => ({
258
- from: window.from,
259
- // Half-open on the way in and back again: these are whole segment
260
- // numbers, and #10..#20 next to #21..#30 must not be read as touching
261
- // at 20 and 21 at once.
262
- to: window.to + 1,
263
- // A window stated without a number is still a want — one
264
- // undifferentiated want, which is what a caller that knows only a
265
- // position states. Zero would read as "nothing wanted here" and the
266
- // merge would drop it.
267
- priority: Number(window.priority) || 1
268
- }))
269
- ]).map((zone) => ({ from: zone.from, to: zone.to - 1, priority: zone.priority }));
280
+ // Two viewers a few seconds apart state stretches that overlap, and the plan
281
+ // puts one encoder on each stretch it is given — so unmerged windows buy an
282
+ // encoder per viewer for film they both want, which is the opposite of what
283
+ // sharing the output is for. Merged, the highest rank and the soonest time
284
+ // per number win and the stretches do not overlap, so one encoder serves
285
+ // everyone standing in front of it.
286
+ //
287
+ // Asked of the register, which is the thing that holds the windows. This
288
+ // used to reach into the layer that STATES them for the same arithmetic,
289
+ // which is the coupling the layer rule forbids; the arithmetic itself now
290
+ // lives where it belongs to nobody.
291
+ const windows = this.demand.mapOn(address);
270
292
  const live = this.runsOn(address).filter((run) => run.isAlive);
271
293
  const actions = planEncoders({
272
294
  coverage,
@@ -277,15 +299,29 @@ export class EncodeOrchestrator {
277
299
  runs: live,
278
300
  maxRuns: this.#affordableOn(address, live),
279
301
  segmentSeconds: this.segmentSeconds,
280
- restartCostSec: this.restartCostSec,
281
- // Measured from this host's own runs, rather than written into the code
282
- // from one machine's reading.
302
+ // What a start and a kill cost, measured from this host's own runs rather
303
+ // than written into the code from one machine's reading. Zero until
304
+ // something has been measured, which is the same convention as the
305
+ // refetch price below and is stated so the bias is known.
283
306
  ...this.#costs.seconds(),
284
307
  // What a second of film costs to fetch again, in seconds of swarm time.
285
308
  // Answered by whoever measures the film's own byte rate and the swarm's;
286
309
  // zero until they have, which makes driving through look cheaper than it
287
310
  // is and is stated here so the bias is known.
288
- refetchSecPerFilmSecond: this.refetchSecPerFilmSecond(address)
311
+ refetchSecPerFilmSecond: this.refetchSecPerFilmSecond(address),
312
+ // How much slower one encoder runs beside others, read off this host's own
313
+ // startup measurement. A pure function over a measured table: beyond what
314
+ // was measured it holds the largest reading rather than continuing a curve
315
+ // nothing observed.
316
+ contentionPenaltyFor: (others) => contentionPenalty(others, this.contentionPenalties).penalty,
317
+ // The best figure this host has: what a run here is doing now, what one
318
+ // was last measured doing, or what the startup benchmark predicted. The
319
+ // first two are this output's own; the third exists before either.
320
+ speedX: Math.max(
321
+ live.reduce((best, run) => Math.max(best, run.speedX || 0), 0),
322
+ this.#lastSpeed.get(address) ?? 0,
323
+ this.startingSpeedFor(address) || 0
324
+ )
289
325
  });
290
326
 
291
327
  for (const action of actions) {
@@ -308,7 +344,22 @@ export class EncodeOrchestrator {
308
344
  }
309
345
  // A run that stays keeps its claim current: the free stretch ahead of it
310
346
  // may have shrunk since it was given one.
311
- this.#claimFor(coverage, action.run, action.from, action.to);
347
+ //
348
+ // THE CLAIM IS THE STRETCH IT WAS GIVEN, and there is one rule for that
349
+ // everywhere. It used to be narrowed here to what the run had already
350
+ // MADE whenever the run had no end, which meant a run claimed the single
351
+ // number it was writing and nothing beyond. The plan then read the road
352
+ // in front of a working encoder as free and started more encoders on it:
353
+ // three processes writing one directory with the same names, field
354
+ // 2026-09-06, and a piece of the film lost for good when the first of
355
+ // them was cleaned up after.
356
+ //
357
+ // The worry that narrowing was written for is real and is answered where
358
+ // it belongs — a viewer opening the same film further in must not find
359
+ // every number taken. That is the plan's business, and the plan can take
360
+ // road away from a run that has no end, because such a run carries no
361
+ // `-to` and simply stops when its head meets somebody else's claim.
362
+ coverage.claim(action.run, action.from, endOfRun({ from: action.from, to: action.to }));
312
363
  }
313
364
  }
314
365
 
@@ -340,6 +391,14 @@ export class EncodeOrchestrator {
340
391
  this.logger.warn(`encode: no encoder could be made for #${from}..#${to} of ${address}`);
341
392
  return;
342
393
  }
394
+ // What this machine has been measured to do on this output, carried over.
395
+ // A restart does not make the machine slower, and without this every moved
396
+ // encoder began as one whose speed nothing had measured — which the plan
397
+ // reads as "no arrival can be computed" and answers by comparing nothing.
398
+ const known = this.#lastSpeed.get(address) ?? 0;
399
+ if (known > 0) {
400
+ run.noteSpeed(known);
401
+ }
343
402
  const onThisOutput = this.#runs.get(address) ?? [];
344
403
  onThisOutput.push(run);
345
404
  this.#runs.set(address, onThisOutput);
@@ -369,11 +428,24 @@ export class EncodeOrchestrator {
369
428
  */
370
429
  #affordableOn(address, live) {
371
430
  const byProcessor = Math.max(0, this.maxRunsFor(address));
372
- const fastest = live.reduce((best, run) => Math.max(best, run.speedX || 0), 0);
431
+ // The best figure this host has: what a run here is doing now, what one was
432
+ // last measured doing, or what the startup benchmark predicted. The first
433
+ // two are this output's own; the third exists before either, so the budget
434
+ // is never asked to price encoders at a speed of zero.
435
+ const fastest = Math.max(
436
+ live.reduce((best, run) => Math.max(best, run.speedX || 0), 0),
437
+ this.#lastSpeed.get(address) ?? 0,
438
+ this.startingSpeedFor(address) || 0
439
+ );
373
440
  const budget = affordableRuns({
374
441
  byProcessor,
375
442
  speedX: fastest,
376
- refetchSecPerFilmSecond: this.refetchSecPerFilmSecond(address)
443
+ refetchSecPerFilmSecond: this.refetchSecPerFilmSecond(address),
444
+ // How much slower one encoder runs beside others, read off this host's own
445
+ // startup measurement. A pure function over a measured table: beyond what
446
+ // was measured it holds the largest reading rather than continuing a curve
447
+ // nothing observed.
448
+ contentionPenaltyFor: (others) => contentionPenalty(others, this.contentionPenalties).penalty
377
449
  });
378
450
  if (budget.runs !== byProcessor && budget.because !== this.#lastBudgetReason.get(address)) {
379
451
  this.#lastBudgetReason.set(address, budget.because);
@@ -407,50 +479,7 @@ export class EncodeOrchestrator {
407
479
  }
408
480
  onThisOutput.push(run);
409
481
  this.#runs.set(address, onThisOutput);
410
- this.#claimFor(this.coverageOf(address), run, run.from, run.to);
411
- }
412
-
413
- /**
414
- * What a run holds, as far as the map is concerned.
415
- *
416
- * A run given an end holds exactly that stretch. A run given NO end — `to`
417
- * below `from`, which is how this is written everywhere here — would hold the
418
- * rest of the film, and that is what must not be claimed: a second viewer
419
- * opening the same film further in would find every number taken and get no
420
- * encoder at all, waiting instead for the first run to encode its way there,
421
- * which on a long film is an hour.
422
- *
423
- * What bounds it in practice is the look-ahead: a run is suspended once it is
424
- * that far in front of the segment its viewer last asked for, and past that it
425
- * produces nothing until somebody asks. So that is the honest extent of the
426
- * claim, and it is a measured figure rather than a chosen one — the same
427
- * allowance the browser sizes its cushion from. `planRunInterval` has applied
428
- * this rule since runs got intervals; this path did not, which is how an
429
- * encoder came to be started and killed every five seconds in the field.
430
- *
431
- * @param {CoverageMap} coverage
432
- * @param {object} run
433
- * @param {number} from
434
- * @param {number} to
435
- */
436
- #claimFor(coverage, run, from, to) {
437
- const end = endOfRun({ from, to });
438
- if (Number.isFinite(end)) {
439
- coverage.claim(run, from, end);
440
- return;
441
- }
442
- // A RUN WITH NO END HOLDS WHAT IT HAS MADE, NOT WHAT IT MIGHT MAKE.
443
- //
444
- // "No end" means the film's length is not known, so there is no last number
445
- // to claim towards. Claiming the rest of the film would leave a viewer who
446
- // opens the same film further in with every number taken and no encoder at
447
- // all. Claiming a fixed distance in front of the head — which is what this
448
- // did — needs a number nobody measured, and the number it used was the
449
- // suspended-encoder threshold that no longer exists.
450
- //
451
- // What it has made is a fact, and it is the only one available here.
452
- const head = Number.isFinite(run?.head) ? run.head : from;
453
- coverage.claim(run, from, Math.max(from, head));
482
+ this.coverageOf(address).claim(run, run.from, endOfRun(run));
454
483
  }
455
484
 
456
485
  /**
@@ -474,13 +503,13 @@ export class EncodeOrchestrator {
474
503
  this.#costs.note(ended);
475
504
  // Exactly one ending is normal — the run reached the end of the stretch it
476
505
  // was given and closed its last file. Every other leaves a piece open, and
477
- // that file's name is indistinguishable from a finished one's: on SIGTERM
478
- // ffmpeg writes the open piece out and names it on the ready channel like
479
- // any other, so it is a valid file holding less film than its name
480
- // promises. The run says which one that was.
506
+ // that file looks finished however the run ended: stopped, ffmpeg writes it
507
+ // out and names it like any other; killed harder, it leaves the bytes it
508
+ // had. Either way it decodes and holds less film than its number promises.
509
+ // So what is kept is what the run PROVED it finished, and nothing beyond.
481
510
  if (ended.ending !== ENCODE_EXIT.COMPLETE && this.segmentStore) {
482
511
  void this.segmentStore
483
- .discardOpenPieceOf(ended.address, { from: ended.from, to: ended.to }, null, ended.flushedName)
512
+ .discardOpenPieceOf(ended.address, { from: ended.from, to: ended.to }, null, ended.provenName)
484
513
  .catch(() => {});
485
514
  }
486
515
  this.coverageOf(ended.address).release(ended.run);
@@ -523,8 +552,8 @@ export class EncodeOrchestrator {
523
552
  const parts = [];
524
553
  for (const address of new Set([...this.demand.addresses(), ...this.#runs.keys()])) {
525
554
  const coverage = this.coverageOf(address);
526
- const stated = this.demand.windowsOn(address);
527
- const windows = stated.map((w) => ({ from: w.from, to: w.to }));
555
+ const stated = this.demand.mapOn(address);
556
+ const windows = stated.map((zone) => ({ from: zone.from, to: zone.to }));
528
557
  const waiting = firstUnmetWant(coverage, windows);
529
558
  const runs = this.runsOn(address)
530
559
  .map((run) => `#${run.head}..#${run.to}@${run.speedX.toFixed(1)}x`)