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