@camstack/addon-pipeline 1.1.20 → 1.1.21
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/dist/decoder-ffmpeg/index.js +540 -285
- package/dist/decoder-ffmpeg/index.mjs +539 -284
- package/dist/{ffmpeg-args-common-c3p-nWtU.mjs → frame-dropper-CwkBTPGV.mjs} +22 -22
- package/dist/stream-broker/index.js +40 -20
- package/dist/stream-broker/index.mjs +30 -10
- package/package.json +1 -1
- package/dist/{ffmpeg-args-common-CASoNt42.js → frame-dropper-7RTo_YyG.js} +21 -21
|
@@ -1,8 +1,292 @@
|
|
|
1
1
|
import { B as BaseAddon, b as decoderCapability, f as RingBuffer, s as DEFAULT_DECODER_HWACCEL_CONFIG, u as HWACCEL_OPTIONS, z as errMsg } from "../dist-tzhTTRq4.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { r as logBannerArgs, t as FrameDropper } from "../frame-dropper-CwkBTPGV.mjs";
|
|
3
3
|
import { FrameRingReaderCache, FrameRingWriter, MIN_RING_SLOTS, computeSegmentSize, computeSlotByteLength, createSegment, deriveSlotCount } from "@camstack/shm-ring";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
|
+
//#region src/decoder-ffmpeg/ffmpeg-args.ts
|
|
7
|
+
/**
|
|
8
|
+
* Pure, side-effect-free ffmpeg argument construction for the
|
|
9
|
+
* `decoder-ffmpeg` addon.
|
|
10
|
+
*
|
|
11
|
+
* The addon decodes video in an **ffmpeg subprocess** (unlike `decoder-nodeav`
|
|
12
|
+
* which decodes in-process via node-av's native bindings). On the Intel hub,
|
|
13
|
+
* node-av's VA-API decode path SIGBUSes (uncatchable) on 8MP h264 and takes the
|
|
14
|
+
* whole decoder runner down; ffmpeg-as-subprocess decodes the same hardware
|
|
15
|
+
* cleanly because a crash is isolated to the child process.
|
|
16
|
+
*
|
|
17
|
+
* These helpers are extracted from the session so the argument shape is
|
|
18
|
+
* unit-testable without spawning a real ffmpeg binary.
|
|
19
|
+
*
|
|
20
|
+
* Validated working command on the Intel hub (renderD128, 8MP h264, ~50fps,
|
|
21
|
+
* exit 0):
|
|
22
|
+
*
|
|
23
|
+
* ffmpeg -hwaccel vaapi -hwaccel_device /dev/dri/renderD128 \
|
|
24
|
+
* -hwaccel_output_format vaapi -f h264 -i <in> \
|
|
25
|
+
* -vf hwdownload,format=nv12,scale=640:360,format=gray \
|
|
26
|
+
* -f rawvideo -y /dev/null
|
|
27
|
+
*/
|
|
28
|
+
/** Default DRM render node used for the VA-API / QSV hardware decode path. */
|
|
29
|
+
var DEFAULT_VAAPI_RENDER_DEVICE = "/dev/dri/renderD128";
|
|
30
|
+
/** Whether a resolved hwaccel value means "decode in software" (no `-hwaccel`). */
|
|
31
|
+
function isSoftwareHwAccel(hwaccel) {
|
|
32
|
+
return hwaccel === null || hwaccel === "" || hwaccel === "none";
|
|
33
|
+
}
|
|
34
|
+
/** Backends whose decoded frames stay on the GPU and need an explicit
|
|
35
|
+
* `-hwaccel_output_format <m>` + `hwdownload,format=nv12` chain. */
|
|
36
|
+
function needsHwDownload(hwaccel) {
|
|
37
|
+
return hwaccel === "vaapi" || hwaccel === "qsv";
|
|
38
|
+
}
|
|
39
|
+
/** Decode backend → its GPU scaler filter. */
|
|
40
|
+
var GPU_SCALE_FILTER_BY_BACKEND = {
|
|
41
|
+
vaapi: "scale_vaapi",
|
|
42
|
+
qsv: "scale_qsv",
|
|
43
|
+
videotoolbox: "scale_vt"
|
|
44
|
+
};
|
|
45
|
+
/** Every GPU scaler this addon knows how to drive — used to scan `-filters`. */
|
|
46
|
+
var KNOWN_GPU_SCALE_FILTERS = [
|
|
47
|
+
"scale_vaapi",
|
|
48
|
+
"scale_qsv",
|
|
49
|
+
"scale_vt"
|
|
50
|
+
];
|
|
51
|
+
/**
|
|
52
|
+
* The GPU scaler that pairs with a decode backend, ignoring build support — a
|
|
53
|
+
* static hwaccel → filter map. Returns null for software / a backend with no
|
|
54
|
+
* GPU scaler (e.g. `cuda`, `drm`). Case-insensitive.
|
|
55
|
+
*/
|
|
56
|
+
function gpuScaleFilterForBackend(hwaccel) {
|
|
57
|
+
if (hwaccel === null) return null;
|
|
58
|
+
return GPU_SCALE_FILTER_BY_BACKEND[hwaccel.toLowerCase()] ?? null;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Resolve the GPU scaler to use for a decode backend GIVEN the filters the
|
|
62
|
+
* configured ffmpeg build actually offers ({@link parseAvailableGpuScaleFilters}).
|
|
63
|
+
* Returns the filter when the backend maps to one AND the build has it; null
|
|
64
|
+
* otherwise — the caller then emits the CPU-scale chain (graceful fallback).
|
|
65
|
+
*/
|
|
66
|
+
function resolveGpuScaleFilter(hwaccel, availableFilters) {
|
|
67
|
+
const filter = gpuScaleFilterForBackend(hwaccel);
|
|
68
|
+
if (filter === null) return null;
|
|
69
|
+
return availableFilters.has(filter) ? filter : null;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Parse `ffmpeg -filters` stdout into the set of {@link KNOWN_GPU_SCALE_FILTERS}
|
|
73
|
+
* the build offers. Matches whole filter tokens (word-boundary) so the plain
|
|
74
|
+
* `scale` filter is never mistaken for a GPU scaler. PURE — the spawn lives in
|
|
75
|
+
* the addon; only the parse is here so it is unit-tested.
|
|
76
|
+
*/
|
|
77
|
+
function parseAvailableGpuScaleFilters(filtersStdout) {
|
|
78
|
+
const present = /* @__PURE__ */ new Set();
|
|
79
|
+
for (const filter of KNOWN_GPU_SCALE_FILTERS) if (new RegExp(`\\b${filter}\\b`).test(filtersStdout)) present.add(filter);
|
|
80
|
+
return present;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Whether the decode keeps frames on the GPU — needs `-hwaccel_output_format`
|
|
84
|
+
* and an `hwdownload` in the filtergraph. VA-API / QSV always do (their decoded
|
|
85
|
+
* surfaces live on the GPU); another backend only when a GPU scaler will run on
|
|
86
|
+
* those surfaces (e.g. `videotoolbox` + `scale_vt`).
|
|
87
|
+
*/
|
|
88
|
+
function keepFramesOnGpu(hwaccel, gpuScaleFilter) {
|
|
89
|
+
return needsHwDownload(hwaccel) || gpuScaleFilter !== null;
|
|
90
|
+
}
|
|
91
|
+
/** Map a cap codec string to the ffmpeg `-f` input demuxer/format. */
|
|
92
|
+
function codecToInputFormat(codec) {
|
|
93
|
+
switch (codec.toLowerCase()) {
|
|
94
|
+
case "h265":
|
|
95
|
+
case "hevc": return "hevc";
|
|
96
|
+
default: return "h264";
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** Map the cap-level output format to a raw ffmpeg pixel format. */
|
|
100
|
+
function rawPixelForFormat(outputFormat) {
|
|
101
|
+
return outputFormat === "gray" ? "gray" : "rgb24";
|
|
102
|
+
}
|
|
103
|
+
/** Packed bytes-per-pixel for a raw output format (rgb24 = 3, gray = 1). */
|
|
104
|
+
function channelsForPixel(pixel) {
|
|
105
|
+
return pixel === "gray" ? 1 : 3;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The maximum output width for a given `scale` divisor — matches the node-av
|
|
109
|
+
* scaler's `maxW = floor(640 / scale)` rule so both decoders produce
|
|
110
|
+
* comparably-sized frames for the motion / detection pipeline.
|
|
111
|
+
*/
|
|
112
|
+
function maxDecodeWidth(scale) {
|
|
113
|
+
return Math.max(2, Math.floor(640 / (scale > 1 ? scale : 1)));
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Compute exact output geometry from a known source resolution — the node-av
|
|
117
|
+
* rule (`outWidth = min(srcW, floor(640/scale))`, `outHeight =
|
|
118
|
+
* round(outWidth*srcH/srcW)`). Provided for callers that DO know the source
|
|
119
|
+
* dimensions; the live broker path leaves them to ffmpeg (`-2` height).
|
|
120
|
+
*/
|
|
121
|
+
function computeOutputGeometry(srcWidth, srcHeight, scale) {
|
|
122
|
+
const width = Math.min(srcWidth, maxDecodeWidth(scale));
|
|
123
|
+
return {
|
|
124
|
+
width,
|
|
125
|
+
height: Math.round(width * srcHeight / srcWidth)
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/** Build the `-vf` filtergraph string for the requested hwaccel + pixel format. */
|
|
129
|
+
function buildVideoFilter(hwaccel, pixel, outWidth, outHeight, maxW, gpuScaleFilter) {
|
|
130
|
+
const exact = outWidth !== void 0 && outWidth > 0 && outHeight !== void 0 && outHeight > 0;
|
|
131
|
+
if (gpuScaleFilter !== null) return `${gpuScaleFilter}=${exact ? `w=${outWidth}:h=${outHeight}` : `w=min(iw\\,${maxW}):h=-2`},hwdownload,format=nv12,format=${pixel}`;
|
|
132
|
+
const scaleAndFormat = `scale=${exact ? `${outWidth}:${outHeight}` : `min(iw\\,${maxW}):-2`},format=${pixel}`;
|
|
133
|
+
return needsHwDownload(hwaccel) ? `hwdownload,format=nv12,${scaleAndFormat}` : scaleAndFormat;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Build the full ffmpeg argv for a decode session. PURE — no spawn, no env
|
|
137
|
+
* access — so the argument shape is asserted in unit tests.
|
|
138
|
+
*
|
|
139
|
+
* Notes:
|
|
140
|
+
* - `-loglevel info -nostats`: the session parses the muxer's `Output #0`
|
|
141
|
+
* stream line from stderr to learn the exact output W×H (the broker does not
|
|
142
|
+
* pass source geometry for the shm path). `-nostats` suppresses the periodic
|
|
143
|
+
* `frame= …` counter while keeping the one-shot Output block.
|
|
144
|
+
* - The input is `pipe:0` (Annex-B packets written to stdin); the output is raw
|
|
145
|
+
* video on `pipe:1`.
|
|
146
|
+
*/
|
|
147
|
+
function buildFfmpegDecodeArgs(config, options) {
|
|
148
|
+
const inputFormat = codecToInputFormat(config.codec);
|
|
149
|
+
const pixel = rawPixelForFormat(config.outputFormat);
|
|
150
|
+
const maxW = maxDecodeWidth(config.scale);
|
|
151
|
+
const { hwaccel } = options;
|
|
152
|
+
const gpuScaleFilter = options.gpuScaleFilter ?? null;
|
|
153
|
+
const vf = buildVideoFilter(hwaccel, pixel, options.outWidth, options.outHeight, maxW, gpuScaleFilter);
|
|
154
|
+
let hwaccelArgs;
|
|
155
|
+
if (isSoftwareHwAccel(hwaccel) || hwaccel === null) hwaccelArgs = [];
|
|
156
|
+
else {
|
|
157
|
+
hwaccelArgs = ["-hwaccel", hwaccel];
|
|
158
|
+
if (needsHwDownload(hwaccel)) hwaccelArgs.push("-hwaccel_device", options.renderDevice ?? "/dev/dri/renderD128");
|
|
159
|
+
if (keepFramesOnGpu(hwaccel, gpuScaleFilter)) hwaccelArgs.push("-hwaccel_output_format", hwaccel);
|
|
160
|
+
}
|
|
161
|
+
return [
|
|
162
|
+
...logBannerArgs("info"),
|
|
163
|
+
"-nostats",
|
|
164
|
+
"-fflags",
|
|
165
|
+
"+nobuffer+flush_packets",
|
|
166
|
+
"-flags",
|
|
167
|
+
"low_delay",
|
|
168
|
+
"-probesize",
|
|
169
|
+
"1M",
|
|
170
|
+
"-analyzeduration",
|
|
171
|
+
"0",
|
|
172
|
+
...hwaccelArgs,
|
|
173
|
+
"-f",
|
|
174
|
+
inputFormat,
|
|
175
|
+
"-i",
|
|
176
|
+
"pipe:0",
|
|
177
|
+
"-vf",
|
|
178
|
+
vf,
|
|
179
|
+
"-f",
|
|
180
|
+
"rawvideo",
|
|
181
|
+
"-pix_fmt",
|
|
182
|
+
pixel,
|
|
183
|
+
"pipe:1"
|
|
184
|
+
];
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Parse the exact output geometry from ffmpeg's stderr. The muxer prints an
|
|
188
|
+
* `Output #0, rawvideo …` block whose `Stream #0:0: Video: rawvideo …, <fmt>,
|
|
189
|
+
* WIDTHxHEIGHT …` line carries the scaled dimensions. Gating on the `Output #`
|
|
190
|
+
* marker avoids picking up the INPUT stream's (source) resolution, which is
|
|
191
|
+
* printed earlier.
|
|
192
|
+
*
|
|
193
|
+
* Returns `null` until the Output block with a `WxH` token has been seen.
|
|
194
|
+
*/
|
|
195
|
+
function parseFfmpegOutputDims(stderr) {
|
|
196
|
+
const outputIdx = stderr.indexOf("Output #");
|
|
197
|
+
if (outputIdx < 0) return null;
|
|
198
|
+
const match = stderr.slice(outputIdx).match(/(\d{2,5})x(\d{2,5})/);
|
|
199
|
+
if (!match) return null;
|
|
200
|
+
const width = Number(match[1]);
|
|
201
|
+
const height = Number(match[2]);
|
|
202
|
+
if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) return null;
|
|
203
|
+
return {
|
|
204
|
+
width,
|
|
205
|
+
height
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Pick the first kernel-preferred `-hwaccel` method the configured ffmpeg build
|
|
210
|
+
* actually supports, or `null` (software) when it supports none. Mirrors the
|
|
211
|
+
* broker's `pickDecodeHwAccel` (`ffmpeg-invocation.ts`) so the decoder gates
|
|
212
|
+
* decode-hwaccel against the same probed evidence:
|
|
213
|
+
* - empty `preferred` → `null` (software);
|
|
214
|
+
* - empty `supportedMethods` (probe miss) → keep the top preference and let the
|
|
215
|
+
* session's per-child software fallback cover a genuine failure;
|
|
216
|
+
* - otherwise the first preference the build supports, or `null`.
|
|
217
|
+
*/
|
|
218
|
+
function pickDecodeHwAccel(preferred, supportedMethods) {
|
|
219
|
+
if (preferred.length === 0) return null;
|
|
220
|
+
if (supportedMethods.length === 0) return preferred[0] ?? null;
|
|
221
|
+
const supported = new Set(supportedMethods.map((m) => m.toLowerCase()));
|
|
222
|
+
return preferred.find((name) => supported.has(name.toLowerCase())) ?? null;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Canonical decode-hwaccel preference order for the ffmpeg-subprocess decoder.
|
|
226
|
+
*
|
|
227
|
+
* `vaapi` is ranked ABOVE `qsv` deliberately: on the Intel hub the `qsv` decode
|
|
228
|
+
* child exits early (`code=171`, no frames) while the `vaapi` path decodes 8MP
|
|
229
|
+
* h264 cleanly at ~50fps. The kernel probe (`resolveHwAccel`) tends to surface
|
|
230
|
+
* `qsv` first on Intel, so we re-rank its result through this table before
|
|
231
|
+
* building the attempt chain. `videotoolbox` leads for macOS; `cuda` / `nvdec`
|
|
232
|
+
* trail for NVIDIA. A method not listed here keeps its incoming relative order,
|
|
233
|
+
* placed AFTER every ranked one.
|
|
234
|
+
*/
|
|
235
|
+
var DECODE_HWACCEL_RANK = [
|
|
236
|
+
"videotoolbox",
|
|
237
|
+
"vaapi",
|
|
238
|
+
"qsv",
|
|
239
|
+
"cuda",
|
|
240
|
+
"nvdec",
|
|
241
|
+
"d3d11va",
|
|
242
|
+
"dxva2",
|
|
243
|
+
"amf",
|
|
244
|
+
"vdpau",
|
|
245
|
+
"drm"
|
|
246
|
+
];
|
|
247
|
+
/** Rank index for {@link DECODE_HWACCEL_RANK} — unranked methods sort last. */
|
|
248
|
+
function decodeHwAccelRankIndex(method) {
|
|
249
|
+
const idx = DECODE_HWACCEL_RANK.indexOf(method.toLowerCase());
|
|
250
|
+
return idx < 0 ? DECODE_HWACCEL_RANK.length : idx;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Build the ORDERED list of hardware `-hwaccel` methods to attempt for an
|
|
254
|
+
* `'auto'` decode session, best-first. The session walks this chain on an
|
|
255
|
+
* early-exit-with-no-frames and only falls to software once it is exhausted.
|
|
256
|
+
*
|
|
257
|
+
* Strategy:
|
|
258
|
+
* - Seed candidates from the kernel-preferred order UNION the canonical rank
|
|
259
|
+
* UNION the build's supported list, so a method the kernel omitted but the
|
|
260
|
+
* build supports (the Intel `vaapi` case — kernel surfaces only `qsv`) is
|
|
261
|
+
* still attempted.
|
|
262
|
+
* - When the build's supported-method list is known (non-empty), keep only
|
|
263
|
+
* methods it actually offers; on a probe miss (empty) keep just the kernel
|
|
264
|
+
* preferences (avoid blindly spawning every backend).
|
|
265
|
+
* - Re-rank the survivors by {@link DECODE_HWACCEL_RANK} so `vaapi` precedes
|
|
266
|
+
* `qsv`. A supported method not in the rank table keeps its position AFTER
|
|
267
|
+
* every ranked one (stable sort). Returns lowercased method names; empty ⇒
|
|
268
|
+
* pure software.
|
|
269
|
+
*/
|
|
270
|
+
function rankDecodeHwAccels(preferred, supportedMethods) {
|
|
271
|
+
const supported = new Set(supportedMethods.map((m) => m.toLowerCase()));
|
|
272
|
+
const hasSupportList = supported.size > 0;
|
|
273
|
+
const pool = hasSupportList ? [
|
|
274
|
+
...preferred,
|
|
275
|
+
...DECODE_HWACCEL_RANK,
|
|
276
|
+
...supportedMethods
|
|
277
|
+
] : [...preferred];
|
|
278
|
+
const seen = /* @__PURE__ */ new Set();
|
|
279
|
+
const candidates = [];
|
|
280
|
+
for (const raw of pool) {
|
|
281
|
+
const method = raw.toLowerCase();
|
|
282
|
+
if (seen.has(method)) continue;
|
|
283
|
+
seen.add(method);
|
|
284
|
+
if (hasSupportList && !supported.has(method)) continue;
|
|
285
|
+
candidates.push(method);
|
|
286
|
+
}
|
|
287
|
+
return candidates.sort((a, b) => decodeHwAccelRankIndex(a) - decodeHwAccelRankIndex(b));
|
|
288
|
+
}
|
|
289
|
+
//#endregion
|
|
6
290
|
//#region src/decoder-ffmpeg/frame-ring-sink.ts
|
|
7
291
|
/**
|
|
8
292
|
* `DecoderFrameRingSink` — the decoder's shared-memory write side (Phase 5 / D9).
|
|
@@ -267,238 +551,55 @@ var DecoderFrameRingSink = class {
|
|
|
267
551
|
}
|
|
268
552
|
};
|
|
269
553
|
//#endregion
|
|
270
|
-
//#region src/decoder-ffmpeg/
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
* Validated working command on the Intel hub (renderD128, 8MP h264, ~50fps,
|
|
285
|
-
* exit 0):
|
|
286
|
-
*
|
|
287
|
-
* ffmpeg -hwaccel vaapi -hwaccel_device /dev/dri/renderD128 \
|
|
288
|
-
* -hwaccel_output_format vaapi -f h264 -i <in> \
|
|
289
|
-
* -vf hwdownload,format=nv12,scale=640:360,format=gray \
|
|
290
|
-
* -f rawvideo -y /dev/null
|
|
291
|
-
*/
|
|
292
|
-
/** Default DRM render node used for the VA-API / QSV hardware decode path. */
|
|
293
|
-
var DEFAULT_VAAPI_RENDER_DEVICE = "/dev/dri/renderD128";
|
|
294
|
-
/** Whether a resolved hwaccel value means "decode in software" (no `-hwaccel`). */
|
|
295
|
-
function isSoftwareHwAccel(hwaccel) {
|
|
296
|
-
return hwaccel === null || hwaccel === "" || hwaccel === "none";
|
|
297
|
-
}
|
|
298
|
-
/** Backends whose decoded frames stay on the GPU and need an explicit
|
|
299
|
-
* `-hwaccel_output_format <m>` + `hwdownload,format=nv12` chain. */
|
|
300
|
-
function needsHwDownload(hwaccel) {
|
|
301
|
-
return hwaccel === "vaapi" || hwaccel === "qsv";
|
|
302
|
-
}
|
|
303
|
-
/** Map a cap codec string to the ffmpeg `-f` input demuxer/format. */
|
|
304
|
-
function codecToInputFormat(codec) {
|
|
305
|
-
switch (codec.toLowerCase()) {
|
|
306
|
-
case "h265":
|
|
307
|
-
case "hevc": return "hevc";
|
|
308
|
-
default: return "h264";
|
|
554
|
+
//#region src/decoder-ffmpeg/frame-stream-splitter.ts
|
|
555
|
+
var FrameStreamSplitter = class {
|
|
556
|
+
frameSize;
|
|
557
|
+
ctl;
|
|
558
|
+
/** Whether a frame is currently being filled (partially received). */
|
|
559
|
+
open = false;
|
|
560
|
+
/** The current frame's fill target, or `null` when the open frame is dropped. */
|
|
561
|
+
target = null;
|
|
562
|
+
/** Bytes already written into the current frame. */
|
|
563
|
+
filled = 0;
|
|
564
|
+
constructor(frameSize, ctl) {
|
|
565
|
+
if (!Number.isInteger(frameSize) || frameSize <= 0) throw new Error(`FrameStreamSplitter: frameSize must be a positive integer, got ${frameSize}`);
|
|
566
|
+
this.frameSize = frameSize;
|
|
567
|
+
this.ctl = ctl;
|
|
309
568
|
}
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
}
|
|
315
|
-
/** Packed bytes-per-pixel for a raw output format (rgb24 = 3, gray = 1). */
|
|
316
|
-
function channelsForPixel(pixel) {
|
|
317
|
-
return pixel === "gray" ? 1 : 3;
|
|
318
|
-
}
|
|
319
|
-
/**
|
|
320
|
-
* The maximum output width for a given `scale` divisor — matches the node-av
|
|
321
|
-
* scaler's `maxW = floor(640 / scale)` rule so both decoders produce
|
|
322
|
-
* comparably-sized frames for the motion / detection pipeline.
|
|
323
|
-
*/
|
|
324
|
-
function maxDecodeWidth(scale) {
|
|
325
|
-
return Math.max(2, Math.floor(640 / (scale > 1 ? scale : 1)));
|
|
326
|
-
}
|
|
327
|
-
/**
|
|
328
|
-
* Compute exact output geometry from a known source resolution — the node-av
|
|
329
|
-
* rule (`outWidth = min(srcW, floor(640/scale))`, `outHeight =
|
|
330
|
-
* round(outWidth*srcH/srcW)`). Provided for callers that DO know the source
|
|
331
|
-
* dimensions; the live broker path leaves them to ffmpeg (`-2` height).
|
|
332
|
-
*/
|
|
333
|
-
function computeOutputGeometry(srcWidth, srcHeight, scale) {
|
|
334
|
-
const width = Math.min(srcWidth, maxDecodeWidth(scale));
|
|
335
|
-
return {
|
|
336
|
-
width,
|
|
337
|
-
height: Math.round(width * srcHeight / srcWidth)
|
|
338
|
-
};
|
|
339
|
-
}
|
|
340
|
-
/** Build the `-vf` filtergraph string for the requested hwaccel + pixel format. */
|
|
341
|
-
function buildVideoFilter(hwaccel, pixel, outWidth, outHeight, maxW) {
|
|
342
|
-
const scaleAndFormat = `scale=${outWidth !== void 0 && outWidth > 0 && outHeight !== void 0 && outHeight > 0 ? `${outWidth}:${outHeight}` : `min(iw\\,${maxW}):-2`},format=${pixel}`;
|
|
343
|
-
return needsHwDownload(hwaccel) ? `hwdownload,format=nv12,${scaleAndFormat}` : scaleAndFormat;
|
|
344
|
-
}
|
|
345
|
-
/**
|
|
346
|
-
* Build the full ffmpeg argv for a decode session. PURE — no spawn, no env
|
|
347
|
-
* access — so the argument shape is asserted in unit tests.
|
|
348
|
-
*
|
|
349
|
-
* Notes:
|
|
350
|
-
* - `-loglevel info -nostats`: the session parses the muxer's `Output #0`
|
|
351
|
-
* stream line from stderr to learn the exact output W×H (the broker does not
|
|
352
|
-
* pass source geometry for the shm path). `-nostats` suppresses the periodic
|
|
353
|
-
* `frame= …` counter while keeping the one-shot Output block.
|
|
354
|
-
* - The input is `pipe:0` (Annex-B packets written to stdin); the output is raw
|
|
355
|
-
* video on `pipe:1`.
|
|
356
|
-
*/
|
|
357
|
-
function buildFfmpegDecodeArgs(config, options) {
|
|
358
|
-
const inputFormat = codecToInputFormat(config.codec);
|
|
359
|
-
const pixel = rawPixelForFormat(config.outputFormat);
|
|
360
|
-
const maxW = maxDecodeWidth(config.scale);
|
|
361
|
-
const vf = buildVideoFilter(options.hwaccel, pixel, options.outWidth, options.outHeight, maxW);
|
|
362
|
-
const { hwaccel } = options;
|
|
363
|
-
let hwaccelArgs;
|
|
364
|
-
if (isSoftwareHwAccel(hwaccel) || hwaccel === null) hwaccelArgs = [];
|
|
365
|
-
else if (needsHwDownload(hwaccel)) hwaccelArgs = [
|
|
366
|
-
"-hwaccel",
|
|
367
|
-
hwaccel,
|
|
368
|
-
"-hwaccel_device",
|
|
369
|
-
options.renderDevice ?? "/dev/dri/renderD128",
|
|
370
|
-
"-hwaccel_output_format",
|
|
371
|
-
hwaccel
|
|
372
|
-
];
|
|
373
|
-
else hwaccelArgs = ["-hwaccel", hwaccel];
|
|
374
|
-
return [
|
|
375
|
-
...logBannerArgs("info"),
|
|
376
|
-
"-nostats",
|
|
377
|
-
"-fflags",
|
|
378
|
-
"+nobuffer+flush_packets",
|
|
379
|
-
"-flags",
|
|
380
|
-
"low_delay",
|
|
381
|
-
"-probesize",
|
|
382
|
-
"1M",
|
|
383
|
-
"-analyzeduration",
|
|
384
|
-
"0",
|
|
385
|
-
...hwaccelArgs,
|
|
386
|
-
"-f",
|
|
387
|
-
inputFormat,
|
|
388
|
-
"-i",
|
|
389
|
-
"pipe:0",
|
|
390
|
-
"-vf",
|
|
391
|
-
vf,
|
|
392
|
-
"-f",
|
|
393
|
-
"rawvideo",
|
|
394
|
-
"-pix_fmt",
|
|
395
|
-
pixel,
|
|
396
|
-
"pipe:1"
|
|
397
|
-
];
|
|
398
|
-
}
|
|
399
|
-
/**
|
|
400
|
-
* Parse the exact output geometry from ffmpeg's stderr. The muxer prints an
|
|
401
|
-
* `Output #0, rawvideo …` block whose `Stream #0:0: Video: rawvideo …, <fmt>,
|
|
402
|
-
* WIDTHxHEIGHT …` line carries the scaled dimensions. Gating on the `Output #`
|
|
403
|
-
* marker avoids picking up the INPUT stream's (source) resolution, which is
|
|
404
|
-
* printed earlier.
|
|
405
|
-
*
|
|
406
|
-
* Returns `null` until the Output block with a `WxH` token has been seen.
|
|
407
|
-
*/
|
|
408
|
-
function parseFfmpegOutputDims(stderr) {
|
|
409
|
-
const outputIdx = stderr.indexOf("Output #");
|
|
410
|
-
if (outputIdx < 0) return null;
|
|
411
|
-
const match = stderr.slice(outputIdx).match(/(\d{2,5})x(\d{2,5})/);
|
|
412
|
-
if (!match) return null;
|
|
413
|
-
const width = Number(match[1]);
|
|
414
|
-
const height = Number(match[2]);
|
|
415
|
-
if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) return null;
|
|
416
|
-
return {
|
|
417
|
-
width,
|
|
418
|
-
height
|
|
419
|
-
};
|
|
420
|
-
}
|
|
421
|
-
/**
|
|
422
|
-
* Pick the first kernel-preferred `-hwaccel` method the configured ffmpeg build
|
|
423
|
-
* actually supports, or `null` (software) when it supports none. Mirrors the
|
|
424
|
-
* broker's `pickDecodeHwAccel` (`ffmpeg-invocation.ts`) so the decoder gates
|
|
425
|
-
* decode-hwaccel against the same probed evidence:
|
|
426
|
-
* - empty `preferred` → `null` (software);
|
|
427
|
-
* - empty `supportedMethods` (probe miss) → keep the top preference and let the
|
|
428
|
-
* session's per-child software fallback cover a genuine failure;
|
|
429
|
-
* - otherwise the first preference the build supports, or `null`.
|
|
430
|
-
*/
|
|
431
|
-
function pickDecodeHwAccel(preferred, supportedMethods) {
|
|
432
|
-
if (preferred.length === 0) return null;
|
|
433
|
-
if (supportedMethods.length === 0) return preferred[0] ?? null;
|
|
434
|
-
const supported = new Set(supportedMethods.map((m) => m.toLowerCase()));
|
|
435
|
-
return preferred.find((name) => supported.has(name.toLowerCase())) ?? null;
|
|
436
|
-
}
|
|
437
|
-
/**
|
|
438
|
-
* Canonical decode-hwaccel preference order for the ffmpeg-subprocess decoder.
|
|
439
|
-
*
|
|
440
|
-
* `vaapi` is ranked ABOVE `qsv` deliberately: on the Intel hub the `qsv` decode
|
|
441
|
-
* child exits early (`code=171`, no frames) while the `vaapi` path decodes 8MP
|
|
442
|
-
* h264 cleanly at ~50fps. The kernel probe (`resolveHwAccel`) tends to surface
|
|
443
|
-
* `qsv` first on Intel, so we re-rank its result through this table before
|
|
444
|
-
* building the attempt chain. `videotoolbox` leads for macOS; `cuda` / `nvdec`
|
|
445
|
-
* trail for NVIDIA. A method not listed here keeps its incoming relative order,
|
|
446
|
-
* placed AFTER every ranked one.
|
|
447
|
-
*/
|
|
448
|
-
var DECODE_HWACCEL_RANK = [
|
|
449
|
-
"videotoolbox",
|
|
450
|
-
"vaapi",
|
|
451
|
-
"qsv",
|
|
452
|
-
"cuda",
|
|
453
|
-
"nvdec",
|
|
454
|
-
"d3d11va",
|
|
455
|
-
"dxva2",
|
|
456
|
-
"amf",
|
|
457
|
-
"vdpau",
|
|
458
|
-
"drm"
|
|
459
|
-
];
|
|
460
|
-
/** Rank index for {@link DECODE_HWACCEL_RANK} — unranked methods sort last. */
|
|
461
|
-
function decodeHwAccelRankIndex(method) {
|
|
462
|
-
const idx = DECODE_HWACCEL_RANK.indexOf(method.toLowerCase());
|
|
463
|
-
return idx < 0 ? DECODE_HWACCEL_RANK.length : idx;
|
|
464
|
-
}
|
|
465
|
-
/**
|
|
466
|
-
* Build the ORDERED list of hardware `-hwaccel` methods to attempt for an
|
|
467
|
-
* `'auto'` decode session, best-first. The session walks this chain on an
|
|
468
|
-
* early-exit-with-no-frames and only falls to software once it is exhausted.
|
|
469
|
-
*
|
|
470
|
-
* Strategy:
|
|
471
|
-
* - Seed candidates from the kernel-preferred order UNION the canonical rank
|
|
472
|
-
* UNION the build's supported list, so a method the kernel omitted but the
|
|
473
|
-
* build supports (the Intel `vaapi` case — kernel surfaces only `qsv`) is
|
|
474
|
-
* still attempted.
|
|
475
|
-
* - When the build's supported-method list is known (non-empty), keep only
|
|
476
|
-
* methods it actually offers; on a probe miss (empty) keep just the kernel
|
|
477
|
-
* preferences (avoid blindly spawning every backend).
|
|
478
|
-
* - Re-rank the survivors by {@link DECODE_HWACCEL_RANK} so `vaapi` precedes
|
|
479
|
-
* `qsv`. A supported method not in the rank table keeps its position AFTER
|
|
480
|
-
* every ranked one (stable sort). Returns lowercased method names; empty ⇒
|
|
481
|
-
* pure software.
|
|
482
|
-
*/
|
|
483
|
-
function rankDecodeHwAccels(preferred, supportedMethods) {
|
|
484
|
-
const supported = new Set(supportedMethods.map((m) => m.toLowerCase()));
|
|
485
|
-
const hasSupportList = supported.size > 0;
|
|
486
|
-
const pool = hasSupportList ? [
|
|
487
|
-
...preferred,
|
|
488
|
-
...DECODE_HWACCEL_RANK,
|
|
489
|
-
...supportedMethods
|
|
490
|
-
] : [...preferred];
|
|
491
|
-
const seen = /* @__PURE__ */ new Set();
|
|
492
|
-
const candidates = [];
|
|
493
|
-
for (const raw of pool) {
|
|
494
|
-
const method = raw.toLowerCase();
|
|
495
|
-
if (seen.has(method)) continue;
|
|
496
|
-
seen.add(method);
|
|
497
|
-
if (hasSupportList && !supported.has(method)) continue;
|
|
498
|
-
candidates.push(method);
|
|
569
|
+
/** True while a frame has been started (a live slot may be open) but not yet
|
|
570
|
+
* completed — the session aborts an open slot on teardown. */
|
|
571
|
+
get hasOpenFrame() {
|
|
572
|
+
return this.open && this.target !== null;
|
|
499
573
|
}
|
|
500
|
-
|
|
501
|
-
|
|
574
|
+
/**
|
|
575
|
+
* Consume a stdout chunk, emitting every frame boundary it completes. A chunk
|
|
576
|
+
* may finish the open frame, contain several whole frames, and/or leave a
|
|
577
|
+
* partial frame buffered for the next chunk.
|
|
578
|
+
*/
|
|
579
|
+
push(chunk) {
|
|
580
|
+
const n = chunk.length;
|
|
581
|
+
let off = 0;
|
|
582
|
+
while (off < n) {
|
|
583
|
+
if (!this.open) {
|
|
584
|
+
this.target = this.ctl.startFrame();
|
|
585
|
+
this.open = true;
|
|
586
|
+
this.filled = 0;
|
|
587
|
+
}
|
|
588
|
+
const take = Math.min(this.frameSize - this.filled, n - off);
|
|
589
|
+
if (this.target !== null) chunk.copy(this.target, this.filled, off, off + take);
|
|
590
|
+
this.filled += take;
|
|
591
|
+
off += take;
|
|
592
|
+
if (this.filled === this.frameSize) {
|
|
593
|
+
const finished = this.target;
|
|
594
|
+
this.open = false;
|
|
595
|
+
this.target = null;
|
|
596
|
+
this.filled = 0;
|
|
597
|
+
if (finished !== null) this.ctl.finishFrame(finished);
|
|
598
|
+
else this.ctl.dropFrame();
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
};
|
|
502
603
|
//#endregion
|
|
503
604
|
//#region src/decoder-ffmpeg/ffmpeg-decoder-session.ts
|
|
504
605
|
/**
|
|
@@ -514,10 +615,14 @@ function rankDecodeHwAccels(preferred, supportedMethods) {
|
|
|
514
615
|
*
|
|
515
616
|
* Data flow per session:
|
|
516
617
|
* Annex-B packets → ffmpeg stdin (pipe:0)
|
|
517
|
-
* ffmpeg → `-vf
|
|
518
|
-
*
|
|
519
|
-
*
|
|
520
|
-
*
|
|
618
|
+
* ffmpeg → `-vf <scale_vaapi=…,hwdownload,… | hwdownload,…,scale=…>` (GPU or
|
|
619
|
+
* CPU scale, per {@link buildFfmpegDecodeArgs})
|
|
620
|
+
* raw video → ffmpeg stdout (pipe:1) → a {@link FrameStreamSplitter} that fills
|
|
621
|
+
* each frame's bytes DIRECTLY into its shm ring slot (zero-copy
|
|
622
|
+
* `beginFrame`/`commitFrame`) → `FrameHandle` (shm mode), or into a
|
|
623
|
+
* per-frame buffer → `DecodedFrame` via `onFrame` (callback mode).
|
|
624
|
+
* No `Buffer.concat` accumulator, one copy per byte, none for dropped
|
|
625
|
+
* frames.
|
|
521
626
|
*
|
|
522
627
|
* Output geometry: the broker does not pass source width/height for the shm
|
|
523
628
|
* path, so ffmpeg picks the height (`scale=min(iw\,maxW):-2`) and the session
|
|
@@ -556,9 +661,31 @@ var FfmpegDecoderSession = class {
|
|
|
556
661
|
hwaccelChain;
|
|
557
662
|
hwaccelIndex = 0;
|
|
558
663
|
hwaccel;
|
|
664
|
+
/** GPU scalers the build offers; drives whether decode scales on the GPU. */
|
|
665
|
+
availableGpuScaleFilters;
|
|
666
|
+
onGpuScaleUnsupported;
|
|
667
|
+
/**
|
|
668
|
+
* Whether the CURRENT backend attempt may use its GPU scaler. Starts true for
|
|
669
|
+
* each backend; a GPU-scale child that dies immediately with no frames flips
|
|
670
|
+
* it false and the SAME backend is retried on the CPU-scale chain (so a broken
|
|
671
|
+
* GPU scaler degrades to hw-decode + CPU-scale, never to software decode).
|
|
672
|
+
*/
|
|
673
|
+
gpuScaleEnabled = true;
|
|
674
|
+
/** The GPU scaler the live child was spawned with, or null (CPU scale). */
|
|
675
|
+
activeGpuScaleFilter = null;
|
|
559
676
|
process = null;
|
|
560
677
|
spawnedAtMs = 0;
|
|
561
|
-
|
|
678
|
+
/**
|
|
679
|
+
* Splits ffmpeg's raw stdout byte stream into frames, filling each DIRECTLY
|
|
680
|
+
* into its shm slot (zero-copy) — created once the output geometry is known.
|
|
681
|
+
*/
|
|
682
|
+
splitter = null;
|
|
683
|
+
/** Stdout chunks received BEFORE geometry is known — replayed into the
|
|
684
|
+
* splitter once `frameSize` is learned. A short-lived list (no concat). */
|
|
685
|
+
pendingStdout = [];
|
|
686
|
+
/** The shm ring slot the splitter is currently filling, if any — aborted on
|
|
687
|
+
* respawn / teardown so a half-filled slot never publishes. */
|
|
688
|
+
openSlot = null;
|
|
562
689
|
stderrAccum = "";
|
|
563
690
|
destroyed = false;
|
|
564
691
|
pixel;
|
|
@@ -589,6 +716,8 @@ var FfmpegDecoderSession = class {
|
|
|
589
716
|
const seededChain = options?.hwaccelChain ?? (options?.hwaccel != null && !isSoftwareHwAccel(options.hwaccel) ? [options.hwaccel] : []);
|
|
590
717
|
this.hwaccelChain = seededChain.filter((m) => !isSoftwareHwAccel(m));
|
|
591
718
|
this.hwaccel = this.currentHwAccel();
|
|
719
|
+
this.availableGpuScaleFilters = options?.availableGpuScaleFilters ?? /* @__PURE__ */ new Set();
|
|
720
|
+
this.onGpuScaleUnsupported = options?.onGpuScaleUnsupported;
|
|
592
721
|
this.pixel = rawPixelForFormat(config.outputFormat);
|
|
593
722
|
this.channels = channelsForPixel(this.pixel);
|
|
594
723
|
this.frameFormat = this.pixel === "gray" ? "gray" : "rgb";
|
|
@@ -619,18 +748,22 @@ var FfmpegDecoderSession = class {
|
|
|
619
748
|
spawnFfmpeg() {
|
|
620
749
|
if (this.destroyed) return;
|
|
621
750
|
this.killFfmpeg();
|
|
622
|
-
this.
|
|
751
|
+
this.abortOpenSlot();
|
|
752
|
+
this.splitter = null;
|
|
753
|
+
this.pendingStdout = [];
|
|
623
754
|
this.stderrAccum = "";
|
|
624
755
|
this.outWidth = 0;
|
|
625
756
|
this.outHeight = 0;
|
|
626
757
|
this.frameSize = 0;
|
|
758
|
+
this.activeGpuScaleFilter = this.gpuScaleEnabled ? resolveGpuScaleFilter(this.hwaccel, this.availableGpuScaleFilters) : null;
|
|
627
759
|
const args = buildFfmpegDecodeArgs({
|
|
628
760
|
codec: this.config.codec,
|
|
629
761
|
scale: this.config.scale,
|
|
630
762
|
outputFormat: this.config.outputFormat
|
|
631
763
|
}, {
|
|
632
764
|
hwaccel: this.hwaccel,
|
|
633
|
-
renderDevice: this.renderDevice
|
|
765
|
+
renderDevice: this.renderDevice,
|
|
766
|
+
gpuScaleFilter: this.activeGpuScaleFilter
|
|
634
767
|
});
|
|
635
768
|
let child;
|
|
636
769
|
try {
|
|
@@ -650,6 +783,7 @@ var FfmpegDecoderSession = class {
|
|
|
650
783
|
child.on("exit", (code, signal) => this.handleExit(code, signal));
|
|
651
784
|
this.logger.info("ffmpeg decoder: spawned", { meta: {
|
|
652
785
|
hwaccel: this.hwaccel,
|
|
786
|
+
gpuScale: this.activeGpuScaleFilter ?? "cpu",
|
|
653
787
|
codec: this.config.codec,
|
|
654
788
|
format: this.frameFormat,
|
|
655
789
|
sink: this.frameSink
|
|
@@ -681,9 +815,24 @@ var FfmpegDecoderSession = class {
|
|
|
681
815
|
if (this.destroyed) return;
|
|
682
816
|
const diedFast = Date.now() - this.spawnedAtMs < HWACCEL_FALLBACK_WINDOW_MS;
|
|
683
817
|
if (!isSoftwareHwAccel(this.hwaccel) && this.outputFrames === 0 && diedFast) {
|
|
818
|
+
if (this.activeGpuScaleFilter !== null) {
|
|
819
|
+
const brokenFilter = this.activeGpuScaleFilter;
|
|
820
|
+
this.gpuScaleEnabled = false;
|
|
821
|
+
this.onGpuScaleUnsupported?.(brokenFilter);
|
|
822
|
+
this.logger.warn("ffmpeg decoder: GPU-scale child exited early with no frames — retrying same backend with CPU scale", { meta: {
|
|
823
|
+
hwaccel: this.hwaccel,
|
|
824
|
+
gpuScaleFilter: brokenFilter,
|
|
825
|
+
code,
|
|
826
|
+
signal
|
|
827
|
+
} });
|
|
828
|
+
this.process = null;
|
|
829
|
+
this.spawnFfmpeg();
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
684
832
|
const failed = this.hwaccel;
|
|
685
833
|
this.hwaccelIndex += 1;
|
|
686
834
|
this.hwaccel = this.currentHwAccel();
|
|
835
|
+
this.gpuScaleEnabled = true;
|
|
687
836
|
const next = isSoftwareHwAccel(this.hwaccel) ? "software" : this.hwaccel;
|
|
688
837
|
this.logger.warn("ffmpeg decoder: hwaccel child exited early with no frames — trying next decode path", { meta: {
|
|
689
838
|
failed,
|
|
@@ -729,58 +878,79 @@ var FfmpegDecoderSession = class {
|
|
|
729
878
|
channels: this.channels,
|
|
730
879
|
frameSize: this.frameSize
|
|
731
880
|
} });
|
|
732
|
-
this.
|
|
881
|
+
this.splitter = new FrameStreamSplitter(this.frameSize, this.makeSlotController());
|
|
882
|
+
const pending = this.pendingStdout;
|
|
883
|
+
this.pendingStdout = [];
|
|
884
|
+
for (const chunk of pending) this.splitter.push(chunk);
|
|
733
885
|
}
|
|
734
886
|
handleStdout(chunk) {
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
this.drainFrames();
|
|
738
|
-
}
|
|
739
|
-
drainFrames() {
|
|
740
|
-
const size = this.frameSize;
|
|
741
|
-
if (size === 0) return;
|
|
742
|
-
let offset = 0;
|
|
743
|
-
while (this.outputBuffer.length - offset >= size) {
|
|
744
|
-
const slice = this.outputBuffer.subarray(offset, offset + size);
|
|
745
|
-
offset += size;
|
|
746
|
-
this.emitFrame(slice);
|
|
747
|
-
}
|
|
748
|
-
if (offset > 0) this.outputBuffer = Buffer.from(this.outputBuffer.subarray(offset));
|
|
749
|
-
}
|
|
750
|
-
emitFrame(slice) {
|
|
751
|
-
if (!this.frameDropper.shouldKeep()) {
|
|
752
|
-
this.droppedFrames++;
|
|
887
|
+
if (this.splitter === null) {
|
|
888
|
+
this.pendingStdout.push(chunk);
|
|
753
889
|
return;
|
|
754
890
|
}
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
891
|
+
this.splitter.push(chunk);
|
|
892
|
+
}
|
|
893
|
+
/**
|
|
894
|
+
* The frame lifecycle the {@link FrameStreamSplitter} drives. `startFrame`
|
|
895
|
+
* reserves a shm slot (zero-copy: the splitter fills the slot view in place)
|
|
896
|
+
* or, for the callback sink, allocates one frame buffer; `finishFrame`
|
|
897
|
+
* publishes the filled frame; `dropFrame` accounts an FPS-skipped one.
|
|
898
|
+
*/
|
|
899
|
+
makeSlotController() {
|
|
900
|
+
return {
|
|
901
|
+
startFrame: () => {
|
|
902
|
+
if (!this.frameDropper.shouldKeep()) return null;
|
|
903
|
+
if (this.frameSink === "shm") {
|
|
904
|
+
const reserved = this.ensureFrameRingSink().beginFrame(this.outWidth, this.outHeight, this.frameFormat);
|
|
905
|
+
if (reserved === null) return null;
|
|
906
|
+
this.openSlot = reserved.slot;
|
|
907
|
+
return reserved.buffer;
|
|
908
|
+
}
|
|
909
|
+
return Buffer.allocUnsafe(this.frameSize);
|
|
910
|
+
},
|
|
911
|
+
finishFrame: (target) => {
|
|
912
|
+
this.outputFrames++;
|
|
913
|
+
if (this.frameSink === "shm") {
|
|
914
|
+
const slot = this.openSlot;
|
|
915
|
+
this.openSlot = null;
|
|
916
|
+
const sink = this.frameRingSink;
|
|
917
|
+
if (slot === null || sink === null) return;
|
|
918
|
+
const handle = sink.commitFrame(slot, {
|
|
919
|
+
width: this.outWidth,
|
|
920
|
+
height: this.outHeight,
|
|
921
|
+
format: this.frameFormat,
|
|
922
|
+
pts: performance.now(),
|
|
923
|
+
byteLength: this.frameSize
|
|
924
|
+
});
|
|
925
|
+
if (handle === null) return;
|
|
926
|
+
const delivered = {
|
|
927
|
+
handle,
|
|
928
|
+
timestamp: Date.now()
|
|
929
|
+
};
|
|
930
|
+
for (const cb of this.handleCallbacks) cb(delivered);
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
const frame = {
|
|
934
|
+
data: target,
|
|
935
|
+
width: this.outWidth,
|
|
936
|
+
height: this.outHeight,
|
|
937
|
+
format: this.frameFormat,
|
|
938
|
+
timestamp: Date.now()
|
|
939
|
+
};
|
|
940
|
+
for (const cb of this.frameCallbacks) cb(frame);
|
|
941
|
+
},
|
|
942
|
+
dropFrame: () => {
|
|
766
943
|
this.droppedFrames++;
|
|
767
|
-
return;
|
|
768
944
|
}
|
|
769
|
-
const delivered = {
|
|
770
|
-
handle,
|
|
771
|
-
timestamp: Date.now()
|
|
772
|
-
};
|
|
773
|
-
for (const cb of this.handleCallbacks) cb(delivered);
|
|
774
|
-
return;
|
|
775
|
-
}
|
|
776
|
-
const frame = {
|
|
777
|
-
data: pixels,
|
|
778
|
-
width: this.outWidth,
|
|
779
|
-
height: this.outHeight,
|
|
780
|
-
format: this.frameFormat,
|
|
781
|
-
timestamp: Date.now()
|
|
782
945
|
};
|
|
783
|
-
|
|
946
|
+
}
|
|
947
|
+
/** Abandon a shm slot the splitter left open (respawn / teardown mid-frame),
|
|
948
|
+
* so a half-filled slot is never published. */
|
|
949
|
+
abortOpenSlot() {
|
|
950
|
+
const slot = this.openSlot;
|
|
951
|
+
if (slot === null) return;
|
|
952
|
+
this.openSlot = null;
|
|
953
|
+
this.frameRingSink?.abortFrame(slot);
|
|
784
954
|
}
|
|
785
955
|
pushPacket(packet) {
|
|
786
956
|
if (this.destroyed) return;
|
|
@@ -817,6 +987,8 @@ var FfmpegDecoderSession = class {
|
|
|
817
987
|
if (this.destroyed) return;
|
|
818
988
|
this.destroyed = true;
|
|
819
989
|
this.killFfmpeg();
|
|
990
|
+
this.abortOpenSlot();
|
|
991
|
+
this.splitter = null;
|
|
820
992
|
this.frameRingSink?.destroy();
|
|
821
993
|
this.frameRingSink = null;
|
|
822
994
|
this.frameCallbacks.clear();
|
|
@@ -833,6 +1005,64 @@ var FfmpegDecoderSession = class {
|
|
|
833
1005
|
}
|
|
834
1006
|
};
|
|
835
1007
|
//#endregion
|
|
1008
|
+
//#region src/decoder-ffmpeg/ffmpeg-filter-probe.ts
|
|
1009
|
+
/**
|
|
1010
|
+
* One-shot probe of the GPU scale filters an ffmpeg build offers.
|
|
1011
|
+
*
|
|
1012
|
+
* The decoder runs the downscale on the GPU (`scale_vaapi` / `scale_qsv` /
|
|
1013
|
+
* `scale_vt`) instead of downloading the full-resolution frame and scaling it on
|
|
1014
|
+
* the CPU — a large saving on VA-API. Whether a given build even compiled those
|
|
1015
|
+
* filters in is build-specific, so the addon probes `ffmpeg -filters` ONCE at
|
|
1016
|
+
* init and passes the result into every session. The parse
|
|
1017
|
+
* ({@link parseAvailableGpuScaleFilters}) is a pure function unit-tested in
|
|
1018
|
+
* `ffmpeg-args.spec.ts`; only the spawn lives here.
|
|
1019
|
+
*/
|
|
1020
|
+
/** How long to wait for `ffmpeg -filters` before giving up (→ empty set). */
|
|
1021
|
+
var PROBE_TIMEOUT_MS = 5e3;
|
|
1022
|
+
/**
|
|
1023
|
+
* Run `<ffmpegPath> -hide_banner -filters` and return the {@link GpuScaleFilter}s
|
|
1024
|
+
* the build lists. Never throws — a missing / slow / failing binary resolves to
|
|
1025
|
+
* an empty set, which makes every session fall back to the CPU-scale chain.
|
|
1026
|
+
*/
|
|
1027
|
+
function probeGpuScaleFilters(ffmpegPath, logger) {
|
|
1028
|
+
return new Promise((resolve) => {
|
|
1029
|
+
let stdout = "";
|
|
1030
|
+
let settled = false;
|
|
1031
|
+
const done = (set) => {
|
|
1032
|
+
if (settled) return;
|
|
1033
|
+
settled = true;
|
|
1034
|
+
clearTimeout(timer);
|
|
1035
|
+
resolve(set);
|
|
1036
|
+
};
|
|
1037
|
+
let child;
|
|
1038
|
+
try {
|
|
1039
|
+
child = spawn(ffmpegPath, ["-hide_banner", "-filters"]);
|
|
1040
|
+
} catch (err) {
|
|
1041
|
+
logger.warn("decoder-ffmpeg: GPU-scale filter probe spawn failed — CPU scale", { meta: { error: errMsg(err) } });
|
|
1042
|
+
done(/* @__PURE__ */ new Set());
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
const timer = setTimeout(() => {
|
|
1046
|
+
try {
|
|
1047
|
+
child.kill("SIGKILL");
|
|
1048
|
+
} catch {}
|
|
1049
|
+
logger.warn("decoder-ffmpeg: GPU-scale filter probe timed out — CPU scale");
|
|
1050
|
+
done(/* @__PURE__ */ new Set());
|
|
1051
|
+
}, PROBE_TIMEOUT_MS);
|
|
1052
|
+
timer.unref?.();
|
|
1053
|
+
child.stdout?.on("data", (chunk) => {
|
|
1054
|
+
stdout += chunk.toString();
|
|
1055
|
+
});
|
|
1056
|
+
child.on("error", (err) => {
|
|
1057
|
+
logger.warn("decoder-ffmpeg: GPU-scale filter probe errored — CPU scale", { meta: { error: err.message } });
|
|
1058
|
+
done(/* @__PURE__ */ new Set());
|
|
1059
|
+
});
|
|
1060
|
+
child.on("exit", () => {
|
|
1061
|
+
done(parseAvailableGpuScaleFilters(stdout));
|
|
1062
|
+
});
|
|
1063
|
+
});
|
|
1064
|
+
}
|
|
1065
|
+
//#endregion
|
|
836
1066
|
//#region src/decoder-ffmpeg/addon/index.ts
|
|
837
1067
|
var FRAME_BUFFER_CAPACITY = 32;
|
|
838
1068
|
var DecoderFfmpegAddon = class extends BaseAddon {
|
|
@@ -851,6 +1081,19 @@ var DecoderFfmpegAddon = class extends BaseAddon {
|
|
|
851
1081
|
* decode-accel probe reports for the right build.
|
|
852
1082
|
*/
|
|
853
1083
|
ffmpegPath = "ffmpeg";
|
|
1084
|
+
/**
|
|
1085
|
+
* GPU scale filters the resolved ffmpeg build offers (`scale_vaapi` /
|
|
1086
|
+
* `scale_qsv` / `scale_vt`), probed once at init. A session running one of
|
|
1087
|
+
* these downscales on the GPU instead of the CPU (a large VA-API saving).
|
|
1088
|
+
*/
|
|
1089
|
+
probedGpuScaleFilters = /* @__PURE__ */ new Set();
|
|
1090
|
+
/**
|
|
1091
|
+
* GPU scalers the build LISTS but that proved non-functional at runtime (a
|
|
1092
|
+
* session's GPU-scale child died with no frames and self-healed to CPU scale).
|
|
1093
|
+
* Excluded from later sessions so the failed spawn happens at most once per
|
|
1094
|
+
* process — e.g. a videotoolbox build whose decode returns software frames.
|
|
1095
|
+
*/
|
|
1096
|
+
unsupportedGpuScaleFilters = /* @__PURE__ */ new Set();
|
|
854
1097
|
constructor() {
|
|
855
1098
|
super(DEFAULT_DECODER_HWACCEL_CONFIG);
|
|
856
1099
|
}
|
|
@@ -886,6 +1129,11 @@ var DecoderFfmpegAddon = class extends BaseAddon {
|
|
|
886
1129
|
this.ctx.logger.info("ffmpeg decoder addon initialized");
|
|
887
1130
|
this.frameReaders = new FrameRingReaderCache(this.ctx.logger);
|
|
888
1131
|
this.ffmpegPath = await this.resolveFfmpegBinaryPath();
|
|
1132
|
+
this.probedGpuScaleFilters = await probeGpuScaleFilters(this.ffmpegPath, this.ctx.logger);
|
|
1133
|
+
this.ctx.logger.info("decoder-ffmpeg: probed GPU scale filters", { meta: {
|
|
1134
|
+
filters: [...this.probedGpuScaleFilters],
|
|
1135
|
+
ffmpeg: this.ffmpegPath
|
|
1136
|
+
} });
|
|
889
1137
|
if (!this.config.probedBestHwaccel) this.reprobeHwaccel().catch((err) => {
|
|
890
1138
|
this.ctx.logger.warn("ffmpeg: auto-reprobe hwaccel failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
891
1139
|
});
|
|
@@ -1001,11 +1249,18 @@ var DecoderFfmpegAddon = class extends BaseAddon {
|
|
|
1001
1249
|
const { frameSink } = config;
|
|
1002
1250
|
const nodeId = this.resolveLocalNodeId();
|
|
1003
1251
|
const hwaccelChain = await this.resolveDecodeHwaccelChain();
|
|
1252
|
+
const availableGpuScaleFilters = new Set([...this.probedGpuScaleFilters].filter((f) => !this.unsupportedGpuScaleFilters.has(f)));
|
|
1004
1253
|
const session = new FfmpegDecoderSession(config, this.ctx.logger, {
|
|
1005
1254
|
hwaccelChain,
|
|
1006
1255
|
ffmpegPath: this.ffmpegPath,
|
|
1007
1256
|
frameSink,
|
|
1008
|
-
nodeId
|
|
1257
|
+
nodeId,
|
|
1258
|
+
availableGpuScaleFilters,
|
|
1259
|
+
onGpuScaleUnsupported: (filter) => {
|
|
1260
|
+
if (this.unsupportedGpuScaleFilters.has(filter)) return;
|
|
1261
|
+
this.unsupportedGpuScaleFilters.add(filter);
|
|
1262
|
+
this.ctx.logger.warn("decoder-ffmpeg: GPU scaler non-functional on this build — future sessions use CPU scale", { meta: { filter } });
|
|
1263
|
+
}
|
|
1009
1264
|
});
|
|
1010
1265
|
const unsub = frameSink === "shm" ? this.wireShmSink(sessionId, session) : this.wireCallbackSink(sessionId, session);
|
|
1011
1266
|
this.sessions.set(sessionId, session);
|