@camstack/addon-pipeline 1.1.20 → 1.1.22

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.
Files changed (34) hide show
  1. package/dist/audio-analyzer/index.js +1 -1
  2. package/dist/audio-analyzer/index.mjs +1 -1
  3. package/dist/audio-codec-ffmpeg/index.js +1 -1
  4. package/dist/audio-codec-ffmpeg/index.mjs +1 -1
  5. package/dist/decoder-ffmpeg/index.js +541 -286
  6. package/dist/decoder-ffmpeg/index.mjs +540 -285
  7. package/dist/detection-pipeline/index.js +1 -1
  8. package/dist/detection-pipeline/index.mjs +1 -1
  9. package/dist/{dist-BiP1gPeY.js → dist-CE3a05qT.js} +228 -16
  10. package/dist/{dist-tzhTTRq4.mjs → dist-pd_-3C0T.mjs} +228 -16
  11. package/dist/{ffmpeg-args-common-c3p-nWtU.mjs → frame-dropper-CwkBTPGV.mjs} +22 -22
  12. package/dist/motion-wasm/index.js +1 -1
  13. package/dist/motion-wasm/index.mjs +1 -1
  14. package/dist/pipeline-runner/index.js +412 -247
  15. package/dist/pipeline-runner/index.mjs +412 -247
  16. package/dist/recorder/index.js +260 -234
  17. package/dist/recorder/index.mjs +260 -234
  18. package/dist/stream-broker/_stub.js +378 -324
  19. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-D08qJTw_.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CLVitFM4.mjs} +2 -2
  20. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BFeBlYRQ.mjs +26 -0
  21. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-C0QtG7oR.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BC1Jt6G8.mjs} +1 -1
  22. package/dist/stream-broker/{hostInit-CGnMNtPl.mjs → hostInit-B15D9Mjv.mjs} +2 -2
  23. package/dist/stream-broker/index.js +13901 -12512
  24. package/dist/stream-broker/index.mjs +13891 -12502
  25. package/dist/stream-broker/remoteEntry.js +1 -1
  26. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-CW4Qu4V6.js → MaskShapeCanvas-DI4BY7W2-BqXU75it.js} +1 -1
  27. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-CHJ39tfi.js → MotionZonesSettings-NcxxQN8r-Ct2fpgCd.js} +1 -1
  28. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-dInJ7kcz.js → PrivacyMaskSettings-APgPLF7p-a9eFVPPl.js} +1 -1
  29. package/embed-dist/assets/index-DhdVxWXL.js +80 -0
  30. package/embed-dist/index.html +1 -1
  31. package/package.json +1 -1
  32. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BSWrW78G.mjs +0 -26
  33. package/embed-dist/assets/index-CKpVWKSG.js +0 -80
  34. package/dist/{ffmpeg-args-common-CASoNt42.js → frame-dropper-7RTo_YyG.js} +21 -21
@@ -2,11 +2,295 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-BiP1gPeY.js");
6
- const require_ffmpeg_args_common = require("../ffmpeg-args-common-CASoNt42.js");
5
+ const require_dist = require("../dist-CE3a05qT.js");
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/ffmpeg-args.ts
275
- /**
276
- * Pure, side-effect-free ffmpeg argument construction for the
277
- * `decoder-ffmpeg` addon.
278
- *
279
- * The addon decodes video in an **ffmpeg subprocess** (unlike `decoder-nodeav`
280
- * which decodes in-process via node-av's native bindings). On the Intel hub,
281
- * node-av's VA-API decode path SIGBUSes (uncatchable) on 8MP h264 and takes the
282
- * whole decoder runner down; ffmpeg-as-subprocess decodes the same hardware
283
- * cleanly because a crash is isolated to the child process.
284
- *
285
- * These helpers are extracted from the session so the argument shape is
286
- * unit-testable without spawning a real ffmpeg binary.
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
- /** Map the cap-level output format to a raw ffmpeg pixel format. */
316
- function rawPixelForFormat(outputFormat) {
317
- return outputFormat === "gray" ? "gray" : "rgb24";
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
- return candidates.sort((a, b) => decodeHwAccelRankIndex(a) - decodeHwAccelRankIndex(b));
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 [hwdownload,format=nv12,]scale=…,format=<rgb24|gray>`
522
- * raw video ffmpeg stdout (pipe:1) → fixed-size frame slices
523
- * each slice`DecoderFrameRingSink.writeFrame``FrameHandle` (shm mode)
524
- * or `DecodedFrame` via `onFrame` (callback mode)
622
+ * ffmpeg → `-vf <scale_vaapi=…,hwdownload,… | hwdownload,…,scale=…>` (GPU or
623
+ * CPU scale, per {@link buildFfmpegDecodeArgs})
624
+ * raw videoffmpeg 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
- outputBuffer = Buffer.alloc(0);
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 require_ffmpeg_args_common.FrameDropper(config.maxFps);
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.outputBuffer = Buffer.alloc(0);
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.drainFrames();
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
- this.outputBuffer = this.outputBuffer.length === 0 ? chunk : Buffer.concat([this.outputBuffer, chunk]);
740
- if (this.frameSize === 0) return;
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
- const pixels = Buffer.from(slice);
760
- this.outputFrames++;
761
- if (this.frameSink === "shm") {
762
- const handle = this.ensureFrameRingSink().writeFrame(pixels, {
763
- width: this.outWidth,
764
- height: this.outHeight,
765
- format: this.frameFormat,
766
- pts: performance.now(),
767
- byteLength: pixels.byteLength
768
- });
769
- if (handle === null) {
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
- for (const cb of this.frameCallbacks) cb(frame);
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);