@torrent-tv/proxy 2.8.0 → 2.9.2

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.8.0",
3
+ "version": "2.9.2",
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$/;
@@ -32,9 +33,6 @@ const DEFAULT_SESSION_TTL_MS = 120 * 1000;
32
33
  const DEFAULT_STARTUP_WAIT_MS = 5_000;
33
34
  const MICROSECONDS_PER_SECOND = 1_000_000;
34
35
  const PROGRESS_LOG_INTERVAL_MS = 5_000;
35
- const VIDEO_TRANSCODE_PRESET = "superfast";
36
- const VIDEO_TRANSCODE_CRF = "24";
37
- const VIDEO_TRANSCODE_FPS = 24;
38
36
 
39
37
  /**
40
38
  * Resolve after a given number of milliseconds.
@@ -349,10 +347,15 @@ export class HlsSessionManager {
349
347
  localPort,
350
348
  segmentDurationSec = DEFAULT_SEGMENT_DURATION_SEC,
351
349
  sessionTtlMs = DEFAULT_SESSION_TTL_MS,
352
- startupWaitMs = DEFAULT_STARTUP_WAIT_MS
350
+ startupWaitMs = DEFAULT_STARTUP_WAIT_MS,
351
+ videoEncoder = null
353
352
  }) {
354
353
  this.enabled = Boolean(enabled);
355
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();
356
359
  this.segmentDurationSec = segmentDurationSec;
357
360
  this.sessionTtlMs = sessionTtlMs;
358
361
  this.startupWaitMs = startupWaitMs;
@@ -509,7 +512,7 @@ export class HlsSessionManager {
509
512
 
510
513
  logger.info(
511
514
  `transcode ${sessionId} start "${logName}" ` +
512
- `video=${transcodeVideo ? "x264" : "copy"} audio=${transcodeAudio ? "aac" : "copy"} ` +
515
+ `video=${transcodeVideo ? this.videoEncoder.name : "copy"} audio=${transcodeAudio ? "aac" : "copy"} ` +
513
516
  `duration=${hasDuration ? formatSeconds(durationSeconds) : "unknown"} segments=${segmentCount}`
514
517
  );
515
518
 
@@ -588,30 +591,26 @@ export class HlsSessionManager {
588
591
  }
589
592
  }
590
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).
591
597
  const videoCodecArgs = session.transcodeVideo
592
- ? [
593
- "-vf",
594
- this.#buildVideoFilter(session.targetWidth, session.targetHeight),
595
- "-c:v",
596
- "libx264",
597
- "-preset",
598
- VIDEO_TRANSCODE_PRESET,
599
- "-crf",
600
- VIDEO_TRANSCODE_CRF,
601
- "-pix_fmt",
602
- "yuv420p",
603
- // Force keyframes on segment boundaries so each segment is
604
- // independently decodable and exactly segmentDuration long — this
605
- // keeps the synthetic playlist's timing accurate.
606
- "-force_key_frames",
607
- `expr:gte(t,n_forced*${this.segmentDurationSec})`
608
- ]
598
+ ? this.videoEncoder.buildVideoArgs({
599
+ targetWidth: session.targetWidth,
600
+ targetHeight: session.targetHeight,
601
+ segmentDurationSec: this.segmentDurationSec
602
+ })
609
603
  : ["-c:v", "copy"];
610
604
  const audioCodecArgs = session.transcodeAudio
611
605
  ? ["-c:a", "aac", "-ac", "2", "-b:a", "128k"]
612
606
  : ["-c:a", "copy"];
613
607
 
614
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
+ }
615
614
  if (startSeconds > 0) {
616
615
  // Fast keyframe-level seek before -i (skips decoding earlier frames).
617
616
  args.push("-ss", String(startSeconds));
@@ -762,12 +761,25 @@ export class HlsSessionManager {
762
761
  logger.info(`transcode ${session.id} encode-run complete "${session.fileName}"`);
763
762
  return;
764
763
  }
765
- session.state = "failed";
766
- session.progress.state = "failed";
767
- session.progress.updatedAt = Date.now();
768
764
  if (!session.lastError) {
769
765
  session.lastError = `ffmpeg exited with code ${code ?? -1}${signal ? ` (signal ${signal})` : ""}`;
770
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();
771
783
  logger.error(`transcode ${session.id} encode-run failed: ${session.lastError}`);
772
784
  });
773
785
  }
@@ -800,12 +812,6 @@ export class HlsSessionManager {
800
812
  this.#startEncodeRun(session, index);
801
813
  }
802
814
 
803
- #buildVideoFilter(targetWidth, targetHeight) {
804
- const safeWidth = Number.isInteger(targetWidth) && targetWidth > 0 ? targetWidth : 1280;
805
- const safeHeight = Number.isInteger(targetHeight) && targetHeight > 0 ? targetHeight : 720;
806
- return `scale=${safeWidth}:${safeHeight}:force_original_aspect_ratio=decrease:force_divisible_by=2,fps=${VIDEO_TRANSCODE_FPS}`;
807
- }
808
-
809
815
  /**
810
816
  * Poll until the HLS playlist file exists and contains a valid `#EXTM3U`
811
817
  * header, or until the session fails, or until the startup timeout elapses.
@@ -0,0 +1,418 @@
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 = "superfast";
29
+ const SOFTWARE_CRF = "24";
30
+ const TRANSCODE_FPS = 24;
31
+
32
+ /**
33
+ * @param {number} targetWidth
34
+ * @param {number} targetHeight
35
+ * @returns {{ w: number, h: number }}
36
+ */
37
+ function safeDimensions(targetWidth, targetHeight) {
38
+ const w = Number.isInteger(targetWidth) && targetWidth > 0 ? targetWidth : 1280;
39
+ const h = Number.isInteger(targetHeight) && targetHeight > 0 ? targetHeight : 720;
40
+ return { w, h };
41
+ }
42
+
43
+ /**
44
+ * Force a keyframe on every segment boundary so each HLS segment is
45
+ * independently decodable and exactly `segmentDurationSec` long.
46
+ *
47
+ * @param {number} segmentDurationSec
48
+ * @returns {string[]}
49
+ */
50
+ function keyFrameArgs(segmentDurationSec) {
51
+ return ["-force_key_frames", `expr:gte(t,n_forced*${segmentDurationSec})`];
52
+ }
53
+
54
+ /** @returns {import("./hwaccel.js").VideoEncoderDescriptor} */
55
+ export function softwareDescriptor() {
56
+ return {
57
+ name: "libx264",
58
+ kind: "software",
59
+ device: null,
60
+ inputArgs: [],
61
+ buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec }) {
62
+ const { w, h } = safeDimensions(targetWidth, targetHeight);
63
+ return [
64
+ "-vf",
65
+ `scale=${w}:${h}:force_original_aspect_ratio=decrease:force_divisible_by=2,fps=${TRANSCODE_FPS}`,
66
+ "-c:v", "libx264",
67
+ "-preset", SOFTWARE_PRESET,
68
+ "-crf", SOFTWARE_CRF,
69
+ "-pix_fmt", "yuv420p",
70
+ ...keyFrameArgs(segmentDurationSec)
71
+ ];
72
+ }
73
+ };
74
+ }
75
+
76
+ /**
77
+ * @param {string} device
78
+ * @returns {import("./hwaccel.js").VideoEncoderDescriptor}
79
+ */
80
+ function vaapiDescriptor(device) {
81
+ return {
82
+ name: "h264_vaapi",
83
+ kind: "vaapi",
84
+ device,
85
+ // Decode on the GPU into VAAPI surfaces; scale and encode stay on-GPU.
86
+ inputArgs: ["-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi", "-vaapi_device", device],
87
+ buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec }) {
88
+ const { w, h } = safeDimensions(targetWidth, targetHeight);
89
+ return [
90
+ "-vf",
91
+ `scale_vaapi=w=${w}:h=${h}:force_original_aspect_ratio=decrease`,
92
+ "-c:v", "h264_vaapi",
93
+ "-qp", "24",
94
+ ...keyFrameArgs(segmentDurationSec)
95
+ ];
96
+ }
97
+ };
98
+ }
99
+
100
+ /**
101
+ * @param {string} device
102
+ * @returns {import("./hwaccel.js").VideoEncoderDescriptor}
103
+ */
104
+ function qsvDescriptor(device) {
105
+ return {
106
+ name: "h264_qsv",
107
+ kind: "qsv",
108
+ device,
109
+ inputArgs: ["-hwaccel", "qsv", "-qsv_device", device],
110
+ buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec }) {
111
+ const { w, h } = safeDimensions(targetWidth, targetHeight);
112
+ return [
113
+ "-vf", `scale_qsv=w=${w}:h=${h}`,
114
+ "-c:v", "h264_qsv",
115
+ "-global_quality", "24",
116
+ ...keyFrameArgs(segmentDurationSec)
117
+ ];
118
+ }
119
+ };
120
+ }
121
+
122
+ /** @returns {import("./hwaccel.js").VideoEncoderDescriptor} */
123
+ function nvencDescriptor() {
124
+ return {
125
+ name: "h264_nvenc",
126
+ kind: "nvenc",
127
+ device: null,
128
+ inputArgs: [],
129
+ buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec }) {
130
+ const { w, h } = safeDimensions(targetWidth, targetHeight);
131
+ return [
132
+ "-vf",
133
+ `scale=${w}:${h}:force_original_aspect_ratio=decrease:force_divisible_by=2,fps=${TRANSCODE_FPS}`,
134
+ "-c:v", "h264_nvenc",
135
+ "-preset", "p4",
136
+ "-cq", "24",
137
+ "-pix_fmt", "yuv420p",
138
+ ...keyFrameArgs(segmentDurationSec)
139
+ ];
140
+ }
141
+ };
142
+ }
143
+
144
+ /** @returns {import("./hwaccel.js").VideoEncoderDescriptor} */
145
+ function v4l2m2mDescriptor() {
146
+ // ARM SoC (e.g. Raspberry Pi / HA Yellow) stateful M2M encoder. No GPU
147
+ // scaler — scale in software, hand YUV420 frames to the hardware encoder.
148
+ // `-g` aligns the GOP to the segment length so an IDR lands on every segment
149
+ // boundary; this is verified by the keyframe-alignment test before use,
150
+ // because v4l2m2m does not always honour these hints.
151
+ return {
152
+ name: "h264_v4l2m2m",
153
+ kind: "v4l2m2m",
154
+ device: null,
155
+ inputArgs: [],
156
+ buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec }) {
157
+ const { w, h } = safeDimensions(targetWidth, targetHeight);
158
+ return [
159
+ "-vf",
160
+ `scale=${w}:${h}:force_original_aspect_ratio=decrease:force_divisible_by=2,fps=${TRANSCODE_FPS},format=yuv420p`,
161
+ "-c:v", "h264_v4l2m2m",
162
+ "-b:v", "3M",
163
+ "-g", String(TRANSCODE_FPS * segmentDurationSec),
164
+ ...keyFrameArgs(segmentDurationSec)
165
+ ];
166
+ }
167
+ };
168
+ }
169
+
170
+
171
+ /**
172
+ * @typedef {Object} VideoEncoderDescriptor
173
+ * @property {string} name
174
+ * @property {"software"|"vaapi"|"qsv"|"nvenc"|"v4l2m2m"} kind
175
+ * @property {string|null} device
176
+ * @property {string[]} inputArgs
177
+ * @property {(opts: { targetWidth: number, targetHeight: number, segmentDurationSec: number }) => string[]} buildVideoArgs
178
+ */
179
+
180
+ /**
181
+ * Run ffmpeg and resolve with its exit code and captured output.
182
+ *
183
+ * @param {string} ffmpegBin
184
+ * @param {string[]} args
185
+ * @param {number} [timeoutMs=12000]
186
+ * @returns {Promise<{ code: number, stdout: string, stderr: string }>}
187
+ */
188
+ function runFfmpeg(ffmpegBin, args, timeoutMs = 12000) {
189
+ return new Promise((resolve) => {
190
+ let stdout = "";
191
+ let stderr = "";
192
+ let settled = false;
193
+ let child;
194
+ const finish = (code) => {
195
+ if (settled) {
196
+ return;
197
+ }
198
+ settled = true;
199
+ resolve({ code, stdout, stderr });
200
+ };
201
+ try {
202
+ child = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
203
+ } catch {
204
+ finish(-1);
205
+ return;
206
+ }
207
+ const timer = setTimeout(() => {
208
+ try {
209
+ child.kill("SIGKILL");
210
+ } catch {
211
+ // ignore
212
+ }
213
+ finish(-1);
214
+ }, timeoutMs);
215
+ child.stdout.on("data", (d) => {
216
+ stdout += String(d);
217
+ });
218
+ child.stderr.on("data", (d) => {
219
+ stderr += String(d);
220
+ });
221
+ child.on("error", () => {
222
+ clearTimeout(timer);
223
+ finish(-1);
224
+ });
225
+ child.on("exit", (code) => {
226
+ clearTimeout(timer);
227
+ finish(code ?? -1);
228
+ });
229
+ });
230
+ }
231
+
232
+ /** @returns {string[]} /dev/dri/renderD* nodes (VAAPI/QSV). */
233
+ function listRenderNodes() {
234
+ try {
235
+ return readdirSync("/dev/dri")
236
+ .filter((n) => n.startsWith("renderD"))
237
+ .map((n) => `/dev/dri/${n}`)
238
+ .sort();
239
+ } catch {
240
+ return [];
241
+ }
242
+ }
243
+
244
+ /** @returns {boolean} Whether any /dev/nvidia* node exists (NVENC). */
245
+ function hasNvidiaDevice() {
246
+ try {
247
+ return readdirSync("/dev").some((n) => /^nvidia(\d+)?$/.test(n));
248
+ } catch {
249
+ return false;
250
+ }
251
+ }
252
+
253
+ /** @returns {boolean} Whether any /dev/video* node exists (V4L2 M2M). */
254
+ function hasV4l2Device() {
255
+ try {
256
+ return readdirSync("/dev").some((n) => /^video\d+$/.test(n));
257
+ } catch {
258
+ return false;
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Build a full ffmpeg command that encodes a short, *moving* synthetic clip
264
+ * (testsrc2 — far more representative than a static black frame) through the
265
+ * candidate encoder into real HLS segments in `outDir`, with keyframes forced
266
+ * on segment boundaries. Verifying the resulting segments (see
267
+ * {@link verifySegmentsDecodeCleanly}) catches encoders that silently produce
268
+ * a corrupted or non-IDR-aligned stream (e.g. some V4L2 M2M builds).
269
+ *
270
+ * @param {VideoEncoderDescriptor} descriptor
271
+ * @param {number} segmentDurationSec
272
+ * @param {string} outDir
273
+ * @returns {string[]}
274
+ */
275
+ function buildEncoderTestArgs(descriptor, segmentDurationSec, outDir) {
276
+ const durationSec = Math.max(8, segmentDurationSec * 3);
277
+ const source = ["-f", "lavfi", "-i", `testsrc2=s=640x360:r=${TRANSCODE_FPS}:d=${durationSec}`];
278
+ const kf = keyFrameArgs(segmentDurationSec);
279
+
280
+ /** @type {string[]} */
281
+ let pre = ["-hide_banner", "-loglevel", "error"];
282
+ /** @type {string[]} */
283
+ let encode;
284
+ switch (descriptor.kind) {
285
+ case "vaapi":
286
+ pre = [...pre, "-vaapi_device", String(descriptor.device)];
287
+ encode = ["-vf", "format=nv12,hwupload", "-c:v", "h264_vaapi", "-qp", "24", ...kf];
288
+ break;
289
+ case "qsv":
290
+ pre = [...pre, "-qsv_device", String(descriptor.device)];
291
+ encode = ["-vf", "hwupload=extra_hw_frames=16,format=qsv", "-c:v", "h264_qsv", "-global_quality", "24", ...kf];
292
+ break;
293
+ case "nvenc":
294
+ encode = ["-c:v", "h264_nvenc", "-preset", "p4", "-cq", "24", "-pix_fmt", "yuv420p", ...kf];
295
+ break;
296
+ case "v4l2m2m":
297
+ encode = ["-pix_fmt", "yuv420p", "-c:v", "h264_v4l2m2m", "-b:v", "3M", "-g", String(TRANSCODE_FPS * segmentDurationSec), ...kf];
298
+ break;
299
+ default:
300
+ encode = ["-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", ...kf];
301
+ break;
302
+ }
303
+
304
+ const hlsOut = [
305
+ "-f", "hls",
306
+ "-hls_time", String(segmentDurationSec),
307
+ "-hls_list_size", "0",
308
+ "-hls_flags", "independent_segments",
309
+ "-hls_segment_filename", path.join(outDir, "seg-%03d.ts"),
310
+ path.join(outDir, "index.m3u8")
311
+ ];
312
+ return [...pre, ...source, ...encode, ...hlsOut];
313
+ }
314
+
315
+ /**
316
+ * Verify the HLS segments produced by the test encode are valid: at least two
317
+ * segments exist, and each decodes standalone without errors. A segment that
318
+ * does not begin with a keyframe (broken/corrupted output) emits decode errors
319
+ * when read on its own, which fails this check.
320
+ *
321
+ * @param {string} ffmpegBin
322
+ * @param {string} outDir
323
+ * @returns {Promise<boolean>}
324
+ */
325
+ async function verifySegmentsDecodeCleanly(ffmpegBin, outDir) {
326
+ let files;
327
+ try {
328
+ files = readdirSync(outDir).filter((n) => /^seg-\d+\.ts$/.test(n)).sort();
329
+ } catch {
330
+ return false;
331
+ }
332
+ if (files.length < 2) {
333
+ return false;
334
+ }
335
+ for (const file of files) {
336
+ const result = await runFfmpeg(
337
+ ffmpegBin,
338
+ ["-hide_banner", "-loglevel", "error", "-i", path.join(outDir, file), "-f", "null", "-"],
339
+ 8000
340
+ );
341
+ if (result.code !== 0 || result.stderr.trim().length > 0) {
342
+ return false;
343
+ }
344
+ }
345
+ return true;
346
+ }
347
+
348
+ /**
349
+ * Detect the best usable H.264 encoder. Always resolves (falls back to
350
+ * software libx264). Each hardware candidate is verified with a real
351
+ * test-encode before being selected.
352
+ *
353
+ * @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void }, segmentDurationSec?: number }} options
354
+ * @returns {Promise<VideoEncoderDescriptor>}
355
+ */
356
+ export async function detectVideoEncoder({ ffmpegBin, logger, segmentDurationSec = 4 }) {
357
+ const log = logger ?? { info: () => {}, warn: () => {} };
358
+ const software = softwareDescriptor();
359
+
360
+ const { code, stdout } = await runFfmpeg(ffmpegBin, ["-hide_banner", "-encoders"], 10000);
361
+ if (code !== 0) {
362
+ log.warn("hwaccel: could not list ffmpeg encoders; using software libx264");
363
+ return software;
364
+ }
365
+ const has = (name) => stdout.includes(name);
366
+
367
+ /** @type {VideoEncoderDescriptor[]} */
368
+ const candidates = [];
369
+ const renderNodes = listRenderNodes();
370
+ if (has("h264_nvenc") && hasNvidiaDevice()) {
371
+ candidates.push(nvencDescriptor());
372
+ }
373
+ if (has("h264_qsv") && renderNodes.length > 0) {
374
+ candidates.push(qsvDescriptor(renderNodes[0]));
375
+ }
376
+ if (has("h264_vaapi") && renderNodes.length > 0) {
377
+ candidates.push(vaapiDescriptor(renderNodes[0]));
378
+ }
379
+ // h264_v4l2m2m (ARM SoC / Raspberry Pi / HA Yellow). It is gated behind the
380
+ // strict keyframe-alignment test below, because some V4L2 M2M builds silently
381
+ // emit a corrupted / non-IDR-aligned stream; the test rejects those and the
382
+ // host falls back to software libx264.
383
+ if (has("h264_v4l2m2m") && hasV4l2Device()) {
384
+ candidates.push(v4l2m2mDescriptor());
385
+ }
386
+
387
+ for (const candidate of candidates) {
388
+ const dir = mkdtempSync(path.join(os.tmpdir(), "tt-hwtest-"));
389
+ let ok = false;
390
+ try {
391
+ const encoded = await runFfmpeg(
392
+ ffmpegBin,
393
+ buildEncoderTestArgs(candidate, segmentDurationSec, dir),
394
+ 25000
395
+ );
396
+ if (encoded.code === 0) {
397
+ ok = await verifySegmentsDecodeCleanly(ffmpegBin, dir);
398
+ }
399
+ } finally {
400
+ try {
401
+ rmSync(dir, { recursive: true, force: true });
402
+ } catch {
403
+ // best effort
404
+ }
405
+ }
406
+ if (ok) {
407
+ log.info(
408
+ `hwaccel: using hardware encoder ${candidate.name}` +
409
+ `${candidate.device ? ` (${candidate.device})` : ""}`
410
+ );
411
+ return candidate;
412
+ }
413
+ log.warn(`hwaccel: ${candidate.name} failed the HLS keyframe-alignment test; skipping`);
414
+ }
415
+
416
+ log.info("hwaccel: no working hardware encoder; using software libx264");
417
+ return software;
418
+ }