@torrent-tv/proxy 2.74.1 → 2.76.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/CHANGELOG.md +1504 -1453
  2. package/CLAUDE.md +19 -6
  3. package/biome.json +182 -1
  4. package/docs/container-architecture.md +27 -6
  5. package/docs/encode-run-state.md +1 -1
  6. package/knip.json +14 -0
  7. package/package.json +1 -1
  8. package/routes/api/sources/warm/post.js +1 -1
  9. package/routes/api/transcode-sessions/post.js +185 -185
  10. package/routes/api/transcode-sessions/progress/get.js +5 -1
  11. package/routes/transcode/audio-file/get.js +11 -1
  12. package/routes/transcode/audio-warm/get.js +11 -1
  13. package/routes/transcode/session-file/get.js +1 -1
  14. package/routes/transcode/variant-file/get.js +10 -1
  15. package/scripts/render-run-graph.js +2 -2
  16. package/server.js +25 -0
  17. package/services/audio-inventory.js +9 -214
  18. package/services/container/AviContainer.js +266 -81
  19. package/services/container/Container.js +281 -80
  20. package/services/container/ContainerFactory.js +67 -0
  21. package/services/container/MatroskaContainer.js +327 -8
  22. package/services/container/Mp4Container.js +373 -27
  23. package/services/container/SubtitleFileContainer.js +0 -1
  24. package/services/controllers/SubtitleController.js +128 -128
  25. package/services/demand/index.js +7 -10
  26. package/services/download/registry.js +0 -14
  27. package/services/encode/CoverageMap.js +281 -0
  28. package/services/encode/EncodePlan.js +255 -0
  29. package/services/encode/EncodeRun.js +587 -0
  30. package/services/encode/Encoder.js +84 -0
  31. package/services/encode/NvencEncoder.js +45 -0
  32. package/services/encode/QsvEncoder.js +47 -0
  33. package/services/encode/SegmentDemand.js +0 -0
  34. package/services/encode/SegmentStore.js +529 -0
  35. package/services/encode/SoftwareEncoder.js +111 -0
  36. package/services/encode/V4l2m2mEncoder.js +53 -0
  37. package/services/encode/VaapiEncoder.js +53 -0
  38. package/services/encode/args.js +200 -0
  39. package/services/{encode-exit.js → encode/encode-exit.js} +17 -0
  40. package/services/encode/index.js +9 -0
  41. package/services/encode/run-command.js +647 -0
  42. package/services/hls-session-manager.js +11073 -10711
  43. package/services/hwaccel.js +1688 -1992
  44. package/services/orchestrators/EncodeOrchestrator.js +359 -0
  45. package/services/output/LiveOutputs.js +213 -0
  46. package/services/output/Output.js +94 -0
  47. package/services/output/OutputSpec.js +195 -0
  48. package/services/output/Timeline.js +220 -0
  49. package/services/output/index.js +1 -0
  50. package/services/output/ladder.js +26 -0
  51. package/services/playback-planner.js +806 -747
  52. package/services/produced-index.js +222 -300
  53. package/services/source/SourceFile.js +346 -0
  54. package/services/{sidecar-files.js → torrent/files.js} +107 -11
  55. package/services/torrent/naming.js +619 -0
  56. package/services/torrent-worker/client.js +10 -0
  57. package/services/torrent-worker/container-tracks.js +71 -43
  58. package/services/torrent-worker/pool-adapter.js +370 -333
  59. package/services/torrent-worker/protocol.js +7 -0
  60. package/services/torrent-worker/subtitle-cues.js +549 -549
  61. package/services/torrent-worker/worker.js +18 -0
  62. package/services/tracks/AudioTrack.js +131 -40
  63. package/services/tracks/TextSubtitleTrack.js +287 -287
  64. package/services/tracks/index.js +15 -14
  65. package/services/viewer/Viewer.js +145 -0
  66. package/services/viewer/Viewers.js +124 -0
  67. package/test/audio-inventory.test.js +176 -177
  68. package/test/auto-quality-step.test.js +508 -506
  69. package/test/behind-head-repair.test.js +17 -7
  70. package/test/coverage-map.test.js +153 -0
  71. package/test/cut-times-timeline.test.js +6 -5
  72. package/test/cuts-follow-published-grid.test.js +4 -4
  73. package/test/decode-cost.test.js +31 -12
  74. package/test/encode-exit.test.js +1 -1
  75. package/test/encode-orchestrator.test.js +196 -0
  76. package/test/encode-plan.test.js +245 -0
  77. package/test/encode-run-state.test.js +2 -2
  78. package/test/encode-run.test.js +168 -0
  79. package/test/encoder-kinds.test.js +122 -0
  80. package/test/held-request-width.test.js +9 -3
  81. package/test/helpers/encode-run.js +128 -0
  82. package/test/keyframe-index-accuracy.test.js +19 -12
  83. package/test/keyframes-belong-to-the-file.test.js +132 -0
  84. package/test/matroska-cues-track.test.js +192 -192
  85. package/test/mp4-composition-times.test.js +0 -0
  86. package/test/orchestrator-wired.test.js +164 -0
  87. package/test/output-shape.test.js +68 -0
  88. package/test/output-spec.test.js +157 -0
  89. package/test/produced-copy-choice.test.js +58 -92
  90. package/test/produced-index.test.js +142 -188
  91. package/test/quality-variants.test.js +1079 -1075
  92. package/test/run-graph-drift.test.js +1 -1
  93. package/test/run-intervals.test.js +329 -0
  94. package/test/run-position-follows-published-grid.test.js +4 -4
  95. package/test/seek-landing.test.js +8 -8
  96. package/test/seek-target-not-superseded.test.js +21 -9
  97. package/test/segment-demand.test.js +82 -0
  98. package/test/segment-serve-wiring.test.js +47 -52
  99. package/test/segment-store.test.js +187 -0
  100. package/test/segments-are-shared.test.js +175 -0
  101. package/test/sidecar-naming.test.js +142 -0
  102. package/test/source-file.test.js +133 -0
  103. package/test/stale-request-after-seek.test.js +18 -12
  104. package/test/subtitle-language.test.js +252 -252
  105. package/test/timeline.test.js +95 -0
  106. package/test/{sidecar-files.test.js → torrent-files.test.js} +44 -1
  107. package/test/torrent-naming.test.js +255 -0
  108. package/test/tracks-begin-together.test.js +44 -32
  109. package/test/two-viewers-one-picture.test.js +347 -0
  110. package/test/video-facts.test.js +102 -0
  111. package/test/viewer-outputs.test.js +273 -0
  112. package/test/viewer.test.js +91 -0
  113. package/utils/perf.js +1 -63
  114. package/services/container/index.js +0 -6
  115. package/services/container-index/avi.js +0 -167
  116. package/services/container-index/index.js +0 -118
  117. package/services/container-index/matroska.js +0 -336
  118. package/services/container-index/mp4.js +0 -358
  119. package/services/controllers/index.js +0 -2
  120. package/services/download/index.js +0 -8
  121. package/services/orchestrators/index.js +0 -2
  122. /package/services/{container-index → container}/ebml-reader.js +0 -0
  123. /package/services/{encode-run-state.js → encode/encode-run-state.js} +0 -0
  124. /package/services/{language-detect.js → tracks/language-detect.js} +0 -0
@@ -1,328 +1,105 @@
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 { mkdtemp, readFile, rm } from "node:fs/promises";
26
- import os from "node:os";
27
- import path from "node:path";
28
- import { fitDecodeCost } from "./decode-cost-fit.js";
29
- import { penaltiesFrom } from "./contention.js";
30
- import { fileURLToPath } from "node:url";
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 { mkdtemp, readFile, rm } from "node:fs/promises";
26
+ import os from "node:os";
27
+ import path from "node:path";
28
+ import { fitDecodeCost } from "./decode-cost-fit.js";
29
+ import { penaltiesFrom } from "./contention.js";
30
+ import { fileURLToPath } from "node:url";
31
+ import {
32
+ parseFfmpegBitrateKbps,
33
+ parseFfmpegDurationSeconds,
34
+ parseFfmpegVideoDimensions,
35
+ parseFfmpegVideoFps
36
+ } from "./ffmpeg-banner.js";
37
+
38
+ import { keyFrameArgs, SOFTWARE_CRF, TRANSCODE_FPS } from "./encode/args.js";
39
+ // The five kinds, one class each. Detection and benchmarking stay in this file;
40
+ // how a kind is driven belongs to the kind.
31
41
  import {
32
- parseFfmpegBitrateKbps,
33
- parseFfmpegDurationSeconds,
34
- parseFfmpegVideoDimensions,
35
- parseFfmpegVideoFps
36
- } from "./ffmpeg-banner.js";
37
-
38
- const SOFTWARE_PRESET = "ultrafast";
39
- const SOFTWARE_CRF = "24";
40
- // HDR→SDR tone-map chain (software). Converts a BT.2020 PQ/HLG source to BT.709
41
- // 8-bit SDR so the re-encode is not washed-out/desaturated. Requires the
42
- // `zscale` (libzimg) and `tonemap` filters — gated by detectTonemapSupport;
43
- // when unavailable the encode falls back to a plain 8-bit convert (no tonemap).
44
- // npl=100 targets ~100-nit SDR; hable is a well-behaved tone-mapping operator.
45
- const TONEMAP_FILTER_CHAIN =
46
- "zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709," +
47
- "tonemap=tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuv420p";
48
- // Default output frame rate when the source rate is unknown, and the rate used
49
- // by the synthetic startup test-encode / preset benchmark. The real encode
50
- // inherits the source rate (rounded to an integer, capped) — see
51
- // chooseOutputFps so 25/30 fps content no longer plays resampled to 24.
52
- export const TRANSCODE_FPS = 24;
53
- // Upper bound on the output frame rate: 50/60 fps sources are halved-in-effort
54
- // by capping to 30, protecting the realtime encode budget on weak hosts.
55
- export const MAX_OUTPUT_FPS = 30;
56
-
57
- /**
58
- * Choose an INTEGER output frame rate from the (possibly fractional) source
59
- * rate, for the frame-count-GOP encoders ONLY (software libx264, v4l2m2m).
60
- * Those place keyframes with `-g = segmentDur × fps` (frame count), so the
61
- * `fps=` filter value must be an integer that makes seg×fps an exact whole
62
- * number of frames per segment otherwise segments drift off the synthetic
63
- * playlist's uniform grid and seek accuracy degrades over a long file. Film
64
- * rates (23.976) round to 24, 25 stays 25, 29.97 rounds to 30; the cap clamps
65
- * high rates (the cap is a SPEED guard for the weak software/v4l2m2m path).
66
- *
67
- * Time-based-keyframe encoders (nvenc, vaapi, qsv) do NOT use this they
68
- * inherit the exact source rate untouched (their keyframes are forced by
69
- * output time, so any rate segments correctly).
70
- *
71
- * @param {number | null | undefined} sourceFps
72
- * @param {number} [cap=MAX_OUTPUT_FPS]
73
- * @returns {number}
74
- */
75
- export function chooseOutputFps(sourceFps, cap = MAX_OUTPUT_FPS) {
76
- if (!Number.isFinite(sourceFps) || sourceFps <= 0) {
77
- return TRANSCODE_FPS;
78
- }
79
- const rounded = Math.round(sourceFps);
80
- if (rounded < 1) {
81
- return TRANSCODE_FPS;
82
- }
83
- return Math.min(cap, rounded);
84
- }
85
- // Software x264 on weak ARM hosts is the transcode bottleneck — use all cores.
86
- const CPU_THREADS = Math.max(1, os.cpus().length);
87
-
88
- // Bitrate caps (constrained CRF). CRF stays the quality driver; -maxrate/
89
- // -bufsize only bound the peaks. Field evidence (iPhone on cellular,
90
- // 2026-07-10): uncapped complex scenes produced 4 s segments of ~18 Mbit/s
91
- // against a 1-6 Mbit/s viewer link — 45 s prebuffer, draining buffer.
92
- // Nominal H.264 rates per rung height; multipliers from webtor's production
93
- // ladder (content-transcoder): maxrate = 1.3x nominal, bufsize = 1.5x.
94
- const RUNG_NOMINAL_KBPS = [
95
- [1080, 5000],
96
- [720, 2800],
97
- [480, 1400],
98
- [360, 800],
99
- [240, 400]
100
- ];
101
- const CAP_MAXRATE_FACTOR = 1.3;
102
- const CAP_BUFSIZE_FACTOR = 1.5;
103
-
104
- /**
105
- * Nominal kbps for an encode height: nearest rung wins (odd heights snap to
106
- * the closest standard rung; anything above the top rung uses the top one).
107
- *
108
- * @param {number} height
109
- * @returns {number}
110
- */
111
- export function nominalKbpsForHeight(height) {
112
- const h = Number.isFinite(height) && height > 0 ? height : 720;
113
- let best = RUNG_NOMINAL_KBPS[0];
114
- for (const rung of RUNG_NOMINAL_KBPS) {
115
- if (Math.abs(rung[0] - h) < Math.abs(best[0] - h)) {
116
- best = rung;
117
- }
118
- }
119
- return best[1];
120
- }
121
-
122
- /**
123
- * The peak this encode may reach, in kbit/s, for a nominal rate.
124
- *
125
- * Exported because the same figure answers a second question: whether a rung
126
- * fits the viewer's measured link. The budget compares the link against what
127
- * the encode is ALLOWED to peak at rather than against what it happened to
128
- * produce in the last few segments, so a rung is judged by the bound we impose
129
- * on it and not by a quiet stretch of the film.
130
- *
131
- * @param {number} nominalKbps
132
- * @returns {number}
133
- */
134
- export function maxrateKbpsFor(nominalKbps) {
135
- return Math.round(nominalKbps * CAP_MAXRATE_FACTOR);
136
- }
137
-
138
- /**
139
- * The nominal rate whose cap is a given peak — the inverse of
140
- * {@link maxrateKbpsFor}.
141
- *
142
- * Used to turn a MEASURED limit into the figure the cap arithmetic takes. The
143
- * viewer's usable link is a peak the stream must not exceed, and the encoder is
144
- * configured from a nominal rate, so the two are converted through the one
145
- * factor rather than through a second constant invented for the purpose.
146
- *
147
- * @param {number} maxrateKbps
148
- * @returns {number}
149
- */
150
- export function nominalKbpsForMaxrate(maxrateKbps) {
151
- return Math.round(maxrateKbps / CAP_MAXRATE_FACTOR);
152
- }
153
-
154
- /**
155
- * `-maxrate`/`-bufsize` args for an encode height (constrained CRF).
156
- *
157
- * `nominalKbps` overrides the height's own nominal rate. It is how a measured
158
- * limit — the viewer's link, the only figure that bounds an encode from
159
- * outside this host — reaches the encoder without touching the picture's SIZE.
160
- * That distinction is the whole point: `-maxrate`, `-bufsize` and CRF do not
161
- * appear in the SPS (x264 writes no HRD parameters by default), so they can be
162
- * moved in the middle of a session while one init segment goes on describing
163
- * every fragment. The size cannot.
164
- *
165
- * @param {number} height
166
- * @param {number | null} [nominalKbps=null]
167
- * @returns {string[]}
168
- */
169
- function bitrateCapArgs(height, nominalKbps = null) {
170
- const nominal = Number.isFinite(nominalKbps) && nominalKbps > 0
171
- ? nominalKbps
172
- : nominalKbpsForHeight(height);
173
- return [
174
- "-maxrate", `${maxrateKbpsFor(nominal)}k`,
175
- "-bufsize", `${Math.round(nominal * CAP_BUFSIZE_FACTOR)}k`
176
- ];
177
- }
178
-
179
- // libx264 presets to benchmark, ordered slowest/highest-quality → fastest.
180
- const BENCHMARK_PRESETS = ["fast", "faster", "veryfast", "superfast", "ultrafast"];
181
- const BENCHMARK_REF_W = 640;
182
- const BENCHMARK_REF_H = 360;
183
- const BENCHMARK_DURATION_SEC = 3;
184
- /**
185
- * The narrowest window a slope may be taken over. Measured 2026-08-15: at a
186
- * fifth of a second the readings were noisy enough to put `faster` and
187
- * `veryfast` BELOW `fast`, which libx264 cannot do — and `pickSoftwarePreset`
188
- * walks the list assuming it ascends. Half a second was still noisy enough for that
189
- * (measured again: veryfast below faster, twice), so a full second it is —
190
- * about six seconds of startup for a ladder the whole budget then rests on.
191
- */
192
- const ENCODE_BENCHMARK_WINDOW_SEC = 1;
193
- /** The narrowest window that may be used when a run ends early. */
194
- const ENCODE_BENCHMARK_MIN_WINDOW_SEC = 0.2;
195
- /** Above this a reading is a fault, not a fast machine. */
196
- const ENCODE_BENCHMARK_MAX_PLAUSIBLE_SPEED = 1000;
197
- /**
198
- * A preset that has not reported twice in this long is hung, not slow: reports
199
- * arrive twice a second whatever the encoding speed.
200
- */
201
- const ENCODE_BENCHMARK_TIMEOUT_MS = 10_000;
202
- /** Progress reports arrive line by line. */
203
- const NEWLINE = String.fromCharCode(10);
204
- // Producing one second of video per second of clock. Not a margin and not a
205
- // choice — the definition of keeping up, and the bar when nothing better is
206
- // known about the supply this step will meet.
207
- const REALTIME = 1;
208
- // The bar where decoding CANNOT be priced — no calibration fit, or a source the
209
- // probe said too little about. This one is not measured and cannot be: the
210
- // prediction it guards counts encoding only, which on the field host was
211
- // several times optimistic, and there is no reading on such a host to correct
212
- // it with. It is left at the figure it has had since before decoding was
213
- // priced, because lowering it to realtime would make the least-measured hosts
214
- // the most permissive. Where decoding IS priced, nothing chosen remains.
215
- const UNPRICED_DECODE_BAR = 1.8;
216
-
217
- /**
218
- * @param {number} targetWidth
219
- * @param {number} targetHeight
220
- * @returns {{ w: number, h: number }}
221
- */
222
- function safeDimensions(targetWidth, targetHeight) {
223
- const w = Number.isInteger(targetWidth) && targetWidth > 0 ? targetWidth : 1280;
224
- const h = Number.isInteger(targetHeight) && targetHeight > 0 ? targetHeight : 720;
225
- return { w, h };
226
- }
227
-
228
- /**
229
- * Force a keyframe on every segment boundary so each HLS segment is
230
- * independently decodable.
231
- *
232
- * Two grids exist. The usual one is even — a keyframe every
233
- * `segmentDurationSec` — and the encoder is free to place them because it is
234
- * producing every frame anyway. The other is the SOURCE's own keyframe times,
235
- * used when this encode has to be interchangeable with a stream that is
236
- * COPIED: a copy can only be cut where the source already has a keyframe, so a
237
- * rung meant to splice into it must be cut at exactly those times and nowhere
238
- * else. Then the times are given outright.
239
- *
240
- * @param {number} segmentDurationSec
241
- * @param {number[] | null} [forcedTimes] - Run-relative seconds, ascending.
242
- * @returns {string[]}
243
- */
244
- function keyFrameArgs(segmentDurationSec, forcedTimes = null) {
245
- if (Array.isArray(forcedTimes) && forcedTimes.length > 0) {
246
- return ["-force_key_frames", forcedTimes.join(",")];
247
- }
248
- return ["-force_key_frames", `expr:gte(t,n_forced*${segmentDurationSec})`];
249
- }
250
-
251
- /**
252
- * Whether an explicit cut list was supplied.
253
- *
254
- * @param {number[] | null | undefined} forcedTimes
255
- * @returns {boolean}
256
- */
257
- function hasForcedTimes(forcedTimes) {
258
- return Array.isArray(forcedTimes) && forcedTimes.length > 0;
259
- }
260
-
42
+ NvencEncoder,
43
+ QsvEncoder,
44
+ SoftwareEncoder,
45
+ V4l2m2mEncoder,
46
+ VaapiEncoder
47
+ } from "./encode/index.js";
48
+ // Re-exported so every caller goes on importing these figures from here:
49
+ // the same calculation, moved to sit beside the encoder kinds built from it.
50
+ export {
51
+ chooseOutputFps,
52
+ maxrateKbpsFor,
53
+ nominalKbpsForHeight,
54
+ nominalKbpsForMaxrate,
55
+ TRANSCODE_FPS
56
+ } from "./encode/args.js";
57
+
58
+ // libx264 presets to benchmark, ordered slowest/highest-quality fastest.
59
+ const BENCHMARK_PRESETS = ["fast", "faster", "veryfast", "superfast", "ultrafast"];
60
+ const BENCHMARK_REF_W = 640;
61
+ const BENCHMARK_REF_H = 360;
62
+ const BENCHMARK_DURATION_SEC = 3;
63
+ /**
64
+ * The narrowest window a slope may be taken over. Measured 2026-08-15: at a
65
+ * fifth of a second the readings were noisy enough to put `faster` and
66
+ * `veryfast` BELOW `fast`, which libx264 cannot do — and `pickSoftwarePreset`
67
+ * walks the list assuming it ascends. Half a second was still noisy enough for that
68
+ * (measured again: veryfast below faster, twice), so a full second it is —
69
+ * about six seconds of startup for a ladder the whole budget then rests on.
70
+ */
71
+ const ENCODE_BENCHMARK_WINDOW_SEC = 1;
72
+ /** The narrowest window that may be used when a run ends early. */
73
+ const ENCODE_BENCHMARK_MIN_WINDOW_SEC = 0.2;
74
+ /** Above this a reading is a fault, not a fast machine. */
75
+ const ENCODE_BENCHMARK_MAX_PLAUSIBLE_SPEED = 1000;
76
+ /**
77
+ * A preset that has not reported twice in this long is hung, not slow: reports
78
+ * arrive twice a second whatever the encoding speed.
79
+ */
80
+ const ENCODE_BENCHMARK_TIMEOUT_MS = 10_000;
81
+ /** Progress reports arrive line by line. */
82
+ const NEWLINE = String.fromCharCode(10);
83
+ // Producing one second of video per second of clock. Not a margin and not a
84
+ // choice — the definition of keeping up, and the bar when nothing better is
85
+ // known about the supply this step will meet.
86
+ const REALTIME = 1;
87
+ // The bar where decoding CANNOT be priced — no calibration fit, or a source the
88
+ // probe said too little about. This one is not measured and cannot be: the
89
+ // prediction it guards counts encoding only, which on the field host was
90
+ // several times optimistic, and there is no reading on such a host to correct
91
+ // it with. It is left at the figure it has had since before decoding was
92
+ // priced, because lowering it to realtime would make the least-measured hosts
93
+ // the most permissive. Where decoding IS priced, nothing chosen remains.
94
+ const UNPRICED_DECODE_BAR = 1.8;
95
+
96
+
97
+ // The five kinds live in `encode/`, one class each, and these keep the names
98
+ // every caller already uses. A kind states its own arguments and its own
99
+ // ladder of speed settings; detection and benchmarking stay here.
261
100
  /** @returns {import("./hwaccel.js").VideoEncoderDescriptor} */
262
101
  export function softwareDescriptor() {
263
- return {
264
- name: "libx264",
265
- kind: "software",
266
- device: null,
267
- inputArgs: [],
268
- buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, preset, fps, tonemap, forcedKeyframeTimes, nominalKbps = null }) {
269
- const { w, h } = safeDimensions(targetWidth, targetHeight);
270
- const chosenPreset = typeof preset === "string" && preset.length > 0 ? preset : SOFTWARE_PRESET;
271
- // Output frame rate: inherited from the source (rounded/capped) by the
272
- // session manager, TRANSCODE_FPS by default. MUST be an integer and MUST
273
- // equal the value used in the GOP below, or keyframes drift off the grid.
274
- const outFps = Number.isInteger(fps) && fps > 0 ? fps : TRANSCODE_FPS;
275
- // HDR→SDR tone-map, inserted AFTER the downscale so it runs on the smaller
276
- // frame (cheaper on ARM); only when the source is HDR and the filters are
277
- // present (session manager gates on both).
278
- const tonemapPart = tonemap === true ? `,${TONEMAP_FILTER_CHAIN}` : "";
279
- return [
280
- // Never upscale: cap the target box to the source size (min with
281
- // iw/ih), so a small source (e.g. 720x400) is encoded at its own
282
- // resolution instead of being scaled up to the viewport — far fewer
283
- // pixels, much faster on ARM. force_original_aspect_ratio keeps aspect.
284
- "-vf",
285
- `scale='min(${w},iw)':'min(${h},ih)':force_original_aspect_ratio=decrease:force_divisible_by=2${tonemapPart},fps=${outFps}`,
286
- "-c:v", "libx264",
287
- // Preset is chosen per stream by the session manager from the startup
288
- // benchmark (highest quality that still encodes the source resolution
289
- // faster than realtime); falls back to the static default.
290
- "-preset", chosenPreset,
291
- "-crf", SOFTWARE_CRF,
292
- // Constrained CRF: bound peak bitrate per rung so a complex scene
293
- // cannot produce segments a thin viewer link (cellular) can't
294
- // download in time. Sized by the TARGET box height (the rung the
295
- // budget/manual selection chose).
296
- ...bitrateCapArgs(h, nominalKbps),
297
- "-threads", String(CPU_THREADS),
298
- "-pix_fmt", "yuv420p",
299
- // Fixed GOP: a keyframe exactly every (segmentDurationSec × fps) frames,
300
- // scene-cut keyframes disabled. This is frame-count based, so it is
301
- // independent of the PTS offset used on seek-restart — every HLS segment
302
- // is exactly segmentDurationSec long and starts on a keyframe, so segment
303
- // boundaries line up with the synthetic playlist with no gaps. (The old
304
- // the OLD `expr:` form of -force_key_frames broke after a seek, because
305
- // the `t` it reads is shifted by `-output_ts_offset`.)
306
- //
307
- // An explicit cut LIST is a different thing and does work: verified by
308
- // running it, its times are on the run's own timeline — the same one
309
- // `-segment_times` is measured on — so both are given one list and
310
- // cannot drift apart. It replaces the frame-count GOP, which cannot
311
- // describe the source's keyframes because they are not evenly spaced.
312
- // `-g` stays as an upper bound on the interval: an extra keyframe
313
- // inside a segment costs a little bitrate and cuts nothing, while
314
- // leaving the interval unbounded means a driver that ignores the list
315
- // produces one enormous segment instead of a wrong but cut one.
316
- // `-keyint_min` goes, since a MINIMUM interval is the one thing that
317
- // could argue with a forced keyframe.
318
- "-g", String(segmentDurationSec * outFps),
319
- ...(hasForcedTimes(forcedKeyframeTimes)
320
- ? keyFrameArgs(segmentDurationSec, forcedKeyframeTimes)
321
- : ["-keyint_min", String(segmentDurationSec * outFps)]),
322
- "-sc_threshold", "0"
323
- ];
324
- }
325
- };
102
+ return new SoftwareEncoder();
326
103
  }
327
104
 
328
105
  /**
@@ -330,25 +107,7 @@ export function softwareDescriptor() {
330
107
  * @returns {import("./hwaccel.js").VideoEncoderDescriptor}
331
108
  */
332
109
  function vaapiDescriptor(device) {
333
- return {
334
- name: "h264_vaapi",
335
- kind: "vaapi",
336
- device,
337
- // Decode on the GPU into VAAPI surfaces; scale and encode stay on-GPU.
338
- inputArgs: ["-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi", "-vaapi_device", device],
339
- // No fps filter: VAAPI inherits the source rate and keeps keyframes on the
340
- // grid via time-based -force_key_frames, so it already honours source fps.
341
- buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, forcedKeyframeTimes }) {
342
- const { w, h } = safeDimensions(targetWidth, targetHeight);
343
- return [
344
- "-vf",
345
- `scale_vaapi=w=${w}:h=${h}:force_original_aspect_ratio=decrease`,
346
- "-c:v", "h264_vaapi",
347
- "-qp", "24",
348
- ...keyFrameArgs(segmentDurationSec, forcedKeyframeTimes)
349
- ];
350
- }
351
- };
110
+ return new VaapiEncoder(device);
352
111
  }
353
112
 
354
113
  /**
@@ -356,1664 +115,1601 @@ function vaapiDescriptor(device) {
356
115
  * @returns {import("./hwaccel.js").VideoEncoderDescriptor}
357
116
  */
358
117
  function qsvDescriptor(device) {
359
- return {
360
- name: "h264_qsv",
361
- kind: "qsv",
362
- device,
363
- inputArgs: ["-hwaccel", "qsv", "-qsv_device", device],
364
- buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, forcedKeyframeTimes }) {
365
- const { w, h } = safeDimensions(targetWidth, targetHeight);
366
- return [
367
- "-vf", `scale_qsv=w=${w}:h=${h}`,
368
- "-c:v", "h264_qsv",
369
- "-global_quality", "24",
370
- ...keyFrameArgs(segmentDurationSec, forcedKeyframeTimes)
371
- ];
372
- }
373
- };
118
+ return new QsvEncoder(device);
374
119
  }
375
120
 
376
121
  /** @returns {import("./hwaccel.js").VideoEncoderDescriptor} */
377
122
  function nvencDescriptor() {
378
- return {
379
- name: "h264_nvenc",
380
- kind: "nvenc",
381
- device: null,
382
- inputArgs: [],
383
- // No fps filter: NVENC is fast and places keyframes by time-based
384
- // -force_key_frames, so it inherits the exact source rate (fractional
385
- // included) with no need to round or cap. Same rationale as VAAPI/QSV.
386
- buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, forcedKeyframeTimes }) {
387
- const { w, h } = safeDimensions(targetWidth, targetHeight);
388
- return [
389
- "-vf",
390
- `scale=${w}:${h}:force_original_aspect_ratio=decrease:force_divisible_by=2`,
391
- "-c:v", "h264_nvenc",
392
- "-preset", "p4",
393
- "-cq", "24",
394
- "-pix_fmt", "yuv420p",
395
- ...keyFrameArgs(segmentDurationSec, forcedKeyframeTimes)
396
- ];
397
- }
398
- };
123
+ return new NvencEncoder();
399
124
  }
400
125
 
401
126
  /** @returns {import("./hwaccel.js").VideoEncoderDescriptor} */
402
127
  function v4l2m2mDescriptor() {
403
- // ARM SoC (e.g. Raspberry Pi / HA Yellow) stateful M2M encoder. No GPU
404
- // scaler — scale in software, hand YUV420 frames to the hardware encoder.
405
- // `-g` aligns the GOP to the segment length so an IDR lands on every segment
406
- // boundary; this is verified by the keyframe-alignment test before use,
407
- // because v4l2m2m does not always honour these hints.
408
- return {
409
- name: "h264_v4l2m2m",
410
- kind: "v4l2m2m",
411
- device: null,
412
- inputArgs: [],
413
- buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, fps, forcedKeyframeTimes }) {
414
- const { w, h } = safeDimensions(targetWidth, targetHeight);
415
- const outFps = Number.isInteger(fps) && fps > 0 ? fps : TRANSCODE_FPS;
416
- return [
417
- "-vf",
418
- `scale=${w}:${h}:force_original_aspect_ratio=decrease:force_divisible_by=2,fps=${outFps},format=yuv420p`,
419
- "-c:v", "h264_v4l2m2m",
420
- // More capture buffers than the default 4 — the default deadlocks /
421
- // drops frames on the CM4 encoder ("All capture buffers returned to
422
- // userspace").
423
- "-num_capture_buffers", "32",
424
- "-b:v", "3M",
425
- // Kept even with an explicit cut list, as an upper bound on the
426
- // interval: this encoder is the one known not always to honour keyframe
427
- // hints, and without any bound a list it ignores yields one segment for
428
- // the whole file rather than a wrongly-cut one.
429
- "-g", String(outFps * segmentDurationSec),
430
- ...keyFrameArgs(segmentDurationSec, forcedKeyframeTimes)
431
- ];
432
- }
433
- };
434
- }
435
-
436
-
437
- /**
438
- * @typedef {Object} VideoEncoderDescriptor
439
- * @property {string} name
440
- * @property {"software"|"vaapi"|"qsv"|"nvenc"|"v4l2m2m"} kind
441
- * @property {string|null} device
442
- * @property {string[]} inputArgs
443
- * @property {(opts: { targetWidth: number, targetHeight: number, segmentDurationSec: number, preset?: string, fps?: number, tonemap?: boolean, forcedKeyframeTimes?: number[] | null, nominalKbps?: number | null }) => string[]} buildVideoArgs
444
- */
445
-
446
- /**
447
- * Run ffmpeg and resolve with its exit code and captured output.
448
- *
449
- * @param {string} ffmpegBin
450
- * @param {string[]} args
451
- * @param {number} [timeoutMs=12000]
452
- * @returns {Promise<{ code: number, stdout: string, stderr: string }>}
453
- */
454
- function runFfmpeg(ffmpegBin, args, timeoutMs = 12000) {
455
- return new Promise((resolve) => {
456
- let stdout = "";
457
- let stderr = "";
458
- let settled = false;
459
- let child;
460
- const finish = (code) => {
461
- if (settled) {
462
- return;
463
- }
464
- settled = true;
465
- resolve({ code, stdout, stderr });
466
- };
467
- try {
468
- child = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
469
- } catch {
470
- finish(-1);
471
- return;
472
- }
473
- const timer = setTimeout(() => {
474
- try {
475
- child.kill("SIGKILL");
476
- } catch {
477
- // already gone
478
- }
479
- finish(-1);
480
- }, timeoutMs);
481
- child.stdout.on("data", (chunk) => {
482
- stdout += String(chunk);
483
- });
484
- child.stderr.on("data", (d) => {
485
- stderr += String(d);
486
- });
487
- child.on("error", () => {
488
- clearTimeout(timer);
489
- finish(-1);
490
- });
491
- child.on("exit", (code) => {
492
- clearTimeout(timer);
493
- finish(code ?? -1);
494
- });
495
- });
496
- }
497
-
498
- /** @returns {string[]} /dev/dri/renderD* nodes (VAAPI/QSV). */
499
- function listRenderNodes() {
500
- try {
501
- return readdirSync("/dev/dri")
502
- .filter((n) => n.startsWith("renderD"))
503
- .map((n) => `/dev/dri/${n}`)
504
- .sort();
505
- } catch {
506
- return [];
507
- }
508
- }
509
-
510
- /** @returns {boolean} Whether any /dev/nvidia* node exists (NVENC). */
511
- function hasNvidiaDevice() {
512
- try {
513
- return readdirSync("/dev").some((n) => /^nvidia(\d+)?$/.test(n));
514
- } catch {
515
- return false;
516
- }
517
- }
518
-
519
- /** @returns {boolean} Whether any /dev/video* node exists (V4L2 M2M). */
520
- function hasV4l2Device() {
521
- try {
522
- return readdirSync("/dev").some((n) => /^video\d+$/.test(n));
523
- } catch {
524
- return false;
525
- }
526
- }
527
-
528
- /**
529
- * Build a full ffmpeg command that encodes a short, *moving* synthetic clip
530
- * (testsrc2 — far more representative than a static black frame) through the
531
- * candidate encoder into real HLS segments in `outDir`, with keyframes forced
532
- * on segment boundaries. Verifying the resulting segments (see
533
- * {@link verifySegmentsDecodeCleanly}) catches encoders that silently produce
534
- * a corrupted or non-IDR-aligned stream (e.g. some V4L2 M2M builds).
535
- *
536
- * @param {VideoEncoderDescriptor} descriptor
537
- * @param {number} segmentDurationSec
538
- * @param {string} outDir
539
- * @returns {string[]}
540
- */
541
- function buildEncoderTestArgs(descriptor, segmentDurationSec, outDir) {
542
- const durationSec = Math.max(8, segmentDurationSec * 3);
543
- const source = ["-f", "lavfi", "-i", `testsrc2=s=640x360:r=${TRANSCODE_FPS}:d=${durationSec}`];
544
- const kf = keyFrameArgs(segmentDurationSec);
545
-
546
- /** @type {string[]} */
547
- let pre = ["-hide_banner", "-loglevel", "error"];
548
- /** @type {string[]} */
549
- let encode;
550
- switch (descriptor.kind) {
551
- case "vaapi":
552
- pre = [...pre, "-vaapi_device", String(descriptor.device)];
553
- encode = ["-vf", "format=nv12,hwupload", "-c:v", "h264_vaapi", "-qp", "24", ...kf];
554
- break;
555
- case "qsv":
556
- pre = [...pre, "-qsv_device", String(descriptor.device)];
557
- encode = ["-vf", "hwupload=extra_hw_frames=16,format=qsv", "-c:v", "h264_qsv", "-global_quality", "24", ...kf];
558
- break;
559
- case "nvenc":
560
- encode = ["-c:v", "h264_nvenc", "-preset", "p4", "-cq", "24", "-pix_fmt", "yuv420p", ...kf];
561
- break;
562
- case "v4l2m2m":
563
- encode = ["-pix_fmt", "yuv420p", "-c:v", "h264_v4l2m2m", "-num_capture_buffers", "32", "-b:v", "3M", "-g", String(TRANSCODE_FPS * segmentDurationSec), ...kf];
564
- break;
565
- default:
566
- encode = ["-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", ...kf];
567
- break;
568
- }
569
-
570
- const hlsOut = [
571
- "-f", "hls",
572
- "-hls_time", String(segmentDurationSec),
573
- "-hls_list_size", "0",
574
- "-hls_flags", "independent_segments",
575
- // fMP4 (CMAF) — matches the runtime pipeline (hls-session-manager).
576
- "-hls_segment_type", "fmp4",
577
- "-hls_fmp4_init_filename", "init.mp4",
578
- "-hls_segment_filename", path.join(outDir, "seg-%03d.m4s"),
579
- path.join(outDir, "index.m3u8")
580
- ];
581
- return [...pre, ...source, ...encode, ...hlsOut];
582
- }
583
-
584
- /**
585
- * Verify the HLS segments produced by the test encode are valid: at least two
586
- * segments exist, and each decodes standalone without errors. A segment that
587
- * does not begin with a keyframe (broken/corrupted output) emits decode errors
588
- * when read on its own, which fails this check.
589
- *
590
- * @param {string} ffmpegBin
591
- * @param {string} outDir
592
- * @returns {Promise<boolean>}
593
- */
594
- async function verifySegmentsDecodeCleanly(ffmpegBin, outDir) {
595
- let files;
596
- try {
597
- files = readdirSync(outDir).filter((n) => /^seg-\d+\.m4s$/.test(n));
598
- } catch {
599
- return false;
600
- }
601
- if (files.length < 2) {
602
- return false;
603
- }
604
- // fMP4: parameter sets (SPS/PPS) live in init.mp4, not in each segment.
605
- // Decode the whole playlist (ffmpeg's own, which references init.mp4 via
606
- // #EXT-X-MAP), so every segment is exercised together with the init. Any
607
- // corrupt / non-conformant segment (e.g. some V4L2 M2M builds emit a stray
608
- // no-picture access unit) surfaces as a decode error here.
609
- const result = await runFfmpeg(
610
- ffmpegBin,
611
- ["-hide_banner", "-loglevel", "error", "-i", path.join(outDir, "index.m3u8"), "-f", "null", "-"],
612
- 12000
613
- );
614
- return result.code === 0 && result.stderr.trim().length === 0;
615
- }
616
-
617
- /**
618
- * Detect the best usable H.264 encoder. Always resolves (falls back to
619
- * software libx264). Each hardware candidate is verified with a real
620
- * test-encode before being selected.
621
- *
622
- * @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void }, segmentDurationSec?: number }} options
623
- * @returns {Promise<VideoEncoderDescriptor>}
624
- */
625
- export async function detectVideoEncoder({ ffmpegBin, logger, segmentDurationSec = 4 }) {
626
- const log = logger ?? { info: () => {}, warn: () => {} };
627
- const software = softwareDescriptor();
628
-
629
- const { code, stdout } = await runFfmpeg(ffmpegBin, ["-hide_banner", "-encoders"], 10000);
630
- if (code !== 0) {
631
- log.warn("hwaccel: could not list ffmpeg encoders; using software libx264");
632
- return software;
633
- }
634
- const has = (name) => stdout.includes(name);
635
-
636
- /** @type {VideoEncoderDescriptor[]} */
637
- const candidates = [];
638
- const renderNodes = listRenderNodes();
639
- if (has("h264_nvenc") && hasNvidiaDevice()) {
640
- candidates.push(nvencDescriptor());
641
- }
642
- if (has("h264_qsv") && renderNodes.length > 0) {
643
- candidates.push(qsvDescriptor(renderNodes[0]));
644
- }
645
- if (has("h264_vaapi") && renderNodes.length > 0) {
646
- candidates.push(vaapiDescriptor(renderNodes[0]));
647
- }
648
- // h264_v4l2m2m (ARM SoC / Raspberry Pi / HA Yellow). It is gated behind the
649
- // strict keyframe-alignment test below, because some V4L2 M2M builds silently
650
- // emit a corrupted / non-IDR-aligned stream; the test rejects those and the
651
- // host falls back to software libx264.
652
- if (has("h264_v4l2m2m") && hasV4l2Device()) {
653
- candidates.push(v4l2m2mDescriptor());
654
- }
655
-
656
- for (const candidate of candidates) {
657
- const dir = mkdtempSync(path.join(os.tmpdir(), "tt-hwtest-"));
658
- let ok = false;
659
- try {
660
- const encoded = await runFfmpeg(
661
- ffmpegBin,
662
- buildEncoderTestArgs(candidate, segmentDurationSec, dir),
663
- 25000
664
- );
665
- if (encoded.code === 0) {
666
- ok = await verifySegmentsDecodeCleanly(ffmpegBin, dir);
667
- }
668
- } finally {
669
- try {
670
- rmSync(dir, { recursive: true, force: true });
671
- } catch {
672
- // best effort
673
- }
674
- }
675
- if (ok) {
676
- log.info(
677
- `hwaccel: using hardware encoder ${candidate.name}` +
678
- `${candidate.device ? ` (${candidate.device})` : ""}`
679
- );
680
- return candidate;
681
- }
682
- log.warn(`hwaccel: ${candidate.name} failed the HLS keyframe-alignment test; skipping`);
683
- }
684
-
685
- log.info("hwaccel: no working hardware encoder; using software libx264");
686
- return software;
687
- }
688
-
689
- /**
690
- * Detect whether this ffmpeg build has the filters needed for the HDR→SDR
691
- * tone-map chain (`zscale`, from libzimg, and `tonemap`). Both are required;
692
- * when either is missing, HDR sources are re-encoded without tone mapping
693
- * (washed-out but playable). Always resolves.
694
- *
695
- * @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void } }} options
696
- * @returns {Promise<boolean>}
697
- */
698
- export async function detectTonemapSupport({ ffmpegBin, logger }) {
699
- const log = logger ?? { info: () => {}, warn: () => {} };
700
- const { code, stdout } = await runFfmpeg(ffmpegBin, ["-hide_banner", "-filters"], 10000);
701
- if (code !== 0) {
702
- log.warn("hwaccel: could not list ffmpeg filters; HDR tone mapping disabled");
703
- return false;
704
- }
705
- // `-filters` prints one filter per line: "... zscale ...", "... tonemap ...".
706
- const hasZscale = /\bzscale\b/.test(stdout);
707
- const hasTonemap = /\btonemap\b/.test(stdout);
708
- const supported = hasZscale && hasTonemap;
709
- log.info(
710
- `hwaccel: HDR tone mapping ${supported ? "available" : "unavailable"} ` +
711
- `(zscale=${hasZscale} tonemap=${hasTonemap})`
712
- );
713
- return supported;
714
- }
715
-
716
-
717
- // The clips the decode cost is fitted from. They ship with the package
718
- // (`assets/calibration/`), cut from Netflix Open Content "Meridian" (CC-BY 4.0)
719
- // — real, grainy live action, because a generated `testsrc2` clip decodes 158 %
720
- // away from a real film where these are 11 % away (measured 2026-08-14).
721
- //
722
- // Three sizes at two bitrates each, with the axes varied INDEPENDENTLY. The set
723
- // this replaced was three clips for three unknowns, two of them at the same
724
- // size: an exact system, which cannot fail visibly. On 2026-08-17 it returned
725
- // `0.007542 × Mpx/s + 0.000000 × Mbit/s + 0.0000 s/s` — the bitrate term and
726
- // the constant exactly zero — and the prediction on top of it was 1.8-2.2x
727
- // optimistic. Six points leave three spare, so the fit has a residual, and a
728
- // term the data does not determine can be refused instead of published as a
729
- // zero that looks measured. See `assets/calibration/NOTICE.md`.
730
- //
731
- // One set PER CODEC FAMILY, because a family is what the model describes. The
732
- // fit used to be H.264 only, while a video that has to be RE-ENCODED is by
733
- // definition one the browser could not play — which is to say HEVC, 10-bit or
734
- // AV1 — and those decode dearer per pixel on the same box. Pricing them with
735
- // H.264 constants is the one case the model is always asked about and was never
736
- // measured on.
737
- //
738
- // A family that has no set of its own is priced with H.264's, which is what
739
- // happened to every family before this; the line says so rather than implying
740
- // it. AV1 has no set yet: the survey of 2026-07-10 found it rare where HEVC was
741
- // 18 % of releases, so it waits for the same treatment.
742
- const CALIBRATION_SETS = {
743
- h264: [
744
- "cal-h264-1080-hi.mp4",
745
- "cal-h264-1080-lo.mp4",
746
- "cal-h264-720-hi.mp4",
747
- "cal-h264-720-lo.mp4",
748
- "cal-h264-480-hi.mp4",
749
- "cal-h264-480-lo.mp4"
750
- ],
751
- hevc: [
752
- "cal-hevc-1080-hi.mp4",
753
- "cal-hevc-1080-lo.mp4",
754
- "cal-hevc-480-hi.mp4",
755
- "cal-hevc-480-lo.mp4"
756
- ],
757
- hevc10: [
758
- "cal-hevc10-1080-hi.mp4",
759
- "cal-hevc10-1080-lo.mp4",
760
- "cal-hevc10-480-hi.mp4",
761
- "cal-hevc10-480-lo.mp4"
762
- ]
763
- };
764
- const CALIBRATION_CLIPS = CALIBRATION_SETS.h264;
765
- const CALIBRATION_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "assets", "calibration");
766
- // How wide the measured window must be before the slope is trusted, and how
767
- // long to wait for it at most.
768
- //
769
- // Half a second, and it is the TIMING noise that sets it rather than the amount
770
- // of video: the slope is output time against wall time, both read from the same
771
- // two progress lines, and the jitter in stamping one is milliseconds — so half
772
- // a second of window is a fraction of a percent of error on any host. What used
773
- // to make a longer window necessary was the clip restarting inside it, and that
774
- // is gone: the stream is continuous now. Measured 2026-08-22 against the
775
- // continuous-pass truth on a desktop: -3.0 % and +0.6 % at half a second, with
776
- // the readings spread 2-6 %, against -25 % and -33 % for the loop it replaces.
777
- // Half a second also costs the startup about 0.6 s per clip less, which matters
778
- // because every clip of every codec family is paid for before any viewer
779
- // exists.
780
- const DECODE_WINDOW_MIN_SEC = 0.5;
781
- const DECODE_WINDOW_MAX_MS = 8000;
782
-
783
- /**
784
- * Read what a calibration clip IS from the decode run's own output: the
785
- * dimensions, the frame rate and the bitrate ffmpeg reports for it. Read rather
786
- * than declared, so replacing a clip cannot silently invalidate the fit.
787
- *
788
- * @param {string} stderr
789
- * @returns {{ megapixelsPerSecond: number, megabitsPerSecond: number, durationSeconds: number } | null}
790
- */
791
- function parseClipCharacteristics(stderr) {
792
- // The same readers the session manager uses on the same banner — one parser
793
- // per fact, so a second copy cannot drift from the first.
794
- const { width, height } = parseFfmpegVideoDimensions(stderr);
795
- const rate = parseFfmpegVideoFps(stderr);
796
- const seconds = parseFfmpegDurationSeconds(stderr);
797
- const kbps = parseFfmpegBitrateKbps(stderr);
798
- if (!(width > 0) || !(height > 0) || !(rate > 0) || !(seconds > 0) || !(kbps > 0)) {
799
- return null;
800
- }
801
- return {
802
- megapixelsPerSecond: (width * height * rate) / 1e6,
803
- megabitsPerSecond: kbps / 1000,
804
- durationSeconds: seconds
805
- };
806
- }
807
-
808
- /**
809
- * Measure what DECODING costs on this host, as seconds of work per second of
810
- * video, and solve it into three host constants:
811
- *
812
- * decodeCost = a × Mpixel/s + b × Mbit/s + c
813
- *
814
- * Why it exists: the preset benchmark below measures ENCODING only, and a
815
- * re-encode pays for both halves. Measured 2026-08-14 on the addon host, that
816
- * omission made the budget offer a 240p rung it then ran at 0.39-0.95× — the
817
- * benchmark said the host cleared the bar 2.5× over. With the decode term the
818
- * same file predicts within 4.8 %; without it the error on that rung was 209 %.
819
- *
820
- * The constants are properties of the HOST, so this runs once at startup (about
821
- * 5 s on a CM4) and any source is then priced from figures the probe already
822
- * has — nothing is added to a session's cold start.
823
- *
824
- * They are also properties of the CODEC, and the clips are H.264: HEVC, AV1 and
825
- * 10-bit decode dearer per pixel on the same machine, and a source that has to
826
- * be re-encoded is by definition one this browser could not play, which is
827
- * usually not H.264. So the fit is optimistic exactly there. Closing that needs
828
- * clips in those codecs, and is its own roadmap item.
829
- *
830
- * @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void }, clipsDir?: string }} options
831
- * @returns {Promise<{ pixelTerm: number, bitrateTerm: number, constantTerm: number } | null>}
832
- */
833
- /**
834
- * What a second job costs on this host, measured rather than assumed.
835
- *
836
- * The budget adds seconds of work per second of content — this encode, plus
837
- * that decode, plus what is already committed — and the addon host contradicted
838
- * that directly on 2026-08-18: decoding ran at 2.10-2.25x alone, 0.79-0.90x
839
- * with one encoder beside it and 0.56-0.64x with two. The same work costs 2.6×
840
- * more for having company. Heat is not the cause (the hot idle machine was the
841
- * fastest reading of all); four cores sharing one path to memory is.
842
- *
843
- * So it is measured the way everything else here is: the same clip decoded
844
- * alone, then decoded again while an encoder of the same clip runs beside it.
845
- * The ratio is the penalty. The encoder is stopped as soon as the reading is
846
- * taken, and the whole thing costs one decode plus one short encode.
847
- *
848
- * @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void }, clipsDir?: string, upTo?: number }} options
849
- * @returns {Promise<Map<number, number> | null>} Penalties by how many other
850
- * jobs were running, or null when the readings could not be taken.
851
- */
852
- export async function benchmarkContention({ ffmpegBin, logger, clipsDir = CALIBRATION_DIR, upTo = 2 }) {
853
- const log = logger ?? { info: () => {}, warn: () => {} };
854
- // The cheapest clip in the set: this measures the MACHINE's behaviour under
855
- // company, not the clip's own cost, so the smallest one says it soonest.
856
- const clip = path.join(clipsDir, "cal-h264-480-lo.mp4");
857
- const startedAt = Date.now();
858
- // Lifted once and decoded three times from the same bytes. Going through
859
- // `measureDecodeSlope` lifted it again for every reading — three process
860
- // starts on a path that is awaited before the proxy's tunnel opens, for a
861
- // remux whose result had not changed.
862
- const streams = await extractFamilyStreams(ffmpegBin, [clip], "h264");
863
- const stream = streams?.[0];
864
- if (!stream) {
865
- log.warn("hwaccel: contention could not be measured; costs will be added as though jobs were independent");
866
- return null;
867
- }
868
- const alone = await decodePipedStream(ffmpegBin, stream, log);
869
- if (!alone?.speed) {
870
- log.warn("hwaccel: contention could not be measured; costs will be added as though jobs were independent");
871
- return null;
872
- }
873
- /** @type {Array<{ others: number, speed: number }>} */
874
- const beside = [];
875
- /** @type {import("node:child_process").ChildProcess[]} */
876
- const load = [];
877
- try {
878
- for (let others = 1; others <= Math.max(1, upTo); others += 1) {
879
- load.push(
880
- spawn(
881
- ffmpegBin,
882
- [
883
- "-hide_banner", "-loglevel", "error", "-nostats",
884
- "-stream_loop", "-1", "-i", clip,
885
- "-an", "-c:v", "libx264", "-preset", "fast", "-f", "null", "-"
886
- ],
887
- { stdio: ["ignore", "ignore", "ignore"], windowsHide: true }
888
- )
889
- );
890
- // Let the encoder reach its own speed before reading anything: an encode
891
- // measured in its first moments is measuring the process starting.
892
- await new Promise((resolve) => {
893
- setTimeout(resolve, 2_000);
894
- });
895
- const withCompany = await decodePipedStream(ffmpegBin, stream, log);
896
- if (withCompany?.speed) {
897
- beside.push({ others, speed: withCompany.speed });
898
- }
899
- }
900
- } finally {
901
- for (const child of load) {
902
- try {
903
- child.kill("SIGKILL");
904
- } catch {
905
- // Already gone: the reading is what mattered, and nothing else uses it.
906
- }
907
- }
908
- }
909
- const penalties = penaltiesFrom(alone.speed, beside);
910
- if (!penalties) {
911
- log.warn("hwaccel: contention readings said nothing; costs will be added as though jobs were independent");
912
- return null;
913
- }
914
- log.info(
915
- `hwaccel: a second job costs ${[...penalties.entries()]
916
- .map(([others, penalty]) => `${penalty.toFixed(2)}x beside ${others}`)
917
- .join(", ")} ` +
918
- `(decode alone ${alone.speed.toFixed(2)}x, ` +
919
- `${beside.map((reading) => `${reading.speed.toFixed(2)}x beside ${reading.others}`).join(", ")}, ` +
920
- `measured in ${((Date.now() - startedAt) / 1000).toFixed(1)}s)`
921
- );
922
- return penalties;
923
- }
924
-
925
- export async function benchmarkDecodeCost({ ffmpegBin, logger, clipsDir = CALIBRATION_DIR }) {
926
- const log = logger ?? { info: () => {}, warn: () => {} };
927
- const startedAllAt = Date.now();
928
- /** @type {Record<string, { pixelTerm: number, bitrateTerm: number, constantTerm: number }>} */
929
- const families = {};
930
- for (const [family, clips] of Object.entries(CALIBRATION_SETS)) {
931
- const fitted = await fitOneFamily({ ffmpegBin, log, clipsDir, family, clips });
932
- if (fitted) {
933
- families[family] = fitted;
934
- }
935
- }
936
- if (!families.h264) {
937
- // H.264 is the family every other one falls back to, so without it there is
938
- // no model at all rather than a partial one. Which families DID fit is said
939
- // anyway: on a fast host the H.264 clips decode at 20-80x and the readings
940
- // stop being ordered — measured 2026-08-20 on a desktop, 1080p at 9.35
941
- // Mbit/s costing 0.0307 s/s against 720p at 9.94 costing 0.0472, which is
942
- // not a thing a decoder does — so a failure here is a measurement problem
943
- // and not a missing file, and the line has to let those be told apart.
944
- log.warn(
945
- "hwaccel: decode cost unknown — the H.264 clips did not fit" +
946
- (Object.keys(families).length > 0
947
- ? `, though ${Object.keys(families).join(" and ")} did`
948
- : "")
949
- );
950
- return null;
951
- }
952
- const missing = Object.keys(CALIBRATION_SETS).filter((family) => !families[family]);
953
- log.info(
954
- `hwaccel: decode cost measured for ${Object.keys(families).join(", ")}` +
955
- (missing.length > 0 ? `; ${missing.join(" and ")} priced as H.264` : "") +
956
- ` (in ${((Date.now() - startedAllAt) / 1000).toFixed(1)}s)`
957
- );
958
- return { families, ...families.h264 };
128
+ return new V4l2m2mEncoder();
959
129
  }
960
130
 
961
- /**
962
- * Fit one codec family's decode cost from its own clips.
963
- *
964
- * @param {{ ffmpegBin: string, log: { info: Function, warn: Function }, clipsDir: string, family: string, clips: string[] }} params
965
- * @returns {Promise<{ pixelTerm: number, bitrateTerm: number, constantTerm: number } | null>}
966
- */
967
- async function fitOneFamily({ ffmpegBin, log, clipsDir, family, clips }) {
968
- const startedAllAt = Date.now();
969
- /** @type {Array<{ megapixelsPerSecond: number, megabitsPerSecond: number, costSecondsPerSecond: number }>} */
970
- const samples = [];
971
- // Every clip of the family is lifted out of its container FIRST, in one
972
- // ffmpeg run. See `extractFamilyStreams` for why one run rather than one per
973
- // clip, and why before the measurements rather than beside them.
974
- const streams = await extractFamilyStreams(
975
- ffmpegBin,
976
- clips.map((clip) => path.join(clipsDir, clip)),
977
- family
978
- );
979
- if (!streams) {
980
- log.warn(
981
- `hwaccel: ${family} cannot be lifted out of its container — no Annex-B filter is mapped for it, ` +
982
- `so its clips were never measured`
983
- );
984
- return null;
985
- }
986
- for (const [index, clip] of clips.entries()) {
987
- const stream = streams[index];
988
- const measured = stream ? await decodePipedStream(ffmpegBin, stream, log) : null;
989
- if (!measured?.speed) {
990
- log.warn(
991
- `hwaccel: decode benchmark "${clip}" said nothing; ${family} not measured` +
992
- (measured?.error ? ` — ${measured.error}` : " — the clip could not be lifted out of its container")
993
- );
994
- return null;
995
- }
996
- const cost = 1 / measured.speed;
997
- samples.push({
998
- megapixelsPerSecond: measured.megapixelsPerSecond,
999
- megabitsPerSecond: measured.megabitsPerSecond,
1000
- costSecondsPerSecond: cost
1001
- });
1002
- log.info(
1003
- `hwaccel: decode "${clip}" ${measured.megapixelsPerSecond.toFixed(1)} Mpx/s ` +
1004
- `${measured.megabitsPerSecond.toFixed(2)} Mbit/s -> ${measured.speed.toFixed(1)}x ` +
1005
- `(cost ${cost.toFixed(4)} s/s, over ${measured.windowSec.toFixed(1)}s of decoding)`
1006
- );
1007
- }
1008
- const fitted = fitDecodeCost(samples);
1009
- if (!fitted) {
1010
- log.warn(`hwaccel: ${family} decode cost could not be fitted to these measurements`);
1011
- return null;
1012
- }
1013
- log.info(
1014
- `hwaccel: ${family} decode cost = ${fitted.pixelTerm.toFixed(6)} × Mpx/s + ${fitted.bitrateTerm.toFixed(6)} × Mbit/s ` +
1015
- `+ ${fitted.constantTerm.toFixed(4)} s/s (${fitted.shape} from ${fitted.samples} clips, ` +
1016
- `typical disagreement ${fitted.residualRms.toFixed(4)} s/s` +
1017
- // Named rather than implied: a zero in the line above means "not
1018
- // measured" for a dropped term and "measured to be nothing" otherwise,
1019
- // and those are different claims.
1020
- (fitted.dropped.length > 0 ? `, ${fitted.dropped.join(" and ")} not determined by these clips` : "") +
1021
- `, measured in ${((Date.now() - startedAllAt) / 1000).toFixed(1)}s)`
1022
- );
1023
- return { pixelTerm: fitted.pixelTerm, bitrateTerm: fitted.bitrateTerm, constantTerm: fitted.constantTerm };
1024
- }
1025
-
1026
- /**
1027
- * The bitstream filter and demuxer that turn a clip's video track into a
1028
- * continuous elementary stream, by codec family.
1029
- *
1030
- * H.264 and HEVC in MP4 keep their parameter sets in the container's `avcC` /
1031
- * `hvcC` and their access units length-prefixed; Annex-B carries them inline,
1032
- * with start codes, which is what makes plain byte concatenation a valid
1033
- * stream. That is the property this whole measurement rests on.
1034
- */
1035
- const ANNEX_B_BY_FAMILY = {
1036
- h264: { filter: "h264_mp4toannexb", demuxer: "h264" },
1037
- hevc: { filter: "hevc_mp4toannexb", demuxer: "hevc" },
1038
- hevc10: { filter: "hevc_mp4toannexb", demuxer: "hevc" }
1039
- };
1040
-
1041
- /**
1042
- * The last complaint in an ffmpeg stderr, for a line that has to say why.
1043
- *
1044
- * @param {string} stderr
1045
- * @returns {string}
1046
- */
1047
- function lastErrorLine(stderr) {
1048
- const lines = String(stderr ?? "")
1049
- .split(/\r?\n/)
1050
- .map((line) => line.trim())
1051
- .filter((line) => line.length > 0);
1052
- return lines[lines.length - 1] ?? "";
1053
- }
1054
-
1055
- /**
1056
- * How long the lift may take before it is abandoned. It is a remux of a few
1057
- * megabytes, so this is not a budget it is the difference between a startup
1058
- * that reports a failure and one that never finishes. Every other ffmpeg run in
1059
- * this file has such a bound; this one did not, and it is awaited before the
1060
- * proxy's tunnel opens.
1061
- */
1062
- const EXTRACT_TIMEOUT_MS = 20_000;
1063
-
1064
- /**
1065
- * Lift a whole family's clips out of their containers, as Annex-B elementary
1066
- * streams, in ONE ffmpeg run.
1067
- *
1068
- * No re-encoding the frames are copied — so the work itself is trivial and
1069
- * the cost is almost entirely the process. Doing one process per clip added
1070
- * 11 s to the startup here (fourteen clips at about 0.83 s each), and running
1071
- * them concurrently did not help: six at once took 4.75 s against 0.89 s for
1072
- * one, so the machine serialises them. One run with many inputs and many
1073
- * outputs costs one process.
1074
- *
1075
- * The outputs go to temporary files because several outputs cannot share one
1076
- * pipe; they are read into memory and deleted immediately, and nothing about
1077
- * this measurement is kept between runs.
1078
- *
1079
- * Before the measurements, never beside them: a remux running next to a decode
1080
- * is a second job on the machine, and this benchmark exists to find out what
1081
- * ONE job costs here.
1082
- *
1083
- * @param {string} ffmpegBin
1084
- * @param {string[]} clipPaths
1085
- * @param {string} family
1086
- * @returns {Promise<Array<{ bytes: Buffer, demuxer: string, megapixelsPerSecond: number, megabitsPerSecond: number, fps: number } | null> | null>}
1087
- * One entry per clip, in order; null when the family cannot be lifted at all.
1088
- */
1089
- async function extractFamilyStreams(ffmpegBin, clipPaths, family) {
1090
- const shape = ANNEX_B_BY_FAMILY[family];
1091
- // A family with no mapping is a hard failure, not a silent fallback to
1092
- // H.264's filter. AV1 has no Annex-B form at all (its packaging is OBU), and
1093
- // MPEG-2 and VC-1 have no `*_mp4toannexb` filter — so the three families the
1094
- // roadmap plans next cannot come through here, and finding that out as
1095
- // "the clip failed" would send the reader after the clip.
1096
- if (!shape) {
1097
- return null;
1098
- }
1099
- const workDir = await mkdtemp(path.join(os.tmpdir(), "ttv-calibration-"));
1100
- const outputs = clipPaths.map((_, index) => path.join(workDir, `stream-${index}.${shape.demuxer}`));
1101
- /** @type {string[]} */
1102
- const args = ["-hide_banner", "-loglevel", "info", "-nostats", "-y"];
1103
- for (const clipPath of clipPaths) {
1104
- args.push("-i", clipPath);
1105
- }
1106
- for (const [index, output] of outputs.entries()) {
1107
- args.push("-map", `${index}:v:0`, "-c:v", "copy", "-bsf:v", shape.filter, "-f", shape.demuxer, output);
1108
- }
1109
- const stderr = await runCapturingStderr(ffmpegBin, args, EXTRACT_TIMEOUT_MS);
1110
- try {
1111
- if (stderr === null) {
1112
- return null;
1113
- }
1114
- // One banner block per input, in the order they were given. Read rather
1115
- // than declared, so replacing a clip cannot silently invalidate the fit
1116
- // that rests on it.
1117
- const blocks = splitInputBlocks(stderr, clipPaths.length);
1118
- return await Promise.all(clipPaths.map(async (_, index) => {
1119
- const block = blocks[index];
1120
- if (!block) {
1121
- return null;
1122
- }
1123
- const clipInfo = parseClipCharacteristics(block);
1124
- const fps = parseFfmpegVideoFps(block);
1125
- if (!clipInfo || !(fps > 0)) {
1126
- return null;
1127
- }
1128
- let bytes;
1129
- try {
1130
- bytes = await readFile(outputs[index]);
1131
- } catch {
1132
- return null;
1133
- }
1134
- if (bytes.length === 0) {
1135
- return null;
1136
- }
1137
- return {
1138
- bytes,
1139
- demuxer: shape.demuxer,
1140
- megapixelsPerSecond: clipInfo.megapixelsPerSecond,
1141
- megabitsPerSecond: clipInfo.megabitsPerSecond,
1142
- fps
1143
- };
1144
- }));
1145
- } finally {
1146
- await rm(workDir, { recursive: true, force: true }).catch(() => {});
1147
- }
1148
- }
1149
-
1150
- /**
1151
- * The part of an ffmpeg banner describing each input, in order.
1152
- *
1153
- * ffmpeg prints one `Input #N, …` block per input and then the stream mapping;
1154
- * the parsers here read a single input's facts, so they are given a single
1155
- * input's text rather than the whole banner.
1156
- *
1157
- * @param {string} stderr
1158
- * @param {number} count
1159
- * @returns {string[]}
1160
- */
1161
- function splitInputBlocks(stderr, count) {
1162
- /** @type {string[]} */
1163
- const blocks = [];
1164
- for (let index = 0; index < count; index += 1) {
1165
- const from = stderr.indexOf(`Input #${index},`);
1166
- if (from < 0) {
1167
- blocks.push("");
1168
- continue;
1169
- }
1170
- const nextInput = stderr.indexOf(`Input #${index + 1},`, from);
1171
- const mapping = stderr.indexOf("Stream mapping:", from);
1172
- const ends = [nextInput, mapping].filter((at) => at > from);
1173
- blocks.push(stderr.slice(from, ends.length > 0 ? Math.min(...ends) : stderr.length));
1174
- }
1175
- return blocks;
1176
- }
1177
-
1178
- /**
1179
- * Run ffmpeg to completion and return its stderr, or null when it failed or
1180
- * outlasted its bound.
1181
- *
1182
- * @param {string} ffmpegBin
1183
- * @param {string[]} args
1184
- * @param {number} timeoutMs
1185
- * @returns {Promise<string | null>}
1186
- */
1187
- function runCapturingStderr(ffmpegBin, args, timeoutMs) {
1188
- return new Promise((resolve) => {
1189
- let stderr = "";
1190
- let settled = false;
1191
- let child;
1192
- const settle = (value) => {
1193
- if (settled) {
1194
- return;
1195
- }
1196
- settled = true;
1197
- clearTimeout(timer);
1198
- try {
1199
- child?.kill("SIGKILL");
1200
- } catch {
1201
- // already gone
1202
- }
1203
- resolve(value);
1204
- };
1205
- const timer = setTimeout(() => settle(null), timeoutMs);
1206
- try {
1207
- child = spawn(ffmpegBin, args, { stdio: ["ignore", "ignore", "pipe"], windowsHide: true });
1208
- } catch {
1209
- settle(null);
1210
- return;
1211
- }
1212
- child.stderr.on("data", (chunk) => {
1213
- stderr += String(chunk);
1214
- });
1215
- child.on("error", () => settle(null));
1216
- child.on("close", (code) => settle(code === 0 ? stderr : null));
1217
- });
1218
- }
1219
-
1220
- /**
1221
- * Measure how fast this host DECODES a clip, from ffmpeg's own report of how
1222
- * much video it has processed.
1223
- *
1224
- * Two things are deliberately outside the measurement.
1225
- *
1226
- * **The process starting.** Wall-clock around the process cannot answer this:
1227
- * starting ffmpeg costs about a second, and on a quick machine a five-second
1228
- * clip decodes in a tenth of that, so the measurement would be of the program
1229
- * starting. Progress lines arrive AFTER it has started, and the slope between
1230
- * two of them — video processed against time taken — contains no part of the
1231
- * startup by construction.
1232
- *
1233
- * **The clip restarting.** This used to loop the clip with `-stream_loop -1`,
1234
- * and a loop is not free: measured 2026-08-22 on a desktop, a restart costs
1235
- * 0.03 s on the 480p clip and 0.12 s on the 1080p one — the decoder tearing
1236
- * down and re-allocating its frame buffers, which is why the price rises with
1237
- * the picture. A five-second clip decoded at 55x restarts eleven times a
1238
- * second, so that cost DOMINATED the reading: the same clips measured 53.7x
1239
- * looped against 80.3x in one continuous pass, and 11.8x against 15.8x. Worse,
1240
- * the bias is not shared — it depends on the clip's own resolution and on how
1241
- * fast the host is — so it does not cancel out of the fit, it tilts it. That is
1242
- * the fast-host failure recorded on 2026-08-20, where 1080p read cheaper than
1243
- * 720p, which is not a thing a decoder does.
1244
- *
1245
- * So the clip is fed to the decoder as ONE stream instead. An Annex-B
1246
- * elementary stream carries its parameter sets inline, so writing the same
1247
- * bytes again is simply more stream — the decoder never re-initialises, and
1248
- * there is no restart inside the window to measure. Verified against the
1249
- * continuous-pass truth on the same host: -0.2 % and -5.5 %, against -25 % and
1250
- * -33 % for the loop. Nothing is written to disk and the process is killed as
1251
- * soon as the window is wide enough.
1252
- *
1253
- * Exported because the property that broke here is checkable and was not being
1254
- * checked: a bigger picture must cost more than a smaller one of the same
1255
- * bitrate, and under the loop it did not.
1256
- *
1257
- * @param {string} ffmpegBin
1258
- * @param {string} clipPath
1259
- * @param {string} [family="h264"]
1260
- * @returns {Promise<{ speed: number, windowSec: number, megapixelsPerSecond: number, megabitsPerSecond: number } | null>}
1261
- */
1262
- export async function measureDecodeSlope(ffmpegBin, clipPath, family = "h264") {
1263
- const streams = await extractFamilyStreams(ffmpegBin, [clipPath], family);
1264
- const stream = streams?.[0];
1265
- if (!stream) {
1266
- return null;
1267
- }
1268
- const measured = await decodePipedStream(ffmpegBin, stream);
1269
- return measured?.speed ? measured : null;
1270
- }
1271
-
1272
- /**
1273
- * Decode an elementary stream fed from memory, and report the slope.
1274
- *
1275
- * @param {string} ffmpegBin
1276
- * @param {{ bytes: Buffer, demuxer: string, megapixelsPerSecond: number, megabitsPerSecond: number, fps: number }} stream
1277
- * @returns {Promise<{ speed: number, windowSec: number, megapixelsPerSecond: number, megabitsPerSecond: number } | null>}
1278
- */
1279
- function decodePipedStream(ffmpegBin, stream, log = { info: () => {}, warn: () => {} }) {
1280
- return new Promise((resolve) => {
1281
- const args = [
1282
- "-hide_banner", "-loglevel", "error", "-nostats",
1283
- // A raw stream states no frame rate, so the one the container declared is
1284
- // given back to it. It decides how output time advances, and therefore
1285
- // what "seconds of video per second of clock" means.
1286
- "-f", stream.demuxer, "-framerate", String(stream.fps), "-i", "pipe:0",
1287
- "-an", "-f", "null", "-",
1288
- "-progress", "pipe:1"
1289
- ];
1290
- /** @type {Array<{ wallSec: number, outSec: number }>} */
1291
- const samples = [];
1292
- let stdout = "";
1293
- // Kept because this path depends on three things the old one did not: the
1294
- // raw demuxer accepting the frame rate, the bitstream filter having
1295
- // produced something parsable, and the fed concatenation being decodable.
1296
- // Without it the only trace of any of those failing is "said nothing".
1297
- let stderr = "";
1298
- let settled = false;
1299
- let child;
1300
- const startedAt = Date.now();
1301
- // Whether the FEED, not the decoder, could be what this reading measures
1302
- // (item 4(d2)). `child.stdin.write()` returning false was tried as the
1303
- // signal never once true would mean this process was never ahead of the
1304
- // pipe and measured false on every reading taken while writing this,
1305
- // including clips this same host decodes at 15-80x with room to spare, so
1306
- // it does not discriminate: `write()`'s return value tracks Node's own
1307
- // internal watermark against the size of what was just handed to it, not
1308
- // real drain state, and answered "no slack" identically whether the pipe
1309
- // or the decoder was the true limit. Rather than publish a verdict that
1310
- // reads the same in both cases, only the byte count is kept, for the
1311
- // MB/s figure below a number to read, not a boolean to trust.
1312
- let bytesWritten = 0;
1313
- const finish = () => {
1314
- if (settled) {
1315
- return;
1316
- }
1317
- settled = true;
1318
- clearTimeout(timer);
1319
- try {
1320
- child?.stdin?.destroy();
1321
- } catch {
1322
- // already gone
1323
- }
1324
- try {
1325
- child?.kill("SIGKILL");
1326
- } catch {
1327
- // already gone
1328
- }
1329
- // The first sample still carries the startup — it reports whatever was
1330
- // processed while the process was coming up. Everything is measured from
1331
- // the second onwards.
1332
- const first = samples[1];
1333
- const last = samples[samples.length - 1];
1334
- if (!first || !last) {
1335
- resolve({ error: lastErrorLine(stderr) || "the decoder reported no progress" });
1336
- return;
1337
- }
1338
- const windowSec = last.wallSec - first.wallSec;
1339
- const producedSec = last.outSec - first.outSec;
1340
- if (!(windowSec >= DECODE_WINDOW_MIN_SEC) || !(producedSec > 0)) {
1341
- resolve({ error: lastErrorLine(stderr) || `the window was ${windowSec.toFixed(2)}s of ${producedSec.toFixed(2)}s produced` });
1342
- return;
1343
- }
1344
- const speed = producedSec / windowSec;
1345
- // Diagnostic only — logged, not acted on. Both figures are taken over the
1346
- // SAME window the speed itself is: bytesWritten is snapshotted alongside
1347
- // every progress sample, so this compares like against like rather than
1348
- // the achieved rate over the whole run (which starts before the first
1349
- // kept sample and reads systematically low against the window's own
1350
- // rate for no reason but that mismatch measured while building this).
1351
- const windowBytes = last.bytesWritten - first.bytesWritten;
1352
- const achievedMBps = windowSec > 0 ? windowBytes / windowSec / 1e6 : 0;
1353
- const requiredMBps = ((stream.megabitsPerSecond * 1e6) / 8) * speed / 1e6;
1354
- // "Far apart" is stated, not left to the reader to eyeball: outside a
1355
- // factor of 1.5 either way is bigger than the write-timing slop this
1356
- // comparison carries on a healthy reading.
1357
- const farApart = requiredMBps > 0 && (achievedMBps / requiredMBps < 1 / 1.5 || achievedMBps / requiredMBps > 1.5);
1358
- log.info(
1359
- `hwaccel: decode pipe fed ${achievedMBps.toFixed(1)} MB/s, ${requiredMBps.toFixed(1)} MB/s ` +
1360
- `needed for ${speed.toFixed(2)}x` +
1361
- (farApart ? " — far enough apart to be worth a second look" : "")
1362
- );
1363
- resolve({
1364
- speed,
1365
- windowSec,
1366
- megapixelsPerSecond: stream.megapixelsPerSecond,
1367
- megabitsPerSecond: stream.megabitsPerSecond,
1368
- pipeThroughputMBps: achievedMBps
1369
- });
1370
- };
1371
- const timer = setTimeout(finish, DECODE_WINDOW_MAX_MS);
1372
- try {
1373
- child = spawn(ffmpegBin, args, { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
1374
- } catch (error) {
1375
- clearTimeout(timer);
1376
- settled = true;
1377
- resolve({ error: error instanceof Error ? error.message : String(error) });
1378
- return;
1379
- }
1380
- child.stderr.on("data", (chunk) => {
1381
- stderr += String(chunk);
1382
- });
1383
- // Keep the decoder fed. `write` returning false means the pipe is full, and
1384
- // the next copy goes on the `drain` — so the decoder is never starved and
1385
- // this process never buffers more than the pipe holds.
1386
- const writeOnce = () => {
1387
- const accepted = child.stdin.write(stream.bytes);
1388
- bytesWritten += stream.bytes.length;
1389
- return accepted;
1390
- };
1391
- const feed = () => {
1392
- while (!settled && child.stdin.writable && writeOnce()) {
1393
- // Written straight through; go round again.
1394
- }
1395
- };
1396
- child.stdin.on("drain", feed);
1397
- // The kill closes the pipe under the writer; that is the intended end.
1398
- child.stdin.on("error", () => {});
1399
- child.stdout.on("data", (chunk) => {
1400
- stdout += String(chunk);
1401
- let newline = stdout.indexOf("\n");
1402
- while (newline >= 0) {
1403
- const line = stdout.slice(0, newline).trim();
1404
- stdout = stdout.slice(newline + 1);
1405
- if (line.startsWith("out_time_ms=")) {
1406
- const microseconds = Number(line.slice("out_time_ms=".length));
1407
- if (Number.isFinite(microseconds)) {
1408
- samples.push({ wallSec: (Date.now() - startedAt) / 1000, outSec: microseconds / 1e6, bytesWritten });
1409
- }
1410
- }
1411
- newline = stdout.indexOf("\n");
1412
- }
1413
- if (samples.length >= 2 && samples[samples.length - 1].wallSec - samples[1].wallSec >= DECODE_WINDOW_MIN_SEC) {
1414
- finish();
1415
- }
1416
- });
1417
- child.on("error", (error) => {
1418
- if (settled) {
1419
- return;
1420
- }
1421
- clearTimeout(timer);
1422
- settled = true;
1423
- try {
1424
- child?.stdin?.destroy();
1425
- } catch {
1426
- // already gone
1427
- }
1428
- try {
1429
- child?.kill("SIGKILL");
1430
- } catch {
1431
- // already gone
1432
- }
1433
- resolve({ error: error instanceof Error ? error.message : String(error) });
1434
- });
1435
- child.on("close", finish);
1436
- feed();
1437
- });
1438
- }
1439
-
1440
-
1441
- /**
1442
- * How many times realtime this host can DECODE a source of these
1443
- * characteristics, from the startup fit. `null` when the fit is unavailable or
1444
- * the source figures are not known.
1445
- *
1446
- * @param {{ pixelTerm: number, bitrateTerm: number, constantTerm: number } | null} model
1447
- * @param {{ megapixelsPerSecond: number, megabitsPerSecond: number }} source
1448
- * @returns {number | null}
1449
- */
1450
- export function decodeSpeedFor(model, source) {
1451
- if (!model) {
1452
- return null;
1453
- }
1454
- const pixels = Number(source?.megapixelsPerSecond);
1455
- const bits = Number(source?.megabitsPerSecond);
1456
- if (!Number.isFinite(pixels) || pixels <= 0 || !Number.isFinite(bits) || bits < 0) {
1457
- return null;
1458
- }
1459
- const cost = model.pixelTerm * pixels + model.bitrateTerm * bits + model.constantTerm;
1460
- if (!(cost > 0)) {
1461
- return null;
1462
- }
1463
- return 1 / cost;
1464
- }
1465
-
1466
- /**
1467
- * How many times realtime a re-encode of this source at this output pixel rate
1468
- * would run: decoding and encoding share the machine, so their costs add and
1469
- * their speeds combine as
1470
- *
1471
- * 1 / (1/decodeSpeed + 1/encodeSpeed)
1472
- *
1473
- * Checked 2026-08-14 on the rung that broke playback: 1/(1/2.31 + 1/5.99) =
1474
- * 1.67× against 1.48× measured. With no decode fit this falls back to the
1475
- * encode speed alone — which is what the budget did before, and which
1476
- * overestimated that rung five to eleven times.
1477
- *
1478
- * @param {{ decodeModel: { pixelTerm: number, bitrateTerm: number, constantTerm: number } | null, encodePixelsPerSec: number, outputPixelsPerSec: number, source: { megapixelsPerSecond: number, megabitsPerSecond: number } | null }} params
1479
- * @returns {number | null}
1480
- */
1481
- export function predictedRealtimeSpeed({
1482
- decodeModel,
1483
- encodePixelsPerSec,
1484
- outputPixelsPerSec,
1485
- source,
1486
- observedDecodeCostSec = null
1487
- }) {
1488
- if (!Number.isFinite(encodePixelsPerSec) || encodePixelsPerSec <= 0) {
1489
- return null;
1490
- }
1491
- if (!Number.isFinite(outputPixelsPerSec) || outputPixelsPerSec <= 0) {
1492
- return null;
1493
- }
1494
- const encodeSpeed = encodePixelsPerSec / outputPixelsPerSec;
1495
- // What this very file has been seen to cost, when it has been: the clips are
1496
- // H.264 and a source that has to be re-encoded usually is not, so a figure
1497
- // taken from the encoder actually running on THIS source beats any model of
1498
- // a stand-in. It arrives seconds into playback and replaces the estimate.
1499
- const decodeSpeed = Number.isFinite(observedDecodeCostSec) && observedDecodeCostSec > 0
1500
- ? 1 / observedDecodeCostSec
1501
- : (source ? decodeSpeedFor(decodeModel, source) : null);
1502
- if (decodeSpeed === null) {
1503
- return encodeSpeed;
1504
- }
1505
- return 1 / (1 / decodeSpeed + 1 / encodeSpeed);
1506
- }
1507
-
1508
- /**
1509
- * Whether this host can hold realtime, with the margin, while re-encoding this
1510
- * source to this output pixel rate — and the predicted speed either way, so a
1511
- * refusal can say what it refused on.
1512
- *
1513
- * The encoder figure is the FASTEST benchmarked preset: it is the best this
1514
- * host can do, so a rung it cannot hold cannot be held at any quality setting.
1515
- *
1516
- * @param {{ benchmark: Array<{ preset: string, pixelsPerSec: number }>, decodeModel?: object | null, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null, outputPixelsPerSec: number, requiredSpeed?: number | null }} params
1517
- * @returns {{ speed: number | null, sustainable: boolean }}
1518
- */
1519
- export function canSustainOutput({
1520
- benchmark,
1521
- decodeModel = null,
1522
- source = null,
1523
- outputPixelsPerSec,
1524
- observedDecodeCostSec = null,
1525
- concurrentCostSec = 0,
1526
- requiredSpeed = null
1527
- }) {
1528
- if (!Array.isArray(benchmark) || benchmark.length === 0) {
1529
- // Nothing measured on this host: the budget cannot refuse what it cannot
1530
- // price, and refusing everything would leave a viewer with no rung at all.
1531
- return { speed: null, sustainable: true };
1532
- }
1533
- const observed = Number.isFinite(observedDecodeCostSec) && observedDecodeCostSec > 0
1534
- ? observedDecodeCostSec
1535
- : null;
1536
- if (observed === null && !isDecodePriced({ decodeModel, source })) {
1537
- // An encoder-only figure was several times too optimistic on the rung this
1538
- // check exists for, so it is not fit to refuse anything. Without the decode
1539
- // term the ladder is offered whole, exactly as it was before.
1540
- return { speed: null, sustainable: true };
1541
- }
1542
- const alone = predictedRealtimeSpeed({
1543
- decodeModel,
1544
- encodePixelsPerSec: cheapestPresetPixelsPerSec(benchmark),
1545
- outputPixelsPerSec,
1546
- source,
1547
- observedDecodeCostSec: observed
1548
- });
1549
- // What ELSE will be running while this rung is. A rung is never the only
1550
- // thing on the machine: the picture it accompanies is being copied or
1551
- // encoded, an audio track may have its own encoder, and a warm-up is two
1552
- // encoders by design. Measured on the addon host, a copy alone takes about an
1553
- // eighth of the machine per second of video, and the field case of
1554
- // 2026-08-15 adds up exactly: 0.125 for the copy plus ~1.05 for the rung is
1555
- // more than the one second per second the machine has, which is what was
1556
- // observed.
1557
- //
1558
- // Zero when nothing else is known to be running, or when nothing has been
1559
- // measured yet then this is a LOWER bound on the cost and the check is as
1560
- // permissive as it was before.
1561
- const speed = alone === null || !(concurrentCostSec > 0)
1562
- ? alone
1563
- : 1 / (1 / alone + concurrentCostSec);
1564
- if (speed === null) {
1565
- return { speed: null, sustainable: true };
1566
- }
1567
- return { speed, sustainable: speed >= speedBar(requiredSpeed) };
1568
- }
1569
-
1570
- /**
1571
- * The speed a step has to reach to be worth offering.
1572
- *
1573
- * Realtime is not enough on its own: a step that produces exactly one second
1574
- * per second never recovers the seconds lost while its reader waits for the
1575
- * swarm, so it survives its own supply only if what it gains between
1576
- * interruptions covers what one interruption costs. That is measured per file
1577
- * and per swarm by the reader — `1 + worst wait / median interval`, in
1578
- * `supply-margin.js` — and on the field torrent of 2026-08-17 it came to 1.67
1579
- * against the 1.5 that used to stand here, and to 4.04-8.14 on a torrent whose
1580
- * swarm no encoder could have kept up with.
1581
- *
1582
- * Where that figure does not exist yet fewer than two interruptions measured
1583
- * the bar is realtime. It is the one thing that can be said without
1584
- * measuring the swarm, and the offer is restated as soon as the reader has
1585
- * something to say.
1586
- *
1587
- * @param {number | null | undefined} requiredSpeed - What this file's own
1588
- * interruptions demand, when they have been measured.
1589
- * @returns {number}
1590
- */
1591
- export function speedBar(requiredSpeed) {
1592
- return Number.isFinite(requiredSpeed) && requiredSpeed > REALTIME ? requiredSpeed : REALTIME;
1593
- }
1594
-
1595
- /**
1596
- * The bar for a cost description the supply's demand where decoding is
1597
- * priced, and never below the unpriced-decode bar where it is not.
1598
- *
1599
- * @param {{ decodeModel?: object | null, source?: object | null, observedDecodeCostSec?: number | null, requiredSpeed?: number | null }} cost
1600
- * @returns {number}
1601
- */
1602
- function barFor(cost) {
1603
- const measured = speedBar(cost?.requiredSpeed);
1604
- return isDecodePriced(cost) ? measured : Math.max(UNPRICED_DECODE_BAR, measured);
1605
- }
1606
-
1607
- /**
1608
- * Benchmark software libx264 presets on this host. Encodes a short synthetic
1609
- * clip at a fixed reference resolution with each preset and measures encoder
1610
- * throughput in pixels/second. The session manager uses this to pick, per
1611
- * stream, the highest-quality preset that still encodes the actual
1612
- * (source-capped) resolution faster than realtime.
1613
- *
1614
- * Runs once at startup; bounded by a per-encode timeout. Presets that fail are
1615
- * omitted from the result.
1616
- *
1617
- * @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void } }} options
1618
- * @returns {Promise<Array<{ preset: string, pixelsPerSec: number }>>} Ordered slowest→fastest.
1619
- */
1620
- export async function benchmarkSoftwarePresets({ ffmpegBin, logger }) {
1621
- const log = logger ?? { info: () => {}, warn: () => {} };
1622
-
1623
- // REAL footage, decoded ONCE into raw frames, and the presets are then timed
1624
- // on those frames.
1625
- //
1626
- // Two reasons, both measured. The pattern this replaced (`testsrc2`) has flat
1627
- // areas and no grain and encodes 1.23x cheaper than film on the same machine
1628
- // and preset an error that always points at offering a rung the host cannot
1629
- // hold. And feeding a compressed clip to each preset instead would put
1630
- // decoding and scaling inside the measurement: subtracting them afterwards
1631
- // compares a wall clock that includes process startup against a decode figure
1632
- // measured to exclude it, while inside one ffmpeg the two halves overlap. On
1633
- // the fastest preset the one every ladder decision reads as the ceiling —
1634
- // that subtraction is most of the number being measured, so a small error in
1635
- // it becomes a large error in the answer.
1636
- //
1637
- // Raw frames remove all of it: no decoder, no scaler, nothing to subtract,
1638
- // and no dependence on the decode model. The cost is 25 MB of memory in a
1639
- // pipe for a few seconds.
1640
- const rawFramesPath = await decodeToRawFrames(ffmpegBin, log);
1641
- if (rawFramesPath === null) {
1642
- // Said once more, in the words that matter to whoever reads the log next:
1643
- // with no benchmark, `#sustainableHeights` filters nothing and every rung
1644
- // is offered, which is the failure of 2026-08-14 in full.
1645
- log.warn("hwaccel: the quality ladder is UNFILTERED on this host — nothing measured the encoder");
1646
- return [];
1647
- }
1648
- /** @type {Array<{ preset: string, pixelsPerSec: number }>} */
1649
- const results = [];
1650
- try {
1651
- for (const preset of BENCHMARK_PRESETS) {
1652
- const speed = await measureEncodeSlope(ffmpegBin, preset, rawFramesPath);
1653
- if (speed === null) {
1654
- log.warn(`hwaccel: preset benchmark "${preset}" produced no usable reading; skipping`);
1655
- continue;
1656
- }
1657
- const pixelsPerSec = BENCHMARK_REF_W * BENCHMARK_REF_H * TRANSCODE_FPS * speed;
1658
- results.push({ preset, pixelsPerSec });
1659
- log.info(
1660
- `hwaccel: preset "${preset}" ~= ${(pixelsPerSec / 1e6).toFixed(1)} Mpx/s ` +
1661
- `(${speed.toFixed(2)}x @ ${BENCHMARK_REF_W}x${BENCHMARK_REF_H}, real footage)`
1662
- );
1663
- }
1664
- } finally {
1665
- // The encoder was killed a moment ago and on Windows the handle outlives
1666
- // the signal, so removal is retried and its failure is not worth a session:
1667
- // this is a temp directory the operating system will clear anyway.
1668
- try {
1669
- rmSync(path.dirname(rawFramesPath), { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
1670
- } catch (error) {
1671
- log.warn(`hwaccel: could not remove the benchmark's raw frames: ${error instanceof Error ? error.message : String(error)}`);
1672
- }
1673
- }
1674
- return results;
1675
- }
1676
-
1677
- /**
1678
- * How fast one preset encodes, from ffmpeg's own reports of how much video it
1679
- * has written not from the clock around the process.
1680
- *
1681
- * Timing whole runs measures the run STARTING. Measured 2026-08-15 on a desktop
1682
- * that spawns ffmpeg in ~0.4 s: three seconds of raw frames encoded that way
1683
- * put `fast` and `ultrafast` within 1.24x of each other, when libx264's own
1684
- * presets differ by several times — the constant had swallowed the difference.
1685
- * The slope between two progress reports contains no part of the startup.
1686
- *
1687
- * The frames are written repeatedly so there is runway to measure over,
1688
- * whatever the preset's speed.
1689
- *
1690
- * @param {string} ffmpegBin
1691
- * @param {string} preset
1692
- * @param {string} rawFramesPath
1693
- * @returns {Promise<number | null>} Video seconds encoded per second of clock.
1694
- */
1695
- /**
1696
- * Video seconds produced per second of clock, from ffmpeg's own reports.
1697
- *
1698
- * Startup is excluded by taking a DIFFERENCE: it lands in the wall clock of
1699
- * every report equally, so it cancels between two of them. (The decode
1700
- * benchmark drops its first report instead, because there the first one is
1701
- * emitted at out_time zero; here reports with no time yet are discarded before
1702
- * they arrive, so the first kept one is already running.)
1703
- *
1704
- * @param {Array<{ wallSec: number, outSec: number }>} samples
1705
- * @param {number} [minimumWindowSec=ENCODE_BENCHMARK_WINDOW_SEC]
1706
- * @returns {number | null}
1707
- */
1708
- export function slopeOf(samples, minimumWindowSec = ENCODE_BENCHMARK_WINDOW_SEC) {
1709
- const first = samples[0];
1710
- const last = samples[samples.length - 1];
1711
- if (!first || !last || first === last) {
1712
- return null;
1713
- }
1714
- const took = last.wallSec - first.wallSec;
1715
- const produced = last.outSec - first.outSec;
1716
- if (!(took >= minimumWindowSec) || !(produced > 0)) {
1717
- return null;
1718
- }
1719
- const slope = produced / took;
1720
- // Nothing encodes a thousand times realtime. A figure above that is a
1721
- // measurement fault, and letting it through opens the whole ladder.
1722
- return slope <= ENCODE_BENCHMARK_MAX_PLAUSIBLE_SPEED ? slope : null;
1723
- }
1724
-
1725
- function measureEncodeSlope(ffmpegBin, preset, rawFramesPath) {
1726
- return new Promise((resolve) => {
1727
- const args = [
1728
- "-hide_banner", "-loglevel", "error", "-nostats",
1729
- "-stream_loop", "-1",
1730
- "-f", "rawvideo", "-pix_fmt", "yuv420p",
1731
- "-s", `${BENCHMARK_REF_W}x${BENCHMARK_REF_H}`, "-r", String(TRANSCODE_FPS),
1732
- "-i", rawFramesPath,
1733
- "-c:v", "libx264", "-preset", preset, "-crf", SOFTWARE_CRF, "-pix_fmt", "yuv420p",
1734
- "-f", "null", "-",
1735
- "-progress", "pipe:1"
1736
- ];
1737
- /** @type {Array<{ wallSec: number, outSec: number }>} */
1738
- const samples = [];
1739
- let settled = false;
1740
- let buffered = "";
1741
- let child;
1742
- const startedAt = Date.now();
1743
- const finish = (value) => {
1744
- if (settled) {
1745
- return;
1746
- }
1747
- settled = true;
1748
- clearTimeout(timer);
1749
- try {
1750
- child?.kill("SIGKILL");
1751
- } catch {
1752
- // already gone
1753
- }
1754
- resolve(value);
1755
- };
1756
- const timer = setTimeout(() => finish(null), ENCODE_BENCHMARK_TIMEOUT_MS);
1757
- try {
1758
- child = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "ignore"], windowsHide: true });
1759
- } catch {
1760
- finish(null);
1761
- return;
1762
- }
1763
- // The frames come from a FILE, read on repeat by ffmpeg itself. Fed through
1764
- // a pipe instead, the fastest presets measured the pipe: `ultrafast` on a
1765
- // desktop wants raw frames at hundreds of megabytes a second, which no
1766
- // writer here can supply, and the reading then describes the feeding rather
1767
- // than the encoder.
1768
- child.stdout.on("data", (chunk) => {
1769
- buffered += String(chunk);
1770
- let newline = buffered.indexOf(NEWLINE);
1771
- while (newline >= 0) {
1772
- const line = buffered.slice(0, newline).trim();
1773
- buffered = buffered.slice(newline + 1);
1774
- if (line.startsWith("out_time_ms=")) {
1775
- const outSec = Number(line.slice("out_time_ms=".length)) / 1e6;
1776
- // `N/A` is not the only way ffmpeg says "no position yet": some builds
1777
- // print the smallest signed 64-bit integer, which IS finite and would
1778
- // be taken for a position nine trillion seconds before the start.
1779
- if (Number.isFinite(outSec) && outSec >= 0) {
1780
- samples.push({ wallSec: (Date.now() - startedAt) / 1000, outSec });
1781
- }
1782
- }
1783
- newline = buffered.indexOf(NEWLINE);
1784
- }
1785
- const slope = slopeOf(samples);
1786
- if (slope !== null) {
1787
- finish(slope);
1788
- }
1789
- });
1790
- child.on("error", () => finish(null));
1791
- // A preset that finished before the window was wide enough is measured from
1792
- // whatever it did report, provided two reports exist at all.
1793
- // A preset that finished before the wide window was covered is still
1794
- // measured but never over a window of nothing. Two reports a millisecond
1795
- // apart would divide a frame of video by that millisecond and call the host
1796
- // twenty times faster than it is, and one such reading becomes the figure
1797
- // every ladder decision is taken from.
1798
- child.on("exit", () => finish(slopeOf(samples, ENCODE_BENCHMARK_MIN_WINDOW_SEC)));
1799
- });
1800
- }
1801
-
1802
- /**
1803
- * The benchmark's footage as raw frames: the calibration clip, looped to the
1804
- * benchmark's length and scaled to its size, decoded once.
1805
- *
1806
- * @param {string} ffmpegBin
1807
- * @param {{ info: (m: string) => void, warn: (m: string) => void }} log
1808
- * @returns {Promise<string | null>} Path to the raw frames, or null.
1809
- */
1810
- async function decodeToRawFrames(ffmpegBin, log) {
1811
- // A benchmark may leave a host unmeasured; it may never stop it from
1812
- // starting. Before this the temp directory was made outside any guard, so a
1813
- // read-only or missing TMPDIR rejected the promise that starts the proxy.
1814
- let directory;
1815
- try {
1816
- directory = mkdtempSync(path.join(os.tmpdir(), "torrent-tv-bench-"));
1817
- } catch (error) {
1818
- log.warn(
1819
- `hwaccel: no writable temp directory for the preset benchmark (${error instanceof Error ? error.message : String(error)}); ` +
1820
- "presets unmeasured, so no quality rung will be refused on this host"
1821
- );
1822
- return null;
1823
- }
1824
- const rawPath = path.join(directory, "frames.yuv");
1825
- const args = [
1826
- "-hide_banner", "-loglevel", "error",
1827
- "-stream_loop", "-1",
1828
- "-i", path.join(CALIBRATION_DIR, CALIBRATION_CLIPS[0]),
1829
- "-t", String(BENCHMARK_DURATION_SEC),
1830
- "-vf", `scale=${BENCHMARK_REF_W}:${BENCHMARK_REF_H},fps=${TRANSCODE_FPS}`,
1831
- "-an", "-f", "rawvideo", "-pix_fmt", "yuv420p", "-y", rawPath
1832
- ];
1833
- const { code } = await runFfmpeg(ffmpegBin, args, 30000);
1834
- const expectedBytes = BENCHMARK_REF_W * BENCHMARK_REF_H * 1.5 * TRANSCODE_FPS * BENCHMARK_DURATION_SEC;
1835
- let written = 0;
1836
- try {
1837
- written = statSync(rawPath).size;
1838
- } catch {
1839
- written = 0;
1840
- }
1841
- if (code !== 0 || written < expectedBytes * 0.9) {
1842
- log.warn(
1843
- "hwaccel: could not decode the calibration clip for the preset benchmark " +
1844
- `(${written} of ~${Math.round(expectedBytes)} bytes); presets unmeasured, ` +
1845
- "so no quality rung will be refused on this host"
1846
- );
1847
- try {
1848
- rmSync(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
1849
- } catch {
1850
- // A temp directory the operating system will clear; not worth a start-up.
1851
- }
1852
- return null;
1853
- }
1854
- return rawPath;
1855
- }
1856
-
1857
- /**
1858
- * Pick the highest-quality (slowest) benchmarked preset that can encode
1859
- * `pixelsPerSecNeeded` with the speed margin. Falls back to the fastest
1860
- * benchmarked preset, or `"ultrafast"` when no benchmark is available.
1861
- *
1862
- * @param {Array<{ preset: string, pixelsPerSec: number }>} benchmark - slowest→fastest
1863
- * @param {number} pixelsPerSecNeeded
1864
- * @param {{ decodeModel?: object | null, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null, requiredSpeed?: number | null }} [cost]
1865
- * @returns {string}
1866
- */
1867
- /**
1868
- * What this host can do at its CHEAPEST preset — the ceiling of the ladder.
1869
- *
1870
- * Deliberately not the largest reading in the array. The list is in quality
1871
- * order, so its last measured entry is the cheapest preset; taking the maximum
1872
- * instead would let one noisy reading of an expensive preset raise the bar that
1873
- * decides which rungs are offered, and a rung offered on noise is a rung the
1874
- * host cannot hold. For choosing a preset the direction of that error is
1875
- * harmless; for deciding what to offer it is not, so the two use different
1876
- * statistics on purpose.
1877
- *
1878
- * @param {Array<{ preset: string, pixelsPerSec: number }>} benchmark
1879
- * @returns {number}
1880
- */
1881
- function cheapestPresetPixelsPerSec(benchmark) {
1882
- return benchmark[benchmark.length - 1]?.pixelsPerSec ?? 0;
1883
- }
1884
-
1885
- export function pickSoftwarePreset(benchmark, pixelsPerSecNeeded, cost = {}) {
1886
- if (!Array.isArray(benchmark) || benchmark.length === 0) {
1887
- return "ultrafast";
1888
- }
1889
- const observed = Number.isFinite(cost?.observedDecodeCostSec) && cost.observedDecodeCostSec > 0
1890
- ? cost.observedDecodeCostSec
1891
- : null;
1892
- const bar = barFor(cost);
1893
- // The FIRST entry that clears the bar wins — the list is in quality order, so
1894
- // that is the best picture this host can hold. Every entry is examined rather
1895
- // than the walk stopping at the first miss, because the measurements do not
1896
- // always ascend with the list: on a busy machine on 2026-08-15 `faster` read
1897
- // below `fast` twice.
1898
- for (const entry of benchmark) {
1899
- const speed = predictedRealtimeSpeed({
1900
- decodeModel: cost.decodeModel ?? null,
1901
- encodePixelsPerSec: entry.pixelsPerSec,
1902
- outputPixelsPerSec: pixelsPerSecNeeded,
1903
- source: cost.source ?? null,
1904
- observedDecodeCostSec: observed
1905
- });
1906
- if (speed !== null && speed >= bar) {
1907
- return entry.preset;
1908
- }
1909
- }
1910
- // Nothing clears the bar: the cheapest preset, which is the last in quality
1911
- // order. Returning whichever preset measured fastest would hand an expensive
1912
- // one to a host that has just been shown to hold no rung at all.
1913
- return benchmark[benchmark.length - 1].preset;
1914
- }
1915
-
1916
- /**
1917
- * Whether a cost description can actually price decoding — a fit AND a source
1918
- * to apply it to. Without both, every prediction is encoder-only.
1919
- *
1920
- * @param {{ decodeModel?: object | null, source?: object | null }} cost
1921
- * @returns {boolean}
1922
- */
1923
- function isDecodePriced(cost) {
1924
- if (Number.isFinite(cost?.observedDecodeCostSec) && cost.observedDecodeCostSec > 0) {
1925
- return true; // measured on the source itself, which needs no fit to stand on
1926
- }
1927
- return Boolean(cost?.decodeModel) && Boolean(cost?.source);
1928
- }
1929
-
1930
- // Resolution-ladder heights (output height rungs), high→low. The ladder is
1931
- // derived per-stream from the ceiling (the client-requested, source-capped
1932
- // output box): only rungs at or below the ceiling height are used, so the
1933
- // budget never upscales past what the client asked for. Standard heights keep
1934
- // the downscaled output at familiar resolutions.
1935
- const RESOLUTION_LADDER_HEIGHTS = [2160, 1440, 1080, 720, 540, 480, 360, 240];
1936
-
1937
- /**
1938
- * Build the resolution ladder for a ceiling box. Returns candidate output
1939
- * dimensions from the ceiling downward, preserving the ceiling's aspect ratio,
1940
- * each even-sized. The ceiling itself is always the top rung; ladder heights
1941
- * at or above it are skipped (never upscale). Deduped by height.
1942
- *
1943
- * @param {number} ceilingWidth
1944
- * @param {number} ceilingHeight
1945
- * @returns {Array<{ width: number, height: number }>} high→low
1946
- */
1947
- export function buildResolutionLadder(ceilingWidth, ceilingHeight) {
1948
- const cw = Number.isInteger(ceilingWidth) && ceilingWidth > 0 ? ceilingWidth : 0;
1949
- const ch = Number.isInteger(ceilingHeight) && ceilingHeight > 0 ? ceilingHeight : 0;
1950
- if (!cw || !ch) {
1951
- return [];
1952
- }
1953
- const even = (v) => {
1954
- const r = Math.round(v);
1955
- return Math.max(2, r - (r % 2));
1956
- };
1957
- /** @type {Array<{ width: number, height: number }>} */
1958
- const rungs = [{ width: cw, height: ch }];
1959
- for (const h of RESOLUTION_LADDER_HEIGHTS) {
1960
- if (h >= ch) {
1961
- continue; // at/above the ceiling — the ceiling rung already covers it
1962
- }
1963
- rungs.push({ width: even(cw * (h / ch)), height: h });
1964
- }
1965
- const seen = new Set();
1966
- return rungs.filter((rung) => {
1967
- if (seen.has(rung.height)) {
1968
- return false;
1969
- }
1970
- seen.add(rung.height);
1971
- return true;
1972
- });
1973
- }
1974
-
1975
- /**
1976
- * Choose the software encode settings (resolution + preset) that fit the
1977
- * realtime budget on this host. From the resolution ladder (ceiling downward),
1978
- * pick the HIGHEST rung whose encode throughput — predicted from the startup
1979
- * benchmark's fastest preset — clears the speed this file's own supply
1980
- * demands (`speedBar`). Then, at that resolution, pick the highest-quality
1981
- * preset that still clears it. When even the lowest rung cannot clear it, use the lowest rung with
1982
- * the fastest preset (best effort a smaller picture beats sub-realtime
1983
- * playback at full size). Returns null when no benchmark or ceiling is
1984
- * available (the caller keeps the ceiling resolution and the default preset).
1985
- *
1986
- * @param {Array<{ preset: string, pixelsPerSec: number }>} benchmark - slowest→fastest
1987
- * @param {{ width: number, height: number }} ceiling
1988
- * @param {number} outputFps
1989
- * @param {{ decodeModel?: object | null, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null, requiredSpeed?: number | null }} [cost]
1990
- * @returns {{ width: number, height: number, preset: string, ladder: Array<{ width: number, height: number }>, rungIndex: number } | null}
1991
- */
1992
- export function chooseSoftwareEncodeSettings(benchmark, ceiling, outputFps, cost = {}) {
1993
- if (!Array.isArray(benchmark) || benchmark.length === 0) {
1994
- return null;
1995
- }
1996
- const fps = Number.isFinite(outputFps) && outputFps > 0 ? outputFps : TRANSCODE_FPS;
1997
- const ladder = buildResolutionLadder(ceiling?.width, ceiling?.height);
1998
- if (ladder.length === 0) {
1999
- return null;
2000
- }
2001
- const fastest = cheapestPresetPixelsPerSec(benchmark); // the cheapest preset's throughput
2002
- const bar = barFor(cost);
2003
- let chosenIndex = ladder.length - 1; // default: lowest rung (best effort)
2004
- for (let i = 0; i < ladder.length; i += 1) {
2005
- const speed = predictedRealtimeSpeed({
2006
- decodeModel: cost.decodeModel ?? null,
2007
- encodePixelsPerSec: fastest,
2008
- outputPixelsPerSec: ladder[i].width * ladder[i].height * fps,
2009
- source: cost.source ?? null
2010
- });
2011
- if (speed !== null && speed >= bar) {
2012
- chosenIndex = i;
2013
- break;
2014
- }
2015
- }
2016
- const chosen = ladder[chosenIndex];
2017
- const preset = pickSoftwarePreset(benchmark, chosen.width * chosen.height * fps, cost);
2018
- return { width: chosen.width, height: chosen.height, preset, ladder, rungIndex: chosenIndex };
2019
- }
131
+
132
+
133
+ /**
134
+ * @typedef {Object} VideoEncoderDescriptor
135
+ * @property {string} name
136
+ * @property {"software"|"vaapi"|"qsv"|"nvenc"|"v4l2m2m"} kind
137
+ * @property {string|null} device
138
+ * @property {string[]} inputArgs
139
+ * @property {(opts: { targetWidth: number, targetHeight: number, segmentDurationSec: number, preset?: string, fps?: number, tonemap?: boolean, forcedKeyframeTimes?: number[] | null, nominalKbps?: number | null }) => string[]} buildVideoArgs
140
+ */
141
+
142
+ /**
143
+ * Run ffmpeg and resolve with its exit code and captured output.
144
+ *
145
+ * @param {string} ffmpegBin
146
+ * @param {string[]} args
147
+ * @param {number} [timeoutMs=12000]
148
+ * @returns {Promise<{ code: number, stdout: string, stderr: string }>}
149
+ */
150
+ function runFfmpeg(ffmpegBin, args, timeoutMs = 12000) {
151
+ return new Promise((resolve) => {
152
+ let stdout = "";
153
+ let stderr = "";
154
+ let settled = false;
155
+ let child;
156
+ const finish = (code) => {
157
+ if (settled) {
158
+ return;
159
+ }
160
+ settled = true;
161
+ resolve({ code, stdout, stderr });
162
+ };
163
+ try {
164
+ child = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
165
+ } catch {
166
+ finish(-1);
167
+ return;
168
+ }
169
+ const timer = setTimeout(() => {
170
+ try {
171
+ child.kill("SIGKILL");
172
+ } catch {
173
+ // already gone
174
+ }
175
+ finish(-1);
176
+ }, timeoutMs);
177
+ child.stdout.on("data", (chunk) => {
178
+ stdout += String(chunk);
179
+ });
180
+ child.stderr.on("data", (d) => {
181
+ stderr += String(d);
182
+ });
183
+ child.on("error", () => {
184
+ clearTimeout(timer);
185
+ finish(-1);
186
+ });
187
+ child.on("exit", (code) => {
188
+ clearTimeout(timer);
189
+ finish(code ?? -1);
190
+ });
191
+ });
192
+ }
193
+
194
+ /** @returns {string[]} /dev/dri/renderD* nodes (VAAPI/QSV). */
195
+ function listRenderNodes() {
196
+ try {
197
+ return readdirSync("/dev/dri")
198
+ .filter((n) => n.startsWith("renderD"))
199
+ .map((n) => `/dev/dri/${n}`)
200
+ .sort();
201
+ } catch {
202
+ return [];
203
+ }
204
+ }
205
+
206
+ /** @returns {boolean} Whether any /dev/nvidia* node exists (NVENC). */
207
+ function hasNvidiaDevice() {
208
+ try {
209
+ return readdirSync("/dev").some((n) => /^nvidia(\d+)?$/.test(n));
210
+ } catch {
211
+ return false;
212
+ }
213
+ }
214
+
215
+ /** @returns {boolean} Whether any /dev/video* node exists (V4L2 M2M). */
216
+ function hasV4l2Device() {
217
+ try {
218
+ return readdirSync("/dev").some((n) => /^video\d+$/.test(n));
219
+ } catch {
220
+ return false;
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Build a full ffmpeg command that encodes a short, *moving* synthetic clip
226
+ * (testsrc2 far more representative than a static black frame) through the
227
+ * candidate encoder into real HLS segments in `outDir`, with keyframes forced
228
+ * on segment boundaries. Verifying the resulting segments (see
229
+ * {@link verifySegmentsDecodeCleanly}) catches encoders that silently produce
230
+ * a corrupted or non-IDR-aligned stream (e.g. some V4L2 M2M builds).
231
+ *
232
+ * @param {VideoEncoderDescriptor} descriptor
233
+ * @param {number} segmentDurationSec
234
+ * @param {string} outDir
235
+ * @returns {string[]}
236
+ */
237
+ function buildEncoderTestArgs(descriptor, segmentDurationSec, outDir) {
238
+ const durationSec = Math.max(8, segmentDurationSec * 3);
239
+ const source = ["-f", "lavfi", "-i", `testsrc2=s=640x360:r=${TRANSCODE_FPS}:d=${durationSec}`];
240
+ const kf = keyFrameArgs(segmentDurationSec);
241
+
242
+ /** @type {string[]} */
243
+ let pre = ["-hide_banner", "-loglevel", "error"];
244
+ /** @type {string[]} */
245
+ let encode;
246
+ switch (descriptor.kind) {
247
+ case "vaapi":
248
+ pre = [...pre, "-vaapi_device", String(descriptor.device)];
249
+ encode = ["-vf", "format=nv12,hwupload", "-c:v", "h264_vaapi", "-qp", "24", ...kf];
250
+ break;
251
+ case "qsv":
252
+ pre = [...pre, "-qsv_device", String(descriptor.device)];
253
+ encode = ["-vf", "hwupload=extra_hw_frames=16,format=qsv", "-c:v", "h264_qsv", "-global_quality", "24", ...kf];
254
+ break;
255
+ case "nvenc":
256
+ encode = ["-c:v", "h264_nvenc", "-preset", "p4", "-cq", "24", "-pix_fmt", "yuv420p", ...kf];
257
+ break;
258
+ case "v4l2m2m":
259
+ encode = ["-pix_fmt", "yuv420p", "-c:v", "h264_v4l2m2m", "-num_capture_buffers", "32", "-b:v", "3M", "-g", String(TRANSCODE_FPS * segmentDurationSec), ...kf];
260
+ break;
261
+ default:
262
+ encode = ["-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", ...kf];
263
+ break;
264
+ }
265
+
266
+ const hlsOut = [
267
+ "-f", "hls",
268
+ "-hls_time", String(segmentDurationSec),
269
+ "-hls_list_size", "0",
270
+ "-hls_flags", "independent_segments",
271
+ // fMP4 (CMAF) — matches the runtime pipeline (hls-session-manager).
272
+ "-hls_segment_type", "fmp4",
273
+ "-hls_fmp4_init_filename", "init.mp4",
274
+ "-hls_segment_filename", path.join(outDir, "seg-%03d.m4s"),
275
+ path.join(outDir, "index.m3u8")
276
+ ];
277
+ return [...pre, ...source, ...encode, ...hlsOut];
278
+ }
279
+
280
+ /**
281
+ * Verify the HLS segments produced by the test encode are valid: at least two
282
+ * segments exist, and each decodes standalone without errors. A segment that
283
+ * does not begin with a keyframe (broken/corrupted output) emits decode errors
284
+ * when read on its own, which fails this check.
285
+ *
286
+ * @param {string} ffmpegBin
287
+ * @param {string} outDir
288
+ * @returns {Promise<boolean>}
289
+ */
290
+ async function verifySegmentsDecodeCleanly(ffmpegBin, outDir) {
291
+ let files;
292
+ try {
293
+ files = readdirSync(outDir).filter((n) => /^seg-\d+\.m4s$/.test(n));
294
+ } catch {
295
+ return false;
296
+ }
297
+ if (files.length < 2) {
298
+ return false;
299
+ }
300
+ // fMP4: parameter sets (SPS/PPS) live in init.mp4, not in each segment.
301
+ // Decode the whole playlist (ffmpeg's own, which references init.mp4 via
302
+ // #EXT-X-MAP), so every segment is exercised together with the init. Any
303
+ // corrupt / non-conformant segment (e.g. some V4L2 M2M builds emit a stray
304
+ // no-picture access unit) surfaces as a decode error here.
305
+ const result = await runFfmpeg(
306
+ ffmpegBin,
307
+ ["-hide_banner", "-loglevel", "error", "-i", path.join(outDir, "index.m3u8"), "-f", "null", "-"],
308
+ 12000
309
+ );
310
+ return result.code === 0 && result.stderr.trim().length === 0;
311
+ }
312
+
313
+ /**
314
+ * Detect the best usable H.264 encoder. Always resolves (falls back to
315
+ * software libx264). Each hardware candidate is verified with a real
316
+ * test-encode before being selected.
317
+ *
318
+ * @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void }, segmentDurationSec?: number }} options
319
+ * @returns {Promise<VideoEncoderDescriptor>}
320
+ */
321
+ export async function detectVideoEncoder({ ffmpegBin, logger, segmentDurationSec = 4 }) {
322
+ const log = logger ?? { info: () => {}, warn: () => {} };
323
+ const software = softwareDescriptor();
324
+
325
+ const { code, stdout } = await runFfmpeg(ffmpegBin, ["-hide_banner", "-encoders"], 10000);
326
+ if (code !== 0) {
327
+ log.warn("hwaccel: could not list ffmpeg encoders; using software libx264");
328
+ return software;
329
+ }
330
+ const has = (name) => stdout.includes(name);
331
+
332
+ /** @type {VideoEncoderDescriptor[]} */
333
+ const candidates = [];
334
+ const renderNodes = listRenderNodes();
335
+ if (has("h264_nvenc") && hasNvidiaDevice()) {
336
+ candidates.push(nvencDescriptor());
337
+ }
338
+ if (has("h264_qsv") && renderNodes.length > 0) {
339
+ candidates.push(qsvDescriptor(renderNodes[0]));
340
+ }
341
+ if (has("h264_vaapi") && renderNodes.length > 0) {
342
+ candidates.push(vaapiDescriptor(renderNodes[0]));
343
+ }
344
+ // h264_v4l2m2m (ARM SoC / Raspberry Pi / HA Yellow). It is gated behind the
345
+ // strict keyframe-alignment test below, because some V4L2 M2M builds silently
346
+ // emit a corrupted / non-IDR-aligned stream; the test rejects those and the
347
+ // host falls back to software libx264.
348
+ if (has("h264_v4l2m2m") && hasV4l2Device()) {
349
+ candidates.push(v4l2m2mDescriptor());
350
+ }
351
+
352
+ for (const candidate of candidates) {
353
+ const dir = mkdtempSync(path.join(os.tmpdir(), "tt-hwtest-"));
354
+ let ok = false;
355
+ try {
356
+ const encoded = await runFfmpeg(
357
+ ffmpegBin,
358
+ buildEncoderTestArgs(candidate, segmentDurationSec, dir),
359
+ 25000
360
+ );
361
+ if (encoded.code === 0) {
362
+ ok = await verifySegmentsDecodeCleanly(ffmpegBin, dir);
363
+ }
364
+ } finally {
365
+ try {
366
+ rmSync(dir, { recursive: true, force: true });
367
+ } catch {
368
+ // best effort
369
+ }
370
+ }
371
+ if (ok) {
372
+ log.info(
373
+ `hwaccel: using hardware encoder ${candidate.name}` +
374
+ `${candidate.device ? ` (${candidate.device})` : ""}`
375
+ );
376
+ return candidate;
377
+ }
378
+ log.warn(`hwaccel: ${candidate.name} failed the HLS keyframe-alignment test; skipping`);
379
+ }
380
+
381
+ log.info("hwaccel: no working hardware encoder; using software libx264");
382
+ return software;
383
+ }
384
+
385
+ /**
386
+ * Detect whether this ffmpeg build has the filters needed for the HDR→SDR
387
+ * tone-map chain (`zscale`, from libzimg, and `tonemap`). Both are required;
388
+ * when either is missing, HDR sources are re-encoded without tone mapping
389
+ * (washed-out but playable). Always resolves.
390
+ *
391
+ * @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void } }} options
392
+ * @returns {Promise<boolean>}
393
+ */
394
+ export async function detectTonemapSupport({ ffmpegBin, logger }) {
395
+ const log = logger ?? { info: () => {}, warn: () => {} };
396
+ const { code, stdout } = await runFfmpeg(ffmpegBin, ["-hide_banner", "-filters"], 10000);
397
+ if (code !== 0) {
398
+ log.warn("hwaccel: could not list ffmpeg filters; HDR tone mapping disabled");
399
+ return false;
400
+ }
401
+ // `-filters` prints one filter per line: "... zscale ...", "... tonemap ...".
402
+ const hasZscale = /\bzscale\b/.test(stdout);
403
+ const hasTonemap = /\btonemap\b/.test(stdout);
404
+ const supported = hasZscale && hasTonemap;
405
+ log.info(
406
+ `hwaccel: HDR tone mapping ${supported ? "available" : "unavailable"} ` +
407
+ `(zscale=${hasZscale} tonemap=${hasTonemap})`
408
+ );
409
+ return supported;
410
+ }
411
+
412
+
413
+ // The clips the decode cost is fitted from. They ship with the package
414
+ // (`assets/calibration/`), cut from Netflix Open Content "Meridian" (CC-BY 4.0)
415
+ // real, grainy live action, because a generated `testsrc2` clip decodes 158 %
416
+ // away from a real film where these are 11 % away (measured 2026-08-14).
417
+ //
418
+ // Three sizes at two bitrates each, with the axes varied INDEPENDENTLY. The set
419
+ // this replaced was three clips for three unknowns, two of them at the same
420
+ // size: an exact system, which cannot fail visibly. On 2026-08-17 it returned
421
+ // `0.007542 × Mpx/s + 0.000000 × Mbit/s + 0.0000 s/s` — the bitrate term and
422
+ // the constant exactly zero — and the prediction on top of it was 1.8-2.2x
423
+ // optimistic. Six points leave three spare, so the fit has a residual, and a
424
+ // term the data does not determine can be refused instead of published as a
425
+ // zero that looks measured. See `assets/calibration/NOTICE.md`.
426
+ //
427
+ // One set PER CODEC FAMILY, because a family is what the model describes. The
428
+ // fit used to be H.264 only, while a video that has to be RE-ENCODED is by
429
+ // definition one the browser could not play — which is to say HEVC, 10-bit or
430
+ // AV1 and those decode dearer per pixel on the same box. Pricing them with
431
+ // H.264 constants is the one case the model is always asked about and was never
432
+ // measured on.
433
+ //
434
+ // A family that has no set of its own is priced with H.264's, which is what
435
+ // happened to every family before this; the line says so rather than implying
436
+ // it. AV1 has no set yet: the survey of 2026-07-10 found it rare where HEVC was
437
+ // 18 % of releases, so it waits for the same treatment.
438
+ const CALIBRATION_SETS = {
439
+ h264: [
440
+ "cal-h264-1080-hi.mp4",
441
+ "cal-h264-1080-lo.mp4",
442
+ "cal-h264-720-hi.mp4",
443
+ "cal-h264-720-lo.mp4",
444
+ "cal-h264-480-hi.mp4",
445
+ "cal-h264-480-lo.mp4"
446
+ ],
447
+ hevc: [
448
+ "cal-hevc-1080-hi.mp4",
449
+ "cal-hevc-1080-lo.mp4",
450
+ "cal-hevc-480-hi.mp4",
451
+ "cal-hevc-480-lo.mp4"
452
+ ],
453
+ hevc10: [
454
+ "cal-hevc10-1080-hi.mp4",
455
+ "cal-hevc10-1080-lo.mp4",
456
+ "cal-hevc10-480-hi.mp4",
457
+ "cal-hevc10-480-lo.mp4"
458
+ ]
459
+ };
460
+ const CALIBRATION_CLIPS = CALIBRATION_SETS.h264;
461
+ const CALIBRATION_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "assets", "calibration");
462
+ // How wide the measured window must be before the slope is trusted, and how
463
+ // long to wait for it at most.
464
+ //
465
+ // Half a second, and it is the TIMING noise that sets it rather than the amount
466
+ // of video: the slope is output time against wall time, both read from the same
467
+ // two progress lines, and the jitter in stamping one is milliseconds — so half
468
+ // a second of window is a fraction of a percent of error on any host. What used
469
+ // to make a longer window necessary was the clip restarting inside it, and that
470
+ // is gone: the stream is continuous now. Measured 2026-08-22 against the
471
+ // continuous-pass truth on a desktop: -3.0 % and +0.6 % at half a second, with
472
+ // the readings spread 2-6 %, against -25 % and -33 % for the loop it replaces.
473
+ // Half a second also costs the startup about 0.6 s per clip less, which matters
474
+ // because every clip of every codec family is paid for before any viewer
475
+ // exists.
476
+ const DECODE_WINDOW_MIN_SEC = 0.5;
477
+ const DECODE_WINDOW_MAX_MS = 8000;
478
+
479
+ /**
480
+ * Read what a calibration clip IS from the decode run's own output: the
481
+ * dimensions, the frame rate and the bitrate ffmpeg reports for it. Read rather
482
+ * than declared, so replacing a clip cannot silently invalidate the fit.
483
+ *
484
+ * @param {string} stderr
485
+ * @returns {{ megapixelsPerSecond: number, megabitsPerSecond: number, durationSeconds: number } | null}
486
+ */
487
+ function parseClipCharacteristics(stderr) {
488
+ // The same readers the session manager uses on the same banner — one parser
489
+ // per fact, so a second copy cannot drift from the first.
490
+ const { width, height } = parseFfmpegVideoDimensions(stderr);
491
+ const rate = parseFfmpegVideoFps(stderr);
492
+ const seconds = parseFfmpegDurationSeconds(stderr);
493
+ const kbps = parseFfmpegBitrateKbps(stderr);
494
+ if (!(width > 0) || !(height > 0) || !(rate > 0) || !(seconds > 0) || !(kbps > 0)) {
495
+ return null;
496
+ }
497
+ return {
498
+ megapixelsPerSecond: (width * height * rate) / 1e6,
499
+ megabitsPerSecond: kbps / 1000,
500
+ durationSeconds: seconds
501
+ };
502
+ }
503
+
504
+ /**
505
+ * Measure what DECODING costs on this host, as seconds of work per second of
506
+ * video, and solve it into three host constants:
507
+ *
508
+ * decodeCost = a × Mpixel/s + b × Mbit/s + c
509
+ *
510
+ * Why it exists: the preset benchmark below measures ENCODING only, and a
511
+ * re-encode pays for both halves. Measured 2026-08-14 on the addon host, that
512
+ * omission made the budget offer a 240p rung it then ran at 0.39-0.95× — the
513
+ * benchmark said the host cleared the bar 2.5× over. With the decode term the
514
+ * same file predicts within 4.8 %; without it the error on that rung was 209 %.
515
+ *
516
+ * The constants are properties of the HOST, so this runs once at startup (about
517
+ * 5 s on a CM4) and any source is then priced from figures the probe already
518
+ * has nothing is added to a session's cold start.
519
+ *
520
+ * They are also properties of the CODEC, and the clips are H.264: HEVC, AV1 and
521
+ * 10-bit decode dearer per pixel on the same machine, and a source that has to
522
+ * be re-encoded is by definition one this browser could not play, which is
523
+ * usually not H.264. So the fit is optimistic exactly there. Closing that needs
524
+ * clips in those codecs, and is its own roadmap item.
525
+ *
526
+ * @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void }, clipsDir?: string }} options
527
+ * @returns {Promise<{ pixelTerm: number, bitrateTerm: number, constantTerm: number } | null>}
528
+ */
529
+ /**
530
+ * What a second job costs on this host, measured rather than assumed.
531
+ *
532
+ * The budget adds seconds of work per second of content — this encode, plus
533
+ * that decode, plus what is already committed — and the addon host contradicted
534
+ * that directly on 2026-08-18: decoding ran at 2.10-2.25x alone, 0.79-0.90x
535
+ * with one encoder beside it and 0.56-0.64x with two. The same work costs 2.6×
536
+ * more for having company. Heat is not the cause (the hot idle machine was the
537
+ * fastest reading of all); four cores sharing one path to memory is.
538
+ *
539
+ * So it is measured the way everything else here is: the same clip decoded
540
+ * alone, then decoded again while an encoder of the same clip runs beside it.
541
+ * The ratio is the penalty. The encoder is stopped as soon as the reading is
542
+ * taken, and the whole thing costs one decode plus one short encode.
543
+ *
544
+ * @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void }, clipsDir?: string, upTo?: number }} options
545
+ * @returns {Promise<Map<number, number> | null>} Penalties by how many other
546
+ * jobs were running, or null when the readings could not be taken.
547
+ */
548
+ export async function benchmarkContention({ ffmpegBin, logger, clipsDir = CALIBRATION_DIR, upTo = 2 }) {
549
+ const log = logger ?? { info: () => {}, warn: () => {} };
550
+ // The cheapest clip in the set: this measures the MACHINE's behaviour under
551
+ // company, not the clip's own cost, so the smallest one says it soonest.
552
+ const clip = path.join(clipsDir, "cal-h264-480-lo.mp4");
553
+ const startedAt = Date.now();
554
+ // Lifted once and decoded three times from the same bytes. Going through
555
+ // `measureDecodeSlope` lifted it again for every reading three process
556
+ // starts on a path that is awaited before the proxy's tunnel opens, for a
557
+ // remux whose result had not changed.
558
+ const streams = await extractFamilyStreams(ffmpegBin, [clip], "h264");
559
+ const stream = streams?.[0];
560
+ if (!stream) {
561
+ log.warn("hwaccel: contention could not be measured; costs will be added as though jobs were independent");
562
+ return null;
563
+ }
564
+ const alone = await decodePipedStream(ffmpegBin, stream, log);
565
+ if (!alone?.speed) {
566
+ log.warn("hwaccel: contention could not be measured; costs will be added as though jobs were independent");
567
+ return null;
568
+ }
569
+ /** @type {Array<{ others: number, speed: number }>} */
570
+ const beside = [];
571
+ /** @type {import("node:child_process").ChildProcess[]} */
572
+ const load = [];
573
+ try {
574
+ for (let others = 1; others <= Math.max(1, upTo); others += 1) {
575
+ load.push(
576
+ spawn(
577
+ ffmpegBin,
578
+ [
579
+ "-hide_banner", "-loglevel", "error", "-nostats",
580
+ "-stream_loop", "-1", "-i", clip,
581
+ "-an", "-c:v", "libx264", "-preset", "fast", "-f", "null", "-"
582
+ ],
583
+ { stdio: ["ignore", "ignore", "ignore"], windowsHide: true }
584
+ )
585
+ );
586
+ // Let the encoder reach its own speed before reading anything: an encode
587
+ // measured in its first moments is measuring the process starting.
588
+ await new Promise((resolve) => {
589
+ setTimeout(resolve, 2_000);
590
+ });
591
+ const withCompany = await decodePipedStream(ffmpegBin, stream, log);
592
+ if (withCompany?.speed) {
593
+ beside.push({ others, speed: withCompany.speed });
594
+ }
595
+ }
596
+ } finally {
597
+ for (const child of load) {
598
+ try {
599
+ child.kill("SIGKILL");
600
+ } catch {
601
+ // Already gone: the reading is what mattered, and nothing else uses it.
602
+ }
603
+ }
604
+ }
605
+ const penalties = penaltiesFrom(alone.speed, beside);
606
+ if (!penalties) {
607
+ log.warn("hwaccel: contention readings said nothing; costs will be added as though jobs were independent");
608
+ return null;
609
+ }
610
+ log.info(
611
+ `hwaccel: a second job costs ${[...penalties.entries()]
612
+ .map(([others, penalty]) => `${penalty.toFixed(2)}x beside ${others}`)
613
+ .join(", ")} ` +
614
+ `(decode alone ${alone.speed.toFixed(2)}x, ` +
615
+ `${beside.map((reading) => `${reading.speed.toFixed(2)}x beside ${reading.others}`).join(", ")}, ` +
616
+ `measured in ${((Date.now() - startedAt) / 1000).toFixed(1)}s)`
617
+ );
618
+ return penalties;
619
+ }
620
+
621
+ export async function benchmarkDecodeCost({ ffmpegBin, logger, clipsDir = CALIBRATION_DIR }) {
622
+ const log = logger ?? { info: () => {}, warn: () => {} };
623
+ const startedAllAt = Date.now();
624
+ /** @type {Record<string, { pixelTerm: number, bitrateTerm: number, constantTerm: number }>} */
625
+ const families = {};
626
+ for (const [family, clips] of Object.entries(CALIBRATION_SETS)) {
627
+ const fitted = await fitOneFamily({ ffmpegBin, log, clipsDir, family, clips });
628
+ if (fitted) {
629
+ families[family] = fitted;
630
+ }
631
+ }
632
+ if (!families.h264) {
633
+ // H.264 is the family every other one falls back to, so without it there is
634
+ // no model at all rather than a partial one. Which families DID fit is said
635
+ // anyway: on a fast host the H.264 clips decode at 20-80x and the readings
636
+ // stop being ordered — measured 2026-08-20 on a desktop, 1080p at 9.35
637
+ // Mbit/s costing 0.0307 s/s against 720p at 9.94 costing 0.0472, which is
638
+ // not a thing a decoder does so a failure here is a measurement problem
639
+ // and not a missing file, and the line has to let those be told apart.
640
+ log.warn(
641
+ "hwaccel: decode cost unknown — the H.264 clips did not fit" +
642
+ (Object.keys(families).length > 0
643
+ ? `, though ${Object.keys(families).join(" and ")} did`
644
+ : "")
645
+ );
646
+ return null;
647
+ }
648
+ const missing = Object.keys(CALIBRATION_SETS).filter((family) => !families[family]);
649
+ log.info(
650
+ `hwaccel: decode cost measured for ${Object.keys(families).join(", ")}` +
651
+ (missing.length > 0 ? `; ${missing.join(" and ")} priced as H.264` : "") +
652
+ ` (in ${((Date.now() - startedAllAt) / 1000).toFixed(1)}s)`
653
+ );
654
+ return { families, ...families.h264 };
655
+ }
656
+
657
+ /**
658
+ * Fit one codec family's decode cost from its own clips.
659
+ *
660
+ * @param {{ ffmpegBin: string, log: { info: Function, warn: Function }, clipsDir: string, family: string, clips: string[] }} params
661
+ * @returns {Promise<{ pixelTerm: number, bitrateTerm: number, constantTerm: number } | null>}
662
+ */
663
+ async function fitOneFamily({ ffmpegBin, log, clipsDir, family, clips }) {
664
+ const startedAllAt = Date.now();
665
+ /** @type {Array<{ megapixelsPerSecond: number, megabitsPerSecond: number, costSecondsPerSecond: number }>} */
666
+ const samples = [];
667
+ // Every clip of the family is lifted out of its container FIRST, in one
668
+ // ffmpeg run. See `extractFamilyStreams` for why one run rather than one per
669
+ // clip, and why before the measurements rather than beside them.
670
+ const streams = await extractFamilyStreams(
671
+ ffmpegBin,
672
+ clips.map((clip) => path.join(clipsDir, clip)),
673
+ family
674
+ );
675
+ if (!streams) {
676
+ log.warn(
677
+ `hwaccel: ${family} cannot be lifted out of its container — no Annex-B filter is mapped for it, ` +
678
+ `so its clips were never measured`
679
+ );
680
+ return null;
681
+ }
682
+ for (const [index, clip] of clips.entries()) {
683
+ const stream = streams[index];
684
+ const measured = stream ? await decodePipedStream(ffmpegBin, stream, log) : null;
685
+ if (!measured?.speed) {
686
+ log.warn(
687
+ `hwaccel: decode benchmark "${clip}" said nothing; ${family} not measured` +
688
+ (measured?.error ? ` — ${measured.error}` : " — the clip could not be lifted out of its container")
689
+ );
690
+ return null;
691
+ }
692
+ const cost = 1 / measured.speed;
693
+ samples.push({
694
+ megapixelsPerSecond: measured.megapixelsPerSecond,
695
+ megabitsPerSecond: measured.megabitsPerSecond,
696
+ costSecondsPerSecond: cost
697
+ });
698
+ log.info(
699
+ `hwaccel: decode "${clip}" ${measured.megapixelsPerSecond.toFixed(1)} Mpx/s ` +
700
+ `${measured.megabitsPerSecond.toFixed(2)} Mbit/s -> ${measured.speed.toFixed(1)}x ` +
701
+ `(cost ${cost.toFixed(4)} s/s, over ${measured.windowSec.toFixed(1)}s of decoding)`
702
+ );
703
+ }
704
+ const fitted = fitDecodeCost(samples);
705
+ if (!fitted) {
706
+ log.warn(`hwaccel: ${family} decode cost could not be fitted to these measurements`);
707
+ return null;
708
+ }
709
+ log.info(
710
+ `hwaccel: ${family} decode cost = ${fitted.pixelTerm.toFixed(6)} × Mpx/s + ${fitted.bitrateTerm.toFixed(6)} × Mbit/s ` +
711
+ `+ ${fitted.constantTerm.toFixed(4)} s/s (${fitted.shape} from ${fitted.samples} clips, ` +
712
+ `typical disagreement ${fitted.residualRms.toFixed(4)} s/s` +
713
+ // Named rather than implied: a zero in the line above means "not
714
+ // measured" for a dropped term and "measured to be nothing" otherwise,
715
+ // and those are different claims.
716
+ (fitted.dropped.length > 0 ? `, ${fitted.dropped.join(" and ")} not determined by these clips` : "") +
717
+ `, measured in ${((Date.now() - startedAllAt) / 1000).toFixed(1)}s)`
718
+ );
719
+ return { pixelTerm: fitted.pixelTerm, bitrateTerm: fitted.bitrateTerm, constantTerm: fitted.constantTerm };
720
+ }
721
+
722
+ /**
723
+ * The bitstream filter and demuxer that turn a clip's video track into a
724
+ * continuous elementary stream, by codec family.
725
+ *
726
+ * H.264 and HEVC in MP4 keep their parameter sets in the container's `avcC` /
727
+ * `hvcC` and their access units length-prefixed; Annex-B carries them inline,
728
+ * with start codes, which is what makes plain byte concatenation a valid
729
+ * stream. That is the property this whole measurement rests on.
730
+ */
731
+ const ANNEX_B_BY_FAMILY = {
732
+ h264: { filter: "h264_mp4toannexb", demuxer: "h264" },
733
+ hevc: { filter: "hevc_mp4toannexb", demuxer: "hevc" },
734
+ hevc10: { filter: "hevc_mp4toannexb", demuxer: "hevc" }
735
+ };
736
+
737
+ /**
738
+ * The last complaint in an ffmpeg stderr, for a line that has to say why.
739
+ *
740
+ * @param {string} stderr
741
+ * @returns {string}
742
+ */
743
+ function lastErrorLine(stderr) {
744
+ const lines = String(stderr ?? "")
745
+ .split(/\r?\n/)
746
+ .map((line) => line.trim())
747
+ .filter((line) => line.length > 0);
748
+ return lines[lines.length - 1] ?? "";
749
+ }
750
+
751
+ /**
752
+ * How long the lift may take before it is abandoned. It is a remux of a few
753
+ * megabytes, so this is not a budget it is the difference between a startup
754
+ * that reports a failure and one that never finishes. Every other ffmpeg run in
755
+ * this file has such a bound; this one did not, and it is awaited before the
756
+ * proxy's tunnel opens.
757
+ */
758
+ const EXTRACT_TIMEOUT_MS = 20_000;
759
+
760
+ /**
761
+ * Lift a whole family's clips out of their containers, as Annex-B elementary
762
+ * streams, in ONE ffmpeg run.
763
+ *
764
+ * No re-encoding — the frames are copied — so the work itself is trivial and
765
+ * the cost is almost entirely the process. Doing one process per clip added
766
+ * 11 s to the startup here (fourteen clips at about 0.83 s each), and running
767
+ * them concurrently did not help: six at once took 4.75 s against 0.89 s for
768
+ * one, so the machine serialises them. One run with many inputs and many
769
+ * outputs costs one process.
770
+ *
771
+ * The outputs go to temporary files because several outputs cannot share one
772
+ * pipe; they are read into memory and deleted immediately, and nothing about
773
+ * this measurement is kept between runs.
774
+ *
775
+ * Before the measurements, never beside them: a remux running next to a decode
776
+ * is a second job on the machine, and this benchmark exists to find out what
777
+ * ONE job costs here.
778
+ *
779
+ * @param {string} ffmpegBin
780
+ * @param {string[]} clipPaths
781
+ * @param {string} family
782
+ * @returns {Promise<Array<{ bytes: Buffer, demuxer: string, megapixelsPerSecond: number, megabitsPerSecond: number, fps: number } | null> | null>}
783
+ * One entry per clip, in order; null when the family cannot be lifted at all.
784
+ */
785
+ async function extractFamilyStreams(ffmpegBin, clipPaths, family) {
786
+ const shape = ANNEX_B_BY_FAMILY[family];
787
+ // A family with no mapping is a hard failure, not a silent fallback to
788
+ // H.264's filter. AV1 has no Annex-B form at all (its packaging is OBU), and
789
+ // MPEG-2 and VC-1 have no `*_mp4toannexb` filter — so the three families the
790
+ // roadmap plans next cannot come through here, and finding that out as
791
+ // "the clip failed" would send the reader after the clip.
792
+ if (!shape) {
793
+ return null;
794
+ }
795
+ const workDir = await mkdtemp(path.join(os.tmpdir(), "ttv-calibration-"));
796
+ const outputs = clipPaths.map((_, index) => path.join(workDir, `stream-${index}.${shape.demuxer}`));
797
+ /** @type {string[]} */
798
+ const args = ["-hide_banner", "-loglevel", "info", "-nostats", "-y"];
799
+ for (const clipPath of clipPaths) {
800
+ args.push("-i", clipPath);
801
+ }
802
+ for (const [index, output] of outputs.entries()) {
803
+ args.push("-map", `${index}:v:0`, "-c:v", "copy", "-bsf:v", shape.filter, "-f", shape.demuxer, output);
804
+ }
805
+ const stderr = await runCapturingStderr(ffmpegBin, args, EXTRACT_TIMEOUT_MS);
806
+ try {
807
+ if (stderr === null) {
808
+ return null;
809
+ }
810
+ // One banner block per input, in the order they were given. Read rather
811
+ // than declared, so replacing a clip cannot silently invalidate the fit
812
+ // that rests on it.
813
+ const blocks = splitInputBlocks(stderr, clipPaths.length);
814
+ return await Promise.all(clipPaths.map(async (_, index) => {
815
+ const block = blocks[index];
816
+ if (!block) {
817
+ return null;
818
+ }
819
+ const clipInfo = parseClipCharacteristics(block);
820
+ const fps = parseFfmpegVideoFps(block);
821
+ if (!clipInfo || !(fps > 0)) {
822
+ return null;
823
+ }
824
+ let bytes;
825
+ try {
826
+ bytes = await readFile(outputs[index]);
827
+ } catch {
828
+ return null;
829
+ }
830
+ if (bytes.length === 0) {
831
+ return null;
832
+ }
833
+ return {
834
+ bytes,
835
+ demuxer: shape.demuxer,
836
+ megapixelsPerSecond: clipInfo.megapixelsPerSecond,
837
+ megabitsPerSecond: clipInfo.megabitsPerSecond,
838
+ fps
839
+ };
840
+ }));
841
+ } finally {
842
+ await rm(workDir, { recursive: true, force: true }).catch(() => {});
843
+ }
844
+ }
845
+
846
+ /**
847
+ * The part of an ffmpeg banner describing each input, in order.
848
+ *
849
+ * ffmpeg prints one `Input #N, …` block per input and then the stream mapping;
850
+ * the parsers here read a single input's facts, so they are given a single
851
+ * input's text rather than the whole banner.
852
+ *
853
+ * @param {string} stderr
854
+ * @param {number} count
855
+ * @returns {string[]}
856
+ */
857
+ function splitInputBlocks(stderr, count) {
858
+ /** @type {string[]} */
859
+ const blocks = [];
860
+ for (let index = 0; index < count; index += 1) {
861
+ const from = stderr.indexOf(`Input #${index},`);
862
+ if (from < 0) {
863
+ blocks.push("");
864
+ continue;
865
+ }
866
+ const nextInput = stderr.indexOf(`Input #${index + 1},`, from);
867
+ const mapping = stderr.indexOf("Stream mapping:", from);
868
+ const ends = [nextInput, mapping].filter((at) => at > from);
869
+ blocks.push(stderr.slice(from, ends.length > 0 ? Math.min(...ends) : stderr.length));
870
+ }
871
+ return blocks;
872
+ }
873
+
874
+ /**
875
+ * Run ffmpeg to completion and return its stderr, or null when it failed or
876
+ * outlasted its bound.
877
+ *
878
+ * @param {string} ffmpegBin
879
+ * @param {string[]} args
880
+ * @param {number} timeoutMs
881
+ * @returns {Promise<string | null>}
882
+ */
883
+ function runCapturingStderr(ffmpegBin, args, timeoutMs) {
884
+ return new Promise((resolve) => {
885
+ let stderr = "";
886
+ let settled = false;
887
+ let child;
888
+ const settle = (value) => {
889
+ if (settled) {
890
+ return;
891
+ }
892
+ settled = true;
893
+ clearTimeout(timer);
894
+ try {
895
+ child?.kill("SIGKILL");
896
+ } catch {
897
+ // already gone
898
+ }
899
+ resolve(value);
900
+ };
901
+ const timer = setTimeout(() => settle(null), timeoutMs);
902
+ try {
903
+ child = spawn(ffmpegBin, args, { stdio: ["ignore", "ignore", "pipe"], windowsHide: true });
904
+ } catch {
905
+ settle(null);
906
+ return;
907
+ }
908
+ child.stderr.on("data", (chunk) => {
909
+ stderr += String(chunk);
910
+ });
911
+ child.on("error", () => settle(null));
912
+ child.on("close", (code) => settle(code === 0 ? stderr : null));
913
+ });
914
+ }
915
+
916
+ /**
917
+ * Measure how fast this host DECODES a clip, from ffmpeg's own report of how
918
+ * much video it has processed.
919
+ *
920
+ * Two things are deliberately outside the measurement.
921
+ *
922
+ * **The process starting.** Wall-clock around the process cannot answer this:
923
+ * starting ffmpeg costs about a second, and on a quick machine a five-second
924
+ * clip decodes in a tenth of that, so the measurement would be of the program
925
+ * starting. Progress lines arrive AFTER it has started, and the slope between
926
+ * two of them video processed against time taken — contains no part of the
927
+ * startup by construction.
928
+ *
929
+ * **The clip restarting.** This used to loop the clip with `-stream_loop -1`,
930
+ * and a loop is not free: measured 2026-08-22 on a desktop, a restart costs
931
+ * 0.03 s on the 480p clip and 0.12 s on the 1080p one — the decoder tearing
932
+ * down and re-allocating its frame buffers, which is why the price rises with
933
+ * the picture. A five-second clip decoded at 55x restarts eleven times a
934
+ * second, so that cost DOMINATED the reading: the same clips measured 53.7x
935
+ * looped against 80.3x in one continuous pass, and 11.8x against 15.8x. Worse,
936
+ * the bias is not shared it depends on the clip's own resolution and on how
937
+ * fast the host is — so it does not cancel out of the fit, it tilts it. That is
938
+ * the fast-host failure recorded on 2026-08-20, where 1080p read cheaper than
939
+ * 720p, which is not a thing a decoder does.
940
+ *
941
+ * So the clip is fed to the decoder as ONE stream instead. An Annex-B
942
+ * elementary stream carries its parameter sets inline, so writing the same
943
+ * bytes again is simply more stream — the decoder never re-initialises, and
944
+ * there is no restart inside the window to measure. Verified against the
945
+ * continuous-pass truth on the same host: -0.2 % and -5.5 %, against -25 % and
946
+ * -33 % for the loop. Nothing is written to disk and the process is killed as
947
+ * soon as the window is wide enough.
948
+ *
949
+ * Exported because the property that broke here is checkable and was not being
950
+ * checked: a bigger picture must cost more than a smaller one of the same
951
+ * bitrate, and under the loop it did not.
952
+ *
953
+ * @param {string} ffmpegBin
954
+ * @param {string} clipPath
955
+ * @param {string} [family="h264"]
956
+ * @returns {Promise<{ speed: number, windowSec: number, megapixelsPerSecond: number, megabitsPerSecond: number } | null>}
957
+ */
958
+ export async function measureDecodeSlope(ffmpegBin, clipPath, family = "h264") {
959
+ const streams = await extractFamilyStreams(ffmpegBin, [clipPath], family);
960
+ const stream = streams?.[0];
961
+ if (!stream) {
962
+ return null;
963
+ }
964
+ const measured = await decodePipedStream(ffmpegBin, stream);
965
+ return measured?.speed ? measured : null;
966
+ }
967
+
968
+ /**
969
+ * Decode an elementary stream fed from memory, and report the slope.
970
+ *
971
+ * @param {string} ffmpegBin
972
+ * @param {{ bytes: Buffer, demuxer: string, megapixelsPerSecond: number, megabitsPerSecond: number, fps: number }} stream
973
+ * @returns {Promise<{ speed: number, windowSec: number, megapixelsPerSecond: number, megabitsPerSecond: number } | null>}
974
+ */
975
+ function decodePipedStream(ffmpegBin, stream, log = { info: () => {}, warn: () => {} }) {
976
+ return new Promise((resolve) => {
977
+ const args = [
978
+ "-hide_banner", "-loglevel", "error", "-nostats",
979
+ // A raw stream states no frame rate, so the one the container declared is
980
+ // given back to it. It decides how output time advances, and therefore
981
+ // what "seconds of video per second of clock" means.
982
+ "-f", stream.demuxer, "-framerate", String(stream.fps), "-i", "pipe:0",
983
+ "-an", "-f", "null", "-",
984
+ "-progress", "pipe:1"
985
+ ];
986
+ /** @type {Array<{ wallSec: number, outSec: number }>} */
987
+ const samples = [];
988
+ let stdout = "";
989
+ // Kept because this path depends on three things the old one did not: the
990
+ // raw demuxer accepting the frame rate, the bitstream filter having
991
+ // produced something parsable, and the fed concatenation being decodable.
992
+ // Without it the only trace of any of those failing is "said nothing".
993
+ let stderr = "";
994
+ let settled = false;
995
+ let child;
996
+ const startedAt = Date.now();
997
+ // Whether the FEED, not the decoder, could be what this reading measures
998
+ // (item 4(d2)). `child.stdin.write()` returning false was tried as the
999
+ // signal — never once true would mean this process was never ahead of the
1000
+ // pipe — and measured false on every reading taken while writing this,
1001
+ // including clips this same host decodes at 15-80x with room to spare, so
1002
+ // it does not discriminate: `write()`'s return value tracks Node's own
1003
+ // internal watermark against the size of what was just handed to it, not
1004
+ // real drain state, and answered "no slack" identically whether the pipe
1005
+ // or the decoder was the true limit. Rather than publish a verdict that
1006
+ // reads the same in both cases, only the byte count is kept, for the
1007
+ // MB/s figure below — a number to read, not a boolean to trust.
1008
+ let bytesWritten = 0;
1009
+ const finish = () => {
1010
+ if (settled) {
1011
+ return;
1012
+ }
1013
+ settled = true;
1014
+ clearTimeout(timer);
1015
+ try {
1016
+ child?.stdin?.destroy();
1017
+ } catch {
1018
+ // already gone
1019
+ }
1020
+ try {
1021
+ child?.kill("SIGKILL");
1022
+ } catch {
1023
+ // already gone
1024
+ }
1025
+ // The first sample still carries the startup — it reports whatever was
1026
+ // processed while the process was coming up. Everything is measured from
1027
+ // the second onwards.
1028
+ const first = samples[1];
1029
+ const last = samples[samples.length - 1];
1030
+ if (!first || !last) {
1031
+ resolve({ error: lastErrorLine(stderr) || "the decoder reported no progress" });
1032
+ return;
1033
+ }
1034
+ const windowSec = last.wallSec - first.wallSec;
1035
+ const producedSec = last.outSec - first.outSec;
1036
+ if (!(windowSec >= DECODE_WINDOW_MIN_SEC) || !(producedSec > 0)) {
1037
+ resolve({ error: lastErrorLine(stderr) || `the window was ${windowSec.toFixed(2)}s of ${producedSec.toFixed(2)}s produced` });
1038
+ return;
1039
+ }
1040
+ const speed = producedSec / windowSec;
1041
+ // Diagnostic only logged, not acted on. Both figures are taken over the
1042
+ // SAME window the speed itself is: bytesWritten is snapshotted alongside
1043
+ // every progress sample, so this compares like against like rather than
1044
+ // the achieved rate over the whole run (which starts before the first
1045
+ // kept sample and reads systematically low against the window's own
1046
+ // rate for no reason but that mismatch — measured while building this).
1047
+ const windowBytes = last.bytesWritten - first.bytesWritten;
1048
+ const achievedMBps = windowSec > 0 ? windowBytes / windowSec / 1e6 : 0;
1049
+ const requiredMBps = ((stream.megabitsPerSecond * 1e6) / 8) * speed / 1e6;
1050
+ // "Far apart" is stated, not left to the reader to eyeball: outside a
1051
+ // factor of 1.5 either way is bigger than the write-timing slop this
1052
+ // comparison carries on a healthy reading.
1053
+ const farApart = requiredMBps > 0 && (achievedMBps / requiredMBps < 1 / 1.5 || achievedMBps / requiredMBps > 1.5);
1054
+ log.info(
1055
+ `hwaccel: decode pipe fed ${achievedMBps.toFixed(1)} MB/s, ${requiredMBps.toFixed(1)} MB/s ` +
1056
+ `needed for ${speed.toFixed(2)}x` +
1057
+ (farApart ? " — far enough apart to be worth a second look" : "")
1058
+ );
1059
+ resolve({
1060
+ speed,
1061
+ windowSec,
1062
+ megapixelsPerSecond: stream.megapixelsPerSecond,
1063
+ megabitsPerSecond: stream.megabitsPerSecond,
1064
+ pipeThroughputMBps: achievedMBps
1065
+ });
1066
+ };
1067
+ const timer = setTimeout(finish, DECODE_WINDOW_MAX_MS);
1068
+ try {
1069
+ child = spawn(ffmpegBin, args, { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
1070
+ } catch (error) {
1071
+ clearTimeout(timer);
1072
+ settled = true;
1073
+ resolve({ error: error instanceof Error ? error.message : String(error) });
1074
+ return;
1075
+ }
1076
+ child.stderr.on("data", (chunk) => {
1077
+ stderr += String(chunk);
1078
+ });
1079
+ // Keep the decoder fed. `write` returning false means the pipe is full, and
1080
+ // the next copy goes on the `drain` so the decoder is never starved and
1081
+ // this process never buffers more than the pipe holds.
1082
+ const writeOnce = () => {
1083
+ const accepted = child.stdin.write(stream.bytes);
1084
+ bytesWritten += stream.bytes.length;
1085
+ return accepted;
1086
+ };
1087
+ const feed = () => {
1088
+ while (!settled && child.stdin.writable && writeOnce()) {
1089
+ // Written straight through; go round again.
1090
+ }
1091
+ };
1092
+ child.stdin.on("drain", feed);
1093
+ // The kill closes the pipe under the writer; that is the intended end.
1094
+ child.stdin.on("error", () => {});
1095
+ child.stdout.on("data", (chunk) => {
1096
+ stdout += String(chunk);
1097
+ let newline = stdout.indexOf("\n");
1098
+ while (newline >= 0) {
1099
+ const line = stdout.slice(0, newline).trim();
1100
+ stdout = stdout.slice(newline + 1);
1101
+ if (line.startsWith("out_time_ms=")) {
1102
+ const microseconds = Number(line.slice("out_time_ms=".length));
1103
+ if (Number.isFinite(microseconds)) {
1104
+ samples.push({ wallSec: (Date.now() - startedAt) / 1000, outSec: microseconds / 1e6, bytesWritten });
1105
+ }
1106
+ }
1107
+ newline = stdout.indexOf("\n");
1108
+ }
1109
+ if (samples.length >= 2 && samples[samples.length - 1].wallSec - samples[1].wallSec >= DECODE_WINDOW_MIN_SEC) {
1110
+ finish();
1111
+ }
1112
+ });
1113
+ child.on("error", (error) => {
1114
+ if (settled) {
1115
+ return;
1116
+ }
1117
+ clearTimeout(timer);
1118
+ settled = true;
1119
+ try {
1120
+ child?.stdin?.destroy();
1121
+ } catch {
1122
+ // already gone
1123
+ }
1124
+ try {
1125
+ child?.kill("SIGKILL");
1126
+ } catch {
1127
+ // already gone
1128
+ }
1129
+ resolve({ error: error instanceof Error ? error.message : String(error) });
1130
+ });
1131
+ child.on("close", finish);
1132
+ feed();
1133
+ });
1134
+ }
1135
+
1136
+
1137
+ /**
1138
+ * How many times realtime this host can DECODE a source of these
1139
+ * characteristics, from the startup fit. `null` when the fit is unavailable or
1140
+ * the source figures are not known.
1141
+ *
1142
+ * @param {{ pixelTerm: number, bitrateTerm: number, constantTerm: number } | null} model
1143
+ * @param {{ megapixelsPerSecond: number, megabitsPerSecond: number }} source
1144
+ * @returns {number | null}
1145
+ */
1146
+ export function decodeSpeedFor(model, source) {
1147
+ if (!model) {
1148
+ return null;
1149
+ }
1150
+ const pixels = Number(source?.megapixelsPerSecond);
1151
+ const bits = Number(source?.megabitsPerSecond);
1152
+ if (!Number.isFinite(pixels) || pixels <= 0 || !Number.isFinite(bits) || bits < 0) {
1153
+ return null;
1154
+ }
1155
+ const cost = model.pixelTerm * pixels + model.bitrateTerm * bits + model.constantTerm;
1156
+ if (!(cost > 0)) {
1157
+ return null;
1158
+ }
1159
+ return 1 / cost;
1160
+ }
1161
+
1162
+ /**
1163
+ * How many times realtime a re-encode of this source at this output pixel rate
1164
+ * would run: decoding and encoding share the machine, so their costs add and
1165
+ * their speeds combine as
1166
+ *
1167
+ * 1 / (1/decodeSpeed + 1/encodeSpeed)
1168
+ *
1169
+ * Checked 2026-08-14 on the rung that broke playback: 1/(1/2.31 + 1/5.99) =
1170
+ * 1.67× against 1.48× measured. With no decode fit this falls back to the
1171
+ * encode speed alone — which is what the budget did before, and which
1172
+ * overestimated that rung five to eleven times.
1173
+ *
1174
+ * @param {{ decodeModel: { pixelTerm: number, bitrateTerm: number, constantTerm: number } | null, encodePixelsPerSec: number, outputPixelsPerSec: number, source: { megapixelsPerSecond: number, megabitsPerSecond: number } | null }} params
1175
+ * @returns {number | null}
1176
+ */
1177
+ export function predictedRealtimeSpeed({
1178
+ decodeModel,
1179
+ encodePixelsPerSec,
1180
+ outputPixelsPerSec,
1181
+ source,
1182
+ observedDecodeCostSec = null
1183
+ }) {
1184
+ if (!Number.isFinite(encodePixelsPerSec) || encodePixelsPerSec <= 0) {
1185
+ return null;
1186
+ }
1187
+ if (!Number.isFinite(outputPixelsPerSec) || outputPixelsPerSec <= 0) {
1188
+ return null;
1189
+ }
1190
+ const encodeSpeed = encodePixelsPerSec / outputPixelsPerSec;
1191
+ // What this very file has been seen to cost, when it has been: the clips are
1192
+ // H.264 and a source that has to be re-encoded usually is not, so a figure
1193
+ // taken from the encoder actually running on THIS source beats any model of
1194
+ // a stand-in. It arrives seconds into playback and replaces the estimate.
1195
+ const decodeSpeed = Number.isFinite(observedDecodeCostSec) && observedDecodeCostSec > 0
1196
+ ? 1 / observedDecodeCostSec
1197
+ : (source ? decodeSpeedFor(decodeModel, source) : null);
1198
+ if (decodeSpeed === null) {
1199
+ return encodeSpeed;
1200
+ }
1201
+ return 1 / (1 / decodeSpeed + 1 / encodeSpeed);
1202
+ }
1203
+
1204
+ /**
1205
+ * Whether this host can hold realtime, with the margin, while re-encoding this
1206
+ * source to this output pixel rate — and the predicted speed either way, so a
1207
+ * refusal can say what it refused on.
1208
+ *
1209
+ * The encoder figure is the FASTEST benchmarked preset: it is the best this
1210
+ * host can do, so a rung it cannot hold cannot be held at any quality setting.
1211
+ *
1212
+ * @param {{ benchmark: Array<{ preset: string, pixelsPerSec: number }>, decodeModel?: object | null, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null, outputPixelsPerSec: number, requiredSpeed?: number | null }} params
1213
+ * @returns {{ speed: number | null, sustainable: boolean }}
1214
+ */
1215
+ export function canSustainOutput({
1216
+ benchmark,
1217
+ decodeModel = null,
1218
+ source = null,
1219
+ outputPixelsPerSec,
1220
+ observedDecodeCostSec = null,
1221
+ concurrentCostSec = 0,
1222
+ requiredSpeed = null
1223
+ }) {
1224
+ if (!Array.isArray(benchmark) || benchmark.length === 0) {
1225
+ // Nothing measured on this host: the budget cannot refuse what it cannot
1226
+ // price, and refusing everything would leave a viewer with no rung at all.
1227
+ return { speed: null, sustainable: true };
1228
+ }
1229
+ const observed = Number.isFinite(observedDecodeCostSec) && observedDecodeCostSec > 0
1230
+ ? observedDecodeCostSec
1231
+ : null;
1232
+ if (observed === null && !isDecodePriced({ decodeModel, source })) {
1233
+ // An encoder-only figure was several times too optimistic on the rung this
1234
+ // check exists for, so it is not fit to refuse anything. Without the decode
1235
+ // term the ladder is offered whole, exactly as it was before.
1236
+ return { speed: null, sustainable: true };
1237
+ }
1238
+ const alone = predictedRealtimeSpeed({
1239
+ decodeModel,
1240
+ encodePixelsPerSec: cheapestPresetPixelsPerSec(benchmark),
1241
+ outputPixelsPerSec,
1242
+ source,
1243
+ observedDecodeCostSec: observed
1244
+ });
1245
+ // What ELSE will be running while this rung is. A rung is never the only
1246
+ // thing on the machine: the picture it accompanies is being copied or
1247
+ // encoded, an audio track may have its own encoder, and a warm-up is two
1248
+ // encoders by design. Measured on the addon host, a copy alone takes about an
1249
+ // eighth of the machine per second of video, and the field case of
1250
+ // 2026-08-15 adds up exactly: 0.125 for the copy plus ~1.05 for the rung is
1251
+ // more than the one second per second the machine has, which is what was
1252
+ // observed.
1253
+ //
1254
+ // Zero when nothing else is known to be running, or when nothing has been
1255
+ // measured yet — then this is a LOWER bound on the cost and the check is as
1256
+ // permissive as it was before.
1257
+ const speed = alone === null || !(concurrentCostSec > 0)
1258
+ ? alone
1259
+ : 1 / (1 / alone + concurrentCostSec);
1260
+ if (speed === null) {
1261
+ return { speed: null, sustainable: true };
1262
+ }
1263
+ return { speed, sustainable: speed >= speedBar(requiredSpeed) };
1264
+ }
1265
+
1266
+ /**
1267
+ * The speed a step has to reach to be worth offering.
1268
+ *
1269
+ * Realtime is not enough on its own: a step that produces exactly one second
1270
+ * per second never recovers the seconds lost while its reader waits for the
1271
+ * swarm, so it survives its own supply only if what it gains between
1272
+ * interruptions covers what one interruption costs. That is measured per file
1273
+ * and per swarm by the reader — `1 + worst wait / median interval`, in
1274
+ * `supply-margin.js` — and on the field torrent of 2026-08-17 it came to 1.67
1275
+ * against the 1.5 that used to stand here, and to 4.04-8.14 on a torrent whose
1276
+ * swarm no encoder could have kept up with.
1277
+ *
1278
+ * Where that figure does not exist yet — fewer than two interruptions measured
1279
+ * — the bar is realtime. It is the one thing that can be said without
1280
+ * measuring the swarm, and the offer is restated as soon as the reader has
1281
+ * something to say.
1282
+ *
1283
+ * @param {number | null | undefined} requiredSpeed - What this file's own
1284
+ * interruptions demand, when they have been measured.
1285
+ * @returns {number}
1286
+ */
1287
+ export function speedBar(requiredSpeed) {
1288
+ return Number.isFinite(requiredSpeed) && requiredSpeed > REALTIME ? requiredSpeed : REALTIME;
1289
+ }
1290
+
1291
+ /**
1292
+ * The bar for a cost description — the supply's demand where decoding is
1293
+ * priced, and never below the unpriced-decode bar where it is not.
1294
+ *
1295
+ * @param {{ decodeModel?: object | null, source?: object | null, observedDecodeCostSec?: number | null, requiredSpeed?: number | null }} cost
1296
+ * @returns {number}
1297
+ */
1298
+ function barFor(cost) {
1299
+ const measured = speedBar(cost?.requiredSpeed);
1300
+ return isDecodePriced(cost) ? measured : Math.max(UNPRICED_DECODE_BAR, measured);
1301
+ }
1302
+
1303
+ /**
1304
+ * Benchmark software libx264 presets on this host. Encodes a short synthetic
1305
+ * clip at a fixed reference resolution with each preset and measures encoder
1306
+ * throughput in pixels/second. The session manager uses this to pick, per
1307
+ * stream, the highest-quality preset that still encodes the actual
1308
+ * (source-capped) resolution faster than realtime.
1309
+ *
1310
+ * Runs once at startup; bounded by a per-encode timeout. Presets that fail are
1311
+ * omitted from the result.
1312
+ *
1313
+ * @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void } }} options
1314
+ * @returns {Promise<Array<{ preset: string, pixelsPerSec: number }>>} Ordered slowest→fastest.
1315
+ */
1316
+ export async function benchmarkSoftwarePresets({ ffmpegBin, logger }) {
1317
+ const log = logger ?? { info: () => {}, warn: () => {} };
1318
+
1319
+ // REAL footage, decoded ONCE into raw frames, and the presets are then timed
1320
+ // on those frames.
1321
+ //
1322
+ // Two reasons, both measured. The pattern this replaced (`testsrc2`) has flat
1323
+ // areas and no grain and encodes 1.23x cheaper than film on the same machine
1324
+ // and preset — an error that always points at offering a rung the host cannot
1325
+ // hold. And feeding a compressed clip to each preset instead would put
1326
+ // decoding and scaling inside the measurement: subtracting them afterwards
1327
+ // compares a wall clock that includes process startup against a decode figure
1328
+ // measured to exclude it, while inside one ffmpeg the two halves overlap. On
1329
+ // the fastest preset — the one every ladder decision reads as the ceiling —
1330
+ // that subtraction is most of the number being measured, so a small error in
1331
+ // it becomes a large error in the answer.
1332
+ //
1333
+ // Raw frames remove all of it: no decoder, no scaler, nothing to subtract,
1334
+ // and no dependence on the decode model. The cost is 25 MB of memory in a
1335
+ // pipe for a few seconds.
1336
+ const rawFramesPath = await decodeToRawFrames(ffmpegBin, log);
1337
+ if (rawFramesPath === null) {
1338
+ // Said once more, in the words that matter to whoever reads the log next:
1339
+ // with no benchmark, `#sustainableHeights` filters nothing and every rung
1340
+ // is offered, which is the failure of 2026-08-14 in full.
1341
+ log.warn("hwaccel: the quality ladder is UNFILTERED on this host — nothing measured the encoder");
1342
+ return [];
1343
+ }
1344
+ /** @type {Array<{ preset: string, pixelsPerSec: number }>} */
1345
+ const results = [];
1346
+ try {
1347
+ for (const preset of BENCHMARK_PRESETS) {
1348
+ const speed = await measureEncodeSlope(ffmpegBin, preset, rawFramesPath);
1349
+ if (speed === null) {
1350
+ log.warn(`hwaccel: preset benchmark "${preset}" produced no usable reading; skipping`);
1351
+ continue;
1352
+ }
1353
+ const pixelsPerSec = BENCHMARK_REF_W * BENCHMARK_REF_H * TRANSCODE_FPS * speed;
1354
+ results.push({ preset, pixelsPerSec });
1355
+ log.info(
1356
+ `hwaccel: preset "${preset}" ~= ${(pixelsPerSec / 1e6).toFixed(1)} Mpx/s ` +
1357
+ `(${speed.toFixed(2)}x @ ${BENCHMARK_REF_W}x${BENCHMARK_REF_H}, real footage)`
1358
+ );
1359
+ }
1360
+ } finally {
1361
+ // The encoder was killed a moment ago and on Windows the handle outlives
1362
+ // the signal, so removal is retried and its failure is not worth a session:
1363
+ // this is a temp directory the operating system will clear anyway.
1364
+ try {
1365
+ rmSync(path.dirname(rawFramesPath), { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
1366
+ } catch (error) {
1367
+ log.warn(`hwaccel: could not remove the benchmark's raw frames: ${error instanceof Error ? error.message : String(error)}`);
1368
+ }
1369
+ }
1370
+ return results;
1371
+ }
1372
+
1373
+ /**
1374
+ * How fast one preset encodes, from ffmpeg's own reports of how much video it
1375
+ * has written — not from the clock around the process.
1376
+ *
1377
+ * Timing whole runs measures the run STARTING. Measured 2026-08-15 on a desktop
1378
+ * that spawns ffmpeg in ~0.4 s: three seconds of raw frames encoded that way
1379
+ * put `fast` and `ultrafast` within 1.24x of each other, when libx264's own
1380
+ * presets differ by several times — the constant had swallowed the difference.
1381
+ * The slope between two progress reports contains no part of the startup.
1382
+ *
1383
+ * The frames are written repeatedly so there is runway to measure over,
1384
+ * whatever the preset's speed.
1385
+ *
1386
+ * @param {string} ffmpegBin
1387
+ * @param {string} preset
1388
+ * @param {string} rawFramesPath
1389
+ * @returns {Promise<number | null>} Video seconds encoded per second of clock.
1390
+ */
1391
+ /**
1392
+ * Video seconds produced per second of clock, from ffmpeg's own reports.
1393
+ *
1394
+ * Startup is excluded by taking a DIFFERENCE: it lands in the wall clock of
1395
+ * every report equally, so it cancels between two of them. (The decode
1396
+ * benchmark drops its first report instead, because there the first one is
1397
+ * emitted at out_time zero; here reports with no time yet are discarded before
1398
+ * they arrive, so the first kept one is already running.)
1399
+ *
1400
+ * @param {Array<{ wallSec: number, outSec: number }>} samples
1401
+ * @param {number} [minimumWindowSec=ENCODE_BENCHMARK_WINDOW_SEC]
1402
+ * @returns {number | null}
1403
+ */
1404
+ export function slopeOf(samples, minimumWindowSec = ENCODE_BENCHMARK_WINDOW_SEC) {
1405
+ const first = samples[0];
1406
+ const last = samples[samples.length - 1];
1407
+ if (!first || !last || first === last) {
1408
+ return null;
1409
+ }
1410
+ const took = last.wallSec - first.wallSec;
1411
+ const produced = last.outSec - first.outSec;
1412
+ if (!(took >= minimumWindowSec) || !(produced > 0)) {
1413
+ return null;
1414
+ }
1415
+ const slope = produced / took;
1416
+ // Nothing encodes a thousand times realtime. A figure above that is a
1417
+ // measurement fault, and letting it through opens the whole ladder.
1418
+ return slope <= ENCODE_BENCHMARK_MAX_PLAUSIBLE_SPEED ? slope : null;
1419
+ }
1420
+
1421
+ function measureEncodeSlope(ffmpegBin, preset, rawFramesPath) {
1422
+ return new Promise((resolve) => {
1423
+ const args = [
1424
+ "-hide_banner", "-loglevel", "error", "-nostats",
1425
+ "-stream_loop", "-1",
1426
+ "-f", "rawvideo", "-pix_fmt", "yuv420p",
1427
+ "-s", `${BENCHMARK_REF_W}x${BENCHMARK_REF_H}`, "-r", String(TRANSCODE_FPS),
1428
+ "-i", rawFramesPath,
1429
+ "-c:v", "libx264", "-preset", preset, "-crf", SOFTWARE_CRF, "-pix_fmt", "yuv420p",
1430
+ "-f", "null", "-",
1431
+ "-progress", "pipe:1"
1432
+ ];
1433
+ /** @type {Array<{ wallSec: number, outSec: number }>} */
1434
+ const samples = [];
1435
+ let settled = false;
1436
+ let buffered = "";
1437
+ let child;
1438
+ const startedAt = Date.now();
1439
+ const finish = (value) => {
1440
+ if (settled) {
1441
+ return;
1442
+ }
1443
+ settled = true;
1444
+ clearTimeout(timer);
1445
+ try {
1446
+ child?.kill("SIGKILL");
1447
+ } catch {
1448
+ // already gone
1449
+ }
1450
+ resolve(value);
1451
+ };
1452
+ const timer = setTimeout(() => finish(null), ENCODE_BENCHMARK_TIMEOUT_MS);
1453
+ try {
1454
+ child = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "ignore"], windowsHide: true });
1455
+ } catch {
1456
+ finish(null);
1457
+ return;
1458
+ }
1459
+ // The frames come from a FILE, read on repeat by ffmpeg itself. Fed through
1460
+ // a pipe instead, the fastest presets measured the pipe: `ultrafast` on a
1461
+ // desktop wants raw frames at hundreds of megabytes a second, which no
1462
+ // writer here can supply, and the reading then describes the feeding rather
1463
+ // than the encoder.
1464
+ child.stdout.on("data", (chunk) => {
1465
+ buffered += String(chunk);
1466
+ let newline = buffered.indexOf(NEWLINE);
1467
+ while (newline >= 0) {
1468
+ const line = buffered.slice(0, newline).trim();
1469
+ buffered = buffered.slice(newline + 1);
1470
+ if (line.startsWith("out_time_ms=")) {
1471
+ const outSec = Number(line.slice("out_time_ms=".length)) / 1e6;
1472
+ // `N/A` is not the only way ffmpeg says "no position yet": some builds
1473
+ // print the smallest signed 64-bit integer, which IS finite and would
1474
+ // be taken for a position nine trillion seconds before the start.
1475
+ if (Number.isFinite(outSec) && outSec >= 0) {
1476
+ samples.push({ wallSec: (Date.now() - startedAt) / 1000, outSec });
1477
+ }
1478
+ }
1479
+ newline = buffered.indexOf(NEWLINE);
1480
+ }
1481
+ const slope = slopeOf(samples);
1482
+ if (slope !== null) {
1483
+ finish(slope);
1484
+ }
1485
+ });
1486
+ child.on("error", () => finish(null));
1487
+ // A preset that finished before the window was wide enough is measured from
1488
+ // whatever it did report, provided two reports exist at all.
1489
+ // A preset that finished before the wide window was covered is still
1490
+ // measured — but never over a window of nothing. Two reports a millisecond
1491
+ // apart would divide a frame of video by that millisecond and call the host
1492
+ // twenty times faster than it is, and one such reading becomes the figure
1493
+ // every ladder decision is taken from.
1494
+ child.on("exit", () => finish(slopeOf(samples, ENCODE_BENCHMARK_MIN_WINDOW_SEC)));
1495
+ });
1496
+ }
1497
+
1498
+ /**
1499
+ * The benchmark's footage as raw frames: the calibration clip, looped to the
1500
+ * benchmark's length and scaled to its size, decoded once.
1501
+ *
1502
+ * @param {string} ffmpegBin
1503
+ * @param {{ info: (m: string) => void, warn: (m: string) => void }} log
1504
+ * @returns {Promise<string | null>} Path to the raw frames, or null.
1505
+ */
1506
+ async function decodeToRawFrames(ffmpegBin, log) {
1507
+ // A benchmark may leave a host unmeasured; it may never stop it from
1508
+ // starting. Before this the temp directory was made outside any guard, so a
1509
+ // read-only or missing TMPDIR rejected the promise that starts the proxy.
1510
+ let directory;
1511
+ try {
1512
+ directory = mkdtempSync(path.join(os.tmpdir(), "torrent-tv-bench-"));
1513
+ } catch (error) {
1514
+ log.warn(
1515
+ `hwaccel: no writable temp directory for the preset benchmark (${error instanceof Error ? error.message : String(error)}); ` +
1516
+ "presets unmeasured, so no quality rung will be refused on this host"
1517
+ );
1518
+ return null;
1519
+ }
1520
+ const rawPath = path.join(directory, "frames.yuv");
1521
+ const args = [
1522
+ "-hide_banner", "-loglevel", "error",
1523
+ "-stream_loop", "-1",
1524
+ "-i", path.join(CALIBRATION_DIR, CALIBRATION_CLIPS[0]),
1525
+ "-t", String(BENCHMARK_DURATION_SEC),
1526
+ "-vf", `scale=${BENCHMARK_REF_W}:${BENCHMARK_REF_H},fps=${TRANSCODE_FPS}`,
1527
+ "-an", "-f", "rawvideo", "-pix_fmt", "yuv420p", "-y", rawPath
1528
+ ];
1529
+ const { code } = await runFfmpeg(ffmpegBin, args, 30000);
1530
+ const expectedBytes = BENCHMARK_REF_W * BENCHMARK_REF_H * 1.5 * TRANSCODE_FPS * BENCHMARK_DURATION_SEC;
1531
+ let written = 0;
1532
+ try {
1533
+ written = statSync(rawPath).size;
1534
+ } catch {
1535
+ written = 0;
1536
+ }
1537
+ if (code !== 0 || written < expectedBytes * 0.9) {
1538
+ log.warn(
1539
+ "hwaccel: could not decode the calibration clip for the preset benchmark " +
1540
+ `(${written} of ~${Math.round(expectedBytes)} bytes); presets unmeasured, ` +
1541
+ "so no quality rung will be refused on this host"
1542
+ );
1543
+ try {
1544
+ rmSync(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
1545
+ } catch {
1546
+ // A temp directory the operating system will clear; not worth a start-up.
1547
+ }
1548
+ return null;
1549
+ }
1550
+ return rawPath;
1551
+ }
1552
+
1553
+ /**
1554
+ * Pick the highest-quality (slowest) benchmarked preset that can encode
1555
+ * `pixelsPerSecNeeded` with the speed margin. Falls back to the fastest
1556
+ * benchmarked preset, or `"ultrafast"` when no benchmark is available.
1557
+ *
1558
+ * @param {Array<{ preset: string, pixelsPerSec: number }>} benchmark - slowest→fastest
1559
+ * @param {number} pixelsPerSecNeeded
1560
+ * @param {{ decodeModel?: object | null, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null, requiredSpeed?: number | null }} [cost]
1561
+ * @returns {string}
1562
+ */
1563
+ /**
1564
+ * What this host can do at its CHEAPEST preset — the ceiling of the ladder.
1565
+ *
1566
+ * Deliberately not the largest reading in the array. The list is in quality
1567
+ * order, so its last measured entry is the cheapest preset; taking the maximum
1568
+ * instead would let one noisy reading of an expensive preset raise the bar that
1569
+ * decides which rungs are offered, and a rung offered on noise is a rung the
1570
+ * host cannot hold. For choosing a preset the direction of that error is
1571
+ * harmless; for deciding what to offer it is not, so the two use different
1572
+ * statistics on purpose.
1573
+ *
1574
+ * @param {Array<{ preset: string, pixelsPerSec: number }>} benchmark
1575
+ * @returns {number}
1576
+ */
1577
+ function cheapestPresetPixelsPerSec(benchmark) {
1578
+ return benchmark[benchmark.length - 1]?.pixelsPerSec ?? 0;
1579
+ }
1580
+
1581
+ export function pickSoftwarePreset(benchmark, pixelsPerSecNeeded, cost = {}) {
1582
+ if (!Array.isArray(benchmark) || benchmark.length === 0) {
1583
+ return "ultrafast";
1584
+ }
1585
+ const observed = Number.isFinite(cost?.observedDecodeCostSec) && cost.observedDecodeCostSec > 0
1586
+ ? cost.observedDecodeCostSec
1587
+ : null;
1588
+ const bar = barFor(cost);
1589
+ // The FIRST entry that clears the bar wins — the list is in quality order, so
1590
+ // that is the best picture this host can hold. Every entry is examined rather
1591
+ // than the walk stopping at the first miss, because the measurements do not
1592
+ // always ascend with the list: on a busy machine on 2026-08-15 `faster` read
1593
+ // below `fast` twice.
1594
+ for (const entry of benchmark) {
1595
+ const speed = predictedRealtimeSpeed({
1596
+ decodeModel: cost.decodeModel ?? null,
1597
+ encodePixelsPerSec: entry.pixelsPerSec,
1598
+ outputPixelsPerSec: pixelsPerSecNeeded,
1599
+ source: cost.source ?? null,
1600
+ observedDecodeCostSec: observed
1601
+ });
1602
+ if (speed !== null && speed >= bar) {
1603
+ return entry.preset;
1604
+ }
1605
+ }
1606
+ // Nothing clears the bar: the cheapest preset, which is the last in quality
1607
+ // order. Returning whichever preset measured fastest would hand an expensive
1608
+ // one to a host that has just been shown to hold no rung at all.
1609
+ return benchmark[benchmark.length - 1].preset;
1610
+ }
1611
+
1612
+ /**
1613
+ * Whether a cost description can actually price decoding — a fit AND a source
1614
+ * to apply it to. Without both, every prediction is encoder-only.
1615
+ *
1616
+ * @param {{ decodeModel?: object | null, source?: object | null }} cost
1617
+ * @returns {boolean}
1618
+ */
1619
+ function isDecodePriced(cost) {
1620
+ if (Number.isFinite(cost?.observedDecodeCostSec) && cost.observedDecodeCostSec > 0) {
1621
+ return true; // measured on the source itself, which needs no fit to stand on
1622
+ }
1623
+ return Boolean(cost?.decodeModel) && Boolean(cost?.source);
1624
+ }
1625
+
1626
+ // Resolution-ladder heights (output height rungs), high→low. The ladder is
1627
+ // derived per-stream from the ceiling (the client-requested, source-capped
1628
+ // output box): only rungs at or below the ceiling height are used, so the
1629
+ // budget never upscales past what the client asked for. Standard heights keep
1630
+ // the downscaled output at familiar resolutions.
1631
+ const RESOLUTION_LADDER_HEIGHTS = [2160, 1440, 1080, 720, 540, 480, 360, 240];
1632
+
1633
+ /**
1634
+ * Build the resolution ladder for a ceiling box. Returns candidate output
1635
+ * dimensions from the ceiling downward, preserving the ceiling's aspect ratio,
1636
+ * each even-sized. The ceiling itself is always the top rung; ladder heights
1637
+ * at or above it are skipped (never upscale). Deduped by height.
1638
+ *
1639
+ * @param {number} ceilingWidth
1640
+ * @param {number} ceilingHeight
1641
+ * @returns {Array<{ width: number, height: number }>} high→low
1642
+ */
1643
+ export function buildResolutionLadder(ceilingWidth, ceilingHeight) {
1644
+ const cw = Number.isInteger(ceilingWidth) && ceilingWidth > 0 ? ceilingWidth : 0;
1645
+ const ch = Number.isInteger(ceilingHeight) && ceilingHeight > 0 ? ceilingHeight : 0;
1646
+ if (!cw || !ch) {
1647
+ return [];
1648
+ }
1649
+ const even = (v) => {
1650
+ const r = Math.round(v);
1651
+ return Math.max(2, r - (r % 2));
1652
+ };
1653
+ /** @type {Array<{ width: number, height: number }>} */
1654
+ const rungs = [{ width: cw, height: ch }];
1655
+ for (const h of RESOLUTION_LADDER_HEIGHTS) {
1656
+ if (h >= ch) {
1657
+ continue; // at/above the ceiling — the ceiling rung already covers it
1658
+ }
1659
+ rungs.push({ width: even(cw * (h / ch)), height: h });
1660
+ }
1661
+ const seen = new Set();
1662
+ return rungs.filter((rung) => {
1663
+ if (seen.has(rung.height)) {
1664
+ return false;
1665
+ }
1666
+ seen.add(rung.height);
1667
+ return true;
1668
+ });
1669
+ }
1670
+
1671
+ /**
1672
+ * Choose the software encode settings (resolution + preset) that fit the
1673
+ * realtime budget on this host. From the resolution ladder (ceiling downward),
1674
+ * pick the HIGHEST rung whose encode throughput — predicted from the startup
1675
+ * benchmark's fastest preset — clears the speed this file's own supply
1676
+ * demands (`speedBar`). Then, at that resolution, pick the highest-quality
1677
+ * preset that still clears it. When even the lowest rung cannot clear it, use the lowest rung with
1678
+ * the fastest preset (best effort — a smaller picture beats sub-realtime
1679
+ * playback at full size). Returns null when no benchmark or ceiling is
1680
+ * available (the caller keeps the ceiling resolution and the default preset).
1681
+ *
1682
+ * @param {Array<{ preset: string, pixelsPerSec: number }>} benchmark - slowest→fastest
1683
+ * @param {{ width: number, height: number }} ceiling
1684
+ * @param {number} outputFps
1685
+ * @param {{ decodeModel?: object | null, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null, requiredSpeed?: number | null }} [cost]
1686
+ * @returns {{ width: number, height: number, preset: string, ladder: Array<{ width: number, height: number }>, rungIndex: number } | null}
1687
+ */
1688
+ export function chooseSoftwareEncodeSettings(benchmark, ceiling, outputFps, cost = {}) {
1689
+ if (!Array.isArray(benchmark) || benchmark.length === 0) {
1690
+ return null;
1691
+ }
1692
+ const fps = Number.isFinite(outputFps) && outputFps > 0 ? outputFps : TRANSCODE_FPS;
1693
+ const ladder = buildResolutionLadder(ceiling?.width, ceiling?.height);
1694
+ if (ladder.length === 0) {
1695
+ return null;
1696
+ }
1697
+ const fastest = cheapestPresetPixelsPerSec(benchmark); // the cheapest preset's throughput
1698
+ const bar = barFor(cost);
1699
+ let chosenIndex = ladder.length - 1; // default: lowest rung (best effort)
1700
+ for (let i = 0; i < ladder.length; i += 1) {
1701
+ const speed = predictedRealtimeSpeed({
1702
+ decodeModel: cost.decodeModel ?? null,
1703
+ encodePixelsPerSec: fastest,
1704
+ outputPixelsPerSec: ladder[i].width * ladder[i].height * fps,
1705
+ source: cost.source ?? null
1706
+ });
1707
+ if (speed !== null && speed >= bar) {
1708
+ chosenIndex = i;
1709
+ break;
1710
+ }
1711
+ }
1712
+ const chosen = ladder[chosenIndex];
1713
+ const preset = pickSoftwarePreset(benchmark, chosen.width * chosen.height * fps, cost);
1714
+ return { width: chosen.width, height: chosen.height, preset, ladder, rungIndex: chosenIndex };
1715
+ }