@torrent-tv/proxy 2.16.0 → 2.17.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.
@@ -1,1248 +1,1502 @@
1
- /**
2
- * @file Hardware-accelerated H.264 encoder auto-detection.
3
- *
4
- * Probes the ffmpeg build and the host for a usable hardware H.264 encoder
5
- * (NVENC / QSV / VAAPI / V4L2 M2M), verifying each candidate with a real
6
- * test-encode before selecting it. Falls back to software libx264 when no
7
- * hardware encoder is present or working.
8
- *
9
- * Deployment-agnostic: relies only on ffmpeg, the filesystem and
10
- * `process.platform`; makes no assumptions about Home Assistant or any
11
- * specific host. A garbled or unsupported hardware path simply fails its
12
- * test-encode and is skipped, so the worst case is software encoding.
13
- *
14
- * A descriptor exposes:
15
- * - `name` human-readable encoder id (e.g. "h264_vaapi")
16
- * - `kind` "software" | "vaapi" | "qsv" | "nvenc" | "v4l2m2m"
17
- * - `device` device node path or null
18
- * - `inputArgs` ffmpeg args inserted before `-i` (decode/hwaccel setup)
19
- * - `buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec })`
20
- * ffmpeg video filter + encoder args inserted after `-map`s
21
- */
22
-
23
- import { spawn } from "node:child_process";
24
- import { mkdtempSync, readdirSync, rmSync } from "node:fs";
25
- import os from "node:os";
26
- import path from "node:path";
27
- import { fileURLToPath } from "node:url";
28
- import {
29
- parseFfmpegBitrateKbps,
30
- parseFfmpegDurationSeconds,
31
- parseFfmpegVideoDimensions,
32
- parseFfmpegVideoFps
33
- } from "./ffmpeg-banner.js";
34
-
35
- const SOFTWARE_PRESET = "ultrafast";
36
- const SOFTWARE_CRF = "24";
37
- // HDR→SDR tone-map chain (software). Converts a BT.2020 PQ/HLG source to BT.709
38
- // 8-bit SDR so the re-encode is not washed-out/desaturated. Requires the
39
- // `zscale` (libzimg) and `tonemap` filters — gated by detectTonemapSupport;
40
- // when unavailable the encode falls back to a plain 8-bit convert (no tonemap).
41
- // npl=100 targets ~100-nit SDR; hable is a well-behaved tone-mapping operator.
42
- const TONEMAP_FILTER_CHAIN =
43
- "zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709," +
44
- "tonemap=tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuv420p";
45
- // Default output frame rate when the source rate is unknown, and the rate used
46
- // by the synthetic startup test-encode / preset benchmark. The real encode
47
- // inherits the source rate (rounded to an integer, capped) — see
48
- // chooseOutputFps — so 25/30 fps content no longer plays resampled to 24.
49
- export const TRANSCODE_FPS = 24;
50
- // Upper bound on the output frame rate: 50/60 fps sources are halved-in-effort
51
- // by capping to 30, protecting the realtime encode budget on weak hosts.
52
- export const MAX_OUTPUT_FPS = 30;
53
-
54
- /**
55
- * Choose an INTEGER output frame rate from the (possibly fractional) source
56
- * rate, for the frame-count-GOP encoders ONLY (software libx264, v4l2m2m).
57
- * Those place keyframes with `-g = segmentDur × fps` (frame count), so the
58
- * `fps=` filter value must be an integer that makes seg×fps an exact whole
59
- * number of frames per segment — otherwise segments drift off the synthetic
60
- * playlist's uniform grid and seek accuracy degrades over a long file. Film
61
- * rates (23.976) round to 24, 25 stays 25, 29.97 rounds to 30; the cap clamps
62
- * high rates (the cap is a SPEED guard for the weak software/v4l2m2m path).
63
- *
64
- * Time-based-keyframe encoders (nvenc, vaapi, qsv) do NOT use this — they
65
- * inherit the exact source rate untouched (their keyframes are forced by
66
- * output time, so any rate segments correctly).
67
- *
68
- * @param {number | null | undefined} sourceFps
69
- * @param {number} [cap=MAX_OUTPUT_FPS]
70
- * @returns {number}
71
- */
72
- export function chooseOutputFps(sourceFps, cap = MAX_OUTPUT_FPS) {
73
- if (!Number.isFinite(sourceFps) || sourceFps <= 0) {
74
- return TRANSCODE_FPS;
75
- }
76
- const rounded = Math.round(sourceFps);
77
- if (rounded < 1) {
78
- return TRANSCODE_FPS;
79
- }
80
- return Math.min(cap, rounded);
81
- }
82
- // Software x264 on weak ARM hosts is the transcode bottleneck — use all cores.
83
- const CPU_THREADS = Math.max(1, os.cpus().length);
84
-
85
- // Bitrate caps (constrained CRF). CRF stays the quality driver; -maxrate/
86
- // -bufsize only bound the peaks. Field evidence (iPhone on cellular,
87
- // 2026-07-10): uncapped complex scenes produced 4 s segments of ~18 Mbit/s
88
- // against a 1-6 Mbit/s viewer link — 45 s prebuffer, draining buffer.
89
- // Nominal H.264 rates per rung height; multipliers from webtor's production
90
- // ladder (content-transcoder): maxrate = 1.3x nominal, bufsize = 1.5x.
91
- const RUNG_NOMINAL_KBPS = [
92
- [1080, 5000],
93
- [720, 2800],
94
- [480, 1400],
95
- [360, 800],
96
- [240, 400]
97
- ];
98
- const CAP_MAXRATE_FACTOR = 1.3;
99
- const CAP_BUFSIZE_FACTOR = 1.5;
100
-
101
- /**
102
- * Nominal kbps for an encode height: nearest rung wins (odd heights snap to
103
- * the closest standard rung; anything above the top rung uses the top one).
104
- *
105
- * @param {number} height
106
- * @returns {number}
107
- */
108
- export function nominalKbpsForHeight(height) {
109
- const h = Number.isFinite(height) && height > 0 ? height : 720;
110
- let best = RUNG_NOMINAL_KBPS[0];
111
- for (const rung of RUNG_NOMINAL_KBPS) {
112
- if (Math.abs(rung[0] - h) < Math.abs(best[0] - h)) {
113
- best = rung;
114
- }
115
- }
116
- return best[1];
117
- }
118
-
119
- /**
120
- * `-maxrate`/`-bufsize` args for an encode height (constrained CRF).
121
- *
122
- * @param {number} height
123
- * @returns {string[]}
124
- */
125
- function bitrateCapArgs(height) {
126
- const nominal = nominalKbpsForHeight(height);
127
- return [
128
- "-maxrate", `${Math.round(nominal * CAP_MAXRATE_FACTOR)}k`,
129
- "-bufsize", `${Math.round(nominal * CAP_BUFSIZE_FACTOR)}k`
130
- ];
131
- }
132
-
133
- // libx264 presets to benchmark, ordered slowest/highest-quality → fastest.
134
- const BENCHMARK_PRESETS = ["fast", "faster", "veryfast", "superfast", "ultrafast"];
135
- const BENCHMARK_REF_W = 640;
136
- const BENCHMARK_REF_H = 360;
137
- const BENCHMARK_DURATION_SEC = 3;
138
- // Require the predicted speed to clear realtime by this much. The benchmarks
139
- // run at startup with an idle CPU; during playback ffmpeg competes with
140
- // in-process WebTorrent (download + hashing) and delivery, so real throughput
141
- // is lower, and the margin covers that plus complex scenes.
142
- //
143
- // It was 1.8 while the prediction counted ENCODING only and was therefore
144
- // several times too optimistic on a re-encode; with the decode term the
145
- // prediction is within ~13 % of measured, so the margin no longer has to stand
146
- // in for a missing term as well as for load.
147
- const PRESET_SPEED_MARGIN = 1.5;
148
- // The bar for a prediction that has NO decode term — a host whose calibration
149
- // clips are missing, or whose fit was rejected. That figure is the one the
150
- // margin was 1.8 for, and lowering it there would make an uncalibrated host
151
- // more permissive than it was before any of this existed.
152
- const ENCODE_ONLY_SPEED_MARGIN = 1.8;
153
-
154
- /**
155
- * @param {number} targetWidth
156
- * @param {number} targetHeight
157
- * @returns {{ w: number, h: number }}
158
- */
159
- function safeDimensions(targetWidth, targetHeight) {
160
- const w = Number.isInteger(targetWidth) && targetWidth > 0 ? targetWidth : 1280;
161
- const h = Number.isInteger(targetHeight) && targetHeight > 0 ? targetHeight : 720;
162
- return { w, h };
163
- }
164
-
165
- /**
166
- * Force a keyframe on every segment boundary so each HLS segment is
167
- * independently decodable.
168
- *
169
- * Two grids exist. The usual one is even a keyframe every
170
- * `segmentDurationSec` and the encoder is free to place them because it is
171
- * producing every frame anyway. The other is the SOURCE's own keyframe times,
172
- * used when this encode has to be interchangeable with a stream that is
173
- * COPIED: a copy can only be cut where the source already has a keyframe, so a
174
- * rung meant to splice into it must be cut at exactly those times and nowhere
175
- * else. Then the times are given outright.
176
- *
177
- * @param {number} segmentDurationSec
178
- * @param {number[] | null} [forcedTimes] - Run-relative seconds, ascending.
179
- * @returns {string[]}
180
- */
181
- function keyFrameArgs(segmentDurationSec, forcedTimes = null) {
182
- if (Array.isArray(forcedTimes) && forcedTimes.length > 0) {
183
- return ["-force_key_frames", forcedTimes.join(",")];
184
- }
185
- return ["-force_key_frames", `expr:gte(t,n_forced*${segmentDurationSec})`];
186
- }
187
-
188
- /**
189
- * Whether an explicit cut list was supplied.
190
- *
191
- * @param {number[] | null | undefined} forcedTimes
192
- * @returns {boolean}
193
- */
194
- function hasForcedTimes(forcedTimes) {
195
- return Array.isArray(forcedTimes) && forcedTimes.length > 0;
196
- }
197
-
198
- /** @returns {import("./hwaccel.js").VideoEncoderDescriptor} */
199
- export function softwareDescriptor() {
200
- return {
201
- name: "libx264",
202
- kind: "software",
203
- device: null,
204
- inputArgs: [],
205
- buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, preset, fps, tonemap, forcedKeyframeTimes }) {
206
- const { w, h } = safeDimensions(targetWidth, targetHeight);
207
- const chosenPreset = typeof preset === "string" && preset.length > 0 ? preset : SOFTWARE_PRESET;
208
- // Output frame rate: inherited from the source (rounded/capped) by the
209
- // session manager, TRANSCODE_FPS by default. MUST be an integer and MUST
210
- // equal the value used in the GOP below, or keyframes drift off the grid.
211
- const outFps = Number.isInteger(fps) && fps > 0 ? fps : TRANSCODE_FPS;
212
- // HDR→SDR tone-map, inserted AFTER the downscale so it runs on the smaller
213
- // frame (cheaper on ARM); only when the source is HDR and the filters are
214
- // present (session manager gates on both).
215
- const tonemapPart = tonemap === true ? `,${TONEMAP_FILTER_CHAIN}` : "";
216
- return [
217
- // Never upscale: cap the target box to the source size (min with
218
- // iw/ih), so a small source (e.g. 720x400) is encoded at its own
219
- // resolution instead of being scaled up to the viewport — far fewer
220
- // pixels, much faster on ARM. force_original_aspect_ratio keeps aspect.
221
- "-vf",
222
- `scale='min(${w},iw)':'min(${h},ih)':force_original_aspect_ratio=decrease:force_divisible_by=2${tonemapPart},fps=${outFps}`,
223
- "-c:v", "libx264",
224
- // Preset is chosen per stream by the session manager from the startup
225
- // benchmark (highest quality that still encodes the source resolution
226
- // faster than realtime); falls back to the static default.
227
- "-preset", chosenPreset,
228
- "-crf", SOFTWARE_CRF,
229
- // Constrained CRF: bound peak bitrate per rung so a complex scene
230
- // cannot produce segments a thin viewer link (cellular) can't
231
- // download in time. Sized by the TARGET box height (the rung the
232
- // budget/manual selection chose).
233
- ...bitrateCapArgs(h),
234
- "-threads", String(CPU_THREADS),
235
- "-pix_fmt", "yuv420p",
236
- // Fixed GOP: a keyframe exactly every (segmentDurationSec × fps) frames,
237
- // scene-cut keyframes disabled. This is frame-count based, so it is
238
- // independent of the PTS offset used on seek-restart every HLS segment
239
- // is exactly segmentDurationSec long and starts on a keyframe, so segment
240
- // boundaries line up with the synthetic playlist with no gaps. (The old
241
- // the OLD `expr:` form of -force_key_frames broke after a seek, because
242
- // the `t` it reads is shifted by `-output_ts_offset`.)
243
- //
244
- // An explicit cut LIST is a different thing and does work: verified by
245
- // running it, its times are on the run's own timeline — the same one
246
- // `-segment_times` is measured on so both are given one list and
247
- // cannot drift apart. It replaces the frame-count GOP, which cannot
248
- // describe the source's keyframes because they are not evenly spaced.
249
- // `-g` stays as an upper bound on the interval: an extra keyframe
250
- // inside a segment costs a little bitrate and cuts nothing, while
251
- // leaving the interval unbounded means a driver that ignores the list
252
- // produces one enormous segment instead of a wrong but cut one.
253
- // `-keyint_min` goes, since a MINIMUM interval is the one thing that
254
- // could argue with a forced keyframe.
255
- "-g", String(segmentDurationSec * outFps),
256
- ...(hasForcedTimes(forcedKeyframeTimes)
257
- ? keyFrameArgs(segmentDurationSec, forcedKeyframeTimes)
258
- : ["-keyint_min", String(segmentDurationSec * outFps)]),
259
- "-sc_threshold", "0"
260
- ];
261
- }
262
- };
263
- }
264
-
265
- /**
266
- * @param {string} device
267
- * @returns {import("./hwaccel.js").VideoEncoderDescriptor}
268
- */
269
- function vaapiDescriptor(device) {
270
- return {
271
- name: "h264_vaapi",
272
- kind: "vaapi",
273
- device,
274
- // Decode on the GPU into VAAPI surfaces; scale and encode stay on-GPU.
275
- inputArgs: ["-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi", "-vaapi_device", device],
276
- // No fps filter: VAAPI inherits the source rate and keeps keyframes on the
277
- // grid via time-based -force_key_frames, so it already honours source fps.
278
- buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, forcedKeyframeTimes }) {
279
- const { w, h } = safeDimensions(targetWidth, targetHeight);
280
- return [
281
- "-vf",
282
- `scale_vaapi=w=${w}:h=${h}:force_original_aspect_ratio=decrease`,
283
- "-c:v", "h264_vaapi",
284
- "-qp", "24",
285
- ...keyFrameArgs(segmentDurationSec, forcedKeyframeTimes)
286
- ];
287
- }
288
- };
289
- }
290
-
291
- /**
292
- * @param {string} device
293
- * @returns {import("./hwaccel.js").VideoEncoderDescriptor}
294
- */
295
- function qsvDescriptor(device) {
296
- return {
297
- name: "h264_qsv",
298
- kind: "qsv",
299
- device,
300
- inputArgs: ["-hwaccel", "qsv", "-qsv_device", device],
301
- buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, forcedKeyframeTimes }) {
302
- const { w, h } = safeDimensions(targetWidth, targetHeight);
303
- return [
304
- "-vf", `scale_qsv=w=${w}:h=${h}`,
305
- "-c:v", "h264_qsv",
306
- "-global_quality", "24",
307
- ...keyFrameArgs(segmentDurationSec, forcedKeyframeTimes)
308
- ];
309
- }
310
- };
311
- }
312
-
313
- /** @returns {import("./hwaccel.js").VideoEncoderDescriptor} */
314
- function nvencDescriptor() {
315
- return {
316
- name: "h264_nvenc",
317
- kind: "nvenc",
318
- device: null,
319
- inputArgs: [],
320
- // No fps filter: NVENC is fast and places keyframes by time-based
321
- // -force_key_frames, so it inherits the exact source rate (fractional
322
- // included) with no need to round or cap. Same rationale as VAAPI/QSV.
323
- buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, forcedKeyframeTimes }) {
324
- const { w, h } = safeDimensions(targetWidth, targetHeight);
325
- return [
326
- "-vf",
327
- `scale=${w}:${h}:force_original_aspect_ratio=decrease:force_divisible_by=2`,
328
- "-c:v", "h264_nvenc",
329
- "-preset", "p4",
330
- "-cq", "24",
331
- "-pix_fmt", "yuv420p",
332
- ...keyFrameArgs(segmentDurationSec, forcedKeyframeTimes)
333
- ];
334
- }
335
- };
336
- }
337
-
338
- /** @returns {import("./hwaccel.js").VideoEncoderDescriptor} */
339
- function v4l2m2mDescriptor() {
340
- // ARM SoC (e.g. Raspberry Pi / HA Yellow) stateful M2M encoder. No GPU
341
- // scaler scale in software, hand YUV420 frames to the hardware encoder.
342
- // `-g` aligns the GOP to the segment length so an IDR lands on every segment
343
- // boundary; this is verified by the keyframe-alignment test before use,
344
- // because v4l2m2m does not always honour these hints.
345
- return {
346
- name: "h264_v4l2m2m",
347
- kind: "v4l2m2m",
348
- device: null,
349
- inputArgs: [],
350
- buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, fps, forcedKeyframeTimes }) {
351
- const { w, h } = safeDimensions(targetWidth, targetHeight);
352
- const outFps = Number.isInteger(fps) && fps > 0 ? fps : TRANSCODE_FPS;
353
- return [
354
- "-vf",
355
- `scale=${w}:${h}:force_original_aspect_ratio=decrease:force_divisible_by=2,fps=${outFps},format=yuv420p`,
356
- "-c:v", "h264_v4l2m2m",
357
- // More capture buffers than the default 4 — the default deadlocks /
358
- // drops frames on the CM4 encoder ("All capture buffers returned to
359
- // userspace").
360
- "-num_capture_buffers", "32",
361
- "-b:v", "3M",
362
- // Kept even with an explicit cut list, as an upper bound on the
363
- // interval: this encoder is the one known not always to honour keyframe
364
- // hints, and without any bound a list it ignores yields one segment for
365
- // the whole file rather than a wrongly-cut one.
366
- "-g", String(outFps * segmentDurationSec),
367
- ...keyFrameArgs(segmentDurationSec, forcedKeyframeTimes)
368
- ];
369
- }
370
- };
371
- }
372
-
373
-
374
- /**
375
- * @typedef {Object} VideoEncoderDescriptor
376
- * @property {string} name
377
- * @property {"software"|"vaapi"|"qsv"|"nvenc"|"v4l2m2m"} kind
378
- * @property {string|null} device
379
- * @property {string[]} inputArgs
380
- * @property {(opts: { targetWidth: number, targetHeight: number, segmentDurationSec: number, preset?: string, fps?: number, tonemap?: boolean, forcedKeyframeTimes?: number[] | null }) => string[]} buildVideoArgs
381
- */
382
-
383
- /**
384
- * Run ffmpeg and resolve with its exit code and captured output.
385
- *
386
- * @param {string} ffmpegBin
387
- * @param {string[]} args
388
- * @param {number} [timeoutMs=12000]
389
- * @returns {Promise<{ code: number, stdout: string, stderr: string }>}
390
- */
391
- function runFfmpeg(ffmpegBin, args, timeoutMs = 12000) {
392
- return new Promise((resolve) => {
393
- let stdout = "";
394
- let stderr = "";
395
- let settled = false;
396
- let child;
397
- const finish = (code) => {
398
- if (settled) {
399
- return;
400
- }
401
- settled = true;
402
- resolve({ code, stdout, stderr });
403
- };
404
- try {
405
- child = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
406
- } catch {
407
- finish(-1);
408
- return;
409
- }
410
- const timer = setTimeout(() => {
411
- try {
412
- child.kill("SIGKILL");
413
- } catch {
414
- // ignore
415
- }
416
- finish(-1);
417
- }, timeoutMs);
418
- child.stdout.on("data", (d) => {
419
- stdout += String(d);
420
- });
421
- child.stderr.on("data", (d) => {
422
- stderr += String(d);
423
- });
424
- child.on("error", () => {
425
- clearTimeout(timer);
426
- finish(-1);
427
- });
428
- child.on("exit", (code) => {
429
- clearTimeout(timer);
430
- finish(code ?? -1);
431
- });
432
- });
433
- }
434
-
435
- /** @returns {string[]} /dev/dri/renderD* nodes (VAAPI/QSV). */
436
- function listRenderNodes() {
437
- try {
438
- return readdirSync("/dev/dri")
439
- .filter((n) => n.startsWith("renderD"))
440
- .map((n) => `/dev/dri/${n}`)
441
- .sort();
442
- } catch {
443
- return [];
444
- }
445
- }
446
-
447
- /** @returns {boolean} Whether any /dev/nvidia* node exists (NVENC). */
448
- function hasNvidiaDevice() {
449
- try {
450
- return readdirSync("/dev").some((n) => /^nvidia(\d+)?$/.test(n));
451
- } catch {
452
- return false;
453
- }
454
- }
455
-
456
- /** @returns {boolean} Whether any /dev/video* node exists (V4L2 M2M). */
457
- function hasV4l2Device() {
458
- try {
459
- return readdirSync("/dev").some((n) => /^video\d+$/.test(n));
460
- } catch {
461
- return false;
462
- }
463
- }
464
-
465
- /**
466
- * Build a full ffmpeg command that encodes a short, *moving* synthetic clip
467
- * (testsrc2 far more representative than a static black frame) through the
468
- * candidate encoder into real HLS segments in `outDir`, with keyframes forced
469
- * on segment boundaries. Verifying the resulting segments (see
470
- * {@link verifySegmentsDecodeCleanly}) catches encoders that silently produce
471
- * a corrupted or non-IDR-aligned stream (e.g. some V4L2 M2M builds).
472
- *
473
- * @param {VideoEncoderDescriptor} descriptor
474
- * @param {number} segmentDurationSec
475
- * @param {string} outDir
476
- * @returns {string[]}
477
- */
478
- function buildEncoderTestArgs(descriptor, segmentDurationSec, outDir) {
479
- const durationSec = Math.max(8, segmentDurationSec * 3);
480
- const source = ["-f", "lavfi", "-i", `testsrc2=s=640x360:r=${TRANSCODE_FPS}:d=${durationSec}`];
481
- const kf = keyFrameArgs(segmentDurationSec);
482
-
483
- /** @type {string[]} */
484
- let pre = ["-hide_banner", "-loglevel", "error"];
485
- /** @type {string[]} */
486
- let encode;
487
- switch (descriptor.kind) {
488
- case "vaapi":
489
- pre = [...pre, "-vaapi_device", String(descriptor.device)];
490
- encode = ["-vf", "format=nv12,hwupload", "-c:v", "h264_vaapi", "-qp", "24", ...kf];
491
- break;
492
- case "qsv":
493
- pre = [...pre, "-qsv_device", String(descriptor.device)];
494
- encode = ["-vf", "hwupload=extra_hw_frames=16,format=qsv", "-c:v", "h264_qsv", "-global_quality", "24", ...kf];
495
- break;
496
- case "nvenc":
497
- encode = ["-c:v", "h264_nvenc", "-preset", "p4", "-cq", "24", "-pix_fmt", "yuv420p", ...kf];
498
- break;
499
- case "v4l2m2m":
500
- encode = ["-pix_fmt", "yuv420p", "-c:v", "h264_v4l2m2m", "-num_capture_buffers", "32", "-b:v", "3M", "-g", String(TRANSCODE_FPS * segmentDurationSec), ...kf];
501
- break;
502
- default:
503
- encode = ["-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", ...kf];
504
- break;
505
- }
506
-
507
- const hlsOut = [
508
- "-f", "hls",
509
- "-hls_time", String(segmentDurationSec),
510
- "-hls_list_size", "0",
511
- "-hls_flags", "independent_segments",
512
- // fMP4 (CMAF) — matches the runtime pipeline (hls-session-manager).
513
- "-hls_segment_type", "fmp4",
514
- "-hls_fmp4_init_filename", "init.mp4",
515
- "-hls_segment_filename", path.join(outDir, "seg-%03d.m4s"),
516
- path.join(outDir, "index.m3u8")
517
- ];
518
- return [...pre, ...source, ...encode, ...hlsOut];
519
- }
520
-
521
- /**
522
- * Verify the HLS segments produced by the test encode are valid: at least two
523
- * segments exist, and each decodes standalone without errors. A segment that
524
- * does not begin with a keyframe (broken/corrupted output) emits decode errors
525
- * when read on its own, which fails this check.
526
- *
527
- * @param {string} ffmpegBin
528
- * @param {string} outDir
529
- * @returns {Promise<boolean>}
530
- */
531
- async function verifySegmentsDecodeCleanly(ffmpegBin, outDir) {
532
- let files;
533
- try {
534
- files = readdirSync(outDir).filter((n) => /^seg-\d+\.m4s$/.test(n));
535
- } catch {
536
- return false;
537
- }
538
- if (files.length < 2) {
539
- return false;
540
- }
541
- // fMP4: parameter sets (SPS/PPS) live in init.mp4, not in each segment.
542
- // Decode the whole playlist (ffmpeg's own, which references init.mp4 via
543
- // #EXT-X-MAP), so every segment is exercised together with the init. Any
544
- // corrupt / non-conformant segment (e.g. some V4L2 M2M builds emit a stray
545
- // no-picture access unit) surfaces as a decode error here.
546
- const result = await runFfmpeg(
547
- ffmpegBin,
548
- ["-hide_banner", "-loglevel", "error", "-i", path.join(outDir, "index.m3u8"), "-f", "null", "-"],
549
- 12000
550
- );
551
- return result.code === 0 && result.stderr.trim().length === 0;
552
- }
553
-
554
- /**
555
- * Detect the best usable H.264 encoder. Always resolves (falls back to
556
- * software libx264). Each hardware candidate is verified with a real
557
- * test-encode before being selected.
558
- *
559
- * @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void }, segmentDurationSec?: number }} options
560
- * @returns {Promise<VideoEncoderDescriptor>}
561
- */
562
- export async function detectVideoEncoder({ ffmpegBin, logger, segmentDurationSec = 4 }) {
563
- const log = logger ?? { info: () => {}, warn: () => {} };
564
- const software = softwareDescriptor();
565
-
566
- const { code, stdout } = await runFfmpeg(ffmpegBin, ["-hide_banner", "-encoders"], 10000);
567
- if (code !== 0) {
568
- log.warn("hwaccel: could not list ffmpeg encoders; using software libx264");
569
- return software;
570
- }
571
- const has = (name) => stdout.includes(name);
572
-
573
- /** @type {VideoEncoderDescriptor[]} */
574
- const candidates = [];
575
- const renderNodes = listRenderNodes();
576
- if (has("h264_nvenc") && hasNvidiaDevice()) {
577
- candidates.push(nvencDescriptor());
578
- }
579
- if (has("h264_qsv") && renderNodes.length > 0) {
580
- candidates.push(qsvDescriptor(renderNodes[0]));
581
- }
582
- if (has("h264_vaapi") && renderNodes.length > 0) {
583
- candidates.push(vaapiDescriptor(renderNodes[0]));
584
- }
585
- // h264_v4l2m2m (ARM SoC / Raspberry Pi / HA Yellow). It is gated behind the
586
- // strict keyframe-alignment test below, because some V4L2 M2M builds silently
587
- // emit a corrupted / non-IDR-aligned stream; the test rejects those and the
588
- // host falls back to software libx264.
589
- if (has("h264_v4l2m2m") && hasV4l2Device()) {
590
- candidates.push(v4l2m2mDescriptor());
591
- }
592
-
593
- for (const candidate of candidates) {
594
- const dir = mkdtempSync(path.join(os.tmpdir(), "tt-hwtest-"));
595
- let ok = false;
596
- try {
597
- const encoded = await runFfmpeg(
598
- ffmpegBin,
599
- buildEncoderTestArgs(candidate, segmentDurationSec, dir),
600
- 25000
601
- );
602
- if (encoded.code === 0) {
603
- ok = await verifySegmentsDecodeCleanly(ffmpegBin, dir);
604
- }
605
- } finally {
606
- try {
607
- rmSync(dir, { recursive: true, force: true });
608
- } catch {
609
- // best effort
610
- }
611
- }
612
- if (ok) {
613
- log.info(
614
- `hwaccel: using hardware encoder ${candidate.name}` +
615
- `${candidate.device ? ` (${candidate.device})` : ""}`
616
- );
617
- return candidate;
618
- }
619
- log.warn(`hwaccel: ${candidate.name} failed the HLS keyframe-alignment test; skipping`);
620
- }
621
-
622
- log.info("hwaccel: no working hardware encoder; using software libx264");
623
- return software;
624
- }
625
-
626
- /**
627
- * Detect whether this ffmpeg build has the filters needed for the HDR→SDR
628
- * tone-map chain (`zscale`, from libzimg, and `tonemap`). Both are required;
629
- * when either is missing, HDR sources are re-encoded without tone mapping
630
- * (washed-out but playable). Always resolves.
631
- *
632
- * @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void } }} options
633
- * @returns {Promise<boolean>}
634
- */
635
- export async function detectTonemapSupport({ ffmpegBin, logger }) {
636
- const log = logger ?? { info: () => {}, warn: () => {} };
637
- const { code, stdout } = await runFfmpeg(ffmpegBin, ["-hide_banner", "-filters"], 10000);
638
- if (code !== 0) {
639
- log.warn("hwaccel: could not list ffmpeg filters; HDR tone mapping disabled");
640
- return false;
641
- }
642
- // `-filters` prints one filter per line: "... zscale ...", "... tonemap ...".
643
- const hasZscale = /\bzscale\b/.test(stdout);
644
- const hasTonemap = /\btonemap\b/.test(stdout);
645
- const supported = hasZscale && hasTonemap;
646
- log.info(
647
- `hwaccel: HDR tone mapping ${supported ? "available" : "unavailable"} ` +
648
- `(zscale=${hasZscale} tonemap=${hasTonemap})`
649
- );
650
- return supported;
651
- }
652
-
653
- /**
654
- * Solve a 3×3 linear system by Gaussian elimination with partial pivoting.
655
- *
656
- * @param {number[][]} rows - Three rows of [c0, c1, c2, rhs].
657
- * @returns {number[] | null} The three unknowns, or null when singular.
658
- */
659
- function solveLinear3(rows) {
660
- const m = rows.map((row) => [...row]);
661
- for (let col = 0; col < 3; col += 1) {
662
- let pivot = col;
663
- for (let row = col + 1; row < 3; row += 1) {
664
- if (Math.abs(m[row][col]) > Math.abs(m[pivot][col])) {
665
- pivot = row;
666
- }
667
- }
668
- if (Math.abs(m[pivot][col]) < 1e-12) {
669
- return null;
670
- }
671
- [m[col], m[pivot]] = [m[pivot], m[col]];
672
- for (let row = 0; row < 3; row += 1) {
673
- if (row === col) {
674
- continue;
675
- }
676
- const factor = m[row][col] / m[col][col];
677
- for (let k = col; k < 4; k += 1) {
678
- m[row][k] -= factor * m[col][k];
679
- }
680
- }
681
- }
682
- return [m[0][3] / m[0][0], m[1][3] / m[1][1], m[2][3] / m[2][2]];
683
- }
684
-
685
- // The clips the decode cost is solved from. They ship with the package
686
- // (`assets/calibration/`), cut from Netflix Open Content "Meridian" (CC-BY 4.0)
687
- // — real, grainy live action, because a generated `testsrc2` clip decodes 158 %
688
- // away from a real film where these are 11 % away (measured 2026-08-14). Two
689
- // share a pixel count and differ 11.7× in bitrate, the third has the same
690
- // bitrate class at fewer pixels: three points, three unknowns.
691
- const CALIBRATION_CLIPS = ["cal-1080-hi.mp4", "cal-1080-lo.mp4", "cal-720.mp4"];
692
- const CALIBRATION_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "assets", "calibration");
693
- // How wide the measured window must be before the slope is trusted, and how
694
- // long to wait for it at most. A second of decoding is thousands of frames on a
695
- // quick host and dozens on a weak one; both give a slope, and neither costs the
696
- // startup more than a second per clip.
697
- const DECODE_WINDOW_MIN_SEC = 1;
698
- const DECODE_WINDOW_MAX_MS = 8000;
699
-
700
- /**
701
- * Read what a calibration clip IS from the decode run's own output: the
702
- * dimensions, the frame rate and the bitrate ffmpeg reports for it. Read rather
703
- * than declared, so replacing a clip cannot silently invalidate the fit.
704
- *
705
- * @param {string} stderr
706
- * @returns {{ megapixelsPerSecond: number, megabitsPerSecond: number, durationSeconds: number } | null}
707
- */
708
- function parseClipCharacteristics(stderr) {
709
- // The same readers the session manager uses on the same banner one parser
710
- // per fact, so a second copy cannot drift from the first.
711
- const { width, height } = parseFfmpegVideoDimensions(stderr);
712
- const rate = parseFfmpegVideoFps(stderr);
713
- const seconds = parseFfmpegDurationSeconds(stderr);
714
- const kbps = parseFfmpegBitrateKbps(stderr);
715
- if (!(width > 0) || !(height > 0) || !(rate > 0) || !(seconds > 0) || !(kbps > 0)) {
716
- return null;
717
- }
718
- return {
719
- megapixelsPerSecond: (width * height * rate) / 1e6,
720
- megabitsPerSecond: kbps / 1000,
721
- durationSeconds: seconds
722
- };
723
- }
724
-
725
- /**
726
- * Measure what DECODING costs on this host, as seconds of work per second of
727
- * video, and solve it into three host constants:
728
- *
729
- * decodeCost = a × Mpixel/s + b × Mbit/s + c
730
- *
731
- * Why it exists: the preset benchmark below measures ENCODING only, and a
732
- * re-encode pays for both halves. Measured 2026-08-14 on the addon host, that
733
- * omission made the budget offer a 240p rung it then ran at 0.39-0.95× — the
734
- * benchmark said the host cleared the bar 2.5× over. With the decode term the
735
- * same file predicts within 4.8 %; without it the error on that rung was 209 %.
736
- *
737
- * The constants are properties of the HOST, so this runs once at startup (about
738
- * 5 s on a CM4) and any source is then priced from figures the probe already
739
- * has nothing is added to a session's cold start.
740
- *
741
- * They are also properties of the CODEC, and the clips are H.264: HEVC, AV1 and
742
- * 10-bit decode dearer per pixel on the same machine, and a source that has to
743
- * be re-encoded is by definition one this browser could not play, which is
744
- * usually not H.264. So the fit is optimistic exactly there. Closing that needs
745
- * clips in those codecs, and is its own roadmap item.
746
- *
747
- * @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void }, clipsDir?: string }} options
748
- * @returns {Promise<{ pixelTerm: number, bitrateTerm: number, constantTerm: number } | null>}
749
- */
750
- export async function benchmarkDecodeCost({ ffmpegBin, logger, clipsDir = CALIBRATION_DIR }) {
751
- const log = logger ?? { info: () => {}, warn: () => {} };
752
- const startedAllAt = Date.now();
753
- /** @type {number[][]} */
754
- const equations = [];
755
- for (const clip of CALIBRATION_CLIPS) {
756
- const measured = await measureDecodeSlope(ffmpegBin, path.join(clipsDir, clip));
757
- if (!measured) {
758
- log.warn(`hwaccel: decode benchmark "${clip}" failed or said nothing; decode cost unknown`);
759
- return null;
760
- }
761
- const cost = 1 / measured.speed;
762
- equations.push([measured.megapixelsPerSecond, measured.megabitsPerSecond, 1, cost]);
763
- log.info(
764
- `hwaccel: decode "${clip}" ${measured.megapixelsPerSecond.toFixed(1)} Mpx/s ` +
765
- `${measured.megabitsPerSecond.toFixed(2)} Mbit/s -> ${measured.speed.toFixed(1)}x ` +
766
- `(cost ${cost.toFixed(4)} s/s, over ${measured.windowSec.toFixed(1)}s of decoding)`
767
- );
768
- }
769
- const fitted = fitDecodeCost(equations);
770
- if (!fitted) {
771
- log.warn("hwaccel: decode cost could not be fitted to these measurements; decode cost unknown");
772
- return null;
773
- }
774
- log.info(
775
- `hwaccel: decode cost = ${fitted.pixelTerm.toFixed(6)} × Mpx/s + ${fitted.bitrateTerm.toFixed(6)} × Mbit/s ` +
776
- `+ ${fitted.constantTerm.toFixed(4)} s/s (${fitted.shape}, measured in ${((Date.now() - startedAllAt) / 1000).toFixed(1)}s)`
777
- );
778
- return { pixelTerm: fitted.pixelTerm, bitrateTerm: fitted.bitrateTerm, constantTerm: fitted.constantTerm };
779
- }
780
-
781
- /**
782
- * Measure how fast this host DECODES a clip, from ffmpeg’s own report of how
783
- * much video it has processed.
784
- *
785
- * Wall-clock around the process cannot answer this: starting ffmpeg costs about
786
- * a second, and on a quick machine a five-second clip decodes in a tenth of
787
- * that, so the measurement would be of the program starting. Progress lines
788
- * arrive twice a second AFTER it has started, and the slope between two of them
789
- * video processed against time taken — contains no part of the startup by
790
- * construction.
791
- *
792
- * The clip is looped forever and the process killed as soon as the window is
793
- * wide enough, so the cost is bounded by the clock rather than by the clip:
794
- * roughly a second of measurement on any host, quick or slow.
795
- *
796
- * @param {string} ffmpegBin
797
- * @param {string} clipPath
798
- * @returns {Promise<{ speed: number, windowSec: number, megapixelsPerSecond: number, megabitsPerSecond: number } | null>}
799
- */
800
- function measureDecodeSlope(ffmpegBin, clipPath) {
801
- return new Promise((resolve) => {
802
- const args = [
803
- "-hide_banner", "-loglevel", "info", "-nostats",
804
- "-stream_loop", "-1",
805
- "-i", clipPath,
806
- "-an", "-f", "null", "-",
807
- "-progress", "pipe:1"
808
- ];
809
- /** @type {Array<{ wallSec: number, outSec: number }>} */
810
- const samples = [];
811
- let stderr = "";
812
- let stdout = "";
813
- let settled = false;
814
- let child;
815
- const startedAt = Date.now();
816
- const finish = () => {
817
- if (settled) {
818
- return;
819
- }
820
- settled = true;
821
- clearTimeout(timer);
822
- try {
823
- child?.kill("SIGKILL");
824
- } catch {
825
- // already gone
826
- }
827
- // The first sample is the one that still carries the startup — it reports
828
- // whatever was processed while the process was coming up. Everything is
829
- // measured from the second onwards.
830
- const first = samples[1];
831
- const last = samples[samples.length - 1];
832
- const clipInfo = parseClipCharacteristics(stderr);
833
- if (!first || !last || !clipInfo) {
834
- resolve(null);
835
- return;
836
- }
837
- const windowSec = last.wallSec - first.wallSec;
838
- const producedSec = last.outSec - first.outSec;
839
- if (!(windowSec >= DECODE_WINDOW_MIN_SEC) || !(producedSec > 0)) {
840
- resolve(null);
841
- return;
842
- }
843
- resolve({
844
- speed: producedSec / windowSec,
845
- windowSec,
846
- megapixelsPerSecond: clipInfo.megapixelsPerSecond,
847
- megabitsPerSecond: clipInfo.megabitsPerSecond
848
- });
849
- };
850
- const timer = setTimeout(finish, DECODE_WINDOW_MAX_MS);
851
- try {
852
- child = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
853
- } catch {
854
- // The timer would otherwise hold the event loop for its full wait and
855
- // then run against a child that was never created.
856
- clearTimeout(timer);
857
- settled = true;
858
- resolve(null);
859
- return;
860
- }
861
- child.stderr.on("data", (chunk) => {
862
- stderr += String(chunk);
863
- });
864
- child.stdout.on("data", (chunk) => {
865
- stdout += String(chunk);
866
- let newline = stdout.indexOf("\n");
867
- while (newline >= 0) {
868
- const line = stdout.slice(0, newline).trim();
869
- stdout = stdout.slice(newline + 1);
870
- if (line.startsWith("out_time_ms=")) {
871
- const microseconds = Number(line.slice("out_time_ms=".length));
872
- if (Number.isFinite(microseconds)) {
873
- samples.push({ wallSec: (Date.now() - startedAt) / 1000, outSec: microseconds / 1e6 });
874
- }
875
- }
876
- newline = stdout.indexOf("\n");
877
- }
878
- if (samples.length >= 2 && samples[samples.length - 1].wallSec - samples[1].wallSec >= DECODE_WINDOW_MIN_SEC) {
879
- finish();
880
- }
881
- });
882
- child.on("error", () => {
883
- if (settled) {
884
- return;
885
- }
886
- clearTimeout(timer);
887
- settled = true;
888
- resolve(null);
889
- });
890
- child.on("close", finish);
891
- });
892
- }
893
-
894
- /**
895
- * Fit the three measurements, and say which shape the data supported.
896
- *
897
- * The three-term fit is exact — three points, three unknowns — and is used
898
- * whenever every term comes out non-negative. A negative term is not a host
899
- * being odd; it says the difference it was solved from is smaller than the
900
- * noise between runs, which is what a fast machine produces: measured on a
901
- * desktop, the 720p clip took LONGER per second than the low-bitrate 1080p one,
902
- * because process startup is a large share of a decode that takes a second.
903
- *
904
- * When that happens the bitrate term — the weak one, and the one solved from a
905
- * single difference — is dropped and the remaining two are fitted by least
906
- * squares over all three points. If even the pixel slope comes out non-positive
907
- * there is no measurable dependence on the source at all, and inventing one is
908
- * worse than having none: the caller then prices the encoder alone and refuses
909
- * nothing.
910
- *
911
- * @param {number[][]} equations - Rows of [Mpixel/s, Mbit/s, 1, cost].
912
- * @returns {{ pixelTerm: number, bitrateTerm: number, constantTerm: number, shape: string } | null}
913
- */
914
- function fitDecodeCost(equations) {
915
- const exact = solveLinear3(equations);
916
- if (exact && exact[0] > 0 && exact[1] >= 0 && exact[2] >= 0) {
917
- return { pixelTerm: exact[0], bitrateTerm: exact[1], constantTerm: exact[2], shape: "pixels+bitrate+constant" };
918
- }
919
- const count = equations.length;
920
- const meanPixels = equations.reduce((sum, row) => sum + row[0], 0) / count;
921
- const meanCost = equations.reduce((sum, row) => sum + row[3], 0) / count;
922
- let covariance = 0;
923
- let variance = 0;
924
- for (const row of equations) {
925
- covariance += (row[0] - meanPixels) * (row[3] - meanCost);
926
- variance += (row[0] - meanPixels) ** 2;
927
- }
928
- if (!(variance > 0)) {
929
- return null;
930
- }
931
- const pixelTerm = covariance / variance;
932
- const constantTerm = meanCost - pixelTerm * meanPixels;
933
- if (pixelTerm > 0 && constantTerm >= 0) {
934
- return { pixelTerm, bitrateTerm: 0, constantTerm, shape: "pixels+constant" };
935
- }
936
- // A negative constant is the line crossing below zero where no clip was
937
- // measured every clip is 22 Mpixel/s or more, and nothing here says what a
938
- // tiny picture costs. Rather than carry a term that would price a small
939
- // source as free work, fit through the origin: cost proportional to pixels,
940
- // which is the relationship the measurements do support.
941
- let weighted = 0;
942
- let squares = 0;
943
- for (const row of equations) {
944
- weighted += row[0] * row[3];
945
- squares += row[0] ** 2;
946
- }
947
- const throughOrigin = squares > 0 ? weighted / squares : 0;
948
- if (!(throughOrigin > 0)) {
949
- return null;
950
- }
951
- return { pixelTerm: throughOrigin, bitrateTerm: 0, constantTerm: 0, shape: "pixels only" };
952
- }
953
-
954
- /**
955
- * How many times realtime this host can DECODE a source of these
956
- * characteristics, from the startup fit. `null` when the fit is unavailable or
957
- * the source figures are not known.
958
- *
959
- * @param {{ pixelTerm: number, bitrateTerm: number, constantTerm: number } | null} model
960
- * @param {{ megapixelsPerSecond: number, megabitsPerSecond: number }} source
961
- * @returns {number | null}
962
- */
963
- export function decodeSpeedFor(model, source) {
964
- if (!model) {
965
- return null;
966
- }
967
- const pixels = Number(source?.megapixelsPerSecond);
968
- const bits = Number(source?.megabitsPerSecond);
969
- if (!Number.isFinite(pixels) || pixels <= 0 || !Number.isFinite(bits) || bits < 0) {
970
- return null;
971
- }
972
- const cost = model.pixelTerm * pixels + model.bitrateTerm * bits + model.constantTerm;
973
- if (!(cost > 0)) {
974
- return null;
975
- }
976
- return 1 / cost;
977
- }
978
-
979
- /**
980
- * How many times realtime a re-encode of this source at this output pixel rate
981
- * would run: decoding and encoding share the machine, so their costs add and
982
- * their speeds combine as
983
- *
984
- * 1 / (1/decodeSpeed + 1/encodeSpeed)
985
- *
986
- * Checked 2026-08-14 on the rung that broke playback: 1/(1/2.31 + 1/5.99) =
987
- * 1.67× against 1.48× measured. With no decode fit this falls back to the
988
- * encode speed alone — which is what the budget did before, and which
989
- * overestimated that rung five to eleven times.
990
- *
991
- * @param {{ decodeModel: { pixelTerm: number, bitrateTerm: number, constantTerm: number } | null, encodePixelsPerSec: number, outputPixelsPerSec: number, source: { megapixelsPerSecond: number, megabitsPerSecond: number } | null }} params
992
- * @returns {number | null}
993
- */
994
- export function predictedRealtimeSpeed({
995
- decodeModel,
996
- encodePixelsPerSec,
997
- outputPixelsPerSec,
998
- source,
999
- observedDecodeCostSec = null
1000
- }) {
1001
- if (!Number.isFinite(encodePixelsPerSec) || encodePixelsPerSec <= 0) {
1002
- return null;
1003
- }
1004
- if (!Number.isFinite(outputPixelsPerSec) || outputPixelsPerSec <= 0) {
1005
- return null;
1006
- }
1007
- const encodeSpeed = encodePixelsPerSec / outputPixelsPerSec;
1008
- // What this very file has been seen to cost, when it has been: the clips are
1009
- // H.264 and a source that has to be re-encoded usually is not, so a figure
1010
- // taken from the encoder actually running on THIS source beats any model of
1011
- // a stand-in. It arrives seconds into playback and replaces the estimate.
1012
- const decodeSpeed = Number.isFinite(observedDecodeCostSec) && observedDecodeCostSec > 0
1013
- ? 1 / observedDecodeCostSec
1014
- : (source ? decodeSpeedFor(decodeModel, source) : null);
1015
- if (decodeSpeed === null) {
1016
- return encodeSpeed;
1017
- }
1018
- return 1 / (1 / decodeSpeed + 1 / encodeSpeed);
1019
- }
1020
-
1021
- /**
1022
- * Whether this host can hold realtime, with the margin, while re-encoding this
1023
- * source to this output pixel rate — and the predicted speed either way, so a
1024
- * refusal can say what it refused on.
1025
- *
1026
- * The encoder figure is the FASTEST benchmarked preset: it is the best this
1027
- * host can do, so a rung it cannot hold cannot be held at any quality setting.
1028
- *
1029
- * @param {{ benchmark: Array<{ preset: string, pixelsPerSec: number }>, decodeModel?: object | null, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null, outputPixelsPerSec: number }} params
1030
- * @returns {{ speed: number | null, sustainable: boolean }}
1031
- */
1032
- export function canSustainOutput({
1033
- benchmark,
1034
- decodeModel = null,
1035
- source = null,
1036
- outputPixelsPerSec,
1037
- observedDecodeCostSec = null
1038
- }) {
1039
- if (!Array.isArray(benchmark) || benchmark.length === 0) {
1040
- // Nothing measured on this host: the budget cannot refuse what it cannot
1041
- // price, and refusing everything would leave a viewer with no rung at all.
1042
- return { speed: null, sustainable: true };
1043
- }
1044
- const observed = Number.isFinite(observedDecodeCostSec) && observedDecodeCostSec > 0
1045
- ? observedDecodeCostSec
1046
- : null;
1047
- if (observed === null && !isDecodePriced({ decodeModel, source })) {
1048
- // An encoder-only figure was several times too optimistic on the rung this
1049
- // check exists for, so it is not fit to refuse anything. Without the decode
1050
- // term the ladder is offered whole, exactly as it was before.
1051
- return { speed: null, sustainable: true };
1052
- }
1053
- const speed = predictedRealtimeSpeed({
1054
- decodeModel,
1055
- encodePixelsPerSec: benchmark[benchmark.length - 1].pixelsPerSec,
1056
- outputPixelsPerSec,
1057
- source,
1058
- observedDecodeCostSec: observed
1059
- });
1060
- if (speed === null) {
1061
- return { speed: null, sustainable: true };
1062
- }
1063
- return { speed, sustainable: speed >= PRESET_SPEED_MARGIN };
1064
- }
1065
-
1066
- /** The margin a predicted speed must clear to be offered. */
1067
- export const REALTIME_SPEED_MARGIN = PRESET_SPEED_MARGIN;
1068
-
1069
- /**
1070
- * Benchmark software libx264 presets on this host. Encodes a short synthetic
1071
- * clip at a fixed reference resolution with each preset and measures encoder
1072
- * throughput in pixels/second. The session manager uses this to pick, per
1073
- * stream, the highest-quality preset that still encodes the actual
1074
- * (source-capped) resolution faster than realtime.
1075
- *
1076
- * Runs once at startup; bounded by a per-encode timeout. Presets that fail are
1077
- * omitted from the result.
1078
- *
1079
- * @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void } }} options
1080
- * @returns {Promise<Array<{ preset: string, pixelsPerSec: number }>>} Ordered slowest→fastest.
1081
- */
1082
- export async function benchmarkSoftwarePresets({ ffmpegBin, logger }) {
1083
- const log = logger ?? { info: () => {}, warn: () => {} };
1084
- const totalPixels = BENCHMARK_REF_W * BENCHMARK_REF_H * TRANSCODE_FPS * BENCHMARK_DURATION_SEC;
1085
- /** @type {Array<{ preset: string, pixelsPerSec: number }>} */
1086
- const results = [];
1087
- for (const preset of BENCHMARK_PRESETS) {
1088
- const args = [
1089
- "-hide_banner", "-loglevel", "error",
1090
- "-f", "lavfi", "-i", `testsrc2=s=${BENCHMARK_REF_W}x${BENCHMARK_REF_H}:r=${TRANSCODE_FPS}:d=${BENCHMARK_DURATION_SEC}`,
1091
- "-c:v", "libx264", "-preset", preset, "-crf", SOFTWARE_CRF, "-pix_fmt", "yuv420p",
1092
- "-f", "null", "-"
1093
- ];
1094
- const startedAt = Date.now();
1095
- const { code } = await runFfmpeg(ffmpegBin, args, 30000);
1096
- const elapsedSec = (Date.now() - startedAt) / 1000;
1097
- if (code !== 0 || elapsedSec <= 0) {
1098
- log.warn(`hwaccel: preset benchmark "${preset}" failed; skipping`);
1099
- continue;
1100
- }
1101
- const pixelsPerSec = totalPixels / elapsedSec;
1102
- results.push({ preset, pixelsPerSec });
1103
- log.info(
1104
- `hwaccel: preset "${preset}" ~= ${(pixelsPerSec / 1e6).toFixed(1)} Mpx/s ` +
1105
- `(${(BENCHMARK_DURATION_SEC / elapsedSec).toFixed(2)}x @ ${BENCHMARK_REF_W}x${BENCHMARK_REF_H})`
1106
- );
1107
- }
1108
- return results;
1109
- }
1110
-
1111
- /**
1112
- * Pick the highest-quality (slowest) benchmarked preset that can encode
1113
- * `pixelsPerSecNeeded` with the speed margin. Falls back to the fastest
1114
- * benchmarked preset, or `"ultrafast"` when no benchmark is available.
1115
- *
1116
- * @param {Array<{ preset: string, pixelsPerSec: number }>} benchmark - slowest→fastest
1117
- * @param {number} pixelsPerSecNeeded
1118
- * @param {{ decodeModel?: object | null, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null }} [cost]
1119
- * @returns {string}
1120
- */
1121
- export function pickSoftwarePreset(benchmark, pixelsPerSecNeeded, cost = {}) {
1122
- if (!Array.isArray(benchmark) || benchmark.length === 0) {
1123
- return "ultrafast";
1124
- }
1125
- const observed = Number.isFinite(cost?.observedDecodeCostSec) && cost.observedDecodeCostSec > 0
1126
- ? cost.observedDecodeCostSec
1127
- : null;
1128
- const priced = isDecodePriced(cost);
1129
- const bar = priced ? PRESET_SPEED_MARGIN : ENCODE_ONLY_SPEED_MARGIN;
1130
- for (const entry of benchmark) {
1131
- const speed = predictedRealtimeSpeed({
1132
- decodeModel: cost.decodeModel ?? null,
1133
- encodePixelsPerSec: entry.pixelsPerSec,
1134
- outputPixelsPerSec: pixelsPerSecNeeded,
1135
- source: cost.source ?? null,
1136
- observedDecodeCostSec: observed
1137
- });
1138
- if (speed !== null && speed >= bar) {
1139
- return entry.preset;
1140
- }
1141
- }
1142
- return benchmark[benchmark.length - 1].preset;
1143
- }
1144
-
1145
- /**
1146
- * Whether a cost description can actually price decoding — a fit AND a source
1147
- * to apply it to. Without both, every prediction is encoder-only.
1148
- *
1149
- * @param {{ decodeModel?: object | null, source?: object | null }} cost
1150
- * @returns {boolean}
1151
- */
1152
- function isDecodePriced(cost) {
1153
- if (Number.isFinite(cost?.observedDecodeCostSec) && cost.observedDecodeCostSec > 0) {
1154
- return true; // measured on the source itself, which needs no fit to stand on
1155
- }
1156
- return Boolean(cost?.decodeModel) && Boolean(cost?.source);
1157
- }
1158
-
1159
- // Resolution-ladder heights (output height rungs), high→low. The ladder is
1160
- // derived per-stream from the ceiling (the client-requested, source-capped
1161
- // output box): only rungs at or below the ceiling height are used, so the
1162
- // budget never upscales past what the client asked for. Standard heights keep
1163
- // the downscaled output at familiar resolutions.
1164
- const RESOLUTION_LADDER_HEIGHTS = [2160, 1440, 1080, 720, 540, 480, 360, 240];
1165
-
1166
- /**
1167
- * Build the resolution ladder for a ceiling box. Returns candidate output
1168
- * dimensions from the ceiling downward, preserving the ceiling's aspect ratio,
1169
- * each even-sized. The ceiling itself is always the top rung; ladder heights
1170
- * at or above it are skipped (never upscale). Deduped by height.
1171
- *
1172
- * @param {number} ceilingWidth
1173
- * @param {number} ceilingHeight
1174
- * @returns {Array<{ width: number, height: number }>} high→low
1175
- */
1176
- export function buildResolutionLadder(ceilingWidth, ceilingHeight) {
1177
- const cw = Number.isInteger(ceilingWidth) && ceilingWidth > 0 ? ceilingWidth : 0;
1178
- const ch = Number.isInteger(ceilingHeight) && ceilingHeight > 0 ? ceilingHeight : 0;
1179
- if (!cw || !ch) {
1180
- return [];
1181
- }
1182
- const even = (v) => {
1183
- const r = Math.round(v);
1184
- return Math.max(2, r - (r % 2));
1185
- };
1186
- /** @type {Array<{ width: number, height: number }>} */
1187
- const rungs = [{ width: cw, height: ch }];
1188
- for (const h of RESOLUTION_LADDER_HEIGHTS) {
1189
- if (h >= ch) {
1190
- continue; // at/above the ceiling the ceiling rung already covers it
1191
- }
1192
- rungs.push({ width: even(cw * (h / ch)), height: h });
1193
- }
1194
- const seen = new Set();
1195
- return rungs.filter((rung) => {
1196
- if (seen.has(rung.height)) {
1197
- return false;
1198
- }
1199
- seen.add(rung.height);
1200
- return true;
1201
- });
1202
- }
1203
-
1204
- /**
1205
- * Choose the software encode settings (resolution + preset) that fit the
1206
- * realtime budget on this host. From the resolution ladder (ceiling downward),
1207
- * pick the HIGHEST rung whose encode throughput — predicted from the startup
1208
- * benchmark's fastest preset — clears realtime × PRESET_SPEED_MARGIN. Then, at
1209
- * that resolution, pick the highest-quality preset that still clears the
1210
- * margin. When even the lowest rung cannot clear it, use the lowest rung with
1211
- * the fastest preset (best effort — a smaller picture beats sub-realtime
1212
- * playback at full size). Returns null when no benchmark or ceiling is
1213
- * available (the caller keeps the ceiling resolution and the default preset).
1214
- *
1215
- * @param {Array<{ preset: string, pixelsPerSec: number }>} benchmark - slowest→fastest
1216
- * @param {{ width: number, height: number }} ceiling
1217
- * @param {number} outputFps
1218
- * @param {{ decodeModel?: object | null, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null }} [cost]
1219
- * @returns {{ width: number, height: number, preset: string, ladder: Array<{ width: number, height: number }>, rungIndex: number } | null}
1220
- */
1221
- export function chooseSoftwareEncodeSettings(benchmark, ceiling, outputFps, cost = {}) {
1222
- if (!Array.isArray(benchmark) || benchmark.length === 0) {
1223
- return null;
1224
- }
1225
- const fps = Number.isFinite(outputFps) && outputFps > 0 ? outputFps : TRANSCODE_FPS;
1226
- const ladder = buildResolutionLadder(ceiling?.width, ceiling?.height);
1227
- if (ladder.length === 0) {
1228
- return null;
1229
- }
1230
- const fastest = benchmark[benchmark.length - 1].pixelsPerSec; // ultrafast throughput
1231
- const bar = isDecodePriced(cost) ? PRESET_SPEED_MARGIN : ENCODE_ONLY_SPEED_MARGIN;
1232
- let chosenIndex = ladder.length - 1; // default: lowest rung (best effort)
1233
- for (let i = 0; i < ladder.length; i += 1) {
1234
- const speed = predictedRealtimeSpeed({
1235
- decodeModel: cost.decodeModel ?? null,
1236
- encodePixelsPerSec: fastest,
1237
- outputPixelsPerSec: ladder[i].width * ladder[i].height * fps,
1238
- source: cost.source ?? null
1239
- });
1240
- if (speed !== null && speed >= bar) {
1241
- chosenIndex = i;
1242
- break;
1243
- }
1244
- }
1245
- const chosen = ladder[chosenIndex];
1246
- const preset = pickSoftwarePreset(benchmark, chosen.width * chosen.height * fps, cost);
1247
- return { width: chosen.width, height: chosen.height, preset, ladder, rungIndex: chosenIndex };
1248
- }
1
+ /**
2
+ * @file Hardware-accelerated H.264 encoder auto-detection.
3
+ *
4
+ * Probes the ffmpeg build and the host for a usable hardware H.264 encoder
5
+ * (NVENC / QSV / VAAPI / V4L2 M2M), verifying each candidate with a real
6
+ * test-encode before selecting it. Falls back to software libx264 when no
7
+ * hardware encoder is present or working.
8
+ *
9
+ * Deployment-agnostic: relies only on ffmpeg, the filesystem and
10
+ * `process.platform`; makes no assumptions about Home Assistant or any
11
+ * specific host. A garbled or unsupported hardware path simply fails its
12
+ * test-encode and is skipped, so the worst case is software encoding.
13
+ *
14
+ * A descriptor exposes:
15
+ * - `name` human-readable encoder id (e.g. "h264_vaapi")
16
+ * - `kind` "software" | "vaapi" | "qsv" | "nvenc" | "v4l2m2m"
17
+ * - `device` device node path or null
18
+ * - `inputArgs` ffmpeg args inserted before `-i` (decode/hwaccel setup)
19
+ * - `buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec })`
20
+ * ffmpeg video filter + encoder args inserted after `-map`s
21
+ */
22
+
23
+ import { spawn } from "node:child_process";
24
+ import { mkdtempSync, readdirSync, rmSync, statSync } from "node:fs";
25
+ import os from "node:os";
26
+ import path from "node:path";
27
+ import { fileURLToPath } from "node:url";
28
+ import {
29
+ parseFfmpegBitrateKbps,
30
+ parseFfmpegDurationSeconds,
31
+ parseFfmpegVideoDimensions,
32
+ parseFfmpegVideoFps
33
+ } from "./ffmpeg-banner.js";
34
+
35
+ const SOFTWARE_PRESET = "ultrafast";
36
+ const SOFTWARE_CRF = "24";
37
+ // HDR→SDR tone-map chain (software). Converts a BT.2020 PQ/HLG source to BT.709
38
+ // 8-bit SDR so the re-encode is not washed-out/desaturated. Requires the
39
+ // `zscale` (libzimg) and `tonemap` filters — gated by detectTonemapSupport;
40
+ // when unavailable the encode falls back to a plain 8-bit convert (no tonemap).
41
+ // npl=100 targets ~100-nit SDR; hable is a well-behaved tone-mapping operator.
42
+ const TONEMAP_FILTER_CHAIN =
43
+ "zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709," +
44
+ "tonemap=tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuv420p";
45
+ // Default output frame rate when the source rate is unknown, and the rate used
46
+ // by the synthetic startup test-encode / preset benchmark. The real encode
47
+ // inherits the source rate (rounded to an integer, capped) — see
48
+ // chooseOutputFps — so 25/30 fps content no longer plays resampled to 24.
49
+ export const TRANSCODE_FPS = 24;
50
+ // Upper bound on the output frame rate: 50/60 fps sources are halved-in-effort
51
+ // by capping to 30, protecting the realtime encode budget on weak hosts.
52
+ export const MAX_OUTPUT_FPS = 30;
53
+
54
+ /**
55
+ * Choose an INTEGER output frame rate from the (possibly fractional) source
56
+ * rate, for the frame-count-GOP encoders ONLY (software libx264, v4l2m2m).
57
+ * Those place keyframes with `-g = segmentDur × fps` (frame count), so the
58
+ * `fps=` filter value must be an integer that makes seg×fps an exact whole
59
+ * number of frames per segment — otherwise segments drift off the synthetic
60
+ * playlist's uniform grid and seek accuracy degrades over a long file. Film
61
+ * rates (23.976) round to 24, 25 stays 25, 29.97 rounds to 30; the cap clamps
62
+ * high rates (the cap is a SPEED guard for the weak software/v4l2m2m path).
63
+ *
64
+ * Time-based-keyframe encoders (nvenc, vaapi, qsv) do NOT use this — they
65
+ * inherit the exact source rate untouched (their keyframes are forced by
66
+ * output time, so any rate segments correctly).
67
+ *
68
+ * @param {number | null | undefined} sourceFps
69
+ * @param {number} [cap=MAX_OUTPUT_FPS]
70
+ * @returns {number}
71
+ */
72
+ export function chooseOutputFps(sourceFps, cap = MAX_OUTPUT_FPS) {
73
+ if (!Number.isFinite(sourceFps) || sourceFps <= 0) {
74
+ return TRANSCODE_FPS;
75
+ }
76
+ const rounded = Math.round(sourceFps);
77
+ if (rounded < 1) {
78
+ return TRANSCODE_FPS;
79
+ }
80
+ return Math.min(cap, rounded);
81
+ }
82
+ // Software x264 on weak ARM hosts is the transcode bottleneck — use all cores.
83
+ const CPU_THREADS = Math.max(1, os.cpus().length);
84
+
85
+ // Bitrate caps (constrained CRF). CRF stays the quality driver; -maxrate/
86
+ // -bufsize only bound the peaks. Field evidence (iPhone on cellular,
87
+ // 2026-07-10): uncapped complex scenes produced 4 s segments of ~18 Mbit/s
88
+ // against a 1-6 Mbit/s viewer link — 45 s prebuffer, draining buffer.
89
+ // Nominal H.264 rates per rung height; multipliers from webtor's production
90
+ // ladder (content-transcoder): maxrate = 1.3x nominal, bufsize = 1.5x.
91
+ const RUNG_NOMINAL_KBPS = [
92
+ [1080, 5000],
93
+ [720, 2800],
94
+ [480, 1400],
95
+ [360, 800],
96
+ [240, 400]
97
+ ];
98
+ const CAP_MAXRATE_FACTOR = 1.3;
99
+ const CAP_BUFSIZE_FACTOR = 1.5;
100
+
101
+ /**
102
+ * Nominal kbps for an encode height: nearest rung wins (odd heights snap to
103
+ * the closest standard rung; anything above the top rung uses the top one).
104
+ *
105
+ * @param {number} height
106
+ * @returns {number}
107
+ */
108
+ export function nominalKbpsForHeight(height) {
109
+ const h = Number.isFinite(height) && height > 0 ? height : 720;
110
+ let best = RUNG_NOMINAL_KBPS[0];
111
+ for (const rung of RUNG_NOMINAL_KBPS) {
112
+ if (Math.abs(rung[0] - h) < Math.abs(best[0] - h)) {
113
+ best = rung;
114
+ }
115
+ }
116
+ return best[1];
117
+ }
118
+
119
+ /**
120
+ * `-maxrate`/`-bufsize` args for an encode height (constrained CRF).
121
+ *
122
+ * @param {number} height
123
+ * @returns {string[]}
124
+ */
125
+ function bitrateCapArgs(height) {
126
+ const nominal = nominalKbpsForHeight(height);
127
+ return [
128
+ "-maxrate", `${Math.round(nominal * CAP_MAXRATE_FACTOR)}k`,
129
+ "-bufsize", `${Math.round(nominal * CAP_BUFSIZE_FACTOR)}k`
130
+ ];
131
+ }
132
+
133
+ // libx264 presets to benchmark, ordered slowest/highest-quality → fastest.
134
+ const BENCHMARK_PRESETS = ["fast", "faster", "veryfast", "superfast", "ultrafast"];
135
+ const BENCHMARK_REF_W = 640;
136
+ const BENCHMARK_REF_H = 360;
137
+ const BENCHMARK_DURATION_SEC = 3;
138
+ /**
139
+ * The narrowest window a slope may be taken over. Measured 2026-08-15: at a
140
+ * fifth of a second the readings were noisy enough to put `faster` and
141
+ * `veryfast` BELOW `fast`, which libx264 cannot do and `pickSoftwarePreset`
142
+ * walks the list assuming it ascends. Half a second was still noisy enough for that
143
+ * (measured again: veryfast below faster, twice), so a full second it is —
144
+ * about six seconds of startup for a ladder the whole budget then rests on.
145
+ */
146
+ const ENCODE_BENCHMARK_WINDOW_SEC = 1;
147
+ /** The narrowest window that may be used when a run ends early. */
148
+ const ENCODE_BENCHMARK_MIN_WINDOW_SEC = 0.2;
149
+ /** Above this a reading is a fault, not a fast machine. */
150
+ const ENCODE_BENCHMARK_MAX_PLAUSIBLE_SPEED = 1000;
151
+ /**
152
+ * A preset that has not reported twice in this long is hung, not slow: reports
153
+ * arrive twice a second whatever the encoding speed.
154
+ */
155
+ const ENCODE_BENCHMARK_TIMEOUT_MS = 10_000;
156
+ /** Progress reports arrive line by line. */
157
+ const NEWLINE = String.fromCharCode(10);
158
+ // Require the predicted speed to clear realtime by this much. The benchmarks
159
+ // run at startup with an idle CPU; during playback ffmpeg competes with
160
+ // in-process WebTorrent (download + hashing) and delivery, so real throughput
161
+ // is lower, and the margin covers that plus complex scenes.
162
+ //
163
+ // It was 1.8 while the prediction counted ENCODING only and was therefore
164
+ // several times too optimistic on a re-encode; with the decode term the
165
+ // prediction is within ~13 % of measured, so the margin no longer has to stand
166
+ // in for a missing term as well as for load.
167
+ const PRESET_SPEED_MARGIN = 1.5;
168
+ // The bar for a prediction that has NO decode term — a host whose calibration
169
+ // clips are missing, or whose fit was rejected. That figure is the one the
170
+ // margin was 1.8 for, and lowering it there would make an uncalibrated host
171
+ // more permissive than it was before any of this existed.
172
+ const ENCODE_ONLY_SPEED_MARGIN = 1.8;
173
+
174
+ /**
175
+ * @param {number} targetWidth
176
+ * @param {number} targetHeight
177
+ * @returns {{ w: number, h: number }}
178
+ */
179
+ function safeDimensions(targetWidth, targetHeight) {
180
+ const w = Number.isInteger(targetWidth) && targetWidth > 0 ? targetWidth : 1280;
181
+ const h = Number.isInteger(targetHeight) && targetHeight > 0 ? targetHeight : 720;
182
+ return { w, h };
183
+ }
184
+
185
+ /**
186
+ * Force a keyframe on every segment boundary so each HLS segment is
187
+ * independently decodable.
188
+ *
189
+ * Two grids exist. The usual one is even — a keyframe every
190
+ * `segmentDurationSec` — and the encoder is free to place them because it is
191
+ * producing every frame anyway. The other is the SOURCE's own keyframe times,
192
+ * used when this encode has to be interchangeable with a stream that is
193
+ * COPIED: a copy can only be cut where the source already has a keyframe, so a
194
+ * rung meant to splice into it must be cut at exactly those times and nowhere
195
+ * else. Then the times are given outright.
196
+ *
197
+ * @param {number} segmentDurationSec
198
+ * @param {number[] | null} [forcedTimes] - Run-relative seconds, ascending.
199
+ * @returns {string[]}
200
+ */
201
+ function keyFrameArgs(segmentDurationSec, forcedTimes = null) {
202
+ if (Array.isArray(forcedTimes) && forcedTimes.length > 0) {
203
+ return ["-force_key_frames", forcedTimes.join(",")];
204
+ }
205
+ return ["-force_key_frames", `expr:gte(t,n_forced*${segmentDurationSec})`];
206
+ }
207
+
208
+ /**
209
+ * Whether an explicit cut list was supplied.
210
+ *
211
+ * @param {number[] | null | undefined} forcedTimes
212
+ * @returns {boolean}
213
+ */
214
+ function hasForcedTimes(forcedTimes) {
215
+ return Array.isArray(forcedTimes) && forcedTimes.length > 0;
216
+ }
217
+
218
+ /** @returns {import("./hwaccel.js").VideoEncoderDescriptor} */
219
+ export function softwareDescriptor() {
220
+ return {
221
+ name: "libx264",
222
+ kind: "software",
223
+ device: null,
224
+ inputArgs: [],
225
+ buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, preset, fps, tonemap, forcedKeyframeTimes }) {
226
+ const { w, h } = safeDimensions(targetWidth, targetHeight);
227
+ const chosenPreset = typeof preset === "string" && preset.length > 0 ? preset : SOFTWARE_PRESET;
228
+ // Output frame rate: inherited from the source (rounded/capped) by the
229
+ // session manager, TRANSCODE_FPS by default. MUST be an integer and MUST
230
+ // equal the value used in the GOP below, or keyframes drift off the grid.
231
+ const outFps = Number.isInteger(fps) && fps > 0 ? fps : TRANSCODE_FPS;
232
+ // HDR→SDR tone-map, inserted AFTER the downscale so it runs on the smaller
233
+ // frame (cheaper on ARM); only when the source is HDR and the filters are
234
+ // present (session manager gates on both).
235
+ const tonemapPart = tonemap === true ? `,${TONEMAP_FILTER_CHAIN}` : "";
236
+ return [
237
+ // Never upscale: cap the target box to the source size (min with
238
+ // iw/ih), so a small source (e.g. 720x400) is encoded at its own
239
+ // resolution instead of being scaled up to the viewport far fewer
240
+ // pixels, much faster on ARM. force_original_aspect_ratio keeps aspect.
241
+ "-vf",
242
+ `scale='min(${w},iw)':'min(${h},ih)':force_original_aspect_ratio=decrease:force_divisible_by=2${tonemapPart},fps=${outFps}`,
243
+ "-c:v", "libx264",
244
+ // Preset is chosen per stream by the session manager from the startup
245
+ // benchmark (highest quality that still encodes the source resolution
246
+ // faster than realtime); falls back to the static default.
247
+ "-preset", chosenPreset,
248
+ "-crf", SOFTWARE_CRF,
249
+ // Constrained CRF: bound peak bitrate per rung so a complex scene
250
+ // cannot produce segments a thin viewer link (cellular) can't
251
+ // download in time. Sized by the TARGET box height (the rung the
252
+ // budget/manual selection chose).
253
+ ...bitrateCapArgs(h),
254
+ "-threads", String(CPU_THREADS),
255
+ "-pix_fmt", "yuv420p",
256
+ // Fixed GOP: a keyframe exactly every (segmentDurationSec × fps) frames,
257
+ // scene-cut keyframes disabled. This is frame-count based, so it is
258
+ // independent of the PTS offset used on seek-restart every HLS segment
259
+ // is exactly segmentDurationSec long and starts on a keyframe, so segment
260
+ // boundaries line up with the synthetic playlist with no gaps. (The old
261
+ // the OLD `expr:` form of -force_key_frames broke after a seek, because
262
+ // the `t` it reads is shifted by `-output_ts_offset`.)
263
+ //
264
+ // An explicit cut LIST is a different thing and does work: verified by
265
+ // running it, its times are on the run's own timeline — the same one
266
+ // `-segment_times` is measured on — so both are given one list and
267
+ // cannot drift apart. It replaces the frame-count GOP, which cannot
268
+ // describe the source's keyframes because they are not evenly spaced.
269
+ // `-g` stays as an upper bound on the interval: an extra keyframe
270
+ // inside a segment costs a little bitrate and cuts nothing, while
271
+ // leaving the interval unbounded means a driver that ignores the list
272
+ // produces one enormous segment instead of a wrong but cut one.
273
+ // `-keyint_min` goes, since a MINIMUM interval is the one thing that
274
+ // could argue with a forced keyframe.
275
+ "-g", String(segmentDurationSec * outFps),
276
+ ...(hasForcedTimes(forcedKeyframeTimes)
277
+ ? keyFrameArgs(segmentDurationSec, forcedKeyframeTimes)
278
+ : ["-keyint_min", String(segmentDurationSec * outFps)]),
279
+ "-sc_threshold", "0"
280
+ ];
281
+ }
282
+ };
283
+ }
284
+
285
+ /**
286
+ * @param {string} device
287
+ * @returns {import("./hwaccel.js").VideoEncoderDescriptor}
288
+ */
289
+ function vaapiDescriptor(device) {
290
+ return {
291
+ name: "h264_vaapi",
292
+ kind: "vaapi",
293
+ device,
294
+ // Decode on the GPU into VAAPI surfaces; scale and encode stay on-GPU.
295
+ inputArgs: ["-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi", "-vaapi_device", device],
296
+ // No fps filter: VAAPI inherits the source rate and keeps keyframes on the
297
+ // grid via time-based -force_key_frames, so it already honours source fps.
298
+ buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, forcedKeyframeTimes }) {
299
+ const { w, h } = safeDimensions(targetWidth, targetHeight);
300
+ return [
301
+ "-vf",
302
+ `scale_vaapi=w=${w}:h=${h}:force_original_aspect_ratio=decrease`,
303
+ "-c:v", "h264_vaapi",
304
+ "-qp", "24",
305
+ ...keyFrameArgs(segmentDurationSec, forcedKeyframeTimes)
306
+ ];
307
+ }
308
+ };
309
+ }
310
+
311
+ /**
312
+ * @param {string} device
313
+ * @returns {import("./hwaccel.js").VideoEncoderDescriptor}
314
+ */
315
+ function qsvDescriptor(device) {
316
+ return {
317
+ name: "h264_qsv",
318
+ kind: "qsv",
319
+ device,
320
+ inputArgs: ["-hwaccel", "qsv", "-qsv_device", device],
321
+ buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, forcedKeyframeTimes }) {
322
+ const { w, h } = safeDimensions(targetWidth, targetHeight);
323
+ return [
324
+ "-vf", `scale_qsv=w=${w}:h=${h}`,
325
+ "-c:v", "h264_qsv",
326
+ "-global_quality", "24",
327
+ ...keyFrameArgs(segmentDurationSec, forcedKeyframeTimes)
328
+ ];
329
+ }
330
+ };
331
+ }
332
+
333
+ /** @returns {import("./hwaccel.js").VideoEncoderDescriptor} */
334
+ function nvencDescriptor() {
335
+ return {
336
+ name: "h264_nvenc",
337
+ kind: "nvenc",
338
+ device: null,
339
+ inputArgs: [],
340
+ // No fps filter: NVENC is fast and places keyframes by time-based
341
+ // -force_key_frames, so it inherits the exact source rate (fractional
342
+ // included) with no need to round or cap. Same rationale as VAAPI/QSV.
343
+ buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, forcedKeyframeTimes }) {
344
+ const { w, h } = safeDimensions(targetWidth, targetHeight);
345
+ return [
346
+ "-vf",
347
+ `scale=${w}:${h}:force_original_aspect_ratio=decrease:force_divisible_by=2`,
348
+ "-c:v", "h264_nvenc",
349
+ "-preset", "p4",
350
+ "-cq", "24",
351
+ "-pix_fmt", "yuv420p",
352
+ ...keyFrameArgs(segmentDurationSec, forcedKeyframeTimes)
353
+ ];
354
+ }
355
+ };
356
+ }
357
+
358
+ /** @returns {import("./hwaccel.js").VideoEncoderDescriptor} */
359
+ function v4l2m2mDescriptor() {
360
+ // ARM SoC (e.g. Raspberry Pi / HA Yellow) stateful M2M encoder. No GPU
361
+ // scaler — scale in software, hand YUV420 frames to the hardware encoder.
362
+ // `-g` aligns the GOP to the segment length so an IDR lands on every segment
363
+ // boundary; this is verified by the keyframe-alignment test before use,
364
+ // because v4l2m2m does not always honour these hints.
365
+ return {
366
+ name: "h264_v4l2m2m",
367
+ kind: "v4l2m2m",
368
+ device: null,
369
+ inputArgs: [],
370
+ buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, fps, forcedKeyframeTimes }) {
371
+ const { w, h } = safeDimensions(targetWidth, targetHeight);
372
+ const outFps = Number.isInteger(fps) && fps > 0 ? fps : TRANSCODE_FPS;
373
+ return [
374
+ "-vf",
375
+ `scale=${w}:${h}:force_original_aspect_ratio=decrease:force_divisible_by=2,fps=${outFps},format=yuv420p`,
376
+ "-c:v", "h264_v4l2m2m",
377
+ // More capture buffers than the default 4 — the default deadlocks /
378
+ // drops frames on the CM4 encoder ("All capture buffers returned to
379
+ // userspace").
380
+ "-num_capture_buffers", "32",
381
+ "-b:v", "3M",
382
+ // Kept even with an explicit cut list, as an upper bound on the
383
+ // interval: this encoder is the one known not always to honour keyframe
384
+ // hints, and without any bound a list it ignores yields one segment for
385
+ // the whole file rather than a wrongly-cut one.
386
+ "-g", String(outFps * segmentDurationSec),
387
+ ...keyFrameArgs(segmentDurationSec, forcedKeyframeTimes)
388
+ ];
389
+ }
390
+ };
391
+ }
392
+
393
+
394
+ /**
395
+ * @typedef {Object} VideoEncoderDescriptor
396
+ * @property {string} name
397
+ * @property {"software"|"vaapi"|"qsv"|"nvenc"|"v4l2m2m"} kind
398
+ * @property {string|null} device
399
+ * @property {string[]} inputArgs
400
+ * @property {(opts: { targetWidth: number, targetHeight: number, segmentDurationSec: number, preset?: string, fps?: number, tonemap?: boolean, forcedKeyframeTimes?: number[] | null }) => string[]} buildVideoArgs
401
+ */
402
+
403
+ /**
404
+ * Run ffmpeg and resolve with its exit code and captured output.
405
+ *
406
+ * @param {string} ffmpegBin
407
+ * @param {string[]} args
408
+ * @param {number} [timeoutMs=12000]
409
+ * @returns {Promise<{ code: number, stdout: string, stderr: string }>}
410
+ */
411
+ function runFfmpeg(ffmpegBin, args, timeoutMs = 12000) {
412
+ return new Promise((resolve) => {
413
+ let stdout = "";
414
+ let stderr = "";
415
+ let settled = false;
416
+ let child;
417
+ const finish = (code) => {
418
+ if (settled) {
419
+ return;
420
+ }
421
+ settled = true;
422
+ resolve({ code, stdout, stderr });
423
+ };
424
+ try {
425
+ child = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
426
+ } catch {
427
+ finish(-1);
428
+ return;
429
+ }
430
+ const timer = setTimeout(() => {
431
+ try {
432
+ child.kill("SIGKILL");
433
+ } catch {
434
+ // already gone
435
+ }
436
+ finish(-1);
437
+ }, timeoutMs);
438
+ child.stdout.on("data", (chunk) => {
439
+ stdout += String(chunk);
440
+ });
441
+ child.stderr.on("data", (d) => {
442
+ stderr += String(d);
443
+ });
444
+ child.on("error", () => {
445
+ clearTimeout(timer);
446
+ finish(-1);
447
+ });
448
+ child.on("exit", (code) => {
449
+ clearTimeout(timer);
450
+ finish(code ?? -1);
451
+ });
452
+ });
453
+ }
454
+
455
+ /** @returns {string[]} /dev/dri/renderD* nodes (VAAPI/QSV). */
456
+ function listRenderNodes() {
457
+ try {
458
+ return readdirSync("/dev/dri")
459
+ .filter((n) => n.startsWith("renderD"))
460
+ .map((n) => `/dev/dri/${n}`)
461
+ .sort();
462
+ } catch {
463
+ return [];
464
+ }
465
+ }
466
+
467
+ /** @returns {boolean} Whether any /dev/nvidia* node exists (NVENC). */
468
+ function hasNvidiaDevice() {
469
+ try {
470
+ return readdirSync("/dev").some((n) => /^nvidia(\d+)?$/.test(n));
471
+ } catch {
472
+ return false;
473
+ }
474
+ }
475
+
476
+ /** @returns {boolean} Whether any /dev/video* node exists (V4L2 M2M). */
477
+ function hasV4l2Device() {
478
+ try {
479
+ return readdirSync("/dev").some((n) => /^video\d+$/.test(n));
480
+ } catch {
481
+ return false;
482
+ }
483
+ }
484
+
485
+ /**
486
+ * Build a full ffmpeg command that encodes a short, *moving* synthetic clip
487
+ * (testsrc2 — far more representative than a static black frame) through the
488
+ * candidate encoder into real HLS segments in `outDir`, with keyframes forced
489
+ * on segment boundaries. Verifying the resulting segments (see
490
+ * {@link verifySegmentsDecodeCleanly}) catches encoders that silently produce
491
+ * a corrupted or non-IDR-aligned stream (e.g. some V4L2 M2M builds).
492
+ *
493
+ * @param {VideoEncoderDescriptor} descriptor
494
+ * @param {number} segmentDurationSec
495
+ * @param {string} outDir
496
+ * @returns {string[]}
497
+ */
498
+ function buildEncoderTestArgs(descriptor, segmentDurationSec, outDir) {
499
+ const durationSec = Math.max(8, segmentDurationSec * 3);
500
+ const source = ["-f", "lavfi", "-i", `testsrc2=s=640x360:r=${TRANSCODE_FPS}:d=${durationSec}`];
501
+ const kf = keyFrameArgs(segmentDurationSec);
502
+
503
+ /** @type {string[]} */
504
+ let pre = ["-hide_banner", "-loglevel", "error"];
505
+ /** @type {string[]} */
506
+ let encode;
507
+ switch (descriptor.kind) {
508
+ case "vaapi":
509
+ pre = [...pre, "-vaapi_device", String(descriptor.device)];
510
+ encode = ["-vf", "format=nv12,hwupload", "-c:v", "h264_vaapi", "-qp", "24", ...kf];
511
+ break;
512
+ case "qsv":
513
+ pre = [...pre, "-qsv_device", String(descriptor.device)];
514
+ encode = ["-vf", "hwupload=extra_hw_frames=16,format=qsv", "-c:v", "h264_qsv", "-global_quality", "24", ...kf];
515
+ break;
516
+ case "nvenc":
517
+ encode = ["-c:v", "h264_nvenc", "-preset", "p4", "-cq", "24", "-pix_fmt", "yuv420p", ...kf];
518
+ break;
519
+ case "v4l2m2m":
520
+ encode = ["-pix_fmt", "yuv420p", "-c:v", "h264_v4l2m2m", "-num_capture_buffers", "32", "-b:v", "3M", "-g", String(TRANSCODE_FPS * segmentDurationSec), ...kf];
521
+ break;
522
+ default:
523
+ encode = ["-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", ...kf];
524
+ break;
525
+ }
526
+
527
+ const hlsOut = [
528
+ "-f", "hls",
529
+ "-hls_time", String(segmentDurationSec),
530
+ "-hls_list_size", "0",
531
+ "-hls_flags", "independent_segments",
532
+ // fMP4 (CMAF) — matches the runtime pipeline (hls-session-manager).
533
+ "-hls_segment_type", "fmp4",
534
+ "-hls_fmp4_init_filename", "init.mp4",
535
+ "-hls_segment_filename", path.join(outDir, "seg-%03d.m4s"),
536
+ path.join(outDir, "index.m3u8")
537
+ ];
538
+ return [...pre, ...source, ...encode, ...hlsOut];
539
+ }
540
+
541
+ /**
542
+ * Verify the HLS segments produced by the test encode are valid: at least two
543
+ * segments exist, and each decodes standalone without errors. A segment that
544
+ * does not begin with a keyframe (broken/corrupted output) emits decode errors
545
+ * when read on its own, which fails this check.
546
+ *
547
+ * @param {string} ffmpegBin
548
+ * @param {string} outDir
549
+ * @returns {Promise<boolean>}
550
+ */
551
+ async function verifySegmentsDecodeCleanly(ffmpegBin, outDir) {
552
+ let files;
553
+ try {
554
+ files = readdirSync(outDir).filter((n) => /^seg-\d+\.m4s$/.test(n));
555
+ } catch {
556
+ return false;
557
+ }
558
+ if (files.length < 2) {
559
+ return false;
560
+ }
561
+ // fMP4: parameter sets (SPS/PPS) live in init.mp4, not in each segment.
562
+ // Decode the whole playlist (ffmpeg's own, which references init.mp4 via
563
+ // #EXT-X-MAP), so every segment is exercised together with the init. Any
564
+ // corrupt / non-conformant segment (e.g. some V4L2 M2M builds emit a stray
565
+ // no-picture access unit) surfaces as a decode error here.
566
+ const result = await runFfmpeg(
567
+ ffmpegBin,
568
+ ["-hide_banner", "-loglevel", "error", "-i", path.join(outDir, "index.m3u8"), "-f", "null", "-"],
569
+ 12000
570
+ );
571
+ return result.code === 0 && result.stderr.trim().length === 0;
572
+ }
573
+
574
+ /**
575
+ * Detect the best usable H.264 encoder. Always resolves (falls back to
576
+ * software libx264). Each hardware candidate is verified with a real
577
+ * test-encode before being selected.
578
+ *
579
+ * @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void }, segmentDurationSec?: number }} options
580
+ * @returns {Promise<VideoEncoderDescriptor>}
581
+ */
582
+ export async function detectVideoEncoder({ ffmpegBin, logger, segmentDurationSec = 4 }) {
583
+ const log = logger ?? { info: () => {}, warn: () => {} };
584
+ const software = softwareDescriptor();
585
+
586
+ const { code, stdout } = await runFfmpeg(ffmpegBin, ["-hide_banner", "-encoders"], 10000);
587
+ if (code !== 0) {
588
+ log.warn("hwaccel: could not list ffmpeg encoders; using software libx264");
589
+ return software;
590
+ }
591
+ const has = (name) => stdout.includes(name);
592
+
593
+ /** @type {VideoEncoderDescriptor[]} */
594
+ const candidates = [];
595
+ const renderNodes = listRenderNodes();
596
+ if (has("h264_nvenc") && hasNvidiaDevice()) {
597
+ candidates.push(nvencDescriptor());
598
+ }
599
+ if (has("h264_qsv") && renderNodes.length > 0) {
600
+ candidates.push(qsvDescriptor(renderNodes[0]));
601
+ }
602
+ if (has("h264_vaapi") && renderNodes.length > 0) {
603
+ candidates.push(vaapiDescriptor(renderNodes[0]));
604
+ }
605
+ // h264_v4l2m2m (ARM SoC / Raspberry Pi / HA Yellow). It is gated behind the
606
+ // strict keyframe-alignment test below, because some V4L2 M2M builds silently
607
+ // emit a corrupted / non-IDR-aligned stream; the test rejects those and the
608
+ // host falls back to software libx264.
609
+ if (has("h264_v4l2m2m") && hasV4l2Device()) {
610
+ candidates.push(v4l2m2mDescriptor());
611
+ }
612
+
613
+ for (const candidate of candidates) {
614
+ const dir = mkdtempSync(path.join(os.tmpdir(), "tt-hwtest-"));
615
+ let ok = false;
616
+ try {
617
+ const encoded = await runFfmpeg(
618
+ ffmpegBin,
619
+ buildEncoderTestArgs(candidate, segmentDurationSec, dir),
620
+ 25000
621
+ );
622
+ if (encoded.code === 0) {
623
+ ok = await verifySegmentsDecodeCleanly(ffmpegBin, dir);
624
+ }
625
+ } finally {
626
+ try {
627
+ rmSync(dir, { recursive: true, force: true });
628
+ } catch {
629
+ // best effort
630
+ }
631
+ }
632
+ if (ok) {
633
+ log.info(
634
+ `hwaccel: using hardware encoder ${candidate.name}` +
635
+ `${candidate.device ? ` (${candidate.device})` : ""}`
636
+ );
637
+ return candidate;
638
+ }
639
+ log.warn(`hwaccel: ${candidate.name} failed the HLS keyframe-alignment test; skipping`);
640
+ }
641
+
642
+ log.info("hwaccel: no working hardware encoder; using software libx264");
643
+ return software;
644
+ }
645
+
646
+ /**
647
+ * Detect whether this ffmpeg build has the filters needed for the HDR→SDR
648
+ * tone-map chain (`zscale`, from libzimg, and `tonemap`). Both are required;
649
+ * when either is missing, HDR sources are re-encoded without tone mapping
650
+ * (washed-out but playable). Always resolves.
651
+ *
652
+ * @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void } }} options
653
+ * @returns {Promise<boolean>}
654
+ */
655
+ export async function detectTonemapSupport({ ffmpegBin, logger }) {
656
+ const log = logger ?? { info: () => {}, warn: () => {} };
657
+ const { code, stdout } = await runFfmpeg(ffmpegBin, ["-hide_banner", "-filters"], 10000);
658
+ if (code !== 0) {
659
+ log.warn("hwaccel: could not list ffmpeg filters; HDR tone mapping disabled");
660
+ return false;
661
+ }
662
+ // `-filters` prints one filter per line: "... zscale ...", "... tonemap ...".
663
+ const hasZscale = /\bzscale\b/.test(stdout);
664
+ const hasTonemap = /\btonemap\b/.test(stdout);
665
+ const supported = hasZscale && hasTonemap;
666
+ log.info(
667
+ `hwaccel: HDR tone mapping ${supported ? "available" : "unavailable"} ` +
668
+ `(zscale=${hasZscale} tonemap=${hasTonemap})`
669
+ );
670
+ return supported;
671
+ }
672
+
673
+ /**
674
+ * Solve a 3×3 linear system by Gaussian elimination with partial pivoting.
675
+ *
676
+ * @param {number[][]} rows - Three rows of [c0, c1, c2, rhs].
677
+ * @returns {number[] | null} The three unknowns, or null when singular.
678
+ */
679
+ function solveLinear3(rows) {
680
+ const m = rows.map((row) => [...row]);
681
+ for (let col = 0; col < 3; col += 1) {
682
+ let pivot = col;
683
+ for (let row = col + 1; row < 3; row += 1) {
684
+ if (Math.abs(m[row][col]) > Math.abs(m[pivot][col])) {
685
+ pivot = row;
686
+ }
687
+ }
688
+ if (Math.abs(m[pivot][col]) < 1e-12) {
689
+ return null;
690
+ }
691
+ [m[col], m[pivot]] = [m[pivot], m[col]];
692
+ for (let row = 0; row < 3; row += 1) {
693
+ if (row === col) {
694
+ continue;
695
+ }
696
+ const factor = m[row][col] / m[col][col];
697
+ for (let k = col; k < 4; k += 1) {
698
+ m[row][k] -= factor * m[col][k];
699
+ }
700
+ }
701
+ }
702
+ return [m[0][3] / m[0][0], m[1][3] / m[1][1], m[2][3] / m[2][2]];
703
+ }
704
+
705
+ // The clips the decode cost is solved from. They ship with the package
706
+ // (`assets/calibration/`), cut from Netflix Open Content "Meridian" (CC-BY 4.0)
707
+ // — real, grainy live action, because a generated `testsrc2` clip decodes 158 %
708
+ // away from a real film where these are 11 % away (measured 2026-08-14). Two
709
+ // share a pixel count and differ 11.7× in bitrate, the third has the same
710
+ // bitrate class at fewer pixels: three points, three unknowns.
711
+ const CALIBRATION_CLIPS = ["cal-1080-hi.mp4", "cal-1080-lo.mp4", "cal-720.mp4"];
712
+ const CALIBRATION_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "assets", "calibration");
713
+ // How wide the measured window must be before the slope is trusted, and how
714
+ // long to wait for it at most. A second of decoding is thousands of frames on a
715
+ // quick host and dozens on a weak one; both give a slope, and neither costs the
716
+ // startup more than a second per clip.
717
+ const DECODE_WINDOW_MIN_SEC = 1;
718
+ const DECODE_WINDOW_MAX_MS = 8000;
719
+
720
+ /**
721
+ * Read what a calibration clip IS from the decode run's own output: the
722
+ * dimensions, the frame rate and the bitrate ffmpeg reports for it. Read rather
723
+ * than declared, so replacing a clip cannot silently invalidate the fit.
724
+ *
725
+ * @param {string} stderr
726
+ * @returns {{ megapixelsPerSecond: number, megabitsPerSecond: number, durationSeconds: number } | null}
727
+ */
728
+ function parseClipCharacteristics(stderr) {
729
+ // The same readers the session manager uses on the same banner — one parser
730
+ // per fact, so a second copy cannot drift from the first.
731
+ const { width, height } = parseFfmpegVideoDimensions(stderr);
732
+ const rate = parseFfmpegVideoFps(stderr);
733
+ const seconds = parseFfmpegDurationSeconds(stderr);
734
+ const kbps = parseFfmpegBitrateKbps(stderr);
735
+ if (!(width > 0) || !(height > 0) || !(rate > 0) || !(seconds > 0) || !(kbps > 0)) {
736
+ return null;
737
+ }
738
+ return {
739
+ megapixelsPerSecond: (width * height * rate) / 1e6,
740
+ megabitsPerSecond: kbps / 1000,
741
+ durationSeconds: seconds
742
+ };
743
+ }
744
+
745
+ /**
746
+ * Measure what DECODING costs on this host, as seconds of work per second of
747
+ * video, and solve it into three host constants:
748
+ *
749
+ * decodeCost = a × Mpixel/s + b × Mbit/s + c
750
+ *
751
+ * Why it exists: the preset benchmark below measures ENCODING only, and a
752
+ * re-encode pays for both halves. Measured 2026-08-14 on the addon host, that
753
+ * omission made the budget offer a 240p rung it then ran at 0.39-0.95× — the
754
+ * benchmark said the host cleared the bar 2.5× over. With the decode term the
755
+ * same file predicts within 4.8 %; without it the error on that rung was 209 %.
756
+ *
757
+ * The constants are properties of the HOST, so this runs once at startup (about
758
+ * 5 s on a CM4) and any source is then priced from figures the probe already
759
+ * has — nothing is added to a session's cold start.
760
+ *
761
+ * They are also properties of the CODEC, and the clips are H.264: HEVC, AV1 and
762
+ * 10-bit decode dearer per pixel on the same machine, and a source that has to
763
+ * be re-encoded is by definition one this browser could not play, which is
764
+ * usually not H.264. So the fit is optimistic exactly there. Closing that needs
765
+ * clips in those codecs, and is its own roadmap item.
766
+ *
767
+ * @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void }, clipsDir?: string }} options
768
+ * @returns {Promise<{ pixelTerm: number, bitrateTerm: number, constantTerm: number } | null>}
769
+ */
770
+ export async function benchmarkDecodeCost({ ffmpegBin, logger, clipsDir = CALIBRATION_DIR }) {
771
+ const log = logger ?? { info: () => {}, warn: () => {} };
772
+ const startedAllAt = Date.now();
773
+ /** @type {number[][]} */
774
+ const equations = [];
775
+ for (const clip of CALIBRATION_CLIPS) {
776
+ const measured = await measureDecodeSlope(ffmpegBin, path.join(clipsDir, clip));
777
+ if (!measured) {
778
+ log.warn(`hwaccel: decode benchmark "${clip}" failed or said nothing; decode cost unknown`);
779
+ return null;
780
+ }
781
+ const cost = 1 / measured.speed;
782
+ equations.push([measured.megapixelsPerSecond, measured.megabitsPerSecond, 1, cost]);
783
+ log.info(
784
+ `hwaccel: decode "${clip}" ${measured.megapixelsPerSecond.toFixed(1)} Mpx/s ` +
785
+ `${measured.megabitsPerSecond.toFixed(2)} Mbit/s -> ${measured.speed.toFixed(1)}x ` +
786
+ `(cost ${cost.toFixed(4)} s/s, over ${measured.windowSec.toFixed(1)}s of decoding)`
787
+ );
788
+ }
789
+ const fitted = fitDecodeCost(equations);
790
+ if (!fitted) {
791
+ log.warn("hwaccel: decode cost could not be fitted to these measurements; decode cost unknown");
792
+ return null;
793
+ }
794
+ log.info(
795
+ `hwaccel: decode cost = ${fitted.pixelTerm.toFixed(6)} × Mpx/s + ${fitted.bitrateTerm.toFixed(6)} × Mbit/s ` +
796
+ `+ ${fitted.constantTerm.toFixed(4)} s/s (${fitted.shape}, measured in ${((Date.now() - startedAllAt) / 1000).toFixed(1)}s)`
797
+ );
798
+ return { pixelTerm: fitted.pixelTerm, bitrateTerm: fitted.bitrateTerm, constantTerm: fitted.constantTerm };
799
+ }
800
+
801
+ /**
802
+ * Measure how fast this host DECODES a clip, from ffmpeg’s own report of how
803
+ * much video it has processed.
804
+ *
805
+ * Wall-clock around the process cannot answer this: starting ffmpeg costs about
806
+ * a second, and on a quick machine a five-second clip decodes in a tenth of
807
+ * that, so the measurement would be of the program starting. Progress lines
808
+ * arrive twice a second AFTER it has started, and the slope between two of them
809
+ * video processed against time taken — contains no part of the startup by
810
+ * construction.
811
+ *
812
+ * The clip is looped forever and the process killed as soon as the window is
813
+ * wide enough, so the cost is bounded by the clock rather than by the clip:
814
+ * roughly a second of measurement on any host, quick or slow.
815
+ *
816
+ * @param {string} ffmpegBin
817
+ * @param {string} clipPath
818
+ * @returns {Promise<{ speed: number, windowSec: number, megapixelsPerSecond: number, megabitsPerSecond: number } | null>}
819
+ */
820
+ function measureDecodeSlope(ffmpegBin, clipPath) {
821
+ return new Promise((resolve) => {
822
+ const args = [
823
+ "-hide_banner", "-loglevel", "info", "-nostats",
824
+ "-stream_loop", "-1",
825
+ "-i", clipPath,
826
+ "-an", "-f", "null", "-",
827
+ "-progress", "pipe:1"
828
+ ];
829
+ /** @type {Array<{ wallSec: number, outSec: number }>} */
830
+ const samples = [];
831
+ let stderr = "";
832
+ let stdout = "";
833
+ let settled = false;
834
+ let child;
835
+ const startedAt = Date.now();
836
+ const finish = () => {
837
+ if (settled) {
838
+ return;
839
+ }
840
+ settled = true;
841
+ clearTimeout(timer);
842
+ try {
843
+ child?.kill("SIGKILL");
844
+ } catch {
845
+ // already gone
846
+ }
847
+ // The first sample is the one that still carries the startup — it reports
848
+ // whatever was processed while the process was coming up. Everything is
849
+ // measured from the second onwards.
850
+ const first = samples[1];
851
+ const last = samples[samples.length - 1];
852
+ const clipInfo = parseClipCharacteristics(stderr);
853
+ if (!first || !last || !clipInfo) {
854
+ resolve(null);
855
+ return;
856
+ }
857
+ const windowSec = last.wallSec - first.wallSec;
858
+ const producedSec = last.outSec - first.outSec;
859
+ if (!(windowSec >= DECODE_WINDOW_MIN_SEC) || !(producedSec > 0)) {
860
+ resolve(null);
861
+ return;
862
+ }
863
+ resolve({
864
+ speed: producedSec / windowSec,
865
+ windowSec,
866
+ megapixelsPerSecond: clipInfo.megapixelsPerSecond,
867
+ megabitsPerSecond: clipInfo.megabitsPerSecond
868
+ });
869
+ };
870
+ const timer = setTimeout(finish, DECODE_WINDOW_MAX_MS);
871
+ try {
872
+ child = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
873
+ } catch {
874
+ // The timer would otherwise hold the event loop for its full wait and
875
+ // then run against a child that was never created.
876
+ clearTimeout(timer);
877
+ settled = true;
878
+ resolve(null);
879
+ return;
880
+ }
881
+ child.stderr.on("data", (chunk) => {
882
+ stderr += String(chunk);
883
+ });
884
+ child.stdout.on("data", (chunk) => {
885
+ stdout += String(chunk);
886
+ let newline = stdout.indexOf("\n");
887
+ while (newline >= 0) {
888
+ const line = stdout.slice(0, newline).trim();
889
+ stdout = stdout.slice(newline + 1);
890
+ if (line.startsWith("out_time_ms=")) {
891
+ const microseconds = Number(line.slice("out_time_ms=".length));
892
+ if (Number.isFinite(microseconds)) {
893
+ samples.push({ wallSec: (Date.now() - startedAt) / 1000, outSec: microseconds / 1e6 });
894
+ }
895
+ }
896
+ newline = stdout.indexOf("\n");
897
+ }
898
+ if (samples.length >= 2 && samples[samples.length - 1].wallSec - samples[1].wallSec >= DECODE_WINDOW_MIN_SEC) {
899
+ finish();
900
+ }
901
+ });
902
+ child.on("error", () => {
903
+ if (settled) {
904
+ return;
905
+ }
906
+ clearTimeout(timer);
907
+ settled = true;
908
+ resolve(null);
909
+ });
910
+ child.on("close", finish);
911
+ });
912
+ }
913
+
914
+ /**
915
+ * Fit the three measurements, and say which shape the data supported.
916
+ *
917
+ * The three-term fit is exact three points, three unknowns — and is used
918
+ * whenever every term comes out non-negative. A negative term is not a host
919
+ * being odd; it says the difference it was solved from is smaller than the
920
+ * noise between runs, which is what a fast machine produces: measured on a
921
+ * desktop, the 720p clip took LONGER per second than the low-bitrate 1080p one,
922
+ * because process startup is a large share of a decode that takes a second.
923
+ *
924
+ * When that happens the bitrate term — the weak one, and the one solved from a
925
+ * single difference is dropped and the remaining two are fitted by least
926
+ * squares over all three points. If even the pixel slope comes out non-positive
927
+ * there is no measurable dependence on the source at all, and inventing one is
928
+ * worse than having none: the caller then prices the encoder alone and refuses
929
+ * nothing.
930
+ *
931
+ * @param {number[][]} equations - Rows of [Mpixel/s, Mbit/s, 1, cost].
932
+ * @returns {{ pixelTerm: number, bitrateTerm: number, constantTerm: number, shape: string } | null}
933
+ */
934
+ function fitDecodeCost(equations) {
935
+ const exact = solveLinear3(equations);
936
+ if (exact && exact[0] > 0 && exact[1] >= 0 && exact[2] >= 0) {
937
+ return { pixelTerm: exact[0], bitrateTerm: exact[1], constantTerm: exact[2], shape: "pixels+bitrate+constant" };
938
+ }
939
+ const count = equations.length;
940
+ const meanPixels = equations.reduce((sum, row) => sum + row[0], 0) / count;
941
+ const meanCost = equations.reduce((sum, row) => sum + row[3], 0) / count;
942
+ let covariance = 0;
943
+ let variance = 0;
944
+ for (const row of equations) {
945
+ covariance += (row[0] - meanPixels) * (row[3] - meanCost);
946
+ variance += (row[0] - meanPixels) ** 2;
947
+ }
948
+ if (!(variance > 0)) {
949
+ return null;
950
+ }
951
+ const pixelTerm = covariance / variance;
952
+ const constantTerm = meanCost - pixelTerm * meanPixels;
953
+ if (pixelTerm > 0 && constantTerm >= 0) {
954
+ return { pixelTerm, bitrateTerm: 0, constantTerm, shape: "pixels+constant" };
955
+ }
956
+ // A negative constant is the line crossing below zero where no clip was
957
+ // measured every clip is 22 Mpixel/s or more, and nothing here says what a
958
+ // tiny picture costs. Rather than carry a term that would price a small
959
+ // source as free work, fit through the origin: cost proportional to pixels,
960
+ // which is the relationship the measurements do support.
961
+ let weighted = 0;
962
+ let squares = 0;
963
+ for (const row of equations) {
964
+ weighted += row[0] * row[3];
965
+ squares += row[0] ** 2;
966
+ }
967
+ const throughOrigin = squares > 0 ? weighted / squares : 0;
968
+ if (!(throughOrigin > 0)) {
969
+ return null;
970
+ }
971
+ return { pixelTerm: throughOrigin, bitrateTerm: 0, constantTerm: 0, shape: "pixels only" };
972
+ }
973
+
974
+ /**
975
+ * How many times realtime this host can DECODE a source of these
976
+ * characteristics, from the startup fit. `null` when the fit is unavailable or
977
+ * the source figures are not known.
978
+ *
979
+ * @param {{ pixelTerm: number, bitrateTerm: number, constantTerm: number } | null} model
980
+ * @param {{ megapixelsPerSecond: number, megabitsPerSecond: number }} source
981
+ * @returns {number | null}
982
+ */
983
+ export function decodeSpeedFor(model, source) {
984
+ if (!model) {
985
+ return null;
986
+ }
987
+ const pixels = Number(source?.megapixelsPerSecond);
988
+ const bits = Number(source?.megabitsPerSecond);
989
+ if (!Number.isFinite(pixels) || pixels <= 0 || !Number.isFinite(bits) || bits < 0) {
990
+ return null;
991
+ }
992
+ const cost = model.pixelTerm * pixels + model.bitrateTerm * bits + model.constantTerm;
993
+ if (!(cost > 0)) {
994
+ return null;
995
+ }
996
+ return 1 / cost;
997
+ }
998
+
999
+ /**
1000
+ * How many times realtime a re-encode of this source at this output pixel rate
1001
+ * would run: decoding and encoding share the machine, so their costs add and
1002
+ * their speeds combine as
1003
+ *
1004
+ * 1 / (1/decodeSpeed + 1/encodeSpeed)
1005
+ *
1006
+ * Checked 2026-08-14 on the rung that broke playback: 1/(1/2.31 + 1/5.99) =
1007
+ * 1.67× against 1.48× measured. With no decode fit this falls back to the
1008
+ * encode speed alone which is what the budget did before, and which
1009
+ * overestimated that rung five to eleven times.
1010
+ *
1011
+ * @param {{ decodeModel: { pixelTerm: number, bitrateTerm: number, constantTerm: number } | null, encodePixelsPerSec: number, outputPixelsPerSec: number, source: { megapixelsPerSecond: number, megabitsPerSecond: number } | null }} params
1012
+ * @returns {number | null}
1013
+ */
1014
+ export function predictedRealtimeSpeed({
1015
+ decodeModel,
1016
+ encodePixelsPerSec,
1017
+ outputPixelsPerSec,
1018
+ source,
1019
+ observedDecodeCostSec = null
1020
+ }) {
1021
+ if (!Number.isFinite(encodePixelsPerSec) || encodePixelsPerSec <= 0) {
1022
+ return null;
1023
+ }
1024
+ if (!Number.isFinite(outputPixelsPerSec) || outputPixelsPerSec <= 0) {
1025
+ return null;
1026
+ }
1027
+ const encodeSpeed = encodePixelsPerSec / outputPixelsPerSec;
1028
+ // What this very file has been seen to cost, when it has been: the clips are
1029
+ // H.264 and a source that has to be re-encoded usually is not, so a figure
1030
+ // taken from the encoder actually running on THIS source beats any model of
1031
+ // a stand-in. It arrives seconds into playback and replaces the estimate.
1032
+ const decodeSpeed = Number.isFinite(observedDecodeCostSec) && observedDecodeCostSec > 0
1033
+ ? 1 / observedDecodeCostSec
1034
+ : (source ? decodeSpeedFor(decodeModel, source) : null);
1035
+ if (decodeSpeed === null) {
1036
+ return encodeSpeed;
1037
+ }
1038
+ return 1 / (1 / decodeSpeed + 1 / encodeSpeed);
1039
+ }
1040
+
1041
+ /**
1042
+ * Whether this host can hold realtime, with the margin, while re-encoding this
1043
+ * source to this output pixel rate — and the predicted speed either way, so a
1044
+ * refusal can say what it refused on.
1045
+ *
1046
+ * The encoder figure is the FASTEST benchmarked preset: it is the best this
1047
+ * host can do, so a rung it cannot hold cannot be held at any quality setting.
1048
+ *
1049
+ * @param {{ benchmark: Array<{ preset: string, pixelsPerSec: number }>, decodeModel?: object | null, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null, outputPixelsPerSec: number }} params
1050
+ * @returns {{ speed: number | null, sustainable: boolean }}
1051
+ */
1052
+ export function canSustainOutput({
1053
+ benchmark,
1054
+ decodeModel = null,
1055
+ source = null,
1056
+ outputPixelsPerSec,
1057
+ observedDecodeCostSec = null
1058
+ }) {
1059
+ if (!Array.isArray(benchmark) || benchmark.length === 0) {
1060
+ // Nothing measured on this host: the budget cannot refuse what it cannot
1061
+ // price, and refusing everything would leave a viewer with no rung at all.
1062
+ return { speed: null, sustainable: true };
1063
+ }
1064
+ const observed = Number.isFinite(observedDecodeCostSec) && observedDecodeCostSec > 0
1065
+ ? observedDecodeCostSec
1066
+ : null;
1067
+ if (observed === null && !isDecodePriced({ decodeModel, source })) {
1068
+ // An encoder-only figure was several times too optimistic on the rung this
1069
+ // check exists for, so it is not fit to refuse anything. Without the decode
1070
+ // term the ladder is offered whole, exactly as it was before.
1071
+ return { speed: null, sustainable: true };
1072
+ }
1073
+ const speed = predictedRealtimeSpeed({
1074
+ decodeModel,
1075
+ encodePixelsPerSec: cheapestPresetPixelsPerSec(benchmark),
1076
+ outputPixelsPerSec,
1077
+ source,
1078
+ observedDecodeCostSec: observed
1079
+ });
1080
+ if (speed === null) {
1081
+ return { speed: null, sustainable: true };
1082
+ }
1083
+ return { speed, sustainable: speed >= PRESET_SPEED_MARGIN };
1084
+ }
1085
+
1086
+ /** The margin a predicted speed must clear to be offered. */
1087
+ export const REALTIME_SPEED_MARGIN = PRESET_SPEED_MARGIN;
1088
+
1089
+ /**
1090
+ * Benchmark software libx264 presets on this host. Encodes a short synthetic
1091
+ * clip at a fixed reference resolution with each preset and measures encoder
1092
+ * throughput in pixels/second. The session manager uses this to pick, per
1093
+ * stream, the highest-quality preset that still encodes the actual
1094
+ * (source-capped) resolution faster than realtime.
1095
+ *
1096
+ * Runs once at startup; bounded by a per-encode timeout. Presets that fail are
1097
+ * omitted from the result.
1098
+ *
1099
+ * @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void } }} options
1100
+ * @returns {Promise<Array<{ preset: string, pixelsPerSec: number }>>} Ordered slowest→fastest.
1101
+ */
1102
+ export async function benchmarkSoftwarePresets({ ffmpegBin, logger }) {
1103
+ const log = logger ?? { info: () => {}, warn: () => {} };
1104
+
1105
+ // REAL footage, decoded ONCE into raw frames, and the presets are then timed
1106
+ // on those frames.
1107
+ //
1108
+ // Two reasons, both measured. The pattern this replaced (`testsrc2`) has flat
1109
+ // areas and no grain and encodes 1.23x cheaper than film on the same machine
1110
+ // and preset — an error that always points at offering a rung the host cannot
1111
+ // hold. And feeding a compressed clip to each preset instead would put
1112
+ // decoding and scaling inside the measurement: subtracting them afterwards
1113
+ // compares a wall clock that includes process startup against a decode figure
1114
+ // measured to exclude it, while inside one ffmpeg the two halves overlap. On
1115
+ // the fastest preset — the one every ladder decision reads as the ceiling —
1116
+ // that subtraction is most of the number being measured, so a small error in
1117
+ // it becomes a large error in the answer.
1118
+ //
1119
+ // Raw frames remove all of it: no decoder, no scaler, nothing to subtract,
1120
+ // and no dependence on the decode model. The cost is 25 MB of memory in a
1121
+ // pipe for a few seconds.
1122
+ const rawFramesPath = await decodeToRawFrames(ffmpegBin, log);
1123
+ if (rawFramesPath === null) {
1124
+ // Said once more, in the words that matter to whoever reads the log next:
1125
+ // with no benchmark, `#sustainableHeights` filters nothing and every rung
1126
+ // is offered, which is the failure of 2026-08-14 in full.
1127
+ log.warn("hwaccel: the quality ladder is UNFILTERED on this host — nothing measured the encoder");
1128
+ return [];
1129
+ }
1130
+ /** @type {Array<{ preset: string, pixelsPerSec: number }>} */
1131
+ const results = [];
1132
+ try {
1133
+ for (const preset of BENCHMARK_PRESETS) {
1134
+ const speed = await measureEncodeSlope(ffmpegBin, preset, rawFramesPath);
1135
+ if (speed === null) {
1136
+ log.warn(`hwaccel: preset benchmark "${preset}" produced no usable reading; skipping`);
1137
+ continue;
1138
+ }
1139
+ const pixelsPerSec = BENCHMARK_REF_W * BENCHMARK_REF_H * TRANSCODE_FPS * speed;
1140
+ results.push({ preset, pixelsPerSec });
1141
+ log.info(
1142
+ `hwaccel: preset "${preset}" ~= ${(pixelsPerSec / 1e6).toFixed(1)} Mpx/s ` +
1143
+ `(${speed.toFixed(2)}x @ ${BENCHMARK_REF_W}x${BENCHMARK_REF_H}, real footage)`
1144
+ );
1145
+ }
1146
+ } finally {
1147
+ // The encoder was killed a moment ago and on Windows the handle outlives
1148
+ // the signal, so removal is retried and its failure is not worth a session:
1149
+ // this is a temp directory the operating system will clear anyway.
1150
+ try {
1151
+ rmSync(path.dirname(rawFramesPath), { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
1152
+ } catch (error) {
1153
+ log.warn(`hwaccel: could not remove the benchmark's raw frames: ${error instanceof Error ? error.message : String(error)}`);
1154
+ }
1155
+ }
1156
+ return results;
1157
+ }
1158
+
1159
+ /**
1160
+ * How fast one preset encodes, from ffmpeg's own reports of how much video it
1161
+ * has written not from the clock around the process.
1162
+ *
1163
+ * Timing whole runs measures the run STARTING. Measured 2026-08-15 on a desktop
1164
+ * that spawns ffmpeg in ~0.4 s: three seconds of raw frames encoded that way
1165
+ * put `fast` and `ultrafast` within 1.24x of each other, when libx264's own
1166
+ * presets differ by several times — the constant had swallowed the difference.
1167
+ * The slope between two progress reports contains no part of the startup.
1168
+ *
1169
+ * The frames are written repeatedly so there is runway to measure over,
1170
+ * whatever the preset's speed.
1171
+ *
1172
+ * @param {string} ffmpegBin
1173
+ * @param {string} preset
1174
+ * @param {string} rawFramesPath
1175
+ * @returns {Promise<number | null>} Video seconds encoded per second of clock.
1176
+ */
1177
+ /**
1178
+ * Video seconds produced per second of clock, from ffmpeg's own reports.
1179
+ *
1180
+ * Startup is excluded by taking a DIFFERENCE: it lands in the wall clock of
1181
+ * every report equally, so it cancels between two of them. (The decode
1182
+ * benchmark drops its first report instead, because there the first one is
1183
+ * emitted at out_time zero; here reports with no time yet are discarded before
1184
+ * they arrive, so the first kept one is already running.)
1185
+ *
1186
+ * @param {Array<{ wallSec: number, outSec: number }>} samples
1187
+ * @param {number} [minimumWindowSec=ENCODE_BENCHMARK_WINDOW_SEC]
1188
+ * @returns {number | null}
1189
+ */
1190
+ export function slopeOf(samples, minimumWindowSec = ENCODE_BENCHMARK_WINDOW_SEC) {
1191
+ const first = samples[0];
1192
+ const last = samples[samples.length - 1];
1193
+ if (!first || !last || first === last) {
1194
+ return null;
1195
+ }
1196
+ const took = last.wallSec - first.wallSec;
1197
+ const produced = last.outSec - first.outSec;
1198
+ if (!(took >= minimumWindowSec) || !(produced > 0)) {
1199
+ return null;
1200
+ }
1201
+ const slope = produced / took;
1202
+ // Nothing encodes a thousand times realtime. A figure above that is a
1203
+ // measurement fault, and letting it through opens the whole ladder.
1204
+ return slope <= ENCODE_BENCHMARK_MAX_PLAUSIBLE_SPEED ? slope : null;
1205
+ }
1206
+
1207
+ function measureEncodeSlope(ffmpegBin, preset, rawFramesPath) {
1208
+ return new Promise((resolve) => {
1209
+ const args = [
1210
+ "-hide_banner", "-loglevel", "error", "-nostats",
1211
+ "-stream_loop", "-1",
1212
+ "-f", "rawvideo", "-pix_fmt", "yuv420p",
1213
+ "-s", `${BENCHMARK_REF_W}x${BENCHMARK_REF_H}`, "-r", String(TRANSCODE_FPS),
1214
+ "-i", rawFramesPath,
1215
+ "-c:v", "libx264", "-preset", preset, "-crf", SOFTWARE_CRF, "-pix_fmt", "yuv420p",
1216
+ "-f", "null", "-",
1217
+ "-progress", "pipe:1"
1218
+ ];
1219
+ /** @type {Array<{ wallSec: number, outSec: number }>} */
1220
+ const samples = [];
1221
+ let settled = false;
1222
+ let buffered = "";
1223
+ let child;
1224
+ const startedAt = Date.now();
1225
+ const finish = (value) => {
1226
+ if (settled) {
1227
+ return;
1228
+ }
1229
+ settled = true;
1230
+ clearTimeout(timer);
1231
+ try {
1232
+ child?.kill("SIGKILL");
1233
+ } catch {
1234
+ // already gone
1235
+ }
1236
+ resolve(value);
1237
+ };
1238
+ const timer = setTimeout(() => finish(null), ENCODE_BENCHMARK_TIMEOUT_MS);
1239
+ try {
1240
+ child = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "ignore"], windowsHide: true });
1241
+ } catch {
1242
+ finish(null);
1243
+ return;
1244
+ }
1245
+ // The frames come from a FILE, read on repeat by ffmpeg itself. Fed through
1246
+ // a pipe instead, the fastest presets measured the pipe: `ultrafast` on a
1247
+ // desktop wants raw frames at hundreds of megabytes a second, which no
1248
+ // writer here can supply, and the reading then describes the feeding rather
1249
+ // than the encoder.
1250
+ child.stdout.on("data", (chunk) => {
1251
+ buffered += String(chunk);
1252
+ let newline = buffered.indexOf(NEWLINE);
1253
+ while (newline >= 0) {
1254
+ const line = buffered.slice(0, newline).trim();
1255
+ buffered = buffered.slice(newline + 1);
1256
+ if (line.startsWith("out_time_ms=")) {
1257
+ const outSec = Number(line.slice("out_time_ms=".length)) / 1e6;
1258
+ // `N/A` is not the only way ffmpeg says "no position yet": some builds
1259
+ // print the smallest signed 64-bit integer, which IS finite and would
1260
+ // be taken for a position nine trillion seconds before the start.
1261
+ if (Number.isFinite(outSec) && outSec >= 0) {
1262
+ samples.push({ wallSec: (Date.now() - startedAt) / 1000, outSec });
1263
+ }
1264
+ }
1265
+ newline = buffered.indexOf(NEWLINE);
1266
+ }
1267
+ const slope = slopeOf(samples);
1268
+ if (slope !== null) {
1269
+ finish(slope);
1270
+ }
1271
+ });
1272
+ child.on("error", () => finish(null));
1273
+ // A preset that finished before the window was wide enough is measured from
1274
+ // whatever it did report, provided two reports exist at all.
1275
+ // A preset that finished before the wide window was covered is still
1276
+ // measured — but never over a window of nothing. Two reports a millisecond
1277
+ // apart would divide a frame of video by that millisecond and call the host
1278
+ // twenty times faster than it is, and one such reading becomes the figure
1279
+ // every ladder decision is taken from.
1280
+ child.on("exit", () => finish(slopeOf(samples, ENCODE_BENCHMARK_MIN_WINDOW_SEC)));
1281
+ });
1282
+ }
1283
+
1284
+ /**
1285
+ * The benchmark's footage as raw frames: the calibration clip, looped to the
1286
+ * benchmark's length and scaled to its size, decoded once.
1287
+ *
1288
+ * @param {string} ffmpegBin
1289
+ * @param {{ info: (m: string) => void, warn: (m: string) => void }} log
1290
+ * @returns {Promise<string | null>} Path to the raw frames, or null.
1291
+ */
1292
+ async function decodeToRawFrames(ffmpegBin, log) {
1293
+ // A benchmark may leave a host unmeasured; it may never stop it from
1294
+ // starting. Before this the temp directory was made outside any guard, so a
1295
+ // read-only or missing TMPDIR rejected the promise that starts the proxy.
1296
+ let directory;
1297
+ try {
1298
+ directory = mkdtempSync(path.join(os.tmpdir(), "torrent-tv-bench-"));
1299
+ } catch (error) {
1300
+ log.warn(
1301
+ `hwaccel: no writable temp directory for the preset benchmark (${error instanceof Error ? error.message : String(error)}); ` +
1302
+ "presets unmeasured, so no quality rung will be refused on this host"
1303
+ );
1304
+ return null;
1305
+ }
1306
+ const rawPath = path.join(directory, "frames.yuv");
1307
+ const args = [
1308
+ "-hide_banner", "-loglevel", "error",
1309
+ "-stream_loop", "-1",
1310
+ "-i", path.join(CALIBRATION_DIR, CALIBRATION_CLIPS[0]),
1311
+ "-t", String(BENCHMARK_DURATION_SEC),
1312
+ "-vf", `scale=${BENCHMARK_REF_W}:${BENCHMARK_REF_H},fps=${TRANSCODE_FPS}`,
1313
+ "-an", "-f", "rawvideo", "-pix_fmt", "yuv420p", "-y", rawPath
1314
+ ];
1315
+ const { code } = await runFfmpeg(ffmpegBin, args, 30000);
1316
+ const expectedBytes = BENCHMARK_REF_W * BENCHMARK_REF_H * 1.5 * TRANSCODE_FPS * BENCHMARK_DURATION_SEC;
1317
+ let written = 0;
1318
+ try {
1319
+ written = statSync(rawPath).size;
1320
+ } catch {
1321
+ written = 0;
1322
+ }
1323
+ if (code !== 0 || written < expectedBytes * 0.9) {
1324
+ log.warn(
1325
+ "hwaccel: could not decode the calibration clip for the preset benchmark " +
1326
+ `(${written} of ~${Math.round(expectedBytes)} bytes); presets unmeasured, ` +
1327
+ "so no quality rung will be refused on this host"
1328
+ );
1329
+ try {
1330
+ rmSync(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
1331
+ } catch {
1332
+ // A temp directory the operating system will clear; not worth a start-up.
1333
+ }
1334
+ return null;
1335
+ }
1336
+ return rawPath;
1337
+ }
1338
+
1339
+ /**
1340
+ * Pick the highest-quality (slowest) benchmarked preset that can encode
1341
+ * `pixelsPerSecNeeded` with the speed margin. Falls back to the fastest
1342
+ * benchmarked preset, or `"ultrafast"` when no benchmark is available.
1343
+ *
1344
+ * @param {Array<{ preset: string, pixelsPerSec: number }>} benchmark - slowest→fastest
1345
+ * @param {number} pixelsPerSecNeeded
1346
+ * @param {{ decodeModel?: object | null, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null }} [cost]
1347
+ * @returns {string}
1348
+ */
1349
+ /**
1350
+ * What this host can do at its CHEAPEST preset — the ceiling of the ladder.
1351
+ *
1352
+ * Deliberately not the largest reading in the array. The list is in quality
1353
+ * order, so its last measured entry is the cheapest preset; taking the maximum
1354
+ * instead would let one noisy reading of an expensive preset raise the bar that
1355
+ * decides which rungs are offered, and a rung offered on noise is a rung the
1356
+ * host cannot hold. For choosing a preset the direction of that error is
1357
+ * harmless; for deciding what to offer it is not, so the two use different
1358
+ * statistics on purpose.
1359
+ *
1360
+ * @param {Array<{ preset: string, pixelsPerSec: number }>} benchmark
1361
+ * @returns {number}
1362
+ */
1363
+ function cheapestPresetPixelsPerSec(benchmark) {
1364
+ return benchmark[benchmark.length - 1]?.pixelsPerSec ?? 0;
1365
+ }
1366
+
1367
+ export function pickSoftwarePreset(benchmark, pixelsPerSecNeeded, cost = {}) {
1368
+ if (!Array.isArray(benchmark) || benchmark.length === 0) {
1369
+ return "ultrafast";
1370
+ }
1371
+ const observed = Number.isFinite(cost?.observedDecodeCostSec) && cost.observedDecodeCostSec > 0
1372
+ ? cost.observedDecodeCostSec
1373
+ : null;
1374
+ const priced = isDecodePriced(cost);
1375
+ const bar = priced ? PRESET_SPEED_MARGIN : ENCODE_ONLY_SPEED_MARGIN;
1376
+ // The FIRST entry that clears the bar wins — the list is in quality order, so
1377
+ // that is the best picture this host can hold. Every entry is examined rather
1378
+ // than the walk stopping at the first miss, because the measurements do not
1379
+ // always ascend with the list: on a busy machine on 2026-08-15 `faster` read
1380
+ // below `fast` twice.
1381
+ for (const entry of benchmark) {
1382
+ const speed = predictedRealtimeSpeed({
1383
+ decodeModel: cost.decodeModel ?? null,
1384
+ encodePixelsPerSec: entry.pixelsPerSec,
1385
+ outputPixelsPerSec: pixelsPerSecNeeded,
1386
+ source: cost.source ?? null,
1387
+ observedDecodeCostSec: observed
1388
+ });
1389
+ if (speed !== null && speed >= bar) {
1390
+ return entry.preset;
1391
+ }
1392
+ }
1393
+ // Nothing clears the bar: the cheapest preset, which is the last in quality
1394
+ // order. Returning whichever preset measured fastest would hand an expensive
1395
+ // one to a host that has just been shown to hold no rung at all.
1396
+ return benchmark[benchmark.length - 1].preset;
1397
+ }
1398
+
1399
+ /**
1400
+ * Whether a cost description can actually price decoding — a fit AND a source
1401
+ * to apply it to. Without both, every prediction is encoder-only.
1402
+ *
1403
+ * @param {{ decodeModel?: object | null, source?: object | null }} cost
1404
+ * @returns {boolean}
1405
+ */
1406
+ function isDecodePriced(cost) {
1407
+ if (Number.isFinite(cost?.observedDecodeCostSec) && cost.observedDecodeCostSec > 0) {
1408
+ return true; // measured on the source itself, which needs no fit to stand on
1409
+ }
1410
+ return Boolean(cost?.decodeModel) && Boolean(cost?.source);
1411
+ }
1412
+
1413
+ // Resolution-ladder heights (output height rungs), high→low. The ladder is
1414
+ // derived per-stream from the ceiling (the client-requested, source-capped
1415
+ // output box): only rungs at or below the ceiling height are used, so the
1416
+ // budget never upscales past what the client asked for. Standard heights keep
1417
+ // the downscaled output at familiar resolutions.
1418
+ const RESOLUTION_LADDER_HEIGHTS = [2160, 1440, 1080, 720, 540, 480, 360, 240];
1419
+
1420
+ /**
1421
+ * Build the resolution ladder for a ceiling box. Returns candidate output
1422
+ * dimensions from the ceiling downward, preserving the ceiling's aspect ratio,
1423
+ * each even-sized. The ceiling itself is always the top rung; ladder heights
1424
+ * at or above it are skipped (never upscale). Deduped by height.
1425
+ *
1426
+ * @param {number} ceilingWidth
1427
+ * @param {number} ceilingHeight
1428
+ * @returns {Array<{ width: number, height: number }>} high→low
1429
+ */
1430
+ export function buildResolutionLadder(ceilingWidth, ceilingHeight) {
1431
+ const cw = Number.isInteger(ceilingWidth) && ceilingWidth > 0 ? ceilingWidth : 0;
1432
+ const ch = Number.isInteger(ceilingHeight) && ceilingHeight > 0 ? ceilingHeight : 0;
1433
+ if (!cw || !ch) {
1434
+ return [];
1435
+ }
1436
+ const even = (v) => {
1437
+ const r = Math.round(v);
1438
+ return Math.max(2, r - (r % 2));
1439
+ };
1440
+ /** @type {Array<{ width: number, height: number }>} */
1441
+ const rungs = [{ width: cw, height: ch }];
1442
+ for (const h of RESOLUTION_LADDER_HEIGHTS) {
1443
+ if (h >= ch) {
1444
+ continue; // at/above the ceiling — the ceiling rung already covers it
1445
+ }
1446
+ rungs.push({ width: even(cw * (h / ch)), height: h });
1447
+ }
1448
+ const seen = new Set();
1449
+ return rungs.filter((rung) => {
1450
+ if (seen.has(rung.height)) {
1451
+ return false;
1452
+ }
1453
+ seen.add(rung.height);
1454
+ return true;
1455
+ });
1456
+ }
1457
+
1458
+ /**
1459
+ * Choose the software encode settings (resolution + preset) that fit the
1460
+ * realtime budget on this host. From the resolution ladder (ceiling downward),
1461
+ * pick the HIGHEST rung whose encode throughput — predicted from the startup
1462
+ * benchmark's fastest preset — clears realtime × PRESET_SPEED_MARGIN. Then, at
1463
+ * that resolution, pick the highest-quality preset that still clears the
1464
+ * margin. When even the lowest rung cannot clear it, use the lowest rung with
1465
+ * the fastest preset (best effort — a smaller picture beats sub-realtime
1466
+ * playback at full size). Returns null when no benchmark or ceiling is
1467
+ * available (the caller keeps the ceiling resolution and the default preset).
1468
+ *
1469
+ * @param {Array<{ preset: string, pixelsPerSec: number }>} benchmark - slowest→fastest
1470
+ * @param {{ width: number, height: number }} ceiling
1471
+ * @param {number} outputFps
1472
+ * @param {{ decodeModel?: object | null, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null }} [cost]
1473
+ * @returns {{ width: number, height: number, preset: string, ladder: Array<{ width: number, height: number }>, rungIndex: number } | null}
1474
+ */
1475
+ export function chooseSoftwareEncodeSettings(benchmark, ceiling, outputFps, cost = {}) {
1476
+ if (!Array.isArray(benchmark) || benchmark.length === 0) {
1477
+ return null;
1478
+ }
1479
+ const fps = Number.isFinite(outputFps) && outputFps > 0 ? outputFps : TRANSCODE_FPS;
1480
+ const ladder = buildResolutionLadder(ceiling?.width, ceiling?.height);
1481
+ if (ladder.length === 0) {
1482
+ return null;
1483
+ }
1484
+ const fastest = cheapestPresetPixelsPerSec(benchmark); // the cheapest preset's throughput
1485
+ const bar = isDecodePriced(cost) ? PRESET_SPEED_MARGIN : ENCODE_ONLY_SPEED_MARGIN;
1486
+ let chosenIndex = ladder.length - 1; // default: lowest rung (best effort)
1487
+ for (let i = 0; i < ladder.length; i += 1) {
1488
+ const speed = predictedRealtimeSpeed({
1489
+ decodeModel: cost.decodeModel ?? null,
1490
+ encodePixelsPerSec: fastest,
1491
+ outputPixelsPerSec: ladder[i].width * ladder[i].height * fps,
1492
+ source: cost.source ?? null
1493
+ });
1494
+ if (speed !== null && speed >= bar) {
1495
+ chosenIndex = i;
1496
+ break;
1497
+ }
1498
+ }
1499
+ const chosen = ladder[chosenIndex];
1500
+ const preset = pickSoftwarePreset(benchmark, chosen.width * chosen.height * fps, cost);
1501
+ return { width: chosen.width, height: chosen.height, preset, ladder, rungIndex: chosenIndex };
1502
+ }