@torrent-tv/proxy 2.75.0 → 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.
- package/CHANGELOG.md +1504 -1461
- package/CLAUDE.md +11 -1
- package/biome.json +182 -1
- package/docs/container-architecture.md +2 -1
- package/docs/encode-run-state.md +1 -1
- package/knip.json +14 -0
- package/package.json +1 -1
- package/routes/api/sources/warm/post.js +1 -1
- package/routes/api/transcode-sessions/post.js +185 -185
- package/routes/api/transcode-sessions/progress/get.js +5 -1
- package/routes/transcode/audio-file/get.js +11 -1
- package/routes/transcode/audio-warm/get.js +11 -1
- package/routes/transcode/session-file/get.js +1 -1
- package/routes/transcode/variant-file/get.js +10 -1
- package/scripts/render-run-graph.js +2 -2
- package/server.js +25 -0
- package/services/audio-inventory.js +206 -201
- package/services/container/AviContainer.js +1 -1
- package/services/container/Container.js +33 -1
- package/services/container/MatroskaContainer.js +1 -1
- package/services/container/Mp4Container.js +1 -1
- package/services/container/SubtitleFileContainer.js +0 -1
- package/services/controllers/SubtitleController.js +128 -128
- package/services/demand/index.js +7 -10
- package/services/download/registry.js +0 -14
- package/services/encode/CoverageMap.js +281 -0
- package/services/encode/EncodePlan.js +255 -0
- package/services/encode/EncodeRun.js +587 -0
- package/services/encode/Encoder.js +84 -0
- package/services/encode/NvencEncoder.js +45 -0
- package/services/encode/QsvEncoder.js +47 -0
- package/services/encode/SegmentDemand.js +0 -0
- package/services/encode/SegmentStore.js +529 -0
- package/services/encode/SoftwareEncoder.js +111 -0
- package/services/encode/V4l2m2mEncoder.js +53 -0
- package/services/encode/VaapiEncoder.js +53 -0
- package/services/encode/args.js +200 -0
- package/services/{encode-exit.js → encode/encode-exit.js} +17 -0
- package/services/encode/index.js +9 -0
- package/services/encode/run-command.js +647 -0
- package/services/hls-session-manager.js +11073 -10711
- package/services/hwaccel.js +1688 -1992
- package/services/orchestrators/EncodeOrchestrator.js +359 -0
- package/services/output/LiveOutputs.js +213 -0
- package/services/output/Output.js +94 -0
- package/services/output/OutputSpec.js +195 -0
- package/services/output/Timeline.js +220 -0
- package/services/output/index.js +1 -0
- package/services/output/ladder.js +26 -0
- package/services/playback-planner.js +806 -775
- package/services/produced-index.js +222 -300
- package/services/source/SourceFile.js +346 -0
- package/services/{sidecar-files.js → torrent/files.js} +107 -11
- package/services/torrent/naming.js +619 -0
- package/services/torrent-worker/client.js +10 -0
- package/services/torrent-worker/container-tracks.js +71 -43
- package/services/torrent-worker/pool-adapter.js +18 -0
- package/services/torrent-worker/protocol.js +7 -0
- package/services/torrent-worker/subtitle-cues.js +549 -549
- package/services/torrent-worker/worker.js +18 -0
- package/services/tracks/TextSubtitleTrack.js +287 -287
- package/services/tracks/index.js +15 -14
- package/services/viewer/Viewer.js +145 -0
- package/services/viewer/Viewers.js +124 -0
- package/test/auto-quality-step.test.js +508 -506
- package/test/behind-head-repair.test.js +17 -7
- package/test/coverage-map.test.js +153 -0
- package/test/cut-times-timeline.test.js +6 -5
- package/test/cuts-follow-published-grid.test.js +4 -4
- package/test/decode-cost.test.js +31 -12
- package/test/encode-exit.test.js +1 -1
- package/test/encode-orchestrator.test.js +196 -0
- package/test/encode-plan.test.js +245 -0
- package/test/encode-run-state.test.js +2 -2
- package/test/encode-run.test.js +168 -0
- package/test/encoder-kinds.test.js +122 -0
- package/test/held-request-width.test.js +9 -3
- package/test/helpers/encode-run.js +128 -0
- package/test/keyframe-index-accuracy.test.js +19 -12
- package/test/keyframes-belong-to-the-file.test.js +132 -0
- package/test/orchestrator-wired.test.js +164 -0
- package/test/output-shape.test.js +68 -0
- package/test/output-spec.test.js +157 -0
- package/test/produced-copy-choice.test.js +58 -92
- package/test/produced-index.test.js +142 -188
- package/test/quality-variants.test.js +1079 -1075
- package/test/run-graph-drift.test.js +1 -1
- package/test/run-intervals.test.js +329 -0
- package/test/run-position-follows-published-grid.test.js +4 -4
- package/test/seek-landing.test.js +8 -8
- package/test/seek-target-not-superseded.test.js +21 -9
- package/test/segment-demand.test.js +82 -0
- package/test/segment-serve-wiring.test.js +47 -52
- package/test/segment-store.test.js +187 -0
- package/test/segments-are-shared.test.js +175 -0
- package/test/sidecar-naming.test.js +142 -0
- package/test/source-file.test.js +133 -0
- package/test/stale-request-after-seek.test.js +18 -12
- package/test/subtitle-language.test.js +252 -252
- package/test/timeline.test.js +95 -0
- package/test/{sidecar-files.test.js → torrent-files.test.js} +44 -1
- package/test/torrent-naming.test.js +255 -0
- package/test/tracks-begin-together.test.js +44 -32
- package/test/two-viewers-one-picture.test.js +347 -0
- package/test/viewer-outputs.test.js +273 -0
- package/test/viewer.test.js +91 -0
- package/utils/perf.js +1 -63
- package/services/container/index.js +0 -6
- package/services/controllers/index.js +0 -2
- package/services/download/index.js +0 -8
- package/services/orchestrators/index.js +0 -2
- /package/services/{encode-run-state.js → encode/encode-run-state.js} +0 -0
- /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
|
+
}
|