@torrent-tv/proxy 2.7.0 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.7.0",
3
+ "version": "2.9.0",
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,6 +27,8 @@ 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";
31
+ import { logger } from "./utils/logger.js";
30
32
 
31
33
  const __filename = fileURLToPath(import.meta.url);
32
34
  const __dirname = path.dirname(__filename);
@@ -91,11 +93,18 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin }
91
93
  const selectedPort = await getPort({
92
94
  port: buildPortCandidates(port)
93
95
  });
96
+ // Auto-detect the best available H.264 encoder (hardware-accelerated or
97
+ // software) once at startup, with a real test-encode and graceful fallback.
98
+ // Only needed when transcoding can occur.
99
+ const videoEncoder = transcodeAudio
100
+ ? await detectVideoEncoder({ ffmpegBin, logger })
101
+ : null;
94
102
  const hlsSessionManager = new HlsSessionManager({
95
103
  enabled: transcodeAudio,
96
104
  ffmpegBin,
97
105
  localBindHost: host,
98
- localPort: selectedPort
106
+ localPort: selectedPort,
107
+ videoEncoder
99
108
  });
100
109
  const playbackPlanner = createPlaybackPlanner({
101
110
  ffmpegBin,
@@ -15,6 +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
19
 
19
20
  const PLAYLIST_FILE_NAME = "index.m3u8";
20
21
  const SEGMENT_FILE_NAME_PATTERN = /^segment-\d{5}\.ts$/;
@@ -24,12 +25,6 @@ const DEFAULT_SEGMENT_DURATION_SEC = 4;
24
25
  // is allowed to be before we restart ffmpeg at that position (server-side seek).
25
26
  // Requests within the window are served by waiting for the running encode.
26
27
  const MAX_LOOKAHEAD_SEGMENTS = 8;
27
- // Just-in-time pacing: pause the encoder once it is this many segments ahead of
28
- // the player's current position, and resume once the lead shrinks to the resume
29
- // threshold. This keeps a comfortable buffer for smooth playback while capping
30
- // CPU near real-time instead of racing through the whole file at once.
31
- const PACING_PAUSE_AHEAD_SEGMENTS = 8;
32
- const PACING_RESUME_AHEAD_SEGMENTS = 4;
33
28
  // Idle TTL: a session is disposed this long after the last segment/playlist
34
29
  // access. Kept short so an ffmpeg process does not keep burning CPU after the
35
30
  // viewer stops or navigates away. Active playback refreshes the timer on every
@@ -38,9 +33,6 @@ const DEFAULT_SESSION_TTL_MS = 120 * 1000;
38
33
  const DEFAULT_STARTUP_WAIT_MS = 5_000;
39
34
  const MICROSECONDS_PER_SECOND = 1_000_000;
40
35
  const PROGRESS_LOG_INTERVAL_MS = 5_000;
41
- const VIDEO_TRANSCODE_PRESET = "superfast";
42
- const VIDEO_TRANSCODE_CRF = "24";
43
- const VIDEO_TRANSCODE_FPS = 24;
44
36
 
45
37
  /**
46
38
  * Resolve after a given number of milliseconds.
@@ -355,10 +347,15 @@ export class HlsSessionManager {
355
347
  localPort,
356
348
  segmentDurationSec = DEFAULT_SEGMENT_DURATION_SEC,
357
349
  sessionTtlMs = DEFAULT_SESSION_TTL_MS,
358
- startupWaitMs = DEFAULT_STARTUP_WAIT_MS
350
+ startupWaitMs = DEFAULT_STARTUP_WAIT_MS,
351
+ videoEncoder = null
359
352
  }) {
360
353
  this.enabled = Boolean(enabled);
361
354
  this.ffmpegBin = ffmpegBin;
355
+ // Detected H.264 encoder descriptor (hardware or software). Defaults to
356
+ // software libx264 when no detection result is supplied. May be downgraded
357
+ // to software at runtime if a hardware encode fails.
358
+ this.videoEncoder = videoEncoder ?? softwareDescriptor();
362
359
  this.segmentDurationSec = segmentDurationSec;
363
360
  this.sessionTtlMs = sessionTtlMs;
364
361
  this.startupWaitMs = startupWaitMs;
@@ -498,11 +495,6 @@ export class HlsSessionManager {
498
495
  encodeStartIndex: 0,
499
496
  // Guards against repeatedly restarting to the same seek position.
500
497
  pendingRestartIndex: -1,
501
- // Just-in-time pacing: highest segment index the player has requested
502
- // (≈ playhead) and whether the encoder is currently paused for running
503
- // far enough ahead.
504
- playheadIndex: 0,
505
- paused: false,
506
498
  progress: {
507
499
  state: "starting",
508
500
  processedSeconds: 0,
@@ -590,44 +582,35 @@ export class HlsSessionManager {
590
582
 
591
583
  // Terminate any existing encode process before starting a new one. The
592
584
  // old process's exit handler no-ops because session.ffmpeg is reassigned
593
- // below (it checks identity). Resume first in case it was paused, so the
594
- // termination signal is actually delivered.
585
+ // below (it checks identity).
595
586
  if (session.ffmpeg && !session.ffmpeg.killed) {
596
587
  try {
597
- if (session.paused) {
598
- session.ffmpeg.kill("SIGCONT");
599
- }
600
588
  session.ffmpeg.kill("SIGTERM");
601
589
  } catch (_error) {
602
590
  // Best effort.
603
591
  }
604
592
  }
605
- session.paused = false;
606
593
 
594
+ // Video: re-encode only when required, using the detected encoder
595
+ // (hardware-accelerated or software). The descriptor builds the filter +
596
+ // codec args (including keyframe alignment on segment boundaries).
607
597
  const videoCodecArgs = session.transcodeVideo
608
- ? [
609
- "-vf",
610
- this.#buildVideoFilter(session.targetWidth, session.targetHeight),
611
- "-c:v",
612
- "libx264",
613
- "-preset",
614
- VIDEO_TRANSCODE_PRESET,
615
- "-crf",
616
- VIDEO_TRANSCODE_CRF,
617
- "-pix_fmt",
618
- "yuv420p",
619
- // Force keyframes on segment boundaries so each segment is
620
- // independently decodable and exactly segmentDuration long — this
621
- // keeps the synthetic playlist's timing accurate.
622
- "-force_key_frames",
623
- `expr:gte(t,n_forced*${this.segmentDurationSec})`
624
- ]
598
+ ? this.videoEncoder.buildVideoArgs({
599
+ targetWidth: session.targetWidth,
600
+ targetHeight: session.targetHeight,
601
+ segmentDurationSec: this.segmentDurationSec
602
+ })
625
603
  : ["-c:v", "copy"];
626
604
  const audioCodecArgs = session.transcodeAudio
627
605
  ? ["-c:a", "aac", "-ac", "2", "-b:a", "128k"]
628
606
  : ["-c:a", "copy"];
629
607
 
630
608
  const args = ["-hide_banner", "-nostats", "-loglevel", "error", "-progress", "pipe:1"];
609
+ // Hardware decode/encode setup (e.g. VAAPI device) must precede -i, and
610
+ // only applies when we actually re-encode the video track.
611
+ if (session.transcodeVideo && Array.isArray(this.videoEncoder.inputArgs)) {
612
+ args.push(...this.videoEncoder.inputArgs);
613
+ }
631
614
  if (startSeconds > 0) {
632
615
  // Fast keyframe-level seek before -i (skips decoding earlier frames).
633
616
  args.push("-ss", String(startSeconds));
@@ -669,7 +652,6 @@ export class HlsSessionManager {
669
652
  session.ffmpeg = ffmpeg;
670
653
  session.encodeStartIndex = safeIndex;
671
654
  session.pendingRestartIndex = -1;
672
- session.playheadIndex = safeIndex;
673
655
  session.state = session.state === "disposed" ? "disposed" : "starting";
674
656
  session.progress.state = "running";
675
657
  session.progress.processedSeconds = startSeconds;
@@ -742,8 +724,6 @@ export class HlsSessionManager {
742
724
  ` speed=${session.progress.speed || "n/a"}`
743
725
  );
744
726
  }
745
- // Just-in-time pacing: pause once we are far enough ahead of the player.
746
- this.#maybePauseEncoder(session);
747
727
  }
748
728
  });
749
729
 
@@ -781,12 +761,25 @@ export class HlsSessionManager {
781
761
  logger.info(`transcode ${session.id} encode-run complete "${session.fileName}"`);
782
762
  return;
783
763
  }
784
- session.state = "failed";
785
- session.progress.state = "failed";
786
- session.progress.updatedAt = Date.now();
787
764
  if (!session.lastError) {
788
765
  session.lastError = `ffmpeg exited with code ${code ?? -1}${signal ? ` (signal ${signal})` : ""}`;
789
766
  }
767
+ // Runtime safety net: if a hardware encode fails, downgrade this proxy to
768
+ // software encoding for all sessions and restart this one, so playback is
769
+ // never permanently broken by a hardware/driver issue.
770
+ if (session.transcodeVideo && this.videoEncoder.kind !== "software") {
771
+ const failedEncoder = this.videoEncoder.name;
772
+ this.videoEncoder = softwareDescriptor();
773
+ logger.warn(
774
+ `transcode ${session.id} hardware encoder ${failedEncoder} failed ` +
775
+ `(${session.lastError}); falling back to software libx264 and restarting`
776
+ );
777
+ this.#startEncodeRun(session, session.encodeStartIndex);
778
+ return;
779
+ }
780
+ session.state = "failed";
781
+ session.progress.state = "failed";
782
+ session.progress.updatedAt = Date.now();
790
783
  logger.error(`transcode ${session.id} encode-run failed: ${session.lastError}`);
791
784
  });
792
785
  }
@@ -819,85 +812,6 @@ export class HlsSessionManager {
819
812
  this.#startEncodeRun(session, index);
820
813
  }
821
814
 
822
- /**
823
- * Segment index ffmpeg has encoded up to so far, derived from the output
824
- * timestamp reported via -progress.
825
- *
826
- * @param {HlsSession} session
827
- * @returns {number}
828
- */
829
- #producedSegmentIndex(session) {
830
- const processed = Number(session.progress?.processedSeconds);
831
- if (!Number.isFinite(processed) || processed <= 0) {
832
- return session.encodeStartIndex;
833
- }
834
- return Math.floor(processed / this.segmentDurationSec);
835
- }
836
-
837
- /**
838
- * Pause the encoder once it has produced enough segments ahead of the
839
- * player's current position (just-in-time pacing). No-op on platforms
840
- * without POSIX job-control signals (fail-open: the encoder keeps running).
841
- *
842
- * @param {HlsSession} session
843
- * @returns {void}
844
- */
845
- #maybePauseEncoder(session) {
846
- if (session.paused || !session.ffmpeg || session.ffmpeg.killed) {
847
- return;
848
- }
849
- if (process.platform === "win32") {
850
- return;
851
- }
852
- const ahead = this.#producedSegmentIndex(session) - session.playheadIndex;
853
- if (ahead < PACING_PAUSE_AHEAD_SEGMENTS) {
854
- return;
855
- }
856
- try {
857
- session.ffmpeg.kill("SIGSTOP");
858
- session.paused = true;
859
- logger.info(
860
- `transcode ${session.id} paused (buffered ${ahead} segments ahead) "${session.fileName}"`
861
- );
862
- } catch (_error) {
863
- // Fail-open: if we cannot pause, let the encoder keep running.
864
- }
865
- }
866
-
867
- /**
868
- * Resume a paused encoder when the player has caught up enough that the
869
- * buffered lead has shrunk to the resume threshold.
870
- *
871
- * @param {HlsSession} session
872
- * @returns {void}
873
- */
874
- #maybeResumeEncoder(session) {
875
- if (!session.paused || !session.ffmpeg || session.ffmpeg.killed) {
876
- return;
877
- }
878
- const ahead = this.#producedSegmentIndex(session) - session.playheadIndex;
879
- if (ahead > PACING_RESUME_AHEAD_SEGMENTS) {
880
- return;
881
- }
882
- try {
883
- session.ffmpeg.kill("SIGCONT");
884
- session.paused = false;
885
- session.progress.updatedAt = Date.now();
886
- logger.info(`transcode ${session.id} resumed (lead ${ahead} segments) "${session.fileName}"`);
887
- } catch (_error) {
888
- // If resume fails, force a restart from the player's position so it does
889
- // not stall (fail-safe toward smooth playback).
890
- session.paused = false;
891
- this.#startEncodeRun(session, session.playheadIndex);
892
- }
893
- }
894
-
895
- #buildVideoFilter(targetWidth, targetHeight) {
896
- const safeWidth = Number.isInteger(targetWidth) && targetWidth > 0 ? targetWidth : 1280;
897
- const safeHeight = Number.isInteger(targetHeight) && targetHeight > 0 ? targetHeight : 720;
898
- return `scale=${safeWidth}:${safeHeight}:force_original_aspect_ratio=decrease:force_divisible_by=2,fps=${VIDEO_TRANSCODE_FPS}`;
899
- }
900
-
901
815
  /**
902
816
  * Poll until the HLS playlist file exists and contains a valid `#EXTM3U`
903
817
  * header, or until the session fails, or until the startup timeout elapses.
@@ -970,16 +884,6 @@ export class HlsSessionManager {
970
884
  }
971
885
  session.lastAccessedAt = Date.now();
972
886
 
973
- // Track the player position (≈ playhead) from segment requests and resume
974
- // a paused encoder as the player approaches the buffered edge.
975
- if (fileName !== PLAYLIST_FILE_NAME) {
976
- const requestedIndex = segmentIndexFromName(fileName);
977
- if (requestedIndex >= 0) {
978
- session.playheadIndex = Math.max(session.playheadIndex, requestedIndex);
979
- this.#maybeResumeEncoder(session);
980
- }
981
- }
982
-
983
887
  // Serve the synthetic VOD playlist (full duration, terminated with
984
888
  // #EXT-X-ENDLIST) so the player gets the correct total length and a fully
985
889
  // seekable timeline up-front, independent of how far ffmpeg has encoded.
@@ -1127,16 +1031,6 @@ export class HlsSessionManager {
1127
1031
  this.sessionIdBySource.delete(session.sourceMapKey);
1128
1032
 
1129
1033
  if (session.ffmpeg && !session.ffmpeg.killed) {
1130
- // Resume first if paused, otherwise SIGTERM is not delivered to a
1131
- // stopped process.
1132
- if (session.paused) {
1133
- try {
1134
- session.ffmpeg.kill("SIGCONT");
1135
- } catch (_error) {
1136
- // Best effort.
1137
- }
1138
- session.paused = false;
1139
- }
1140
1034
  session.ffmpeg.kill("SIGTERM");
1141
1035
  await waitForChildExit(session.ffmpeg);
1142
1036
  }
@@ -0,0 +1,345 @@
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 { readdirSync } from "node:fs";
25
+
26
+ const SOFTWARE_PRESET = "superfast";
27
+ const SOFTWARE_CRF = "24";
28
+ const TRANSCODE_FPS = 24;
29
+
30
+ /**
31
+ * @param {number} targetWidth
32
+ * @param {number} targetHeight
33
+ * @returns {{ w: number, h: number }}
34
+ */
35
+ function safeDimensions(targetWidth, targetHeight) {
36
+ const w = Number.isInteger(targetWidth) && targetWidth > 0 ? targetWidth : 1280;
37
+ const h = Number.isInteger(targetHeight) && targetHeight > 0 ? targetHeight : 720;
38
+ return { w, h };
39
+ }
40
+
41
+ /**
42
+ * Force a keyframe on every segment boundary so each HLS segment is
43
+ * independently decodable and exactly `segmentDurationSec` long.
44
+ *
45
+ * @param {number} segmentDurationSec
46
+ * @returns {string[]}
47
+ */
48
+ function keyFrameArgs(segmentDurationSec) {
49
+ return ["-force_key_frames", `expr:gte(t,n_forced*${segmentDurationSec})`];
50
+ }
51
+
52
+ /** @returns {import("./hwaccel.js").VideoEncoderDescriptor} */
53
+ export function softwareDescriptor() {
54
+ return {
55
+ name: "libx264",
56
+ kind: "software",
57
+ device: null,
58
+ inputArgs: [],
59
+ buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec }) {
60
+ const { w, h } = safeDimensions(targetWidth, targetHeight);
61
+ return [
62
+ "-vf",
63
+ `scale=${w}:${h}:force_original_aspect_ratio=decrease:force_divisible_by=2,fps=${TRANSCODE_FPS}`,
64
+ "-c:v", "libx264",
65
+ "-preset", SOFTWARE_PRESET,
66
+ "-crf", SOFTWARE_CRF,
67
+ "-pix_fmt", "yuv420p",
68
+ ...keyFrameArgs(segmentDurationSec)
69
+ ];
70
+ }
71
+ };
72
+ }
73
+
74
+ /**
75
+ * @param {string} device
76
+ * @returns {import("./hwaccel.js").VideoEncoderDescriptor}
77
+ */
78
+ function vaapiDescriptor(device) {
79
+ return {
80
+ name: "h264_vaapi",
81
+ kind: "vaapi",
82
+ device,
83
+ // Decode on the GPU into VAAPI surfaces; scale and encode stay on-GPU.
84
+ inputArgs: ["-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi", "-vaapi_device", device],
85
+ buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec }) {
86
+ const { w, h } = safeDimensions(targetWidth, targetHeight);
87
+ return [
88
+ "-vf",
89
+ `scale_vaapi=w=${w}:h=${h}:force_original_aspect_ratio=decrease`,
90
+ "-c:v", "h264_vaapi",
91
+ "-qp", "24",
92
+ ...keyFrameArgs(segmentDurationSec)
93
+ ];
94
+ }
95
+ };
96
+ }
97
+
98
+ /**
99
+ * @param {string} device
100
+ * @returns {import("./hwaccel.js").VideoEncoderDescriptor}
101
+ */
102
+ function qsvDescriptor(device) {
103
+ return {
104
+ name: "h264_qsv",
105
+ kind: "qsv",
106
+ device,
107
+ inputArgs: ["-hwaccel", "qsv", "-qsv_device", device],
108
+ buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec }) {
109
+ const { w, h } = safeDimensions(targetWidth, targetHeight);
110
+ return [
111
+ "-vf", `scale_qsv=w=${w}:h=${h}`,
112
+ "-c:v", "h264_qsv",
113
+ "-global_quality", "24",
114
+ ...keyFrameArgs(segmentDurationSec)
115
+ ];
116
+ }
117
+ };
118
+ }
119
+
120
+ /** @returns {import("./hwaccel.js").VideoEncoderDescriptor} */
121
+ function nvencDescriptor() {
122
+ return {
123
+ name: "h264_nvenc",
124
+ kind: "nvenc",
125
+ device: null,
126
+ inputArgs: [],
127
+ buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec }) {
128
+ const { w, h } = safeDimensions(targetWidth, targetHeight);
129
+ return [
130
+ "-vf",
131
+ `scale=${w}:${h}:force_original_aspect_ratio=decrease:force_divisible_by=2,fps=${TRANSCODE_FPS}`,
132
+ "-c:v", "h264_nvenc",
133
+ "-preset", "p4",
134
+ "-cq", "24",
135
+ "-pix_fmt", "yuv420p",
136
+ ...keyFrameArgs(segmentDurationSec)
137
+ ];
138
+ }
139
+ };
140
+ }
141
+
142
+ /** @returns {import("./hwaccel.js").VideoEncoderDescriptor} */
143
+ function v4l2m2mDescriptor() {
144
+ // ARM SoC (e.g. Raspberry Pi) stateful M2M encoder. No GPU scaler — scale in
145
+ // software, then hand YUV420 frames to the hardware encoder.
146
+ return {
147
+ name: "h264_v4l2m2m",
148
+ kind: "v4l2m2m",
149
+ device: null,
150
+ inputArgs: [],
151
+ buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec }) {
152
+ const { w, h } = safeDimensions(targetWidth, targetHeight);
153
+ return [
154
+ "-vf",
155
+ `scale=${w}:${h}:force_original_aspect_ratio=decrease:force_divisible_by=2,fps=${TRANSCODE_FPS},format=yuv420p`,
156
+ "-c:v", "h264_v4l2m2m",
157
+ "-b:v", "3M",
158
+ ...keyFrameArgs(segmentDurationSec)
159
+ ];
160
+ }
161
+ };
162
+ }
163
+
164
+ /**
165
+ * @typedef {Object} VideoEncoderDescriptor
166
+ * @property {string} name
167
+ * @property {"software"|"vaapi"|"qsv"|"nvenc"|"v4l2m2m"} kind
168
+ * @property {string|null} device
169
+ * @property {string[]} inputArgs
170
+ * @property {(opts: { targetWidth: number, targetHeight: number, segmentDurationSec: number }) => string[]} buildVideoArgs
171
+ */
172
+
173
+ /**
174
+ * Run ffmpeg and resolve with its exit code and captured output.
175
+ *
176
+ * @param {string} ffmpegBin
177
+ * @param {string[]} args
178
+ * @param {number} [timeoutMs=12000]
179
+ * @returns {Promise<{ code: number, stdout: string, stderr: string }>}
180
+ */
181
+ function runFfmpeg(ffmpegBin, args, timeoutMs = 12000) {
182
+ return new Promise((resolve) => {
183
+ let stdout = "";
184
+ let stderr = "";
185
+ let settled = false;
186
+ let child;
187
+ const finish = (code) => {
188
+ if (settled) {
189
+ return;
190
+ }
191
+ settled = true;
192
+ resolve({ code, stdout, stderr });
193
+ };
194
+ try {
195
+ child = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
196
+ } catch {
197
+ finish(-1);
198
+ return;
199
+ }
200
+ const timer = setTimeout(() => {
201
+ try {
202
+ child.kill("SIGKILL");
203
+ } catch {
204
+ // ignore
205
+ }
206
+ finish(-1);
207
+ }, timeoutMs);
208
+ child.stdout.on("data", (d) => {
209
+ stdout += String(d);
210
+ });
211
+ child.stderr.on("data", (d) => {
212
+ stderr += String(d);
213
+ });
214
+ child.on("error", () => {
215
+ clearTimeout(timer);
216
+ finish(-1);
217
+ });
218
+ child.on("exit", (code) => {
219
+ clearTimeout(timer);
220
+ finish(code ?? -1);
221
+ });
222
+ });
223
+ }
224
+
225
+ /** @returns {string[]} /dev/dri/renderD* nodes (VAAPI/QSV). */
226
+ function listRenderNodes() {
227
+ try {
228
+ return readdirSync("/dev/dri")
229
+ .filter((n) => n.startsWith("renderD"))
230
+ .map((n) => `/dev/dri/${n}`)
231
+ .sort();
232
+ } catch {
233
+ return [];
234
+ }
235
+ }
236
+
237
+ /** @returns {boolean} Whether any /dev/nvidia* node exists (NVENC). */
238
+ function hasNvidiaDevice() {
239
+ try {
240
+ return readdirSync("/dev").some((n) => /^nvidia(\d+)?$/.test(n));
241
+ } catch {
242
+ return false;
243
+ }
244
+ }
245
+
246
+ /** @returns {boolean} Whether any /dev/video* node exists (V4L2 M2M). */
247
+ function hasV4l2Device() {
248
+ try {
249
+ return readdirSync("/dev").some((n) => /^video\d+$/.test(n));
250
+ } catch {
251
+ return false;
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Kind-specific test-encode args that verify the encoder initialises and
257
+ * encodes a few frames from a synthetic source.
258
+ *
259
+ * @param {VideoEncoderDescriptor} descriptor
260
+ * @returns {string[]}
261
+ */
262
+ function testEncodeArgs(descriptor) {
263
+ const src = ["-f", "lavfi", "-i", "color=c=black:s=320x240:r=15:d=0.4"];
264
+ switch (descriptor.kind) {
265
+ case "vaapi":
266
+ return [
267
+ "-hide_banner", "-loglevel", "error",
268
+ "-vaapi_device", String(descriptor.device),
269
+ ...src,
270
+ "-vf", "format=nv12,hwupload", "-c:v", "h264_vaapi",
271
+ "-f", "null", "-"
272
+ ];
273
+ case "qsv":
274
+ return [
275
+ "-hide_banner", "-loglevel", "error",
276
+ "-qsv_device", String(descriptor.device),
277
+ ...src,
278
+ "-vf", "hwupload=extra_hw_frames=16,format=qsv", "-c:v", "h264_qsv",
279
+ "-f", "null", "-"
280
+ ];
281
+ case "nvenc":
282
+ return ["-hide_banner", "-loglevel", "error", ...src, "-c:v", "h264_nvenc", "-f", "null", "-"];
283
+ case "v4l2m2m":
284
+ return [
285
+ "-hide_banner", "-loglevel", "error",
286
+ ...src, "-pix_fmt", "yuv420p", "-c:v", "h264_v4l2m2m", "-f", "null", "-"
287
+ ];
288
+ default:
289
+ return [
290
+ "-hide_banner", "-loglevel", "error",
291
+ ...src, "-c:v", "libx264", "-preset", "ultrafast", "-f", "null", "-"
292
+ ];
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Detect the best usable H.264 encoder. Always resolves (falls back to
298
+ * software libx264). Each hardware candidate is verified with a real
299
+ * test-encode before being selected.
300
+ *
301
+ * @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void } }} options
302
+ * @returns {Promise<VideoEncoderDescriptor>}
303
+ */
304
+ export async function detectVideoEncoder({ ffmpegBin, logger }) {
305
+ const log = logger ?? { info: () => {}, warn: () => {} };
306
+ const software = softwareDescriptor();
307
+
308
+ const { code, stdout } = await runFfmpeg(ffmpegBin, ["-hide_banner", "-encoders"], 10000);
309
+ if (code !== 0) {
310
+ log.warn("hwaccel: could not list ffmpeg encoders; using software libx264");
311
+ return software;
312
+ }
313
+ const has = (name) => stdout.includes(name);
314
+
315
+ /** @type {VideoEncoderDescriptor[]} */
316
+ const candidates = [];
317
+ const renderNodes = listRenderNodes();
318
+ if (has("h264_nvenc") && hasNvidiaDevice()) {
319
+ candidates.push(nvencDescriptor());
320
+ }
321
+ if (has("h264_qsv") && renderNodes.length > 0) {
322
+ candidates.push(qsvDescriptor(renderNodes[0]));
323
+ }
324
+ if (has("h264_vaapi") && renderNodes.length > 0) {
325
+ candidates.push(vaapiDescriptor(renderNodes[0]));
326
+ }
327
+ if (has("h264_v4l2m2m") && hasV4l2Device()) {
328
+ candidates.push(v4l2m2mDescriptor());
329
+ }
330
+
331
+ for (const candidate of candidates) {
332
+ const result = await runFfmpeg(ffmpegBin, testEncodeArgs(candidate), 12000);
333
+ if (result.code === 0) {
334
+ log.info(
335
+ `hwaccel: using hardware encoder ${candidate.name}` +
336
+ `${candidate.device ? ` (${candidate.device})` : ""}`
337
+ );
338
+ return candidate;
339
+ }
340
+ log.warn(`hwaccel: ${candidate.name} present but test-encode failed; skipping`);
341
+ }
342
+
343
+ log.info("hwaccel: no working hardware encoder; using software libx264");
344
+ return software;
345
+ }