@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.
- package/CHANGELOG.md +10 -0
- package/bin/cli.js +9 -1
- package/openspec/changes/disk-cap/.openspec.yaml +2 -0
- package/openspec/changes/disk-cap/proposal.md +37 -0
- package/openspec/changes/disk-cap/specs/disk-cap/spec.md +25 -0
- package/openspec/changes/disk-cap/tasks.md +19 -0
- package/openspec/changes/subtitle-language/.openspec.yaml +2 -0
- package/openspec/changes/subtitle-language/proposal.md +50 -0
- package/openspec/changes/subtitle-language/specs/subtitle-language/spec.md +34 -0
- package/openspec/changes/subtitle-language/tasks.md +26 -0
- package/openspec/changes/transcode-quality/.openspec.yaml +2 -0
- package/openspec/changes/transcode-quality/proposal.md +51 -0
- package/openspec/changes/transcode-quality/specs/transcode-quality/spec.md +37 -0
- package/openspec/changes/transcode-quality/tasks.md +34 -0
- package/package.json +2 -1
- package/routes/api/sources/files/get.js +47 -4
- package/routes/api/subtitles/get.js +92 -30
- package/server.js +3 -2
- package/services/hls-session-manager.js +33 -1
- package/services/hwaccel.js +560 -514
- package/services/language-detect.js +73 -0
- package/services/subtitle-convert.js +150 -0
- package/services/torrent-pool.js +131 -3
package/services/hwaccel.js
CHANGED
|
@@ -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
|
-
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
return
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
"-vf",
|
|
124
|
-
`
|
|
125
|
-
"-c:v", "
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec }) {
|
|
163
|
-
const { w, h } = safeDimensions(targetWidth, targetHeight);
|
|
164
|
-
return [
|
|
165
|
-
"-vf",
|
|
166
|
-
`
|
|
167
|
-
"-c:v", "
|
|
168
|
-
"-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
*
|
|
501
|
-
*
|
|
502
|
-
*
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
+
}
|