@torrent-tv/proxy 2.74.1 → 2.76.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.
Files changed (124) hide show
  1. package/CHANGELOG.md +1504 -1453
  2. package/CLAUDE.md +19 -6
  3. package/biome.json +182 -1
  4. package/docs/container-architecture.md +27 -6
  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 +9 -214
  18. package/services/container/AviContainer.js +266 -81
  19. package/services/container/Container.js +281 -80
  20. package/services/container/ContainerFactory.js +67 -0
  21. package/services/container/MatroskaContainer.js +327 -8
  22. package/services/container/Mp4Container.js +373 -27
  23. package/services/container/SubtitleFileContainer.js +0 -1
  24. package/services/controllers/SubtitleController.js +128 -128
  25. package/services/demand/index.js +7 -10
  26. package/services/download/registry.js +0 -14
  27. package/services/encode/CoverageMap.js +281 -0
  28. package/services/encode/EncodePlan.js +255 -0
  29. package/services/encode/EncodeRun.js +587 -0
  30. package/services/encode/Encoder.js +84 -0
  31. package/services/encode/NvencEncoder.js +45 -0
  32. package/services/encode/QsvEncoder.js +47 -0
  33. package/services/encode/SegmentDemand.js +0 -0
  34. package/services/encode/SegmentStore.js +529 -0
  35. package/services/encode/SoftwareEncoder.js +111 -0
  36. package/services/encode/V4l2m2mEncoder.js +53 -0
  37. package/services/encode/VaapiEncoder.js +53 -0
  38. package/services/encode/args.js +200 -0
  39. package/services/{encode-exit.js → encode/encode-exit.js} +17 -0
  40. package/services/encode/index.js +9 -0
  41. package/services/encode/run-command.js +647 -0
  42. package/services/hls-session-manager.js +11073 -10711
  43. package/services/hwaccel.js +1688 -1992
  44. package/services/orchestrators/EncodeOrchestrator.js +359 -0
  45. package/services/output/LiveOutputs.js +213 -0
  46. package/services/output/Output.js +94 -0
  47. package/services/output/OutputSpec.js +195 -0
  48. package/services/output/Timeline.js +220 -0
  49. package/services/output/index.js +1 -0
  50. package/services/output/ladder.js +26 -0
  51. package/services/playback-planner.js +806 -747
  52. package/services/produced-index.js +222 -300
  53. package/services/source/SourceFile.js +346 -0
  54. package/services/{sidecar-files.js → torrent/files.js} +107 -11
  55. package/services/torrent/naming.js +619 -0
  56. package/services/torrent-worker/client.js +10 -0
  57. package/services/torrent-worker/container-tracks.js +71 -43
  58. package/services/torrent-worker/pool-adapter.js +370 -333
  59. package/services/torrent-worker/protocol.js +7 -0
  60. package/services/torrent-worker/subtitle-cues.js +549 -549
  61. package/services/torrent-worker/worker.js +18 -0
  62. package/services/tracks/AudioTrack.js +131 -40
  63. package/services/tracks/TextSubtitleTrack.js +287 -287
  64. package/services/tracks/index.js +15 -14
  65. package/services/viewer/Viewer.js +145 -0
  66. package/services/viewer/Viewers.js +124 -0
  67. package/test/audio-inventory.test.js +176 -177
  68. package/test/auto-quality-step.test.js +508 -506
  69. package/test/behind-head-repair.test.js +17 -7
  70. package/test/coverage-map.test.js +153 -0
  71. package/test/cut-times-timeline.test.js +6 -5
  72. package/test/cuts-follow-published-grid.test.js +4 -4
  73. package/test/decode-cost.test.js +31 -12
  74. package/test/encode-exit.test.js +1 -1
  75. package/test/encode-orchestrator.test.js +196 -0
  76. package/test/encode-plan.test.js +245 -0
  77. package/test/encode-run-state.test.js +2 -2
  78. package/test/encode-run.test.js +168 -0
  79. package/test/encoder-kinds.test.js +122 -0
  80. package/test/held-request-width.test.js +9 -3
  81. package/test/helpers/encode-run.js +128 -0
  82. package/test/keyframe-index-accuracy.test.js +19 -12
  83. package/test/keyframes-belong-to-the-file.test.js +132 -0
  84. package/test/matroska-cues-track.test.js +192 -192
  85. package/test/mp4-composition-times.test.js +0 -0
  86. package/test/orchestrator-wired.test.js +164 -0
  87. package/test/output-shape.test.js +68 -0
  88. package/test/output-spec.test.js +157 -0
  89. package/test/produced-copy-choice.test.js +58 -92
  90. package/test/produced-index.test.js +142 -188
  91. package/test/quality-variants.test.js +1079 -1075
  92. package/test/run-graph-drift.test.js +1 -1
  93. package/test/run-intervals.test.js +329 -0
  94. package/test/run-position-follows-published-grid.test.js +4 -4
  95. package/test/seek-landing.test.js +8 -8
  96. package/test/seek-target-not-superseded.test.js +21 -9
  97. package/test/segment-demand.test.js +82 -0
  98. package/test/segment-serve-wiring.test.js +47 -52
  99. package/test/segment-store.test.js +187 -0
  100. package/test/segments-are-shared.test.js +175 -0
  101. package/test/sidecar-naming.test.js +142 -0
  102. package/test/source-file.test.js +133 -0
  103. package/test/stale-request-after-seek.test.js +18 -12
  104. package/test/subtitle-language.test.js +252 -252
  105. package/test/timeline.test.js +95 -0
  106. package/test/{sidecar-files.test.js → torrent-files.test.js} +44 -1
  107. package/test/torrent-naming.test.js +255 -0
  108. package/test/tracks-begin-together.test.js +44 -32
  109. package/test/two-viewers-one-picture.test.js +347 -0
  110. package/test/video-facts.test.js +102 -0
  111. package/test/viewer-outputs.test.js +273 -0
  112. package/test/viewer.test.js +91 -0
  113. package/utils/perf.js +1 -63
  114. package/services/container/index.js +0 -6
  115. package/services/container-index/avi.js +0 -167
  116. package/services/container-index/index.js +0 -118
  117. package/services/container-index/matroska.js +0 -336
  118. package/services/container-index/mp4.js +0 -358
  119. package/services/controllers/index.js +0 -2
  120. package/services/download/index.js +0 -8
  121. package/services/orchestrators/index.js +0 -2
  122. /package/services/{container-index → container}/ebml-reader.js +0 -0
  123. /package/services/{encode-run-state.js → encode/encode-run-state.js} +0 -0
  124. /package/services/{language-detect.js → tracks/language-detect.js} +0 -0
@@ -0,0 +1,587 @@
1
+ /**
2
+ * @file One running encoder: its process, the stretch it was given, where it
3
+ * has got to, and how it ended.
4
+ *
5
+ * Until now a run was ten fields on a session — the process, the run state, the
6
+ * directory, the start and end numbers, a generation counter, the superseded
7
+ * processes, the run number, its label, its argument list — and there could be
8
+ * exactly one of them, because there was nowhere to put a second. It is an
9
+ * object here so that an output can have as many as the machine affords, and so
10
+ * that a run belongs to a STRETCH of an output rather than to a viewer.
11
+ *
12
+ * **The identity check disappears with the fields.** Every handler used to open
13
+ * with "is this still the session's process", because a replaced run's exit
14
+ * would otherwise write the session's error, its state and its failure tally —
15
+ * one set of fields for however many processes had lived. A run writes its own
16
+ * state and nothing else's, so a predecessor dying after its replacement has
17
+ * spawned can no longer be mistaken for the current run failing. That mistake
18
+ * cost a hardware encoder: on any host with one, every seek downgraded the
19
+ * proxy to libx264 for good.
20
+ *
21
+ * **A run has an end.** Neither `-to` nor `-t` appeared anywhere in the
22
+ * arguments this proxy built, so every run went until something killed it from
23
+ * outside. Given an end it finishes by itself, and two runs on one output
24
+ * cannot write over each other because their stretches do not overlap.
25
+ *
26
+ * **Every start and every end is recorded, with its cause** (required by the
27
+ * user 2026-09-04, because abnormal endings are frequent here). Exactly one
28
+ * ending is normal: the run reached the end of the stretch it was given and
29
+ * exited by itself. Every other ending, our own kill included, is abnormal and
30
+ * says so — with the code, the signal, how far it got, how long it lived, and
31
+ * what was cleared up after it.
32
+ *
33
+ * **Nothing here decides anything.** Where a run belongs, whether it should be
34
+ * moved and whether it should exist at all are `EncodePlan`'s, from numbers.
35
+ * What to do about an ending — fall back to software, wait for the input to
36
+ * come back, stop retrying a position that keeps failing — belongs to whoever
37
+ * owns the output. This carries a decision out and reports what happened.
38
+ */
39
+
40
+ import { ENCODE_RUN_EVENT, ENCODE_RUN_STATE, INITIAL_RUN_STATE, nextState } from "./encode-run-state.js";
41
+ import { classifyEncodeExit, ENCODE_EXIT } from "./encode-exit.js";
42
+
43
+ /** Microseconds in a second, as ffmpeg's `out_time_ms` counts them. */
44
+ const MICROSECONDS_PER_SECOND = 1_000_000;
45
+
46
+
47
+ /**
48
+ * @typedef {object} RunEnded
49
+ * @property {EncodeRun} run - The run itself. It has no name: what identifies
50
+ * it is that it IS itself, and what identifies it in a line is its output and
51
+ * the stretch it was given, which no other live run of that output holds.
52
+ * @property {string} address
53
+ * @property {string} ending - One of {@link ENCODE_EXIT}.
54
+ * @property {string} because - Why, in words, including who asked when we did.
55
+ * @property {number | null} code
56
+ * @property {string | null} signal
57
+ * @property {number} from
58
+ * @property {number} to
59
+ * @property {number} reached - The last number it finished, or `from - 1` when
60
+ * it finished none.
61
+ * @property {number} livedMs
62
+ * @property {boolean} normal - Whether this ending is the expected one.
63
+ * @property {string} lastError - The last thing ffmpeg said on stderr.
64
+ */
65
+
66
+ export class EncodeRun {
67
+ /** @type {import("node:child_process").ChildProcess | null} */
68
+ #process = null;
69
+
70
+ /** @type {string} */
71
+ #state = INITIAL_RUN_STATE;
72
+
73
+ /** Numbers this run has finished. @type {Set<number>} */
74
+ #produced = new Set();
75
+
76
+ /** @type {number} */
77
+ #startedAt = 0;
78
+
79
+ /** @type {number} */
80
+ #speedX = 0;
81
+
82
+ /** @type {boolean} */
83
+ #stopping = false;
84
+
85
+ /** @type {string} */
86
+ #stopReason = "";
87
+
88
+ /** @type {boolean} */
89
+ #ended = false;
90
+
91
+ /**
92
+ * Whether this platform refuses to suspend a process.
93
+ *
94
+ * Asked once and remembered: `SIGSTOP` does not exist on Windows, and asking
95
+ * again every look-ahead pass would log the same refusal for the life of the
96
+ * run.
97
+ * @type {boolean}
98
+ */
99
+ #pauseUnsupported = false;
100
+
101
+ /**
102
+ * @param {object} params
103
+ * @param {string} params.address - The output this run makes segments of.
104
+ * @param {import("./Encoder.js").Encoder} params.encoder
105
+ * @param {number} params.from - First segment number it is to make.
106
+ * @param {number} params.to - Last segment number it is to make, inclusive;
107
+ * below `from` means it was given no end.
108
+ * @param {() => string[]} params.buildArgs - The full argument list for this
109
+ * run. Supplied rather than built here: what to map, where to read from and
110
+ * how to cut belong to whoever knows the source, and this owns the process.
111
+ * @param {(args: string[]) => import("node:child_process").ChildProcess} params.spawn
112
+ * @param {{ info: (line: string) => void, warn: (line: string) => void, error?: (line: string) => void }} params.logger
113
+ * @param {() => number} [params.now]
114
+ * @param {(ended: RunEnded) => void} [params.onEnded]
115
+ * @param {(progress: { processedSeconds: number | null, speed: string | null }) => void} [params.onProgress]
116
+ * Called for every `-progress` report. Seconds count from the START OF THIS
117
+ * RUN on both branches — neither `-output_ts_offset` nor `-copyts` changes
118
+ * what `-progress` reports, both measured — so rebasing them onto the
119
+ * source's timeline is the caller's, which is the only side that knows
120
+ * where this run began.
121
+ * @param {() => number | null} [params.lastSegmentIndex] - The film's last
122
+ * segment number, for telling "reached the end" from "the input dried up".
123
+ * ffmpeg exits zero for both, and over a torrent it cannot tell them apart.
124
+ * @param {(message: string) => boolean} [params.inputUnavailable] - Whether
125
+ * ffmpeg's own message names a missing input rather than a bad encode.
126
+ * @param {string} [params.argsDescribed] - The command as one readable line,
127
+ * kept so a failure can quote what produced it.
128
+ * @param {boolean} [params.usesExplicitCuts] - Whether this run cuts at times
129
+ * it was given, which decides how a segment is judged finished.
130
+ */
131
+ constructor({
132
+ address,
133
+ encoder,
134
+ from,
135
+ to,
136
+ buildArgs,
137
+ spawn,
138
+ logger,
139
+ now,
140
+ onEnded,
141
+ onProgress,
142
+ lastSegmentIndex,
143
+ inputUnavailable,
144
+ argsDescribed = "",
145
+ usesExplicitCuts = false
146
+ }) {
147
+ this.address = address;
148
+ this.encoder = encoder;
149
+ this.from = from;
150
+ this.to = to;
151
+ this.buildArgs = buildArgs;
152
+ this.spawnProcess = spawn;
153
+ this.logger = logger;
154
+ this.now = typeof now === "function" ? now : Date.now;
155
+ this.onEnded = typeof onEnded === "function" ? onEnded : () => {};
156
+ this.onProgress = typeof onProgress === "function" ? onProgress : () => {};
157
+ this.lastSegmentIndex = typeof lastSegmentIndex === "function" ? lastSegmentIndex : () => null;
158
+ this.inputUnavailable = typeof inputUnavailable === "function" ? inputUnavailable : () => false;
159
+ this.argsDescribed = argsDescribed;
160
+ this.usesExplicitCuts = usesExplicitCuts === true;
161
+ /** The last thing ffmpeg said on stderr, which is what a failure is explained by. */
162
+ this.lastError = "";
163
+ }
164
+
165
+ /** @returns {string} */
166
+ get state() {
167
+ return this.#state;
168
+ }
169
+
170
+ /** @returns {number} */
171
+ get speedX() {
172
+ return this.#speedX;
173
+ }
174
+
175
+ /** @returns {number} When it was spawned, or 0 before that. */
176
+ get startedAt() {
177
+ return this.#startedAt;
178
+ }
179
+
180
+ /**
181
+ * The process itself, for the two things only a handle can answer: whether it
182
+ * has exited, and its pid. Nothing outside may kill it — that is `stop`, which
183
+ * records the cause.
184
+ *
185
+ * @returns {import("node:child_process").ChildProcess | null}
186
+ */
187
+ get process() {
188
+ return this.#process;
189
+ }
190
+
191
+ /**
192
+ * The next number this run will produce.
193
+ *
194
+ * Its position, and the figure the plan compares against what is already
195
+ * covered. Before it has finished anything that is where it started.
196
+ *
197
+ * @returns {number}
198
+ */
199
+ get head() {
200
+ let head = this.from;
201
+ while (this.#produced.has(head)) {
202
+ head += 1;
203
+ }
204
+ return head;
205
+ }
206
+
207
+ /**
208
+ * The last number it finished, or one before its start when it finished none.
209
+ *
210
+ * @returns {number}
211
+ */
212
+ get reached() {
213
+ return this.head - 1;
214
+ }
215
+
216
+ /** @returns {number[]} */
217
+ get produced() {
218
+ return [...this.#produced].sort((left, right) => left - right);
219
+ }
220
+
221
+ /**
222
+ * Whether this run can still produce.
223
+ *
224
+ * A run told to stop cannot, whatever its process is still doing about the
225
+ * signal: the exit arrives a turn or two later, and until then everything
226
+ * asking "is anything encoding" would be answered yes by a run that is on its
227
+ * way out — and a caller deciding whether to start one would decide not to.
228
+ *
229
+ * @returns {boolean}
230
+ */
231
+ get isAlive() {
232
+ return this.#process !== null && !this.#ended && !this.#stopping;
233
+ }
234
+
235
+ /**
236
+ * Whether it has been told to stop and its exit has not arrived yet.
237
+ *
238
+ * Asked by whoever sweeps for runs that ended without saying so: this one is
239
+ * going to say so.
240
+ *
241
+ * @returns {boolean}
242
+ */
243
+ get isStopping() {
244
+ return this.#stopping && !this.#ended;
245
+ }
246
+
247
+ /** @returns {boolean} Whether it is stopped where it stands, producing nothing. */
248
+ get isSuspended() {
249
+ return this.#state === ENCODE_RUN_STATE.SUSPENDED;
250
+ }
251
+
252
+ /**
253
+ * Start it, and say why it is being started.
254
+ *
255
+ * The reason is not decoration: a start whose cause is not recorded cannot be
256
+ * told from any other start when several runs exist at once, and the argument
257
+ * list alone does not say whether this was a first open, a viewer's seek, a
258
+ * quality step or a move off covered material.
259
+ *
260
+ * @param {string} because
261
+ */
262
+ start(because) {
263
+ const args = this.buildArgs();
264
+ this.#startedAt = this.now();
265
+ this.logger.info(
266
+ `encode-run #${this.from}..#${this.to} of ${this.address} ` +
267
+ `by ${this.encoder?.name ?? "?"}: ${because} ` +
268
+ `:: ffmpeg ${this.argsDescribed || args.join(" ")}`
269
+ );
270
+ this.#process = this.spawnProcess(args);
271
+ this.#transition(ENCODE_RUN_EVENT.SPAWNED);
272
+ this.#wire(this.#process);
273
+ }
274
+
275
+ /**
276
+ * Everything the process says about itself: how far it has got, how fast, and
277
+ * what went wrong.
278
+ *
279
+ * No handler asks whether this process is still the current one. It writes
280
+ * this run's own fields and nothing shared, so a predecessor still dying
281
+ * after its replacement has spawned cannot be mistaken for the current run.
282
+ *
283
+ * @param {import("node:child_process").ChildProcess} process
284
+ */
285
+ #wire(process) {
286
+ process.stdout?.on("data", (chunk) => this.#readProgress(String(chunk)));
287
+ process.stderr?.on("data", (chunk) => {
288
+ const line = String(chunk).trim();
289
+ if (line.length > 0) {
290
+ this.lastError = line;
291
+ this.logger.warn(`ffmpeg #${this.from}..#${this.to} of ${this.address}: ${line}`);
292
+ }
293
+ });
294
+ process.on("error", (error) => {
295
+ const message = error instanceof Error ? error.message : String(error);
296
+ this.lastError = message;
297
+ this.#finish(ENCODE_EXIT.FAILED, `the process could not be started: ${message}`, null, null);
298
+ });
299
+ process.on("exit", (code, signal) => this.#onExit(code, signal));
300
+ }
301
+
302
+ /**
303
+ * ffmpeg's `-progress` stream: `key=value` lines, one block per report.
304
+ *
305
+ * @param {string} text
306
+ */
307
+ #readProgress(text) {
308
+ for (const line of text.split(/\r?\n/)) {
309
+ const normalized = line.trim();
310
+ if (!normalized) {
311
+ continue;
312
+ }
313
+ const separator = normalized.indexOf("=");
314
+ if (separator <= 0) {
315
+ continue;
316
+ }
317
+ const key = normalized.slice(0, separator);
318
+ const value = normalized.slice(separator + 1);
319
+ if (key === "out_time_ms") {
320
+ const numeric = Number(value);
321
+ if (Number.isFinite(numeric) && numeric >= 0) {
322
+ this.onProgress({ processedSeconds: numeric / MICROSECONDS_PER_SECOND, speed: null });
323
+ }
324
+ } else if (key === "out_time") {
325
+ this.onProgress({ processedSeconds: null, speed: null, outTime: value });
326
+ } else if (key === "speed") {
327
+ this.noteSpeed(Number.parseFloat(value));
328
+ this.onProgress({ processedSeconds: null, speed: value });
329
+ }
330
+ }
331
+ }
332
+
333
+ /**
334
+ * A segment this run has finished.
335
+ *
336
+ * @param {number} index
337
+ */
338
+ noteProduced(index) {
339
+ if (Number.isInteger(index) && index >= this.from) {
340
+ this.#produced.add(index);
341
+ if (this.#state === ENCODE_RUN_STATE.STARTING) {
342
+ this.#transition(ENCODE_RUN_EVENT.FIRST_SEGMENT);
343
+ }
344
+ }
345
+ }
346
+
347
+ /**
348
+ * What ffmpeg says about its own speed.
349
+ *
350
+ * @param {number} speedX - Times realtime.
351
+ */
352
+ noteSpeed(speedX) {
353
+ if (Number.isFinite(speedX) && speedX > 0) {
354
+ this.#speedX = speedX;
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Stop it on purpose. Abnormal by the rule above, and recorded as such: our
360
+ * own kill is a thing that happened to a run before it finished, and hiding
361
+ * it among the normal endings would make the count of abnormal endings
362
+ * useless.
363
+ *
364
+ * @param {string} because
365
+ */
366
+ stop(because) {
367
+ if (this.#process === null || this.#ended) {
368
+ return;
369
+ }
370
+ this.#stopping = true;
371
+ this.#stopReason = because;
372
+ this.#transition(ENCODE_RUN_EVENT.STOP_ORDERED);
373
+ // A suspended process does not act on SIGTERM until it is continued, so the
374
+ // wait for its exit would never end. Let it run before asking it to stop.
375
+ this.#continue();
376
+ try {
377
+ this.#process.kill("SIGTERM");
378
+ } catch {
379
+ // Best effort: it may already be gone, and its exit will say so.
380
+ }
381
+ }
382
+
383
+ /**
384
+ * Stop it where it stands, producing nothing, without ending it.
385
+ *
386
+ * What the look-ahead does when a run is far enough in front of every viewer:
387
+ * the process keeps its decoder, its position and its open piece, and costs
388
+ * no processor at all until it is let go again.
389
+ *
390
+ * @param {string} reason
391
+ * @returns {boolean} Whether it was suspended by this call.
392
+ */
393
+ pause(reason) {
394
+ if (this.#state === ENCODE_RUN_STATE.SUSPENDED || this.#pauseUnsupported || !this.#process?.pid) {
395
+ return false;
396
+ }
397
+ try {
398
+ globalThis.process.kill(this.#process.pid, "SIGSTOP");
399
+ } catch (error) {
400
+ this.#pauseUnsupported = true;
401
+ this.logger.info(
402
+ `encode-run #${this.from}..#${this.to} cannot suspend the encoder on this platform ` +
403
+ `(${error instanceof Error ? error.message : String(error)}); look-ahead stays unbounded`
404
+ );
405
+ return false;
406
+ }
407
+ this.#transition(ENCODE_RUN_EVENT.SUSPEND_ORDERED);
408
+ this.logger.info(`encode-run #${this.from}..#${this.to} suspended — ${reason}`);
409
+ return true;
410
+ }
411
+
412
+ /**
413
+ * Let it go again.
414
+ *
415
+ * @param {string} reason
416
+ * @returns {boolean} Whether anything was actually resumed. Two records of
417
+ * one moment must not contradict each other: a line saying the encoder
418
+ * resumed, beside a return value saying nothing was, is the sort of pair
419
+ * that costs an hour of reading a field log.
420
+ */
421
+ resume(reason) {
422
+ if (this.#state !== ENCODE_RUN_STATE.SUSPENDED || !this.#process?.pid) {
423
+ return false;
424
+ }
425
+ const continued = this.#continue();
426
+ this.logger.info(
427
+ continued
428
+ ? `encode-run #${this.from}..#${this.to} resumed — ${reason}`
429
+ : `encode-run #${this.from}..#${this.to} could not be resumed (the process is gone) — ${reason}`
430
+ );
431
+ return continued;
432
+ }
433
+
434
+ /**
435
+ * Send `SIGCONT` without deciding anything about it.
436
+ *
437
+ * @returns {boolean}
438
+ */
439
+ #continue() {
440
+ if (this.#state !== ENCODE_RUN_STATE.SUSPENDED || !this.#process?.pid) {
441
+ return false;
442
+ }
443
+ let continued = true;
444
+ try {
445
+ globalThis.process.kill(this.#process.pid, "SIGCONT");
446
+ } catch {
447
+ // The process is gone; its exit handler will deal with it. The state is
448
+ // moved either way — but nothing was resumed, and saying so is what stops
449
+ // a dead run being reported as producing again.
450
+ continued = false;
451
+ }
452
+ this.#transition(ENCODE_RUN_EVENT.RESUMED);
453
+ return continued;
454
+ }
455
+
456
+ /**
457
+ * @param {number | null} code
458
+ * @param {string | null} signal
459
+ */
460
+ #onExit(code, signal) {
461
+ if (this.#stopping) {
462
+ this.#finish(ENCODE_EXIT.STOPPED, this.#stopReason, code, signal);
463
+ return;
464
+ }
465
+ // What "it finished" means is the end of its own STRETCH where it was given
466
+ // one, and the end of the film where it was not. A run told to make #10..#14
467
+ // that exits cleanly at #11 has not finished, whatever the film's length;
468
+ // and a run with no end has nothing but the film to be measured against.
469
+ const endOfWork = this.to >= this.from ? this.to : this.lastSegmentIndex();
470
+ const outcome = classifyEncodeExit({
471
+ code,
472
+ producedThrough: this.#produced.size > 0 ? this.reached : null,
473
+ lastSegmentIndex: endOfWork,
474
+ inputUnavailable: this.inputUnavailable(this.lastError)
475
+ });
476
+ if (outcome === ENCODE_EXIT.COMPLETE) {
477
+ this.#finish(ENCODE_EXIT.COMPLETE, "it reached the end of what it was given", code, signal);
478
+ return;
479
+ }
480
+ if (outcome === ENCODE_EXIT.SHORT) {
481
+ // ffmpeg exits zero both at the end of a file and when its input simply
482
+ // stops producing bytes; over a torrent the two look identical to it. A
483
+ // run that stopped short of the film has not finished it.
484
+ this.#finish(
485
+ ENCODE_EXIT.SHORT,
486
+ `it exited cleanly at #${this.reached} of #${endOfWork ?? "?"} — its input stopped`,
487
+ code,
488
+ signal
489
+ );
490
+ return;
491
+ }
492
+ if (outcome === ENCODE_EXIT.INPUT_LOST) {
493
+ this.#finish(ENCODE_EXIT.INPUT_LOST, `its input went away: ${this.lastError}`, code, signal);
494
+ return;
495
+ }
496
+ this.#finish(ENCODE_EXIT.FAILED, `it exited with code ${code ?? "?"}: ${this.lastError}`, code, signal);
497
+ }
498
+
499
+ /**
500
+ * @param {string} ending - One of {@link ENCODE_EXIT}.
501
+ * @param {string} because
502
+ * @param {number | null} code
503
+ * @param {string | null} signal
504
+ */
505
+ #finish(ending, because, code, signal) {
506
+ if (this.#ended) {
507
+ return;
508
+ }
509
+ this.#ended = true;
510
+ this.#process = null;
511
+ // A run we stopped is already in STOPPED, ordered before the signal was
512
+ // sent; its exit is the answer to that order and not a second event.
513
+ if (ending !== ENCODE_EXIT.STOPPED) {
514
+ this.#transition(
515
+ ending === ENCODE_EXIT.COMPLETE
516
+ ? ENCODE_RUN_EVENT.EXITED_COMPLETE
517
+ : ending === ENCODE_EXIT.SHORT
518
+ ? ENCODE_RUN_EVENT.EXITED_SHORT
519
+ : ending === ENCODE_EXIT.INPUT_LOST
520
+ ? ENCODE_RUN_EVENT.EXITED_INPUT_LOST
521
+ : ENCODE_RUN_EVENT.EXITED_FAILED
522
+ );
523
+ }
524
+ const livedMs = this.#startedAt > 0 ? this.now() - this.#startedAt : 0;
525
+ /** @type {RunEnded} */
526
+ const ended = {
527
+ run: this,
528
+ address: this.address,
529
+ ending,
530
+ because,
531
+ code: code ?? null,
532
+ signal: signal ?? null,
533
+ from: this.from,
534
+ to: this.to,
535
+ reached: this.reached,
536
+ livedMs,
537
+ normal: ending === ENCODE_EXIT.COMPLETE,
538
+ lastError: this.lastError
539
+ };
540
+ const line =
541
+ `encode-run ${ending} #${this.from}..#${this.to} of ${this.address}, ` +
542
+ `reached #${ended.reached} (${this.#produced.size} segment(s)) after ${livedMs}ms` +
543
+ `${code === null ? "" : `, code ${code}`}${signal ? `, signal ${signal}` : ""}: ${because}`;
544
+ if (ended.normal) {
545
+ this.logger.info(line);
546
+ } else {
547
+ this.logger.warn(line);
548
+ }
549
+ this.onEnded(ended);
550
+ }
551
+
552
+ /**
553
+ * The wait after a lost input is over, and something is about to start
554
+ * again.
555
+ *
556
+ * The run itself is finished; this moves it out of the waiting state so that
557
+ * a second timer firing on the same run cannot start a second attempt.
558
+ */
559
+ retryDue() {
560
+ this.#transition(ENCODE_RUN_EVENT.RETRY_DUE);
561
+ }
562
+
563
+ /**
564
+ * Report an ending nobody watched: the run was found to be over without
565
+ * having said so. Only for a run handed over from elsewhere.
566
+ *
567
+ * @param {string} because
568
+ */
569
+ reportGone(because) {
570
+ this.#finish(ENCODE_EXIT.GONE, because, null, null);
571
+ }
572
+
573
+ /**
574
+ * @param {string} event
575
+ */
576
+ #transition(event) {
577
+ const from = this.#state;
578
+ const to = nextState(from, event);
579
+ if (to === null) {
580
+ this.logger.warn(`run-state #${this.from}..#${this.to} ${from} + ${event} — no such edge; ignored`);
581
+ return from;
582
+ }
583
+ this.#state = to;
584
+ this.logger.info(`run-state #${this.from}..#${this.to} of ${this.address} ${from} --${event}--> ${to}`);
585
+ return to;
586
+ }
587
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @file One kind of encoder: what it is called, how it is fed, how its
3
+ * arguments are built, and what its own setting for speed is.
4
+ *
5
+ * Until 2026-09-04 each kind was an object literal returned by a factory in
6
+ * `hwaccel.js`, and everything a kind knows beyond its arguments was either
7
+ * absent or spread into conditions elsewhere. The clearest case is the ladder
8
+ * of speed settings: `pickSoftwarePreset` walks one for libx264, and the four
9
+ * hardware kinds have none at all — NVENC is given a hardcoded `-preset p4`,
10
+ * QSV and VAAPI are given no speed setting whatever. That is not a decision
11
+ * anybody took; it is a gap that had nowhere to be written down.
12
+ *
13
+ * So a kind states its own ladder here, and says whether that ladder has been
14
+ * MEASURED on a host or only read out of ffmpeg's option list. Nothing consumes
15
+ * it yet: benchmarking the hardware ladders is its own work, and a hardware
16
+ * encoder that passes the strict startup test at one setting is not thereby
17
+ * correct at another — `h264_v4l2m2m` on the CM4 is the standing reminder.
18
+ * Stating it is what makes the gap visible instead of invisible.
19
+ */
20
+
21
+ /**
22
+ * A kind's own setting for trading picture against speed.
23
+ *
24
+ * @typedef {object} SpeedLadder
25
+ * @property {string} flag - The ffmpeg option, e.g. `-preset`.
26
+ * @property {string[]} values - Ordered slowest first, fastest last. Empty when
27
+ * the kind has no such setting at all.
28
+ * @property {boolean} measured - Whether a host has ever been benchmarked
29
+ * across these values. False means the values were read from ffmpeg's own
30
+ * option list and nothing here knows what they cost.
31
+ * @property {string} note - What is known and what is not, in words.
32
+ */
33
+
34
+ export class Encoder {
35
+ /**
36
+ * @param {object} params
37
+ * @param {string} params.name - ffmpeg's own name for it, e.g. `libx264`.
38
+ * @param {"software"|"vaapi"|"qsv"|"nvenc"|"v4l2m2m"} params.kind
39
+ * @param {string|null} [params.device] - The render node or device path, for
40
+ * the kinds that take one.
41
+ * @param {string[]} [params.inputArgs] - Arguments that belong BEFORE the
42
+ * input, because they decide how frames are decoded and where they land.
43
+ */
44
+ constructor({ name, kind, device = null, inputArgs = [] }) {
45
+ this.name = name;
46
+ this.kind = kind;
47
+ this.device = device;
48
+ this.inputArgs = inputArgs;
49
+ }
50
+
51
+ /**
52
+ * Whether frames are encoded by dedicated silicon rather than by the
53
+ * processor. It decides what a runtime failure means: a hardware encode that
54
+ * fails takes this proxy down to software for the rest of the process, and a
55
+ * software encode that fails has nowhere to fall.
56
+ *
57
+ * @returns {boolean}
58
+ */
59
+ get isHardware() {
60
+ return this.kind !== "software";
61
+ }
62
+
63
+ /**
64
+ * @returns {SpeedLadder}
65
+ */
66
+ get speedLadder() {
67
+ return {
68
+ flag: "",
69
+ values: [],
70
+ measured: false,
71
+ note: "This kind has not stated a speed setting."
72
+ };
73
+ }
74
+
75
+ /**
76
+ * The arguments that produce the picture. Every kind states its own.
77
+ *
78
+ * @param {{ targetWidth: number, targetHeight: number, segmentDurationSec: number, preset?: string, fps?: number, tonemap?: boolean, forcedKeyframeTimes?: number[] | null, nominalKbps?: number | null }} _options
79
+ * @returns {string[]}
80
+ */
81
+ buildVideoArgs(_options) {
82
+ throw new Error(`${this.name} does not say how to build its video arguments.`);
83
+ }
84
+ }