@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 +1 -1
- package/server.js +10 -1
- package/services/hls-session-manager.js +37 -31
- package/services/hwaccel.js +418 -0
package/package.json
CHANGED
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 ?
|
|
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
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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
|
+
}
|