@torrent-tv/proxy 2.9.7 → 2.9.9

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 CHANGED
@@ -1,3 +1,10 @@
1
+ ## 2.9.8
2
+
3
+ - **Fix**: Software (libx264) video transcode is much faster on weak ARM hosts, so playback keeps up with realtime: encode uses all CPU cores (`-threads`), and the scaler **never upscales** — the target box is capped to the source size via `min(W,iw)`/`min(H,ih)`, so a small source (e.g. 720x400) is encoded at its own resolution instead of being scaled up to the viewport (far fewer pixels).
4
+ - **New**: Adaptive software preset (preset auto-benchmark). At startup the proxy benchmarks libx264 presets (`fast`→`ultrafast`) on this host and records encode throughput (pixels/sec). Per stream, `hls-session-manager` picks the **highest-quality preset that still encodes the actual (source-capped) output resolution faster than realtime** with a safety margin, falling back to `ultrafast`. This maximises quality without dropping below 1× (which causes stalls). Logged as `video=libx264/<preset>` at session start.
5
+ - **New**: The input probe (`probeInputMediaInfo`, formerly `probeInputDurationSeconds`) now also extracts the source video resolution from the container header (used by the adaptive preset to compute the output pixel rate). Still returns on the header without decoding the stream.
6
+ - **Fix**: Transcode no longer thrashes between positions. `#ensureEncodingFor` now anchors the look-ahead window on the **current** encode position (not the run's start), and a `RESTART_COOLDOWN_MS` guard ignores competing seek-restart requests for a few seconds. Previously a stalled player requesting distant segments (e.g. #2 and #107) made ffmpeg ping-pong, restarting endlessly and producing nothing — which `Error opening input file` races confirmed.
7
+
1
8
  ## 2.9.7
2
9
 
3
10
  - **Fix**: `playback-planner` retries the codec probe while the file header is still downloading and no longer caches an **empty** probe result. Previously a transient empty probe (common for a later file in a multi-file torrent whose pieces arrive late) was cached permanently, so the file was mis-planned as directly playable forever — an unsupported video codec (e.g. xvid) got copied and played as a **black screen**. The probe now retries (up to 60 s) until at least one codec is detected, and only a successful detection is cached.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.7",
3
+ "version": "2.9.9",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
package/server.js CHANGED
@@ -27,7 +27,7 @@ import { createSourceRegistry } from "./store/source-registry.js";
27
27
  import { TorrentPool } from "./services/torrent-pool.js";
28
28
  import { HlsSessionManager } from "./services/hls-session-manager.js";
29
29
  import { createPlaybackPlanner } from "./services/playback-planner.js";
30
- import { detectVideoEncoder } from "./services/hwaccel.js";
30
+ import { detectVideoEncoder, benchmarkSoftwarePresets } from "./services/hwaccel.js";
31
31
  import { logger } from "./utils/logger.js";
32
32
 
33
33
  const __filename = fileURLToPath(import.meta.url);
@@ -99,12 +99,19 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin }
99
99
  const videoEncoder = transcodeAudio
100
100
  ? await detectVideoEncoder({ ffmpegBin, logger })
101
101
  : null;
102
+ // For software libx264, benchmark preset throughput once at startup so the
103
+ // session manager can pick the highest-quality preset that still encodes each
104
+ // stream faster than realtime. Hardware encoders use their own fixed preset.
105
+ const softwarePresetBenchmark = videoEncoder?.kind === "software"
106
+ ? await benchmarkSoftwarePresets({ ffmpegBin, logger })
107
+ : null;
102
108
  const hlsSessionManager = new HlsSessionManager({
103
109
  enabled: transcodeAudio,
104
110
  ffmpegBin,
105
111
  localBindHost: host,
106
112
  localPort: selectedPort,
107
- videoEncoder
113
+ videoEncoder,
114
+ softwarePresetBenchmark
108
115
  });
109
116
  const playbackPlanner = createPlaybackPlanner({
110
117
  ffmpegBin,
@@ -15,7 +15,7 @@ import path from "node:path";
15
15
  import { randomUUID } from "node:crypto";
16
16
  import { spawn } from "node:child_process";
17
17
  import { logger } from "../utils/logger.js";
18
- import { softwareDescriptor } from "./hwaccel.js";
18
+ import { softwareDescriptor, pickSoftwarePreset, TRANSCODE_FPS } from "./hwaccel.js";
19
19
 
20
20
  const PLAYLIST_FILE_NAME = "index.m3u8";
21
21
  const SEGMENT_FILE_NAME_PATTERN = /^segment-\d{5}\.ts$/;
@@ -25,6 +25,11 @@ const DEFAULT_SEGMENT_DURATION_SEC = 4;
25
25
  // is allowed to be before we restart ffmpeg at that position (server-side seek).
26
26
  // Requests within the window are served by waiting for the running encode.
27
27
  const MAX_LOOKAHEAD_SEGMENTS = 8;
28
+ // After a seek-restart, ignore competing restart requests for this long. The
29
+ // synthetic VOD playlist lets the player request distant segments in quick
30
+ // succession (stall-recovery seeks); without a cooldown ffmpeg ping-pongs
31
+ // between positions, restarting endlessly and producing nothing.
32
+ const RESTART_COOLDOWN_MS = 4_000;
28
33
  // Idle TTL: a session is disposed this long after the last segment/playlist
29
34
  // access. Kept short so an ffmpeg process does not keep burning CPU after the
30
35
  // viewer stops or navigates away. Active playback refreshes the timer on every
@@ -247,14 +252,39 @@ function computeProgressMetrics(processedSeconds, totalSeconds, startPositionSec
247
252
  }
248
253
 
249
254
  /**
250
- * Run a short ffmpeg probe to extract the total duration of a stream.
251
- * Times out after 8 s and returns `null` on failure.
255
+ * Parse the source video resolution from ffmpeg's stderr (the "Stream Video:
256
+ * WxH" line). Returns `{ width: null, height: null }` when absent.
257
+ *
258
+ * @param {string} stderrText
259
+ * @returns {{ width: number | null, height: number | null }}
260
+ */
261
+ function parseFfmpegVideoDimensions(stderrText) {
262
+ if (typeof stderrText !== "string" || stderrText.length === 0) {
263
+ return { width: null, height: null };
264
+ }
265
+ const match = stderrText.match(/Video:[^\n]*?\b(\d{2,5})x(\d{2,5})\b/i);
266
+ if (!match) {
267
+ return { width: null, height: null };
268
+ }
269
+ const width = Number(match[1]);
270
+ const height = Number(match[2]);
271
+ return {
272
+ width: Number.isFinite(width) && width > 0 ? width : null,
273
+ height: Number.isFinite(height) && height > 0 ? height : null
274
+ };
275
+ }
276
+
277
+ /**
278
+ * Run a short ffmpeg probe to extract the total duration AND video resolution
279
+ * of a stream from the container header. Both are printed almost immediately
280
+ * (before any decoding), so this returns as soon as they are seen; an 8 s
281
+ * timeout guards the rest.
252
282
  *
253
283
  * @param {string} ffmpegBin - Path to the ffmpeg executable.
254
284
  * @param {string | URL} inputUrl - URL of the stream to probe.
255
- * @returns {Promise<number | null>} Duration in seconds, or `null`.
285
+ * @returns {Promise<{ durationSeconds: number | null, width: number | null, height: number | null }>}
256
286
  */
257
- async function probeInputDurationSeconds(ffmpegBin, inputUrl) {
287
+ async function probeInputMediaInfo(ffmpegBin, inputUrl) {
258
288
  return new Promise((resolve) => {
259
289
  const ffmpeg = spawn(ffmpegBin, ["-hide_banner", "-loglevel", "info", "-i", inputUrl, "-f", "null", "-"], {
260
290
  stdio: ["ignore", "ignore", "pipe"],
@@ -262,44 +292,74 @@ async function probeInputDurationSeconds(ffmpegBin, inputUrl) {
262
292
  });
263
293
  let stderr = "";
264
294
  let settled = false;
265
- const finish = (value) => {
295
+ const finish = () => {
266
296
  if (settled) {
267
297
  return;
268
298
  }
269
299
  settled = true;
270
- resolve(value);
300
+ const dims = parseFfmpegVideoDimensions(stderr);
301
+ resolve({ durationSeconds: parseFfmpegDurationSeconds(stderr), width: dims.width, height: dims.height });
271
302
  };
272
303
  const timeoutId = setTimeout(() => {
273
304
  if (!ffmpeg.killed) {
274
305
  ffmpeg.kill("SIGTERM");
275
306
  }
276
- finish(parseFfmpegDurationSeconds(stderr));
307
+ finish();
277
308
  }, 8_000);
278
309
  ffmpeg.stderr.on("data", (chunk) => {
279
310
  stderr += String(chunk);
280
- // ffmpeg prints the container header ("Duration:") almost immediately,
281
- // long before it decodes anything. Bail as soon as we have it instead of
282
- // letting `-f null -` decode the whole stream until the 8 s timeout.
311
+ // The header ("Duration:" then the "Video: … WxH" stream line) is printed
312
+ // before any decoding. Bail as soon as both are present instead of letting
313
+ // `-f null -` decode the whole stream until the 8 s timeout.
283
314
  const duration = parseFfmpegDurationSeconds(stderr);
284
- if (duration != null) {
315
+ const dims = parseFfmpegVideoDimensions(stderr);
316
+ if (duration != null && dims.width != null) {
285
317
  clearTimeout(timeoutId);
286
318
  if (!ffmpeg.killed) {
287
319
  ffmpeg.kill("SIGTERM");
288
320
  }
289
- finish(duration);
321
+ finish();
290
322
  }
291
323
  });
292
324
  ffmpeg.on("error", () => {
293
325
  clearTimeout(timeoutId);
294
- finish(null);
326
+ finish();
295
327
  });
296
328
  ffmpeg.on("exit", () => {
297
329
  clearTimeout(timeoutId);
298
- finish(parseFfmpegDurationSeconds(stderr));
330
+ finish();
299
331
  });
300
332
  });
301
333
  }
302
334
 
335
+ /**
336
+ * Compute the actual output resolution ffmpeg will produce: the target box
337
+ * capped to the source (never upscaled), preserving aspect, divisible by 2.
338
+ * Mirrors the `scale='min(w,iw)':'min(h,ih)':force_original_aspect_ratio=decrease`
339
+ * filter. Returns `null` when the source size is unknown.
340
+ *
341
+ * @param {number} targetWidth
342
+ * @param {number} targetHeight
343
+ * @param {number | null} sourceWidth
344
+ * @param {number | null} sourceHeight
345
+ * @returns {{ w: number, h: number } | null}
346
+ */
347
+ function computeOutputDimensions(targetWidth, targetHeight, sourceWidth, sourceHeight) {
348
+ const sw = Number.isFinite(sourceWidth) && sourceWidth > 0 ? sourceWidth : 0;
349
+ const sh = Number.isFinite(sourceHeight) && sourceHeight > 0 ? sourceHeight : 0;
350
+ if (!sw || !sh) {
351
+ return null;
352
+ }
353
+ const tw = Number.isInteger(targetWidth) && targetWidth > 0 ? targetWidth : sw;
354
+ const th = Number.isInteger(targetHeight) && targetHeight > 0 ? targetHeight : sh;
355
+ const scale = Math.min(tw / sw, th / sh, 1);
356
+ let w = Math.round(sw * scale);
357
+ let h = Math.round(sh * scale);
358
+ w -= w % 2;
359
+ h -= h % 2;
360
+ return { w: Math.max(2, w), h: Math.max(2, h) };
361
+ }
362
+
303
363
  function isWarmupTimeoutError(error) {
304
364
  if (!(error instanceof Error)) {
305
365
  return false;
@@ -364,7 +424,8 @@ export class HlsSessionManager {
364
424
  segmentDurationSec = DEFAULT_SEGMENT_DURATION_SEC,
365
425
  sessionTtlMs = DEFAULT_SESSION_TTL_MS,
366
426
  startupWaitMs = DEFAULT_STARTUP_WAIT_MS,
367
- videoEncoder = null
427
+ videoEncoder = null,
428
+ softwarePresetBenchmark = null
368
429
  }) {
369
430
  this.enabled = Boolean(enabled);
370
431
  this.ffmpegBin = ffmpegBin;
@@ -372,6 +433,10 @@ export class HlsSessionManager {
372
433
  // software libx264 when no detection result is supplied. May be downgraded
373
434
  // to software at runtime if a hardware encode fails.
374
435
  this.videoEncoder = videoEncoder ?? softwareDescriptor();
436
+ // Per-preset software encode throughput (pixels/sec) measured at startup,
437
+ // used to pick the best preset per stream. Null when unavailable (hardware
438
+ // encoder, or benchmark skipped/failed).
439
+ this.softwarePresetBenchmark = Array.isArray(softwarePresetBenchmark) ? softwarePresetBenchmark : null;
375
440
  this.segmentDurationSec = segmentDurationSec;
376
441
  this.sessionTtlMs = sessionTtlMs;
377
442
  this.startupWaitMs = startupWaitMs;
@@ -469,7 +534,10 @@ export class HlsSessionManager {
469
534
  // playlist (terminated with #EXT-X-ENDLIST) immediately. This gives the
470
535
  // player the correct total duration and a fully seekable timeline before a
471
536
  // single segment has been transcoded.
472
- const durationSeconds = await probeInputDurationSeconds(this.ffmpegBin, inputUrl.toString());
537
+ const mediaInfo = await probeInputMediaInfo(this.ffmpegBin, inputUrl.toString());
538
+ const durationSeconds = mediaInfo.durationSeconds;
539
+ const sourceWidth = mediaInfo.width;
540
+ const sourceHeight = mediaInfo.height;
473
541
  const hasDuration = Number.isFinite(durationSeconds) && durationSeconds > 0;
474
542
  const logName = normalizeLogFileName(fileName, fileIndex);
475
543
  if (!hasDuration) {
@@ -482,6 +550,18 @@ export class HlsSessionManager {
482
550
  ? Math.max(1, Math.ceil(durationSeconds / this.segmentDurationSec))
483
551
  : 0;
484
552
 
553
+ // Pick the highest-quality software preset that still encodes the actual
554
+ // (source-capped) output resolution faster than realtime. Null for hardware
555
+ // encoders or when the source size / benchmark is unavailable — buildVideoArgs
556
+ // then uses its static default preset.
557
+ const softwarePreset = this.#chooseSoftwarePreset({
558
+ transcodeVideo,
559
+ targetWidth: normalizedTargetWidth,
560
+ targetHeight: normalizedTargetHeight,
561
+ sourceWidth,
562
+ sourceHeight
563
+ });
564
+
485
565
  const session = {
486
566
  id: sessionId,
487
567
  sourceMapKey,
@@ -501,6 +581,10 @@ export class HlsSessionManager {
501
581
  transcodeAudio,
502
582
  targetWidth: normalizedTargetWidth,
503
583
  targetHeight: normalizedTargetHeight,
584
+ sourceWidth,
585
+ sourceHeight,
586
+ // Chosen libx264 preset for this stream (software only), or null.
587
+ softwarePreset,
504
588
  inputUrl: inputUrl.toString(),
505
589
  // VOD playlist bookkeeping.
506
590
  useSyntheticPlaylist: hasDuration,
@@ -511,6 +595,8 @@ export class HlsSessionManager {
511
595
  encodeStartIndex: 0,
512
596
  // Guards against repeatedly restarting to the same seek position.
513
597
  pendingRestartIndex: -1,
598
+ // Timestamp of the last encode (re)start, for the restart cooldown.
599
+ lastRestartAt: 0,
514
600
  progress: {
515
601
  state: "starting",
516
602
  processedSeconds: 0,
@@ -528,7 +614,9 @@ export class HlsSessionManager {
528
614
 
529
615
  logger.info(
530
616
  `transcode ${sessionId} start "${logName}" ` +
531
- `video=${transcodeVideo ? this.videoEncoder.name : "copy"} audio=${transcodeAudio ? "aac" : "copy"} ` +
617
+ `video=${transcodeVideo ? `${this.videoEncoder.name}${softwarePreset ? `/${softwarePreset}` : ""}` : "copy"} ` +
618
+ `audio=${transcodeAudio ? "aac" : "copy"} ` +
619
+ `${sourceWidth && sourceHeight ? `src=${sourceWidth}x${sourceHeight} ` : ""}` +
532
620
  `duration=${hasDuration ? formatSeconds(durationSeconds) : "unknown"} segments=${segmentCount}`
533
621
  );
534
622
 
@@ -580,6 +668,28 @@ export class HlsSessionManager {
580
668
  return `${lines.join("\n")}\n`;
581
669
  }
582
670
 
671
+ /**
672
+ * Choose the libx264 preset for a software video transcode: the highest
673
+ * quality the startup benchmark says this host can encode at the actual
674
+ * (source-capped) output resolution faster than realtime. Returns null when
675
+ * not applicable (no video transcode, hardware encoder, or missing
676
+ * benchmark/source size) — buildVideoArgs then uses its default preset.
677
+ *
678
+ * @param {{ transcodeVideo: boolean, targetWidth: number, targetHeight: number, sourceWidth: number | null, sourceHeight: number | null }} params
679
+ * @returns {string | null}
680
+ */
681
+ #chooseSoftwarePreset({ transcodeVideo, targetWidth, targetHeight, sourceWidth, sourceHeight }) {
682
+ if (!transcodeVideo || this.videoEncoder?.kind !== "software" || !this.softwarePresetBenchmark) {
683
+ return null;
684
+ }
685
+ const out = computeOutputDimensions(targetWidth, targetHeight, sourceWidth, sourceHeight);
686
+ if (!out) {
687
+ return null;
688
+ }
689
+ const pixelsPerSecNeeded = out.w * out.h * TRANSCODE_FPS;
690
+ return pickSoftwarePreset(this.softwarePresetBenchmark, pixelsPerSecNeeded);
691
+ }
692
+
583
693
  /**
584
694
  * (Re)start the ffmpeg encode run beginning at segment `startIndex`.
585
695
  *
@@ -614,7 +724,9 @@ export class HlsSessionManager {
614
724
  ? this.videoEncoder.buildVideoArgs({
615
725
  targetWidth: session.targetWidth,
616
726
  targetHeight: session.targetHeight,
617
- segmentDurationSec: this.segmentDurationSec
727
+ segmentDurationSec: this.segmentDurationSec,
728
+ // Software-only; hardware descriptors ignore it.
729
+ preset: session.softwarePreset ?? undefined
618
730
  })
619
731
  : ["-c:v", "copy"];
620
732
  const audioCodecArgs = session.transcodeAudio
@@ -668,6 +780,7 @@ export class HlsSessionManager {
668
780
  session.ffmpeg = ffmpeg;
669
781
  session.encodeStartIndex = safeIndex;
670
782
  session.pendingRestartIndex = -1;
783
+ session.lastRestartAt = Date.now();
671
784
  session.state = session.state === "disposed" ? "disposed" : "starting";
672
785
  session.progress.state = "running";
673
786
  session.progress.processedSeconds = startSeconds;
@@ -815,15 +928,32 @@ export class HlsSessionManager {
815
928
  return;
816
929
  }
817
930
  const head = session.encodeStartIndex;
818
- const withinWindow = index >= head && index <= head + MAX_LOOKAHEAD_SEGMENTS;
931
+ // Anchor the look-ahead window on the CURRENT encode position (start index +
932
+ // seconds already processed), not the run's start index. Otherwise a long
933
+ // run that has encoded well past `head` would needlessly restart for a
934
+ // request just ahead of the live edge.
935
+ const processed = Number.isFinite(session.progress?.processedSeconds)
936
+ ? session.progress.processedSeconds
937
+ : head * this.segmentDurationSec;
938
+ const currentSeg = Math.max(head, Math.floor(processed / this.segmentDurationSec));
939
+ const withinWindow = index >= head && index <= currentSeg + MAX_LOOKAHEAD_SEGMENTS;
819
940
  if (withinWindow) {
820
941
  return;
821
942
  }
822
943
  if (session.pendingRestartIndex === index) {
823
944
  return;
824
945
  }
946
+ // Restart cooldown: a stalled player requests several distant segments in
947
+ // quick succession; without this guard ffmpeg ping-pongs between them and
948
+ // never makes progress. Skip the restart during the cooldown — the caller
949
+ // long-polls / the client retries, and a genuine seek is honored once the
950
+ // cooldown elapses.
951
+ const sinceLastRestart = Date.now() - (session.lastRestartAt ?? 0);
952
+ if (sinceLastRestart < RESTART_COOLDOWN_MS) {
953
+ return;
954
+ }
825
955
  logger.info(
826
- `transcode ${session.id} seek → restart at segment #${index} (encode head #${head})`
956
+ `transcode ${session.id} seek → restart at segment #${index} (encode head #${head}, current #${currentSeg})`
827
957
  );
828
958
  this.#startEncodeRun(session, index);
829
959
  }
@@ -25,9 +25,20 @@ import { mkdtempSync, readdirSync, rmSync } from "node:fs";
25
25
  import os from "node:os";
26
26
  import path from "node:path";
27
27
 
28
- const SOFTWARE_PRESET = "superfast";
28
+ const SOFTWARE_PRESET = "ultrafast";
29
29
  const SOFTWARE_CRF = "24";
30
- const TRANSCODE_FPS = 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, leaving headroom for complex scenes and delivery.
41
+ const PRESET_SPEED_MARGIN = 1.3;
31
42
 
32
43
  /**
33
44
  * @param {number} targetWidth
@@ -58,14 +69,23 @@ export function softwareDescriptor() {
58
69
  kind: "software",
59
70
  device: null,
60
71
  inputArgs: [],
61
- buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec }) {
72
+ buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, preset }) {
62
73
  const { w, h } = safeDimensions(targetWidth, targetHeight);
74
+ const chosenPreset = typeof preset === "string" && preset.length > 0 ? preset : SOFTWARE_PRESET;
63
75
  return [
76
+ // Never upscale: cap the target box to the source size (min with
77
+ // iw/ih), so a small source (e.g. 720x400) is encoded at its own
78
+ // resolution instead of being scaled up to the viewport — far fewer
79
+ // pixels, much faster on ARM. force_original_aspect_ratio keeps aspect.
64
80
  "-vf",
65
- `scale=${w}:${h}:force_original_aspect_ratio=decrease:force_divisible_by=2,fps=${TRANSCODE_FPS}`,
81
+ `scale='min(${w},iw)':'min(${h},ih)':force_original_aspect_ratio=decrease:force_divisible_by=2,fps=${TRANSCODE_FPS}`,
66
82
  "-c:v", "libx264",
67
- "-preset", SOFTWARE_PRESET,
83
+ // Preset is chosen per stream by the session manager from the startup
84
+ // benchmark (highest quality that still encodes the source resolution
85
+ // faster than realtime); falls back to the static default.
86
+ "-preset", chosenPreset,
68
87
  "-crf", SOFTWARE_CRF,
88
+ "-threads", String(CPU_THREADS),
69
89
  "-pix_fmt", "yuv420p",
70
90
  ...keyFrameArgs(segmentDurationSec)
71
91
  ];
@@ -416,3 +436,66 @@ export async function detectVideoEncoder({ ffmpegBin, logger, segmentDurationSec
416
436
  log.info("hwaccel: no working hardware encoder; using software libx264");
417
437
  return software;
418
438
  }
439
+
440
+ /**
441
+ * Benchmark software libx264 presets on this host. Encodes a short synthetic
442
+ * clip at a fixed reference resolution with each preset and measures encoder
443
+ * throughput in pixels/second. The session manager uses this to pick, per
444
+ * stream, the highest-quality preset that still encodes the actual
445
+ * (source-capped) resolution faster than realtime.
446
+ *
447
+ * Runs once at startup; bounded by a per-encode timeout. Presets that fail are
448
+ * omitted from the result.
449
+ *
450
+ * @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void } }} options
451
+ * @returns {Promise<Array<{ preset: string, pixelsPerSec: number }>>} Ordered slowest→fastest.
452
+ */
453
+ export async function benchmarkSoftwarePresets({ ffmpegBin, logger }) {
454
+ const log = logger ?? { info: () => {}, warn: () => {} };
455
+ const totalPixels = BENCHMARK_REF_W * BENCHMARK_REF_H * TRANSCODE_FPS * BENCHMARK_DURATION_SEC;
456
+ /** @type {Array<{ preset: string, pixelsPerSec: number }>} */
457
+ const results = [];
458
+ for (const preset of BENCHMARK_PRESETS) {
459
+ const args = [
460
+ "-hide_banner", "-loglevel", "error",
461
+ "-f", "lavfi", "-i", `testsrc2=s=${BENCHMARK_REF_W}x${BENCHMARK_REF_H}:r=${TRANSCODE_FPS}:d=${BENCHMARK_DURATION_SEC}`,
462
+ "-c:v", "libx264", "-preset", preset, "-crf", SOFTWARE_CRF, "-pix_fmt", "yuv420p",
463
+ "-f", "null", "-"
464
+ ];
465
+ const startedAt = Date.now();
466
+ const { code } = await runFfmpeg(ffmpegBin, args, 30000);
467
+ const elapsedSec = (Date.now() - startedAt) / 1000;
468
+ if (code !== 0 || elapsedSec <= 0) {
469
+ log.warn(`hwaccel: preset benchmark "${preset}" failed; skipping`);
470
+ continue;
471
+ }
472
+ const pixelsPerSec = totalPixels / elapsedSec;
473
+ results.push({ preset, pixelsPerSec });
474
+ log.info(
475
+ `hwaccel: preset "${preset}" ~= ${(pixelsPerSec / 1e6).toFixed(1)} Mpx/s ` +
476
+ `(${(BENCHMARK_DURATION_SEC / elapsedSec).toFixed(2)}x @ ${BENCHMARK_REF_W}x${BENCHMARK_REF_H})`
477
+ );
478
+ }
479
+ return results;
480
+ }
481
+
482
+ /**
483
+ * Pick the highest-quality (slowest) benchmarked preset that can encode
484
+ * `pixelsPerSecNeeded` with the speed margin. Falls back to the fastest
485
+ * benchmarked preset, or `"ultrafast"` when no benchmark is available.
486
+ *
487
+ * @param {Array<{ preset: string, pixelsPerSec: number }>} benchmark - slowest→fastest
488
+ * @param {number} pixelsPerSecNeeded
489
+ * @returns {string}
490
+ */
491
+ export function pickSoftwarePreset(benchmark, pixelsPerSecNeeded) {
492
+ if (!Array.isArray(benchmark) || benchmark.length === 0) {
493
+ return "ultrafast";
494
+ }
495
+ for (const entry of benchmark) {
496
+ if (entry.pixelsPerSec >= pixelsPerSecNeeded * PRESET_SPEED_MARGIN) {
497
+ return entry.preset;
498
+ }
499
+ }
500
+ return benchmark[benchmark.length - 1].preset;
501
+ }