@torrent-tv/proxy 2.9.27 → 2.9.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,514 +1,560 @@
1
- /**
2
- * @file Hardware-accelerated H.264 encoder auto-detection.
3
- *
4
- * Probes the ffmpeg build and the host for a usable hardware H.264 encoder
5
- * (NVENC / QSV / VAAPI / V4L2 M2M), verifying each candidate with a real
6
- * test-encode before selecting it. Falls back to software libx264 when no
7
- * hardware encoder is present or working.
8
- *
9
- * Deployment-agnostic: relies only on ffmpeg, the filesystem and
10
- * `process.platform`; makes no assumptions about Home Assistant or any
11
- * specific host. A garbled or unsupported hardware path simply fails its
12
- * test-encode and is skipped, so the worst case is software encoding.
13
- *
14
- * A descriptor exposes:
15
- * - `name` human-readable encoder id (e.g. "h264_vaapi")
16
- * - `kind` "software" | "vaapi" | "qsv" | "nvenc" | "v4l2m2m"
17
- * - `device` device node path or null
18
- * - `inputArgs` ffmpeg args inserted before `-i` (decode/hwaccel setup)
19
- * - `buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec })`
20
- * ffmpeg video filter + encoder args inserted after `-map`s
21
- */
22
-
23
- import { spawn } from "node:child_process";
24
- import { mkdtempSync, readdirSync, rmSync } from "node:fs";
25
- import os from "node:os";
26
- import path from "node:path";
27
-
28
- const SOFTWARE_PRESET = "ultrafast";
29
- const SOFTWARE_CRF = "24";
30
- export const TRANSCODE_FPS = 24;
31
- // Software x264 on weak ARM hosts is the transcode bottleneck — use all cores.
32
- const CPU_THREADS = Math.max(1, os.cpus().length);
33
-
34
- // libx264 presets to benchmark, ordered slowest/highest-quality → fastest.
35
- const BENCHMARK_PRESETS = ["fast", "faster", "veryfast", "superfast", "ultrafast"];
36
- const BENCHMARK_REF_W = 640;
37
- const BENCHMARK_REF_H = 360;
38
- const BENCHMARK_DURATION_SEC = 3;
39
- // Require the encoder to be this much faster than realtime for the target
40
- // resolution. The benchmark runs at startup with an idle CPU; during playback
41
- // ffmpeg competes with in-process WebTorrent (download + hashing) and delivery,
42
- // so real throughput is lower. A generous margin keeps playback above under
43
- // that real load and absorbs complex scenes.
44
- const PRESET_SPEED_MARGIN = 1.8;
45
-
46
- /**
47
- * @param {number} targetWidth
48
- * @param {number} targetHeight
49
- * @returns {{ w: number, h: number }}
50
- */
51
- function safeDimensions(targetWidth, targetHeight) {
52
- const w = Number.isInteger(targetWidth) && targetWidth > 0 ? targetWidth : 1280;
53
- const h = Number.isInteger(targetHeight) && targetHeight > 0 ? targetHeight : 720;
54
- return { w, h };
55
- }
56
-
57
- /**
58
- * Force a keyframe on every segment boundary so each HLS segment is
59
- * independently decodable and exactly `segmentDurationSec` long.
60
- *
61
- * @param {number} segmentDurationSec
62
- * @returns {string[]}
63
- */
64
- function keyFrameArgs(segmentDurationSec) {
65
- return ["-force_key_frames", `expr:gte(t,n_forced*${segmentDurationSec})`];
66
- }
67
-
68
- /** @returns {import("./hwaccel.js").VideoEncoderDescriptor} */
69
- export function softwareDescriptor() {
70
- return {
71
- name: "libx264",
72
- kind: "software",
73
- device: null,
74
- inputArgs: [],
75
- buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, preset }) {
76
- const { w, h } = safeDimensions(targetWidth, targetHeight);
77
- const chosenPreset = typeof preset === "string" && preset.length > 0 ? preset : SOFTWARE_PRESET;
78
- return [
79
- // Never upscale: cap the target box to the source size (min with
80
- // iw/ih), so a small source (e.g. 720x400) is encoded at its own
81
- // resolution instead of being scaled up to the viewport — far fewer
82
- // pixels, much faster on ARM. force_original_aspect_ratio keeps aspect.
83
- "-vf",
84
- `scale='min(${w},iw)':'min(${h},ih)':force_original_aspect_ratio=decrease:force_divisible_by=2,fps=${TRANSCODE_FPS}`,
85
- "-c:v", "libx264",
86
- // Preset is chosen per stream by the session manager from the startup
87
- // benchmark (highest quality that still encodes the source resolution
88
- // faster than realtime); falls back to the static default.
89
- "-preset", chosenPreset,
90
- "-crf", SOFTWARE_CRF,
91
- "-threads", String(CPU_THREADS),
92
- "-pix_fmt", "yuv420p",
93
- // Fixed GOP: a keyframe exactly every (segmentDurationSec × fps) frames,
94
- // scene-cut keyframes disabled. This is frame-count based, so it is
95
- // independent of the PTS offset used on seek-restart — every HLS segment
96
- // is exactly segmentDurationSec long and starts on a keyframe, so segment
97
- // boundaries line up with the synthetic playlist with no gaps. (The old
98
- // `-force_key_frames expr:gte(t,n_forced*SEG)` broke after a seek because
99
- // `t` is offset by `-output_ts_offset`, forcing keyframes at the wrong
100
- // places.)
101
- "-g", String(segmentDurationSec * TRANSCODE_FPS),
102
- "-keyint_min", String(segmentDurationSec * TRANSCODE_FPS),
103
- "-sc_threshold", "0"
104
- ];
105
- }
106
- };
107
- }
108
-
109
- /**
110
- * @param {string} device
111
- * @returns {import("./hwaccel.js").VideoEncoderDescriptor}
112
- */
113
- function vaapiDescriptor(device) {
114
- return {
115
- name: "h264_vaapi",
116
- kind: "vaapi",
117
- device,
118
- // Decode on the GPU into VAAPI surfaces; scale and encode stay on-GPU.
119
- inputArgs: ["-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi", "-vaapi_device", device],
120
- buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec }) {
121
- const { w, h } = safeDimensions(targetWidth, targetHeight);
122
- return [
123
- "-vf",
124
- `scale_vaapi=w=${w}:h=${h}:force_original_aspect_ratio=decrease`,
125
- "-c:v", "h264_vaapi",
126
- "-qp", "24",
127
- ...keyFrameArgs(segmentDurationSec)
128
- ];
129
- }
130
- };
131
- }
132
-
133
- /**
134
- * @param {string} device
135
- * @returns {import("./hwaccel.js").VideoEncoderDescriptor}
136
- */
137
- function qsvDescriptor(device) {
138
- return {
139
- name: "h264_qsv",
140
- kind: "qsv",
141
- device,
142
- inputArgs: ["-hwaccel", "qsv", "-qsv_device", device],
143
- buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec }) {
144
- const { w, h } = safeDimensions(targetWidth, targetHeight);
145
- return [
146
- "-vf", `scale_qsv=w=${w}:h=${h}`,
147
- "-c:v", "h264_qsv",
148
- "-global_quality", "24",
149
- ...keyFrameArgs(segmentDurationSec)
150
- ];
151
- }
152
- };
153
- }
154
-
155
- /** @returns {import("./hwaccel.js").VideoEncoderDescriptor} */
156
- function nvencDescriptor() {
157
- return {
158
- name: "h264_nvenc",
159
- kind: "nvenc",
160
- device: null,
161
- inputArgs: [],
162
- buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec }) {
163
- const { w, h } = safeDimensions(targetWidth, targetHeight);
164
- return [
165
- "-vf",
166
- `scale=${w}:${h}:force_original_aspect_ratio=decrease:force_divisible_by=2,fps=${TRANSCODE_FPS}`,
167
- "-c:v", "h264_nvenc",
168
- "-preset", "p4",
169
- "-cq", "24",
170
- "-pix_fmt", "yuv420p",
171
- ...keyFrameArgs(segmentDurationSec)
172
- ];
173
- }
174
- };
175
- }
176
-
177
- /** @returns {import("./hwaccel.js").VideoEncoderDescriptor} */
178
- function v4l2m2mDescriptor() {
179
- // ARM SoC (e.g. Raspberry Pi / HA Yellow) stateful M2M encoder. No GPU
180
- // scaler — scale in software, hand YUV420 frames to the hardware encoder.
181
- // `-g` aligns the GOP to the segment length so an IDR lands on every segment
182
- // boundary; this is verified by the keyframe-alignment test before use,
183
- // because v4l2m2m does not always honour these hints.
184
- return {
185
- name: "h264_v4l2m2m",
186
- kind: "v4l2m2m",
187
- device: null,
188
- inputArgs: [],
189
- buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec }) {
190
- const { w, h } = safeDimensions(targetWidth, targetHeight);
191
- return [
192
- "-vf",
193
- `scale=${w}:${h}:force_original_aspect_ratio=decrease:force_divisible_by=2,fps=${TRANSCODE_FPS},format=yuv420p`,
194
- "-c:v", "h264_v4l2m2m",
195
- "-b:v", "3M",
196
- "-g", String(TRANSCODE_FPS * segmentDurationSec),
197
- ...keyFrameArgs(segmentDurationSec)
198
- ];
199
- }
200
- };
201
- }
202
-
203
-
204
- /**
205
- * @typedef {Object} VideoEncoderDescriptor
206
- * @property {string} name
207
- * @property {"software"|"vaapi"|"qsv"|"nvenc"|"v4l2m2m"} kind
208
- * @property {string|null} device
209
- * @property {string[]} inputArgs
210
- * @property {(opts: { targetWidth: number, targetHeight: number, segmentDurationSec: number }) => string[]} buildVideoArgs
211
- */
212
-
213
- /**
214
- * Run ffmpeg and resolve with its exit code and captured output.
215
- *
216
- * @param {string} ffmpegBin
217
- * @param {string[]} args
218
- * @param {number} [timeoutMs=12000]
219
- * @returns {Promise<{ code: number, stdout: string, stderr: string }>}
220
- */
221
- function runFfmpeg(ffmpegBin, args, timeoutMs = 12000) {
222
- return new Promise((resolve) => {
223
- let stdout = "";
224
- let stderr = "";
225
- let settled = false;
226
- let child;
227
- const finish = (code) => {
228
- if (settled) {
229
- return;
230
- }
231
- settled = true;
232
- resolve({ code, stdout, stderr });
233
- };
234
- try {
235
- child = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
236
- } catch {
237
- finish(-1);
238
- return;
239
- }
240
- const timer = setTimeout(() => {
241
- try {
242
- child.kill("SIGKILL");
243
- } catch {
244
- // ignore
245
- }
246
- finish(-1);
247
- }, timeoutMs);
248
- child.stdout.on("data", (d) => {
249
- stdout += String(d);
250
- });
251
- child.stderr.on("data", (d) => {
252
- stderr += String(d);
253
- });
254
- child.on("error", () => {
255
- clearTimeout(timer);
256
- finish(-1);
257
- });
258
- child.on("exit", (code) => {
259
- clearTimeout(timer);
260
- finish(code ?? -1);
261
- });
262
- });
263
- }
264
-
265
- /** @returns {string[]} /dev/dri/renderD* nodes (VAAPI/QSV). */
266
- function listRenderNodes() {
267
- try {
268
- return readdirSync("/dev/dri")
269
- .filter((n) => n.startsWith("renderD"))
270
- .map((n) => `/dev/dri/${n}`)
271
- .sort();
272
- } catch {
273
- return [];
274
- }
275
- }
276
-
277
- /** @returns {boolean} Whether any /dev/nvidia* node exists (NVENC). */
278
- function hasNvidiaDevice() {
279
- try {
280
- return readdirSync("/dev").some((n) => /^nvidia(\d+)?$/.test(n));
281
- } catch {
282
- return false;
283
- }
284
- }
285
-
286
- /** @returns {boolean} Whether any /dev/video* node exists (V4L2 M2M). */
287
- function hasV4l2Device() {
288
- try {
289
- return readdirSync("/dev").some((n) => /^video\d+$/.test(n));
290
- } catch {
291
- return false;
292
- }
293
- }
294
-
295
- /**
296
- * Build a full ffmpeg command that encodes a short, *moving* synthetic clip
297
- * (testsrc2 — far more representative than a static black frame) through the
298
- * candidate encoder into real HLS segments in `outDir`, with keyframes forced
299
- * on segment boundaries. Verifying the resulting segments (see
300
- * {@link verifySegmentsDecodeCleanly}) catches encoders that silently produce
301
- * a corrupted or non-IDR-aligned stream (e.g. some V4L2 M2M builds).
302
- *
303
- * @param {VideoEncoderDescriptor} descriptor
304
- * @param {number} segmentDurationSec
305
- * @param {string} outDir
306
- * @returns {string[]}
307
- */
308
- function buildEncoderTestArgs(descriptor, segmentDurationSec, outDir) {
309
- const durationSec = Math.max(8, segmentDurationSec * 3);
310
- const source = ["-f", "lavfi", "-i", `testsrc2=s=640x360:r=${TRANSCODE_FPS}:d=${durationSec}`];
311
- const kf = keyFrameArgs(segmentDurationSec);
312
-
313
- /** @type {string[]} */
314
- let pre = ["-hide_banner", "-loglevel", "error"];
315
- /** @type {string[]} */
316
- let encode;
317
- switch (descriptor.kind) {
318
- case "vaapi":
319
- pre = [...pre, "-vaapi_device", String(descriptor.device)];
320
- encode = ["-vf", "format=nv12,hwupload", "-c:v", "h264_vaapi", "-qp", "24", ...kf];
321
- break;
322
- case "qsv":
323
- pre = [...pre, "-qsv_device", String(descriptor.device)];
324
- encode = ["-vf", "hwupload=extra_hw_frames=16,format=qsv", "-c:v", "h264_qsv", "-global_quality", "24", ...kf];
325
- break;
326
- case "nvenc":
327
- encode = ["-c:v", "h264_nvenc", "-preset", "p4", "-cq", "24", "-pix_fmt", "yuv420p", ...kf];
328
- break;
329
- case "v4l2m2m":
330
- encode = ["-pix_fmt", "yuv420p", "-c:v", "h264_v4l2m2m", "-b:v", "3M", "-g", String(TRANSCODE_FPS * segmentDurationSec), ...kf];
331
- break;
332
- default:
333
- encode = ["-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", ...kf];
334
- break;
335
- }
336
-
337
- const hlsOut = [
338
- "-f", "hls",
339
- "-hls_time", String(segmentDurationSec),
340
- "-hls_list_size", "0",
341
- "-hls_flags", "independent_segments",
342
- "-hls_segment_filename", path.join(outDir, "seg-%03d.ts"),
343
- path.join(outDir, "index.m3u8")
344
- ];
345
- return [...pre, ...source, ...encode, ...hlsOut];
346
- }
347
-
348
- /**
349
- * Verify the HLS segments produced by the test encode are valid: at least two
350
- * segments exist, and each decodes standalone without errors. A segment that
351
- * does not begin with a keyframe (broken/corrupted output) emits decode errors
352
- * when read on its own, which fails this check.
353
- *
354
- * @param {string} ffmpegBin
355
- * @param {string} outDir
356
- * @returns {Promise<boolean>}
357
- */
358
- async function verifySegmentsDecodeCleanly(ffmpegBin, outDir) {
359
- let files;
360
- try {
361
- files = readdirSync(outDir).filter((n) => /^seg-\d+\.ts$/.test(n)).sort();
362
- } catch {
363
- return false;
364
- }
365
- if (files.length < 2) {
366
- return false;
367
- }
368
- for (const file of files) {
369
- const result = await runFfmpeg(
370
- ffmpegBin,
371
- ["-hide_banner", "-loglevel", "error", "-i", path.join(outDir, file), "-f", "null", "-"],
372
- 8000
373
- );
374
- if (result.code !== 0 || result.stderr.trim().length > 0) {
375
- return false;
376
- }
377
- }
378
- return true;
379
- }
380
-
381
- /**
382
- * Detect the best usable H.264 encoder. Always resolves (falls back to
383
- * software libx264). Each hardware candidate is verified with a real
384
- * test-encode before being selected.
385
- *
386
- * @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void }, segmentDurationSec?: number }} options
387
- * @returns {Promise<VideoEncoderDescriptor>}
388
- */
389
- export async function detectVideoEncoder({ ffmpegBin, logger, segmentDurationSec = 4 }) {
390
- const log = logger ?? { info: () => {}, warn: () => {} };
391
- const software = softwareDescriptor();
392
-
393
- const { code, stdout } = await runFfmpeg(ffmpegBin, ["-hide_banner", "-encoders"], 10000);
394
- if (code !== 0) {
395
- log.warn("hwaccel: could not list ffmpeg encoders; using software libx264");
396
- return software;
397
- }
398
- const has = (name) => stdout.includes(name);
399
-
400
- /** @type {VideoEncoderDescriptor[]} */
401
- const candidates = [];
402
- const renderNodes = listRenderNodes();
403
- if (has("h264_nvenc") && hasNvidiaDevice()) {
404
- candidates.push(nvencDescriptor());
405
- }
406
- if (has("h264_qsv") && renderNodes.length > 0) {
407
- candidates.push(qsvDescriptor(renderNodes[0]));
408
- }
409
- if (has("h264_vaapi") && renderNodes.length > 0) {
410
- candidates.push(vaapiDescriptor(renderNodes[0]));
411
- }
412
- // h264_v4l2m2m (ARM SoC / Raspberry Pi / HA Yellow). It is gated behind the
413
- // strict keyframe-alignment test below, because some V4L2 M2M builds silently
414
- // emit a corrupted / non-IDR-aligned stream; the test rejects those and the
415
- // host falls back to software libx264.
416
- if (has("h264_v4l2m2m") && hasV4l2Device()) {
417
- candidates.push(v4l2m2mDescriptor());
418
- }
419
-
420
- for (const candidate of candidates) {
421
- const dir = mkdtempSync(path.join(os.tmpdir(), "tt-hwtest-"));
422
- let ok = false;
423
- try {
424
- const encoded = await runFfmpeg(
425
- ffmpegBin,
426
- buildEncoderTestArgs(candidate, segmentDurationSec, dir),
427
- 25000
428
- );
429
- if (encoded.code === 0) {
430
- ok = await verifySegmentsDecodeCleanly(ffmpegBin, dir);
431
- }
432
- } finally {
433
- try {
434
- rmSync(dir, { recursive: true, force: true });
435
- } catch {
436
- // best effort
437
- }
438
- }
439
- if (ok) {
440
- log.info(
441
- `hwaccel: using hardware encoder ${candidate.name}` +
442
- `${candidate.device ? ` (${candidate.device})` : ""}`
443
- );
444
- return candidate;
445
- }
446
- log.warn(`hwaccel: ${candidate.name} failed the HLS keyframe-alignment test; skipping`);
447
- }
448
-
449
- log.info("hwaccel: no working hardware encoder; using software libx264");
450
- return software;
451
- }
452
-
453
- /**
454
- * Benchmark software libx264 presets on this host. Encodes a short synthetic
455
- * clip at a fixed reference resolution with each preset and measures encoder
456
- * throughput in pixels/second. The session manager uses this to pick, per
457
- * stream, the highest-quality preset that still encodes the actual
458
- * (source-capped) resolution faster than realtime.
459
- *
460
- * Runs once at startup; bounded by a per-encode timeout. Presets that fail are
461
- * omitted from the result.
462
- *
463
- * @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void } }} options
464
- * @returns {Promise<Array<{ preset: string, pixelsPerSec: number }>>} Ordered slowest→fastest.
465
- */
466
- export async function benchmarkSoftwarePresets({ ffmpegBin, logger }) {
467
- const log = logger ?? { info: () => {}, warn: () => {} };
468
- const totalPixels = BENCHMARK_REF_W * BENCHMARK_REF_H * TRANSCODE_FPS * BENCHMARK_DURATION_SEC;
469
- /** @type {Array<{ preset: string, pixelsPerSec: number }>} */
470
- const results = [];
471
- for (const preset of BENCHMARK_PRESETS) {
472
- const args = [
473
- "-hide_banner", "-loglevel", "error",
474
- "-f", "lavfi", "-i", `testsrc2=s=${BENCHMARK_REF_W}x${BENCHMARK_REF_H}:r=${TRANSCODE_FPS}:d=${BENCHMARK_DURATION_SEC}`,
475
- "-c:v", "libx264", "-preset", preset, "-crf", SOFTWARE_CRF, "-pix_fmt", "yuv420p",
476
- "-f", "null", "-"
477
- ];
478
- const startedAt = Date.now();
479
- const { code } = await runFfmpeg(ffmpegBin, args, 30000);
480
- const elapsedSec = (Date.now() - startedAt) / 1000;
481
- if (code !== 0 || elapsedSec <= 0) {
482
- log.warn(`hwaccel: preset benchmark "${preset}" failed; skipping`);
483
- continue;
484
- }
485
- const pixelsPerSec = totalPixels / elapsedSec;
486
- results.push({ preset, pixelsPerSec });
487
- log.info(
488
- `hwaccel: preset "${preset}" ~= ${(pixelsPerSec / 1e6).toFixed(1)} Mpx/s ` +
489
- `(${(BENCHMARK_DURATION_SEC / elapsedSec).toFixed(2)}x @ ${BENCHMARK_REF_W}x${BENCHMARK_REF_H})`
490
- );
491
- }
492
- return results;
493
- }
494
-
495
- /**
496
- * Pick the highest-quality (slowest) benchmarked preset that can encode
497
- * `pixelsPerSecNeeded` with the speed margin. Falls back to the fastest
498
- * benchmarked preset, or `"ultrafast"` when no benchmark is available.
499
- *
500
- * @param {Array<{ preset: string, pixelsPerSec: number }>} benchmark - slowest→fastest
501
- * @param {number} pixelsPerSecNeeded
502
- * @returns {string}
503
- */
504
- export function pickSoftwarePreset(benchmark, pixelsPerSecNeeded) {
505
- if (!Array.isArray(benchmark) || benchmark.length === 0) {
506
- return "ultrafast";
507
- }
508
- for (const entry of benchmark) {
509
- if (entry.pixelsPerSec >= pixelsPerSecNeeded * PRESET_SPEED_MARGIN) {
510
- return entry.preset;
511
- }
512
- }
513
- return benchmark[benchmark.length - 1].preset;
514
- }
1
+ /**
2
+ * @file Hardware-accelerated H.264 encoder auto-detection.
3
+ *
4
+ * Probes the ffmpeg build and the host for a usable hardware H.264 encoder
5
+ * (NVENC / QSV / VAAPI / V4L2 M2M), verifying each candidate with a real
6
+ * test-encode before selecting it. Falls back to software libx264 when no
7
+ * hardware encoder is present or working.
8
+ *
9
+ * Deployment-agnostic: relies only on ffmpeg, the filesystem and
10
+ * `process.platform`; makes no assumptions about Home Assistant or any
11
+ * specific host. A garbled or unsupported hardware path simply fails its
12
+ * test-encode and is skipped, so the worst case is software encoding.
13
+ *
14
+ * A descriptor exposes:
15
+ * - `name` human-readable encoder id (e.g. "h264_vaapi")
16
+ * - `kind` "software" | "vaapi" | "qsv" | "nvenc" | "v4l2m2m"
17
+ * - `device` device node path or null
18
+ * - `inputArgs` ffmpeg args inserted before `-i` (decode/hwaccel setup)
19
+ * - `buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec })`
20
+ * ffmpeg video filter + encoder args inserted after `-map`s
21
+ */
22
+
23
+ import { spawn } from "node:child_process";
24
+ import { mkdtempSync, readdirSync, rmSync } from "node:fs";
25
+ import os from "node:os";
26
+ import path from "node:path";
27
+
28
+ const SOFTWARE_PRESET = "ultrafast";
29
+ const SOFTWARE_CRF = "24";
30
+ // Default output frame rate when the source rate is unknown, and the rate used
31
+ // by the synthetic startup test-encode / preset benchmark. The real encode
32
+ // inherits the source rate (rounded to an integer, capped) — see
33
+ // chooseOutputFps — so 25/30 fps content no longer plays resampled to 24.
34
+ export const TRANSCODE_FPS = 24;
35
+ // Upper bound on the output frame rate: 50/60 fps sources are halved-in-effort
36
+ // by capping to 30, protecting the realtime encode budget on weak hosts.
37
+ export const MAX_OUTPUT_FPS = 30;
38
+
39
+ /**
40
+ * Choose an INTEGER output frame rate from the (possibly fractional) source
41
+ * rate, for the frame-count-GOP encoders ONLY (software libx264, v4l2m2m).
42
+ * Those place keyframes with `-g = segmentDur × fps` (frame count), so the
43
+ * `fps=` filter value must be an integer that makes seg×fps an exact whole
44
+ * number of frames per segment — otherwise segments drift off the synthetic
45
+ * playlist's uniform grid and seek accuracy degrades over a long file. Film
46
+ * rates (23.976) round to 24, 25 stays 25, 29.97 rounds to 30; the cap clamps
47
+ * high rates (the cap is a SPEED guard for the weak software/v4l2m2m path).
48
+ *
49
+ * Time-based-keyframe encoders (nvenc, vaapi, qsv) do NOT use this — they
50
+ * inherit the exact source rate untouched (their keyframes are forced by
51
+ * output time, so any rate segments correctly).
52
+ *
53
+ * @param {number | null | undefined} sourceFps
54
+ * @param {number} [cap=MAX_OUTPUT_FPS]
55
+ * @returns {number}
56
+ */
57
+ export function chooseOutputFps(sourceFps, cap = MAX_OUTPUT_FPS) {
58
+ if (!Number.isFinite(sourceFps) || sourceFps <= 0) {
59
+ return TRANSCODE_FPS;
60
+ }
61
+ const rounded = Math.round(sourceFps);
62
+ if (rounded < 1) {
63
+ return TRANSCODE_FPS;
64
+ }
65
+ return Math.min(cap, rounded);
66
+ }
67
+ // Software x264 on weak ARM hosts is the transcode bottleneck — use all cores.
68
+ const CPU_THREADS = Math.max(1, os.cpus().length);
69
+
70
+ // libx264 presets to benchmark, ordered slowest/highest-quality → fastest.
71
+ const BENCHMARK_PRESETS = ["fast", "faster", "veryfast", "superfast", "ultrafast"];
72
+ const BENCHMARK_REF_W = 640;
73
+ const BENCHMARK_REF_H = 360;
74
+ const BENCHMARK_DURATION_SEC = 3;
75
+ // Require the encoder to be this much faster than realtime for the target
76
+ // resolution. The benchmark runs at startup with an idle CPU; during playback
77
+ // ffmpeg competes with in-process WebTorrent (download + hashing) and delivery,
78
+ // so real throughput is lower. A generous margin keeps playback above 1× under
79
+ // that real load and absorbs complex scenes.
80
+ const PRESET_SPEED_MARGIN = 1.8;
81
+
82
+ /**
83
+ * @param {number} targetWidth
84
+ * @param {number} targetHeight
85
+ * @returns {{ w: number, h: number }}
86
+ */
87
+ function safeDimensions(targetWidth, targetHeight) {
88
+ const w = Number.isInteger(targetWidth) && targetWidth > 0 ? targetWidth : 1280;
89
+ const h = Number.isInteger(targetHeight) && targetHeight > 0 ? targetHeight : 720;
90
+ return { w, h };
91
+ }
92
+
93
+ /**
94
+ * Force a keyframe on every segment boundary so each HLS segment is
95
+ * independently decodable and exactly `segmentDurationSec` long.
96
+ *
97
+ * @param {number} segmentDurationSec
98
+ * @returns {string[]}
99
+ */
100
+ function keyFrameArgs(segmentDurationSec) {
101
+ return ["-force_key_frames", `expr:gte(t,n_forced*${segmentDurationSec})`];
102
+ }
103
+
104
+ /** @returns {import("./hwaccel.js").VideoEncoderDescriptor} */
105
+ export function softwareDescriptor() {
106
+ return {
107
+ name: "libx264",
108
+ kind: "software",
109
+ device: null,
110
+ inputArgs: [],
111
+ buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, preset, fps }) {
112
+ const { w, h } = safeDimensions(targetWidth, targetHeight);
113
+ const chosenPreset = typeof preset === "string" && preset.length > 0 ? preset : SOFTWARE_PRESET;
114
+ // Output frame rate: inherited from the source (rounded/capped) by the
115
+ // session manager, TRANSCODE_FPS by default. MUST be an integer and MUST
116
+ // equal the value used in the GOP below, or keyframes drift off the grid.
117
+ const outFps = Number.isInteger(fps) && fps > 0 ? fps : TRANSCODE_FPS;
118
+ return [
119
+ // Never upscale: cap the target box to the source size (min with
120
+ // iw/ih), so a small source (e.g. 720x400) is encoded at its own
121
+ // resolution instead of being scaled up to the viewport — far fewer
122
+ // pixels, much faster on ARM. force_original_aspect_ratio keeps aspect.
123
+ "-vf",
124
+ `scale='min(${w},iw)':'min(${h},ih)':force_original_aspect_ratio=decrease:force_divisible_by=2,fps=${outFps}`,
125
+ "-c:v", "libx264",
126
+ // Preset is chosen per stream by the session manager from the startup
127
+ // benchmark (highest quality that still encodes the source resolution
128
+ // faster than realtime); falls back to the static default.
129
+ "-preset", chosenPreset,
130
+ "-crf", SOFTWARE_CRF,
131
+ "-threads", String(CPU_THREADS),
132
+ "-pix_fmt", "yuv420p",
133
+ // Fixed GOP: a keyframe exactly every (segmentDurationSec × fps) frames,
134
+ // scene-cut keyframes disabled. This is frame-count based, so it is
135
+ // independent of the PTS offset used on seek-restart — every HLS segment
136
+ // is exactly segmentDurationSec long and starts on a keyframe, so segment
137
+ // boundaries line up with the synthetic playlist with no gaps. (The old
138
+ // `-force_key_frames expr:gte(t,n_forced*SEG)` broke after a seek because
139
+ // `t` is offset by `-output_ts_offset`, forcing keyframes at the wrong
140
+ // places.)
141
+ "-g", String(segmentDurationSec * outFps),
142
+ "-keyint_min", String(segmentDurationSec * outFps),
143
+ "-sc_threshold", "0"
144
+ ];
145
+ }
146
+ };
147
+ }
148
+
149
+ /**
150
+ * @param {string} device
151
+ * @returns {import("./hwaccel.js").VideoEncoderDescriptor}
152
+ */
153
+ function vaapiDescriptor(device) {
154
+ return {
155
+ name: "h264_vaapi",
156
+ kind: "vaapi",
157
+ device,
158
+ // Decode on the GPU into VAAPI surfaces; scale and encode stay on-GPU.
159
+ inputArgs: ["-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi", "-vaapi_device", device],
160
+ // No fps filter: VAAPI inherits the source rate and keeps keyframes on the
161
+ // grid via time-based -force_key_frames, so it already honours source fps.
162
+ buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec }) {
163
+ const { w, h } = safeDimensions(targetWidth, targetHeight);
164
+ return [
165
+ "-vf",
166
+ `scale_vaapi=w=${w}:h=${h}:force_original_aspect_ratio=decrease`,
167
+ "-c:v", "h264_vaapi",
168
+ "-qp", "24",
169
+ ...keyFrameArgs(segmentDurationSec)
170
+ ];
171
+ }
172
+ };
173
+ }
174
+
175
+ /**
176
+ * @param {string} device
177
+ * @returns {import("./hwaccel.js").VideoEncoderDescriptor}
178
+ */
179
+ function qsvDescriptor(device) {
180
+ return {
181
+ name: "h264_qsv",
182
+ kind: "qsv",
183
+ device,
184
+ inputArgs: ["-hwaccel", "qsv", "-qsv_device", device],
185
+ buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec }) {
186
+ const { w, h } = safeDimensions(targetWidth, targetHeight);
187
+ return [
188
+ "-vf", `scale_qsv=w=${w}:h=${h}`,
189
+ "-c:v", "h264_qsv",
190
+ "-global_quality", "24",
191
+ ...keyFrameArgs(segmentDurationSec)
192
+ ];
193
+ }
194
+ };
195
+ }
196
+
197
+ /** @returns {import("./hwaccel.js").VideoEncoderDescriptor} */
198
+ function nvencDescriptor() {
199
+ return {
200
+ name: "h264_nvenc",
201
+ kind: "nvenc",
202
+ device: null,
203
+ inputArgs: [],
204
+ // No fps filter: NVENC is fast and places keyframes by time-based
205
+ // -force_key_frames, so it inherits the exact source rate (fractional
206
+ // included) with no need to round or cap. Same rationale as VAAPI/QSV.
207
+ buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec }) {
208
+ const { w, h } = safeDimensions(targetWidth, targetHeight);
209
+ return [
210
+ "-vf",
211
+ `scale=${w}:${h}:force_original_aspect_ratio=decrease:force_divisible_by=2`,
212
+ "-c:v", "h264_nvenc",
213
+ "-preset", "p4",
214
+ "-cq", "24",
215
+ "-pix_fmt", "yuv420p",
216
+ ...keyFrameArgs(segmentDurationSec)
217
+ ];
218
+ }
219
+ };
220
+ }
221
+
222
+ /** @returns {import("./hwaccel.js").VideoEncoderDescriptor} */
223
+ function v4l2m2mDescriptor() {
224
+ // ARM SoC (e.g. Raspberry Pi / HA Yellow) stateful M2M encoder. No GPU
225
+ // scaler scale in software, hand YUV420 frames to the hardware encoder.
226
+ // `-g` aligns the GOP to the segment length so an IDR lands on every segment
227
+ // boundary; this is verified by the keyframe-alignment test before use,
228
+ // because v4l2m2m does not always honour these hints.
229
+ return {
230
+ name: "h264_v4l2m2m",
231
+ kind: "v4l2m2m",
232
+ device: null,
233
+ inputArgs: [],
234
+ buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, fps }) {
235
+ const { w, h } = safeDimensions(targetWidth, targetHeight);
236
+ const outFps = Number.isInteger(fps) && fps > 0 ? fps : TRANSCODE_FPS;
237
+ return [
238
+ "-vf",
239
+ `scale=${w}:${h}:force_original_aspect_ratio=decrease:force_divisible_by=2,fps=${outFps},format=yuv420p`,
240
+ "-c:v", "h264_v4l2m2m",
241
+ "-b:v", "3M",
242
+ "-g", String(outFps * segmentDurationSec),
243
+ ...keyFrameArgs(segmentDurationSec)
244
+ ];
245
+ }
246
+ };
247
+ }
248
+
249
+
250
+ /**
251
+ * @typedef {Object} VideoEncoderDescriptor
252
+ * @property {string} name
253
+ * @property {"software"|"vaapi"|"qsv"|"nvenc"|"v4l2m2m"} kind
254
+ * @property {string|null} device
255
+ * @property {string[]} inputArgs
256
+ * @property {(opts: { targetWidth: number, targetHeight: number, segmentDurationSec: number }) => string[]} buildVideoArgs
257
+ */
258
+
259
+ /**
260
+ * Run ffmpeg and resolve with its exit code and captured output.
261
+ *
262
+ * @param {string} ffmpegBin
263
+ * @param {string[]} args
264
+ * @param {number} [timeoutMs=12000]
265
+ * @returns {Promise<{ code: number, stdout: string, stderr: string }>}
266
+ */
267
+ function runFfmpeg(ffmpegBin, args, timeoutMs = 12000) {
268
+ return new Promise((resolve) => {
269
+ let stdout = "";
270
+ let stderr = "";
271
+ let settled = false;
272
+ let child;
273
+ const finish = (code) => {
274
+ if (settled) {
275
+ return;
276
+ }
277
+ settled = true;
278
+ resolve({ code, stdout, stderr });
279
+ };
280
+ try {
281
+ child = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
282
+ } catch {
283
+ finish(-1);
284
+ return;
285
+ }
286
+ const timer = setTimeout(() => {
287
+ try {
288
+ child.kill("SIGKILL");
289
+ } catch {
290
+ // ignore
291
+ }
292
+ finish(-1);
293
+ }, timeoutMs);
294
+ child.stdout.on("data", (d) => {
295
+ stdout += String(d);
296
+ });
297
+ child.stderr.on("data", (d) => {
298
+ stderr += String(d);
299
+ });
300
+ child.on("error", () => {
301
+ clearTimeout(timer);
302
+ finish(-1);
303
+ });
304
+ child.on("exit", (code) => {
305
+ clearTimeout(timer);
306
+ finish(code ?? -1);
307
+ });
308
+ });
309
+ }
310
+
311
+ /** @returns {string[]} /dev/dri/renderD* nodes (VAAPI/QSV). */
312
+ function listRenderNodes() {
313
+ try {
314
+ return readdirSync("/dev/dri")
315
+ .filter((n) => n.startsWith("renderD"))
316
+ .map((n) => `/dev/dri/${n}`)
317
+ .sort();
318
+ } catch {
319
+ return [];
320
+ }
321
+ }
322
+
323
+ /** @returns {boolean} Whether any /dev/nvidia* node exists (NVENC). */
324
+ function hasNvidiaDevice() {
325
+ try {
326
+ return readdirSync("/dev").some((n) => /^nvidia(\d+)?$/.test(n));
327
+ } catch {
328
+ return false;
329
+ }
330
+ }
331
+
332
+ /** @returns {boolean} Whether any /dev/video* node exists (V4L2 M2M). */
333
+ function hasV4l2Device() {
334
+ try {
335
+ return readdirSync("/dev").some((n) => /^video\d+$/.test(n));
336
+ } catch {
337
+ return false;
338
+ }
339
+ }
340
+
341
+ /**
342
+ * Build a full ffmpeg command that encodes a short, *moving* synthetic clip
343
+ * (testsrc2 — far more representative than a static black frame) through the
344
+ * candidate encoder into real HLS segments in `outDir`, with keyframes forced
345
+ * on segment boundaries. Verifying the resulting segments (see
346
+ * {@link verifySegmentsDecodeCleanly}) catches encoders that silently produce
347
+ * a corrupted or non-IDR-aligned stream (e.g. some V4L2 M2M builds).
348
+ *
349
+ * @param {VideoEncoderDescriptor} descriptor
350
+ * @param {number} segmentDurationSec
351
+ * @param {string} outDir
352
+ * @returns {string[]}
353
+ */
354
+ function buildEncoderTestArgs(descriptor, segmentDurationSec, outDir) {
355
+ const durationSec = Math.max(8, segmentDurationSec * 3);
356
+ const source = ["-f", "lavfi", "-i", `testsrc2=s=640x360:r=${TRANSCODE_FPS}:d=${durationSec}`];
357
+ const kf = keyFrameArgs(segmentDurationSec);
358
+
359
+ /** @type {string[]} */
360
+ let pre = ["-hide_banner", "-loglevel", "error"];
361
+ /** @type {string[]} */
362
+ let encode;
363
+ switch (descriptor.kind) {
364
+ case "vaapi":
365
+ pre = [...pre, "-vaapi_device", String(descriptor.device)];
366
+ encode = ["-vf", "format=nv12,hwupload", "-c:v", "h264_vaapi", "-qp", "24", ...kf];
367
+ break;
368
+ case "qsv":
369
+ pre = [...pre, "-qsv_device", String(descriptor.device)];
370
+ encode = ["-vf", "hwupload=extra_hw_frames=16,format=qsv", "-c:v", "h264_qsv", "-global_quality", "24", ...kf];
371
+ break;
372
+ case "nvenc":
373
+ encode = ["-c:v", "h264_nvenc", "-preset", "p4", "-cq", "24", "-pix_fmt", "yuv420p", ...kf];
374
+ break;
375
+ case "v4l2m2m":
376
+ encode = ["-pix_fmt", "yuv420p", "-c:v", "h264_v4l2m2m", "-b:v", "3M", "-g", String(TRANSCODE_FPS * segmentDurationSec), ...kf];
377
+ break;
378
+ default:
379
+ encode = ["-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", ...kf];
380
+ break;
381
+ }
382
+
383
+ const hlsOut = [
384
+ "-f", "hls",
385
+ "-hls_time", String(segmentDurationSec),
386
+ "-hls_list_size", "0",
387
+ "-hls_flags", "independent_segments",
388
+ "-hls_segment_filename", path.join(outDir, "seg-%03d.ts"),
389
+ path.join(outDir, "index.m3u8")
390
+ ];
391
+ return [...pre, ...source, ...encode, ...hlsOut];
392
+ }
393
+
394
+ /**
395
+ * Verify the HLS segments produced by the test encode are valid: at least two
396
+ * segments exist, and each decodes standalone without errors. A segment that
397
+ * does not begin with a keyframe (broken/corrupted output) emits decode errors
398
+ * when read on its own, which fails this check.
399
+ *
400
+ * @param {string} ffmpegBin
401
+ * @param {string} outDir
402
+ * @returns {Promise<boolean>}
403
+ */
404
+ async function verifySegmentsDecodeCleanly(ffmpegBin, outDir) {
405
+ let files;
406
+ try {
407
+ files = readdirSync(outDir).filter((n) => /^seg-\d+\.ts$/.test(n)).sort();
408
+ } catch {
409
+ return false;
410
+ }
411
+ if (files.length < 2) {
412
+ return false;
413
+ }
414
+ for (const file of files) {
415
+ const result = await runFfmpeg(
416
+ ffmpegBin,
417
+ ["-hide_banner", "-loglevel", "error", "-i", path.join(outDir, file), "-f", "null", "-"],
418
+ 8000
419
+ );
420
+ if (result.code !== 0 || result.stderr.trim().length > 0) {
421
+ return false;
422
+ }
423
+ }
424
+ return true;
425
+ }
426
+
427
+ /**
428
+ * Detect the best usable H.264 encoder. Always resolves (falls back to
429
+ * software libx264). Each hardware candidate is verified with a real
430
+ * test-encode before being selected.
431
+ *
432
+ * @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void }, segmentDurationSec?: number }} options
433
+ * @returns {Promise<VideoEncoderDescriptor>}
434
+ */
435
+ export async function detectVideoEncoder({ ffmpegBin, logger, segmentDurationSec = 4 }) {
436
+ const log = logger ?? { info: () => {}, warn: () => {} };
437
+ const software = softwareDescriptor();
438
+
439
+ const { code, stdout } = await runFfmpeg(ffmpegBin, ["-hide_banner", "-encoders"], 10000);
440
+ if (code !== 0) {
441
+ log.warn("hwaccel: could not list ffmpeg encoders; using software libx264");
442
+ return software;
443
+ }
444
+ const has = (name) => stdout.includes(name);
445
+
446
+ /** @type {VideoEncoderDescriptor[]} */
447
+ const candidates = [];
448
+ const renderNodes = listRenderNodes();
449
+ if (has("h264_nvenc") && hasNvidiaDevice()) {
450
+ candidates.push(nvencDescriptor());
451
+ }
452
+ if (has("h264_qsv") && renderNodes.length > 0) {
453
+ candidates.push(qsvDescriptor(renderNodes[0]));
454
+ }
455
+ if (has("h264_vaapi") && renderNodes.length > 0) {
456
+ candidates.push(vaapiDescriptor(renderNodes[0]));
457
+ }
458
+ // h264_v4l2m2m (ARM SoC / Raspberry Pi / HA Yellow). It is gated behind the
459
+ // strict keyframe-alignment test below, because some V4L2 M2M builds silently
460
+ // emit a corrupted / non-IDR-aligned stream; the test rejects those and the
461
+ // host falls back to software libx264.
462
+ if (has("h264_v4l2m2m") && hasV4l2Device()) {
463
+ candidates.push(v4l2m2mDescriptor());
464
+ }
465
+
466
+ for (const candidate of candidates) {
467
+ const dir = mkdtempSync(path.join(os.tmpdir(), "tt-hwtest-"));
468
+ let ok = false;
469
+ try {
470
+ const encoded = await runFfmpeg(
471
+ ffmpegBin,
472
+ buildEncoderTestArgs(candidate, segmentDurationSec, dir),
473
+ 25000
474
+ );
475
+ if (encoded.code === 0) {
476
+ ok = await verifySegmentsDecodeCleanly(ffmpegBin, dir);
477
+ }
478
+ } finally {
479
+ try {
480
+ rmSync(dir, { recursive: true, force: true });
481
+ } catch {
482
+ // best effort
483
+ }
484
+ }
485
+ if (ok) {
486
+ log.info(
487
+ `hwaccel: using hardware encoder ${candidate.name}` +
488
+ `${candidate.device ? ` (${candidate.device})` : ""}`
489
+ );
490
+ return candidate;
491
+ }
492
+ log.warn(`hwaccel: ${candidate.name} failed the HLS keyframe-alignment test; skipping`);
493
+ }
494
+
495
+ log.info("hwaccel: no working hardware encoder; using software libx264");
496
+ return software;
497
+ }
498
+
499
+ /**
500
+ * Benchmark software libx264 presets on this host. Encodes a short synthetic
501
+ * clip at a fixed reference resolution with each preset and measures encoder
502
+ * throughput in pixels/second. The session manager uses this to pick, per
503
+ * stream, the highest-quality preset that still encodes the actual
504
+ * (source-capped) resolution faster than realtime.
505
+ *
506
+ * Runs once at startup; bounded by a per-encode timeout. Presets that fail are
507
+ * omitted from the result.
508
+ *
509
+ * @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void } }} options
510
+ * @returns {Promise<Array<{ preset: string, pixelsPerSec: number }>>} Ordered slowest→fastest.
511
+ */
512
+ export async function benchmarkSoftwarePresets({ ffmpegBin, logger }) {
513
+ const log = logger ?? { info: () => {}, warn: () => {} };
514
+ const totalPixels = BENCHMARK_REF_W * BENCHMARK_REF_H * TRANSCODE_FPS * BENCHMARK_DURATION_SEC;
515
+ /** @type {Array<{ preset: string, pixelsPerSec: number }>} */
516
+ const results = [];
517
+ for (const preset of BENCHMARK_PRESETS) {
518
+ const args = [
519
+ "-hide_banner", "-loglevel", "error",
520
+ "-f", "lavfi", "-i", `testsrc2=s=${BENCHMARK_REF_W}x${BENCHMARK_REF_H}:r=${TRANSCODE_FPS}:d=${BENCHMARK_DURATION_SEC}`,
521
+ "-c:v", "libx264", "-preset", preset, "-crf", SOFTWARE_CRF, "-pix_fmt", "yuv420p",
522
+ "-f", "null", "-"
523
+ ];
524
+ const startedAt = Date.now();
525
+ const { code } = await runFfmpeg(ffmpegBin, args, 30000);
526
+ const elapsedSec = (Date.now() - startedAt) / 1000;
527
+ if (code !== 0 || elapsedSec <= 0) {
528
+ log.warn(`hwaccel: preset benchmark "${preset}" failed; skipping`);
529
+ continue;
530
+ }
531
+ const pixelsPerSec = totalPixels / elapsedSec;
532
+ results.push({ preset, pixelsPerSec });
533
+ log.info(
534
+ `hwaccel: preset "${preset}" ~= ${(pixelsPerSec / 1e6).toFixed(1)} Mpx/s ` +
535
+ `(${(BENCHMARK_DURATION_SEC / elapsedSec).toFixed(2)}x @ ${BENCHMARK_REF_W}x${BENCHMARK_REF_H})`
536
+ );
537
+ }
538
+ return results;
539
+ }
540
+
541
+ /**
542
+ * Pick the highest-quality (slowest) benchmarked preset that can encode
543
+ * `pixelsPerSecNeeded` with the speed margin. Falls back to the fastest
544
+ * benchmarked preset, or `"ultrafast"` when no benchmark is available.
545
+ *
546
+ * @param {Array<{ preset: string, pixelsPerSec: number }>} benchmark - slowest→fastest
547
+ * @param {number} pixelsPerSecNeeded
548
+ * @returns {string}
549
+ */
550
+ export function pickSoftwarePreset(benchmark, pixelsPerSecNeeded) {
551
+ if (!Array.isArray(benchmark) || benchmark.length === 0) {
552
+ return "ultrafast";
553
+ }
554
+ for (const entry of benchmark) {
555
+ if (entry.pixelsPerSec >= pixelsPerSecNeeded * PRESET_SPEED_MARGIN) {
556
+ return entry.preset;
557
+ }
558
+ }
559
+ return benchmark[benchmark.length - 1].preset;
560
+ }