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