@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 +1 -1
- package/server.js +10 -1
- package/services/hls-session-manager.js +37 -143
- package/services/hwaccel.js +345 -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$/;
|
|
@@ -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).
|
|
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
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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
|
+
}
|