@torrent-tv/proxy 2.75.0 → 2.76.1

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 (113) hide show
  1. package/CHANGELOG.md +1509 -1461
  2. package/CLAUDE.md +11 -1
  3. package/biome.json +182 -1
  4. package/docs/container-architecture.md +2 -1
  5. package/docs/encode-run-state.md +1 -1
  6. package/knip.json +14 -0
  7. package/package.json +1 -1
  8. package/routes/api/sources/warm/post.js +1 -1
  9. package/routes/api/transcode-sessions/post.js +185 -185
  10. package/routes/api/transcode-sessions/progress/get.js +5 -1
  11. package/routes/transcode/audio-file/get.js +11 -1
  12. package/routes/transcode/audio-warm/get.js +11 -1
  13. package/routes/transcode/session-file/get.js +1 -1
  14. package/routes/transcode/variant-file/get.js +10 -1
  15. package/scripts/render-run-graph.js +2 -2
  16. package/server.js +25 -0
  17. package/services/audio-inventory.js +206 -201
  18. package/services/container/AviContainer.js +1 -1
  19. package/services/container/Container.js +33 -1
  20. package/services/container/MatroskaContainer.js +1 -1
  21. package/services/container/Mp4Container.js +1 -1
  22. package/services/container/SubtitleFileContainer.js +0 -1
  23. package/services/controllers/SubtitleController.js +128 -128
  24. package/services/demand/index.js +7 -10
  25. package/services/download/registry.js +0 -14
  26. package/services/encode/CoverageMap.js +281 -0
  27. package/services/encode/EncodePlan.js +255 -0
  28. package/services/encode/EncodeRun.js +587 -0
  29. package/services/encode/Encoder.js +84 -0
  30. package/services/encode/NvencEncoder.js +45 -0
  31. package/services/encode/QsvEncoder.js +47 -0
  32. package/services/encode/SegmentDemand.js +0 -0
  33. package/services/encode/SegmentStore.js +529 -0
  34. package/services/encode/SoftwareEncoder.js +111 -0
  35. package/services/encode/V4l2m2mEncoder.js +53 -0
  36. package/services/encode/VaapiEncoder.js +53 -0
  37. package/services/encode/args.js +200 -0
  38. package/services/{encode-exit.js → encode/encode-exit.js} +17 -0
  39. package/services/encode/index.js +9 -0
  40. package/services/encode/run-command.js +647 -0
  41. package/services/hls-session-manager.js +11100 -10711
  42. package/services/hwaccel.js +1688 -1992
  43. package/services/orchestrators/EncodeOrchestrator.js +359 -0
  44. package/services/output/LiveOutputs.js +213 -0
  45. package/services/output/Output.js +94 -0
  46. package/services/output/OutputSpec.js +195 -0
  47. package/services/output/Timeline.js +220 -0
  48. package/services/output/index.js +1 -0
  49. package/services/output/ladder.js +26 -0
  50. package/services/playback-planner.js +806 -775
  51. package/services/produced-index.js +222 -300
  52. package/services/source/SourceFile.js +346 -0
  53. package/services/{sidecar-files.js → torrent/files.js} +107 -11
  54. package/services/torrent/naming.js +619 -0
  55. package/services/torrent-worker/client.js +10 -0
  56. package/services/torrent-worker/container-tracks.js +71 -43
  57. package/services/torrent-worker/pool-adapter.js +18 -0
  58. package/services/torrent-worker/protocol.js +7 -0
  59. package/services/torrent-worker/subtitle-cues.js +549 -549
  60. package/services/torrent-worker/worker.js +18 -0
  61. package/services/tracks/TextSubtitleTrack.js +287 -287
  62. package/services/tracks/index.js +15 -14
  63. package/services/viewer/Viewer.js +145 -0
  64. package/services/viewer/Viewers.js +124 -0
  65. package/test/auto-quality-step.test.js +508 -506
  66. package/test/behind-head-repair.test.js +17 -7
  67. package/test/coverage-map.test.js +153 -0
  68. package/test/cut-times-timeline.test.js +6 -5
  69. package/test/cuts-follow-published-grid.test.js +4 -4
  70. package/test/decode-cost.test.js +31 -12
  71. package/test/encode-exit.test.js +1 -1
  72. package/test/encode-orchestrator.test.js +196 -0
  73. package/test/encode-plan.test.js +245 -0
  74. package/test/encode-run-state.test.js +2 -2
  75. package/test/encode-run.test.js +168 -0
  76. package/test/encoder-kinds.test.js +122 -0
  77. package/test/held-request-width.test.js +9 -3
  78. package/test/helpers/encode-run.js +128 -0
  79. package/test/keyframe-index-accuracy.test.js +19 -12
  80. package/test/keyframes-belong-to-the-file.test.js +132 -0
  81. package/test/orchestrator-wired.test.js +164 -0
  82. package/test/output-shape.test.js +68 -0
  83. package/test/output-spec.test.js +157 -0
  84. package/test/produced-copy-choice.test.js +58 -92
  85. package/test/produced-index.test.js +142 -188
  86. package/test/quality-variants.test.js +1079 -1075
  87. package/test/run-graph-drift.test.js +1 -1
  88. package/test/run-intervals.test.js +329 -0
  89. package/test/run-position-follows-published-grid.test.js +4 -4
  90. package/test/seek-landing.test.js +8 -8
  91. package/test/seek-target-not-superseded.test.js +21 -9
  92. package/test/segment-demand.test.js +82 -0
  93. package/test/segment-serve-wiring.test.js +47 -52
  94. package/test/segment-store.test.js +187 -0
  95. package/test/segments-are-shared.test.js +175 -0
  96. package/test/sidecar-naming.test.js +142 -0
  97. package/test/source-file.test.js +133 -0
  98. package/test/stale-request-after-seek.test.js +18 -12
  99. package/test/subtitle-language.test.js +252 -252
  100. package/test/timeline.test.js +95 -0
  101. package/test/{sidecar-files.test.js → torrent-files.test.js} +44 -1
  102. package/test/torrent-naming.test.js +255 -0
  103. package/test/tracks-begin-together.test.js +44 -32
  104. package/test/two-viewers-one-picture.test.js +347 -0
  105. package/test/viewer-outputs.test.js +273 -0
  106. package/test/viewer.test.js +91 -0
  107. package/utils/perf.js +1 -63
  108. package/services/container/index.js +0 -6
  109. package/services/controllers/index.js +0 -2
  110. package/services/download/index.js +0 -8
  111. package/services/orchestrators/index.js +0 -2
  112. /package/services/{encode-run-state.js → encode/encode-run-state.js} +0 -0
  113. /package/services/{language-detect.js → tracks/language-detect.js} +0 -0
@@ -0,0 +1,359 @@
1
+ /**
2
+ * @file How many encoders run on this proxy, on which outputs, and over which
3
+ * stretches.
4
+ *
5
+ * The application layer over `encode/`. It holds four things and owns none of
6
+ * their rules:
7
+ *
8
+ * 1. what viewers want (`SegmentDemand`), stated once each and read as a union;
9
+ * 2. what has been made and what is being made, one `CoverageMap` per output;
10
+ * 3. the encoders that are running (`EncodeRun`), each over a stretch;
11
+ * 4. a tally of how every run ended, which is what makes "abnormal endings do
12
+ * not happen" a number rather than an impression.
13
+ *
14
+ * The decision itself is `EncodePlan.planEncoders`, from numbers alone. This
15
+ * carries it out, and everything it cannot know is injected: how many encoders
16
+ * this machine can afford, how a run is built for a given stretch, and which
17
+ * segments already exist.
18
+ *
19
+ * **No viewer reaches the decision.** A viewer states a window and is forgotten
20
+ * as a name; what the plan sees is a union of windows. That is the rule the
21
+ * layer exists for, stated by the user 2026-09-04: requests come from any
22
+ * viewers in any number, encoders are managed to suit them, and viewers get the
23
+ * result when it is ready.
24
+ */
25
+
26
+ import { CoverageMap } from "../encode/CoverageMap.js";
27
+ import { firstUnmetWant, planEncoders } from "../encode/EncodePlan.js";
28
+ import { ENCODE_EXIT } from "../encode/encode-exit.js";
29
+ import { SegmentDemand } from "../encode/SegmentDemand.js";
30
+
31
+ export class EncodeOrchestrator {
32
+ /** Output address to what has been made of it. @type {Map<string, CoverageMap>} */
33
+ #coverage = new Map();
34
+
35
+ /** Output address to the runs on it. @type {Map<string, import("../encode/EncodeRun.js").EncodeRun[]>} */
36
+ #runs = new Map();
37
+
38
+ /** How runs have ended, by cause. @type {Map<string, number>} */
39
+ #endings = new Map();
40
+
41
+ /**
42
+ * @param {object} params
43
+ * @param {(address: string) => number} params.maxRunsFor - How many encoders
44
+ * this machine can afford on one output. The same arithmetic that decides
45
+ * the quality offer; measured per host, never chosen here.
46
+ * @param {(params: { address: string, from: number, to: number }) =>
47
+ * import("../encode/EncodeRun.js").EncodeRun} params.makeRun - Build a run
48
+ * for a stretch. What to read, what to map and how to cut belong to whoever
49
+ * knows the source.
50
+ * @param {number} params.segmentSeconds
51
+ * @param {number} params.restartCostSec - Measured: 0.12 s on the addon host.
52
+ * @param {{ info: (line: string) => void, warn: (line: string) => void }} params.logger
53
+ * @param {() => number} [params.now]
54
+ */
55
+ constructor({ maxRunsFor, makeRun, segmentSeconds, restartCostSec, logger, now }) {
56
+ this.demand = new SegmentDemand();
57
+ this.maxRunsFor = maxRunsFor;
58
+ this.makeRun = makeRun;
59
+ this.segmentSeconds = segmentSeconds;
60
+ this.restartCostSec = restartCostSec;
61
+ this.logger = logger;
62
+ this.now = typeof now === "function" ? now : Date.now;
63
+ }
64
+
65
+ /**
66
+ * The map of one output, made on first mention.
67
+ *
68
+ * @param {string} address
69
+ * @returns {CoverageMap}
70
+ */
71
+ coverageOf(address) {
72
+ let map = this.#coverage.get(address);
73
+ if (!map) {
74
+ map = new CoverageMap();
75
+ this.#coverage.set(address, map);
76
+ }
77
+ return map;
78
+ }
79
+
80
+ /**
81
+ * @param {string} address
82
+ * @returns {import("../encode/EncodeRun.js").EncodeRun[]}
83
+ */
84
+ runsOn(address) {
85
+ return this.#runs.get(address) ?? [];
86
+ }
87
+
88
+ /**
89
+ * How long an output is, once its playlist is known.
90
+ *
91
+ * @param {string} address
92
+ * @param {number} segmentCount
93
+ */
94
+ setSegmentCount(address, segmentCount) {
95
+ this.coverageOf(address).setSegmentCount(segmentCount);
96
+ }
97
+
98
+ /**
99
+ * Segments that already exist — from a previous life of this process, or
100
+ * because somebody else made them. Told to the map, which is what stops an
101
+ * encoder being started to make them again.
102
+ *
103
+ * @param {string} address
104
+ * @param {Iterable<number>} indexes
105
+ */
106
+ noteAlreadyMade(address, indexes) {
107
+ this.coverageOf(address).markReadyAll(indexes);
108
+ }
109
+
110
+ /**
111
+ * A viewer states what it is waiting for. Replaces whatever it said before.
112
+ *
113
+ * @param {object} params
114
+ * @param {string} params.claimant
115
+ * @param {string} params.address
116
+ * @param {number} params.from
117
+ * @param {number} params.to
118
+ */
119
+ want({ claimant, address, from, to }) {
120
+ this.demand.state({ claimant, address, from, to, statedAt: this.now() });
121
+ }
122
+
123
+ /**
124
+ * A viewer has gone.
125
+ *
126
+ * @param {string} claimant
127
+ */
128
+ release(claimant) {
129
+ this.demand.forget(claimant);
130
+ }
131
+
132
+ /**
133
+ * A segment has been finished, by whichever run made it.
134
+ *
135
+ * @param {string} address
136
+ * @param {number} index
137
+ */
138
+ noteProduced(address, index) {
139
+ this.coverageOf(address).markReady(index);
140
+ for (const run of this.runsOn(address)) {
141
+ run.noteProduced(index);
142
+ }
143
+ }
144
+
145
+ /**
146
+ * @param {string} address
147
+ * @param {object} run
148
+ * @param {number} speedX
149
+ */
150
+ noteSpeed(address, wanted, speedX) {
151
+ for (const run of this.runsOn(address)) {
152
+ if (run === wanted) {
153
+ run.noteSpeed(speedX);
154
+ }
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Decide and act, for every output anybody wants anything of and every output
160
+ * that still has an encoder on it.
161
+ *
162
+ * Safe to call as often as anything changes: the plan is a function of the
163
+ * state, so a pass that finds nothing to change does nothing.
164
+ */
165
+ reconcile() {
166
+ const addresses = new Set([...this.demand.addresses(), ...this.#runs.keys()]);
167
+ for (const address of addresses) {
168
+ this.#reconcileOne(address);
169
+ }
170
+ }
171
+
172
+ /**
173
+ * @param {string} address
174
+ */
175
+ #reconcileOne(address) {
176
+ const coverage = this.coverageOf(address);
177
+ // A run that has ended and said nothing. One built here reports its own
178
+ // ending and is released by `noteEnded`; one ADOPTED from elsewhere — a
179
+ // session whose encoder stopped — has no such promise, and its claim would
180
+ // otherwise sit in the map for the life of the process, telling the plan
181
+ // that a stretch nobody is making is being made. Nothing would ever be
182
+ // started there again.
183
+ for (const run of this.runsOn(address)) {
184
+ if (!run.isAlive && !run.isStopping) {
185
+ this.noteEnded({
186
+ address,
187
+ run,
188
+ ending: ENCODE_EXIT.GONE,
189
+ because: "it is no longer running, and it did not say so"
190
+ });
191
+ }
192
+ }
193
+ const windows = this.demand.windowsOn(address).map((window) => ({
194
+ from: window.from,
195
+ to: window.to
196
+ }));
197
+ const live = this.runsOn(address).filter((run) => run.isAlive);
198
+ const actions = planEncoders({
199
+ coverage,
200
+ windows,
201
+ // The runs themselves. The plan is arithmetic and reads four numbers off
202
+ // each; what it hands back names the run by BEING it, so nothing has to
203
+ // invent a token to refer to one by.
204
+ runs: live,
205
+ maxRuns: Math.max(0, this.maxRunsFor(address)),
206
+ segmentSeconds: this.segmentSeconds,
207
+ restartCostSec: this.restartCostSec
208
+ });
209
+
210
+ for (const action of actions) {
211
+ if (action.type === "stop") {
212
+ this.#stop(action.run, action.because);
213
+ continue;
214
+ }
215
+ if (action.type === "move") {
216
+ // A running encoder's position cannot be changed — it is fixed when the
217
+ // process starts — so a move is this one ending and another beginning
218
+ // where the material is missing. Both halves are recorded as what they
219
+ // are, which is why the ending of a moved run is not called normal.
220
+ this.#stop(action.run, action.because);
221
+ this.#start(address, action.from, action.to, action.because);
222
+ continue;
223
+ }
224
+ if (action.type === "start") {
225
+ this.#start(address, action.from, action.to, action.because);
226
+ continue;
227
+ }
228
+ // A run that stays keeps its claim current: the free stretch ahead of it
229
+ // may have shrunk since it was given one.
230
+ coverage.claim(action.run, action.from, action.to);
231
+ }
232
+ }
233
+
234
+ /**
235
+ * @param {string} address
236
+ * @param {number} from
237
+ * @param {number} to
238
+ * @param {string} because
239
+ */
240
+ #start(address, from, to, because) {
241
+ // The run names itself: identity is a property of the thing, and two
242
+ // places minting names is how one stops being unique.
243
+ const run = this.makeRun({ address, from, to });
244
+ if (!run) {
245
+ // Not necessarily a failure: making an encoder can take a probe and a
246
+ // keyframe read, and this call is arithmetic that must not wait on
247
+ // either. Whoever builds one answers with nothing while it is on its way,
248
+ // and the next pass sees it.
249
+ this.logger.info(`encode: an encoder for #${from}..#${to} of ${address} is not there yet`);
250
+ return;
251
+ }
252
+ const onThisOutput = this.#runs.get(address) ?? [];
253
+ onThisOutput.push(run);
254
+ this.#runs.set(address, onThisOutput);
255
+ this.coverageOf(address).claim(run, from, to);
256
+ run.start(because);
257
+ }
258
+
259
+ /**
260
+ * Take charge of a run this class did not start.
261
+ *
262
+ * The browser asks for a stream and a run begins for it, long before this
263
+ * class has an opinion. Left unknown, that run would be invisible to the plan
264
+ * — which would then start a second encoder over the same numbers, believing
265
+ * nothing was being made there. So whoever starts one hands it over, and from
266
+ * then on it is planned like any other.
267
+ *
268
+ * @param {string} address
269
+ * @param {{ id: string, from: number, to: number, head: number, speedX: number, isAlive: boolean, stop: (because: string) => void }} run
270
+ */
271
+ adopt(address, run) {
272
+ if (!run) {
273
+ return;
274
+ }
275
+ const onThisOutput = this.#runs.get(address) ?? [];
276
+ if (onThisOutput.includes(run)) {
277
+ return;
278
+ }
279
+ onThisOutput.push(run);
280
+ this.#runs.set(address, onThisOutput);
281
+ this.coverageOf(address).claim(run, run.from, run.to);
282
+ }
283
+
284
+ /**
285
+ * @param {object} run
286
+ * @param {string} because
287
+ */
288
+ #stop(run, because) {
289
+ run.stop(because);
290
+ }
291
+
292
+ /**
293
+ * A run has ended, however it ended. Its stretch goes back to the map — what
294
+ * it finished stays made — and the ending is counted.
295
+ *
296
+ * Wired by whoever builds the run, so that a run built outside this class is
297
+ * still accounted for.
298
+ *
299
+ * @param {import("../encode/EncodeRun.js").RunEnded} ended
300
+ */
301
+ noteEnded(ended) {
302
+ this.coverageOf(ended.address).release(ended.run);
303
+ const remaining = this.runsOn(ended.address).filter((run) => run !== ended.run);
304
+ if (remaining.length === 0) {
305
+ this.#runs.delete(ended.address);
306
+ } else {
307
+ this.#runs.set(ended.address, remaining);
308
+ }
309
+ this.#endings.set(ended.ending, (this.#endings.get(ended.ending) ?? 0) + 1);
310
+ }
311
+
312
+ /**
313
+ * How runs have ended over the life of this process, by cause.
314
+ *
315
+ * The abnormal classes are meant to stand at zero. Without the count,
316
+ * "we understand why it ended" is indistinguishable from "we noticed it once".
317
+ *
318
+ * @returns {Record<string, number>}
319
+ */
320
+ endings() {
321
+ /** @type {Record<string, number>} */
322
+ const tally = {};
323
+ for (const ending of Object.values(ENCODE_EXIT)) {
324
+ tally[ending] = this.#endings.get(ending) ?? 0;
325
+ }
326
+ return tally;
327
+ }
328
+
329
+ /**
330
+ * One line saying what this proxy is encoding and whether anybody is waiting.
331
+ *
332
+ * `waiting` is the point of it: a proxy with encoders running and a viewer
333
+ * still stopped at a segment nobody is making is the failure this layer was
334
+ * built to remove, and it is visible here rather than inferred from a log.
335
+ *
336
+ * @returns {string}
337
+ */
338
+ describe() {
339
+ const parts = [];
340
+ for (const address of new Set([...this.demand.addresses(), ...this.#runs.keys()])) {
341
+ const coverage = this.coverageOf(address);
342
+ const windows = this.demand.windowsOn(address).map((w) => ({ from: w.from, to: w.to }));
343
+ const waiting = firstUnmetWant(coverage, windows);
344
+ const runs = this.runsOn(address)
345
+ .map((run) => `#${run.head}..#${run.to}@${run.speedX.toFixed(1)}x`)
346
+ .join(" ");
347
+ parts.push(
348
+ `${address.slice(0, 60)} ready=${coverage.stats().ready} ` +
349
+ `viewers=${windows.length} runs=[${runs}] ` +
350
+ `waiting=${waiting === null ? "nobody" : `#${waiting}`}`
351
+ );
352
+ }
353
+ const tally = this.endings();
354
+ const endings = Object.entries(tally)
355
+ .map(([cause, count]) => `${cause}=${count}`)
356
+ .join(" ");
357
+ return `encode: ${parts.length === 0 ? "nothing wanted" : parts.join(" | ")} :: endings ${endings}`;
358
+ }
359
+ }
@@ -0,0 +1,213 @@
1
+ /**
2
+ * @file Which outputs of one file exist right now, and what each of them is.
3
+ *
4
+ * Every question here is answered by walking the live sessions and looking at
5
+ * what they ARE — the same file, a step, a soundtrack, this height — and not by
6
+ * following a list of ids anybody keeps. A list of ids is a link between
7
+ * sessions: it ties their lifetimes together, it goes stale when one of them is
8
+ * disposed, and it has to be cleaned from the other side. What an output is is
9
+ * enough to find it, which is the rule `OutputSpec` exists for.
10
+ *
11
+ * Nothing here writes anything except the two answers a session memoizes about
12
+ * itself, and nothing here knows about encoders, viewers, the torrent or the
13
+ * disk. It is the layer the quality budget and the serving path both stand on,
14
+ * and it is separated first for that reason.
15
+ */
16
+
17
+ import { variantHeightsFor } from "./ladder.js";
18
+
19
+ export class LiveOutputs {
20
+ /**
21
+ * @param {object} params
22
+ * @param {Map<string, object>} params.sessionsById - The live sessions. Read,
23
+ * never written.
24
+ */
25
+ constructor({ sessionsById }) {
26
+ this.sessionsById = sessionsById;
27
+ }
28
+
29
+ /**
30
+ * Every live session of one file: the picture, its quality steps, and the
31
+ * soundtracks published separately.
32
+ *
33
+ * The file is what they share, so the file is what this asks about. Where the
34
+ * sums this feeds are concerned that is also the right question: two pictures
35
+ * of one file are two encoders on one machine whether or not anybody thinks
36
+ * of them as one film.
37
+ *
38
+ * @param {object} session
39
+ * @returns {object[]}
40
+ */
41
+ familyOf(session) {
42
+ const family = [session];
43
+ const key = session?.file?.key;
44
+ for (const other of this.sessionsById.values()) {
45
+ if (other === session || other.state === "disposed" || other.file?.key !== key) {
46
+ continue;
47
+ }
48
+ family.push(other);
49
+ }
50
+ return family;
51
+ }
52
+
53
+ /**
54
+ * The soundtracks published separately for this picture, live ones only.
55
+ *
56
+ * @param {object} base
57
+ * @returns {object[]}
58
+ */
59
+ renditionsOf(base) {
60
+ return this.familyOf(base).filter((session) => session !== base && session.audioOnly === true);
61
+ }
62
+
63
+ /**
64
+ * The quality steps of this picture, live ones only.
65
+ *
66
+ * A step is a session made as one — `isStep`, written where it is created —
67
+ * and not merely "another session of this file that carries a picture". The
68
+ * difference is the second picture: one file can hold two — a browser that
69
+ * understands rendition groups and one that needs the sound muxed in produce
70
+ * two — and calling one of them a step of the other would let a switch away
71
+ * from a step stop the encoder of somebody else's picture.
72
+ *
73
+ * @param {object} base
74
+ * @returns {object[]}
75
+ */
76
+ stepsOf(base) {
77
+ return this.familyOf(base).filter((session) => session !== base && session.isStep === true);
78
+ }
79
+
80
+ /**
81
+ * The picture a step belongs to, or the session itself when it is not a step.
82
+ *
83
+ * How a session came to be is a fact about it, not a link to another session
84
+ * — so a step whose picture has gone answers for itself rather than following
85
+ * a dead reference, and nothing has to be cleaned from the other side when
86
+ * one of them ends.
87
+ *
88
+ * One file can carry two pictures at once, and then this returns whichever
89
+ * was made first. Everything asked of the answer is a fact of the FILE and of
90
+ * this host: what heights can be offered, what a step costs, when the budget
91
+ * last acted. Two pictures of one file answer all of those alike.
92
+ *
93
+ * @param {object} session
94
+ * @returns {object}
95
+ */
96
+ pictureOf(session) {
97
+ if (!session || session.isStep !== true) {
98
+ return session;
99
+ }
100
+ for (const other of this.familyOf(session)) {
101
+ if (other.isStep !== true && other.audioOnly !== true) {
102
+ return other;
103
+ }
104
+ }
105
+ return session;
106
+ }
107
+
108
+ /**
109
+ * Which variant a session IS, as a height. Zero encode height means "keep the
110
+ * source", so the source's own height is the answer.
111
+ *
112
+ * Settled once and then kept, because it is a NAME — the player addresses the
113
+ * variant by it for the whole session, having fetched the master exactly
114
+ * once. The height a session encodes at is not stable: the realtime budget
115
+ * steps it down when the host cannot keep up. Deriving the name afresh each
116
+ * time would mean a downshift silently renames the variant the viewer is
117
+ * watching, and the next segment request under the old name would build a
118
+ * SECOND session at the height the host had just proved it could not manage.
119
+ * A downshift changes the picture inside the variant instead, which is what
120
+ * it has always done.
121
+ *
122
+ * @param {object} session
123
+ * @returns {number}
124
+ */
125
+ variantHeightOf(session) {
126
+ if (Number.isInteger(session.variantHeight) && session.variantHeight > 0) {
127
+ return session.variantHeight;
128
+ }
129
+ const encodeHeight = Number(session.output.encodeHeight) || 0;
130
+ session.variantHeight = encodeHeight > 0
131
+ ? encodeHeight
132
+ : Math.round(Number(session.file.height) || 0);
133
+ return session.variantHeight;
134
+ }
135
+
136
+ /**
137
+ * The height a session's encoder is actually producing, or 0 when it produces
138
+ * no encoded picture of its own (a copy, or a soundtrack).
139
+ *
140
+ * A COPY must never be adopted: it costs no encoder at all, so handing it to
141
+ * a request for a re-encoded rung would give away the one thing this host can
142
+ * always serve.
143
+ *
144
+ * @param {object} session
145
+ * @returns {number}
146
+ */
147
+ producedHeightOf(session) {
148
+ if (!session || session.transcodeVideo !== true || session.audioOnly === true) {
149
+ return 0;
150
+ }
151
+ return Math.round(Number(session.output.encodeHeight) || 0);
152
+ }
153
+
154
+ /**
155
+ * The heights this file's variants CAN be spliced at — a fact about the
156
+ * source and the cut grid, settled once and never moved.
157
+ *
158
+ * Separate from which of them are worth OFFERING to the viewer right now, on
159
+ * a machine whose load moves every five seconds. Both were the same list
160
+ * until 2026-08-18, and that is what broke playback outright: the browser is
161
+ * told at session creation that a master playlist exists, and 192 ms later —
162
+ * after the session's own encoder had started and the first supply reading
163
+ * had arrived — the live list had fallen from five rungs to one,
164
+ * `buildMasterPlaylist` returned null for having fewer than two, and the
165
+ * master answered 404 to the very session that had just published it. hls.js
166
+ * treats that as fatal and unrecoverable, so nothing played at all (session
167
+ * `4ef731d8`, "Moana (2016).mkv", 17:43:01).
168
+ *
169
+ * A live figure may decide what to offer. It may not decide whether a
170
+ * published document exists.
171
+ *
172
+ * @param {object} session
173
+ * @returns {number[]} Largest first.
174
+ */
175
+ splicableHeights(session) {
176
+ const owner = this.pictureOf(session);
177
+ if (Array.isArray(owner.splicableHeights)) {
178
+ return owner.splicableHeights;
179
+ }
180
+ const heights = new Set(variantHeightsFor(Number(owner.file.height) || 0));
181
+ const own = this.variantHeightOf(owner);
182
+ if (own > 0) {
183
+ heights.add(own);
184
+ }
185
+ owner.splicableHeights = [...heights].sort((left, right) => right - left);
186
+ return owner.splicableHeights;
187
+ }
188
+
189
+ /**
190
+ * Whether this stream publishes a master playlist at all — that is, whether
191
+ * there is anything for a player to move BETWEEN.
192
+ *
193
+ * Asked in one place because two callers depend on the same answer and used
194
+ * to compute it differently: the builder refused a copied stream whose cut
195
+ * grid is a fiction, while the budget looked only at how many heights could
196
+ * in principle be spliced. A copy with no readable keyframe index therefore
197
+ * had requests recorded against it — asking a player with no variants to
198
+ * change variant, once every window, for the whole film.
199
+ *
200
+ * @param {object} session
201
+ * @returns {boolean}
202
+ */
203
+ publishesVariants(session) {
204
+ const owner = this.pictureOf(session);
205
+ // A copy can only be cut where the source already has a keyframe, so a rung
206
+ // meant to splice into it has to be cut at exactly those times. A copy that
207
+ // fell back to an even grid ffmpeg does not cut on has nothing to align to.
208
+ if (!owner.transcodeVideo && owner.timeline.cutGrid !== "keyframe") {
209
+ return false;
210
+ }
211
+ return this.splicableHeights(owner).length >= 2;
212
+ }
213
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * @file What one output is encoded AS, decided once.
3
+ *
4
+ * `OutputSpec` says what an output is — which tracks, in what form, cut how.
5
+ * This is the other half: the shape the encoder is actually given for it. The
6
+ * box in pixels, the frame rate, the speed setting, whether the picture is tone
7
+ * mapped down from HDR.
8
+ *
9
+ * **Why it is not a fact of a session.** It is decided by the realtime budget
10
+ * at the moment a session is created — what this machine could hold just then —
11
+ * so two sessions of one output, made minutes apart, could be given different
12
+ * shapes while claiming the same identity. Everything downstream assumes
13
+ * otherwise: a segment of one is supposed to be interchangeable with a segment
14
+ * of the other, and the master playlist names one `RESOLUTION` for both.
15
+ *
16
+ * Decided once per output and held here, that cannot happen. What the budget
17
+ * learns afterwards moves the RATE cap, which is deliberately not here: rate
18
+ * control appears in neither the SPS nor the PPS, so it can move under a player
19
+ * that has already cached the init. The size cannot, which is exactly why the
20
+ * size belongs to the output and the cap belongs to the run.
21
+ */
22
+
23
+ export class Output {
24
+ /**
25
+ * @param {object} params
26
+ * @param {number} params.encodeWidth - 0 means the source's own width.
27
+ * @param {number} params.encodeHeight - 0 means the source's own height.
28
+ * @param {number} params.outputFps
29
+ * @param {string | null} params.softwarePreset - The speed setting, where the
30
+ * encoder has a ladder and one was chosen from it.
31
+ * @param {boolean} params.applyTonemap
32
+ */
33
+ constructor({ encodeWidth, encodeHeight, outputFps, softwarePreset, applyTonemap }) {
34
+ this.encodeWidth = Number.isFinite(encodeWidth) ? encodeWidth : 0;
35
+ this.encodeHeight = Number.isFinite(encodeHeight) ? encodeHeight : 0;
36
+ this.outputFps = Number.isFinite(outputFps) && outputFps > 0 ? outputFps : 0;
37
+ this.softwarePreset = typeof softwarePreset === "string" ? softwarePreset : null;
38
+ this.applyTonemap = applyTonemap === true;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * The shapes this proxy has decided, one per output.
44
+ *
45
+ * Keyed by `OutputSpec.toKey()` and by nothing else: the shape is a property of
46
+ * what is being produced, and two requests that produce the same thing must be
47
+ * given the same one however far apart they arrive.
48
+ */
49
+ export class Outputs {
50
+ /** @type {Map<string, Output>} */
51
+ #byKey = new Map();
52
+
53
+ /**
54
+ * The shape for this output, decided by `decide` the first time it is asked
55
+ * for and never again.
56
+ *
57
+ * @param {string} key
58
+ * @param {() => Output} decide
59
+ * @returns {Output}
60
+ */
61
+ get(key, decide) {
62
+ let output = this.#byKey.get(key);
63
+ if (!output) {
64
+ output = decide();
65
+ this.#byKey.set(key, output);
66
+ }
67
+ return output;
68
+ }
69
+
70
+ /**
71
+ * Drop every shape nobody is holding.
72
+ *
73
+ * Same reason the timelines are swept: a map that only grows is the shape of
74
+ * half the memory faults recorded in this project.
75
+ *
76
+ * @param {Set<Output>} inUse
77
+ * @returns {number}
78
+ */
79
+ forgetUnused(inUse) {
80
+ let dropped = 0;
81
+ for (const [key, output] of [...this.#byKey]) {
82
+ if (!inUse.has(output)) {
83
+ this.#byKey.delete(key);
84
+ dropped += 1;
85
+ }
86
+ }
87
+ return dropped;
88
+ }
89
+
90
+ /** @returns {number} */
91
+ get size() {
92
+ return this.#byKey.size;
93
+ }
94
+ }