@camstack/addon-pipeline 1.1.34 → 1.1.35

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 (63) hide show
  1. package/assets/reference-audio/dog-bark.wav +0 -0
  2. package/assets/reference-audio/meow.wav +0 -0
  3. package/assets/reference-audio/silence.wav +0 -0
  4. package/assets/reference-audio/siren.wav +0 -0
  5. package/assets/reference-audio/speech.wav +0 -0
  6. package/assets/reference-audio/synthetic-alarm.wav +0 -0
  7. package/assets/reference-audio/synthetic-bark.wav +0 -0
  8. package/assets/reference-audio/synthetic-cry.wav +0 -0
  9. package/assets/reference-audio/synthetic-speech.wav +0 -0
  10. package/assets/reference-images/1-bird.jpg +0 -0
  11. package/assets/reference-images/1-car-plate.jpg +0 -0
  12. package/assets/reference-images/2-birds.jpg +0 -0
  13. package/assets/reference-images/2-cars-plates.jpg +0 -0
  14. package/assets/reference-images/3-persons.jpg +0 -0
  15. package/assets/reference-images/README.md +16 -0
  16. package/assets/reference-images/animals-group.jpg +0 -0
  17. package/assets/reference-images/ground-truth/1-bird.json +22 -0
  18. package/assets/reference-images/ground-truth/1-car-plate.json +85 -0
  19. package/assets/reference-images/ground-truth/2-birds.json +35 -0
  20. package/assets/reference-images/ground-truth/2-cars-plates.json +137 -0
  21. package/assets/reference-images/ground-truth/3-persons.json +186 -0
  22. package/assets/reference-images/ground-truth/animals-group.json +87 -0
  23. package/assets/reference-images/ground-truth/persons-cars-animal.json +136 -0
  24. package/assets/reference-images/ground-truth/sinner.json +73 -0
  25. package/assets/reference-images/persons-cars-animal.jpg +0 -0
  26. package/assets/reference-images/sinner.jpg +0 -0
  27. package/dist/audio-analyzer/index.js +1 -1
  28. package/dist/audio-analyzer/index.mjs +1 -1
  29. package/dist/audio-codec-ffmpeg/index.js +1 -1
  30. package/dist/audio-codec-ffmpeg/index.mjs +1 -1
  31. package/dist/detection-pipeline/index.js +3 -1
  32. package/dist/detection-pipeline/index.mjs +3 -1
  33. package/dist/{dist-CfSORkv2.mjs → dist-BESCfHIV.mjs} +35 -128
  34. package/dist/{dist-BrIE85BG.js → dist-BZBQc2QZ.js} +34 -145
  35. package/dist/{frame-handle-plane-C05rq9Zs.mjs → frame-handle-plane-CYtnVPIg.mjs} +1 -1
  36. package/dist/{frame-handle-plane-zMju9vmk.js → frame-handle-plane-ChjxN8oI.js} +1 -1
  37. package/dist/motion-wasm/index.js +1 -1
  38. package/dist/motion-wasm/index.mjs +1 -1
  39. package/dist/pipeline-runner/index.js +2 -2
  40. package/dist/pipeline-runner/index.mjs +2 -2
  41. package/dist/recorder/index.js +1 -1
  42. package/dist/recorder/index.mjs +1 -1
  43. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DPFAbcUo.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-B0wZ-iCZ.mjs} +2 -2
  44. package/dist/stream-broker/{hostInit-C8slrxw1.mjs → hostInit-DuzG6QmV.mjs} +2 -2
  45. package/dist/stream-broker/index.js +133 -18
  46. package/dist/stream-broker/index.mjs +119 -4
  47. package/dist/stream-broker/remoteEntry.js +1 -1
  48. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-WUVfFzT-.js → MaskShapeCanvas-DI4BY7W2-BL-S3O0Z.js} +1 -1
  49. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-CF7FXXLs.js → MotionZonesSettings-NcxxQN8r-DcnKZAt6.js} +1 -1
  50. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-qQCpVob3.js → PrivacyMaskSettings-APgPLF7p-DQ0MK5T9.js} +1 -1
  51. package/embed-dist/assets/{index-BNhisVUZ.js → index-BCGUC-8v.js} +7 -7
  52. package/embed-dist/index.html +1 -1
  53. package/package.json +3 -57
  54. package/dist/decoder-ffmpeg/index.js +0 -1114
  55. package/dist/decoder-ffmpeg/index.mjs +0 -1097
  56. package/dist/decoder-nodeav/index.js +0 -1422
  57. package/dist/decoder-nodeav/index.mjs +0 -1414
  58. package/dist/ffmpeg-args-CpeRWwL4.mjs +0 -326
  59. package/dist/ffmpeg-args-Dl9mLcOC.js +0 -421
  60. package/dist/frame-dropper-AjheBGMG.mjs +0 -22
  61. package/dist/frame-dropper-DKLM6pMz.js +0 -27
  62. package/dist/frame-ring-sink-1aLGcNNf.mjs +0 -557
  63. package/dist/frame-ring-sink-CvlcAUOu.js +0 -604
@@ -1,1097 +0,0 @@
1
- import { B as errMsg, V as BaseAddon, d as HWACCEL_OPTIONS, s as DEFAULT_DECODER_HWACCEL_CONFIG, x as decoderCapability } from "../dist-CfSORkv2.mjs";
2
- import { c as DEFAULT_DECODER_BACKEND, i as NotifyingRingBuffer, n as RING_BUDGET_MB, s as resolveOwnDecoderBackend, t as DecoderFrameRingSink } from "../frame-ring-sink-1aLGcNNf.mjs";
3
- import { a as codecToInputFormat, c as maxDecodeWidth, d as pickDecodeHwAccel, f as rankDecodeHwAccels, i as channelsForPixel, l as parseAvailableGpuScaleFilters, m as resolveGpuScaleFilter, n as DEFAULT_VAAPI_RENDER_DEVICE, o as computeOutputGeometry, p as rawPixelForFormat, r as buildFfmpegDecodeArgs, s as isSoftwareHwAccel, t as DECODE_HWACCEL_RANK, u as parseFfmpegOutputDims } from "../ffmpeg-args-CpeRWwL4.mjs";
4
- import { t as FrameDropper } from "../frame-dropper-AjheBGMG.mjs";
5
- import { FrameRingReaderCache } from "@camstack/shm-ring";
6
- import { randomUUID } from "node:crypto";
7
- import { spawn } from "node:child_process";
8
- //#region src/decoder-ffmpeg/frame-stream-splitter.ts
9
- var FrameStreamSplitter = class {
10
- frameSize;
11
- ctl;
12
- /** Whether a frame is currently being filled (partially received). */
13
- open = false;
14
- /** The current frame's fill target, or `null` when the open frame is dropped. */
15
- target = null;
16
- /** Bytes already written into the current frame. */
17
- filled = 0;
18
- constructor(frameSize, ctl) {
19
- if (!Number.isInteger(frameSize) || frameSize <= 0) throw new Error(`FrameStreamSplitter: frameSize must be a positive integer, got ${frameSize}`);
20
- this.frameSize = frameSize;
21
- this.ctl = ctl;
22
- }
23
- /** True while a frame has been started (a live slot may be open) but not yet
24
- * completed — the session aborts an open slot on teardown. */
25
- get hasOpenFrame() {
26
- return this.open && this.target !== null;
27
- }
28
- /**
29
- * Consume a stdout chunk, emitting every frame boundary it completes. A chunk
30
- * may finish the open frame, contain several whole frames, and/or leave a
31
- * partial frame buffered for the next chunk.
32
- */
33
- push(chunk) {
34
- const n = chunk.length;
35
- let off = 0;
36
- while (off < n) {
37
- if (!this.open) {
38
- this.target = this.ctl.startFrame();
39
- this.open = true;
40
- this.filled = 0;
41
- }
42
- const take = Math.min(this.frameSize - this.filled, n - off);
43
- if (this.target !== null) chunk.copy(this.target, this.filled, off, off + take);
44
- this.filled += take;
45
- off += take;
46
- if (this.filled === this.frameSize) {
47
- const finished = this.target;
48
- this.open = false;
49
- this.target = null;
50
- this.filled = 0;
51
- if (finished !== null) this.ctl.finishFrame(finished);
52
- else this.ctl.dropFrame();
53
- }
54
- }
55
- }
56
- };
57
- //#endregion
58
- //#region src/decoder-ffmpeg/ffmpeg-decoder-session.ts
59
- /**
60
- * FfmpegDecoderSession — an `IDecoderSession` that decodes video in an **ffmpeg
61
- * subprocess** and feeds the shared-memory frame ring (Phase 5 / D9), the same
62
- * ring `decoder-nodeav` writes into.
63
- *
64
- * Why a subprocess: on the Intel hub, node-av's in-process VA-API decode path
65
- * SIGBUSes (uncatchable) on 8MP h264 and kills the whole decoder runner. ffmpeg
66
- * decodes the identical hardware cleanly because it runs out-of-process — a
67
- * crashing child just stops producing frames; the addon survives and the
68
- * frame-plane rotates/retries.
69
- *
70
- * Data flow per session:
71
- * Annex-B packets → ffmpeg stdin (pipe:0)
72
- * ffmpeg → `-vf <scale_vaapi=…,hwdownload,… | hwdownload,…,scale=…>` (GPU or
73
- * CPU scale, per {@link buildFfmpegDecodeArgs})
74
- * raw video → ffmpeg stdout (pipe:1) → a {@link FrameStreamSplitter} that fills
75
- * each frame's bytes DIRECTLY into its shm ring slot (zero-copy
76
- * `beginFrame`/`commitFrame`) → `FrameHandle` (shm mode), or into a
77
- * per-frame buffer → `DecodedFrame` via `onFrame` (callback mode).
78
- * No `Buffer.concat` accumulator, one copy per byte, none for dropped
79
- * frames.
80
- *
81
- * Output geometry: the broker does not pass source width/height for the shm
82
- * path, so ffmpeg picks the height (`scale=min(iw\,maxW):-2`) and the session
83
- * learns the exact output W×H by parsing the muxer's `Output #0` stderr block
84
- * ({@link parseFfmpegOutputDims}). Until dims are known, stdout is buffered.
85
- */
86
- var noopLogger = {
87
- debug() {},
88
- info() {},
89
- warn() {},
90
- error() {},
91
- child() {
92
- return noopLogger;
93
- },
94
- withTags() {
95
- return noopLogger;
96
- }
97
- };
98
- /** A hardware child that dies faster than this (with no frames) advances the
99
- * session to the NEXT decode path (next hwaccel, else software) — a genuine
100
- * hwaccel init failure (missing render node, unsupported codec on GPU, the
101
- * Intel `qsv` `code=171`) surfaces this early. */
102
- var HWACCEL_FALLBACK_WINDOW_MS = 4e3;
103
- /**
104
- * Delay before a PULL-MODE session re-dials its input URL after the child
105
- * exits (RTSP EOF, restream teardown, network blip). Push mode never
106
- * self-respawns on a plain exit — the broker's packet feed drives recovery —
107
- * but a pull-mode child owns its own input, so the session must re-dial or a
108
- * single EOF would silence it forever. The delay turns a hard-down source
109
- * into a slow probe instead of a spawn storm; the consumer tearing the
110
- * session down (`destroy`) cancels the pending re-dial.
111
- */
112
- var PULL_REDIAL_DELAY_MS = 3e3;
113
- /**
114
- * Safety ceiling for the current-GOP replay buffer (push mode). A GOP resets on
115
- * every keyframe so this is never reached by a well-behaved stream; it only
116
- * bounds a pathological long/no-keyframe stream. When exceeded, only the
117
- * keyframe is retained — enough to re-prime the decoder's SPS/PPS on a re-spawn.
118
- */
119
- var MAX_GOP_BUFFER_BYTES = 16 * 1024 * 1024;
120
- var FfmpegDecoderSession = class {
121
- config;
122
- logger;
123
- frameSink;
124
- nodeId;
125
- renderDevice;
126
- ffmpegPath;
127
- /**
128
- * Ordered hardware decode paths to attempt, best-first. Empty ⇒ software
129
- * only. The session walks it via {@link hwaccelIndex}; an index past the end
130
- * means software (`null`).
131
- */
132
- hwaccelChain;
133
- hwaccelIndex = 0;
134
- hwaccel;
135
- /** GPU scalers the build offers; drives whether decode scales on the GPU. */
136
- availableGpuScaleFilters;
137
- onGpuScaleUnsupported;
138
- /**
139
- * Whether the CURRENT backend attempt may use its GPU scaler. Starts true for
140
- * each backend; a GPU-scale child that dies immediately with no frames flips
141
- * it false and the SAME backend is retried on the CPU-scale chain (so a broken
142
- * GPU scaler degrades to hw-decode + CPU-scale, never to software decode).
143
- */
144
- gpuScaleEnabled = true;
145
- /** The GPU scaler the live child was spawned with, or null (CPU scale). */
146
- activeGpuScaleFilter = null;
147
- process = null;
148
- spawnedAtMs = 0;
149
- /**
150
- * Whether a spawn has ever been attempted. Drives the LAZY first spawn
151
- * (first `pushPacket` / `openStream`) and preserves the recovery semantics
152
- * afterwards: a push-mode child that later dies is NOT respawned by the
153
- * packet feed (the consumer / frame-plane self-heal recreates the session),
154
- * and `updateConfig` only restarts a session that has already spawned.
155
- */
156
- spawnAttempted = false;
157
- /**
158
- * PULL-MODE input URL (P2b). `null` = push mode (packets via stdin — the
159
- * default). Set by {@link openStream}; every (re)spawn since reads this URL
160
- * directly instead of `pipe:0`. A session is push-mode or pull-mode for its
161
- * whole life — `openStream` on a packet-fed session throws.
162
- */
163
- pullUrl = null;
164
- /** Pending pull-mode re-dial timer (armed on a pull child exit). */
165
- pullRedialTimer = null;
166
- /**
167
- * Splits ffmpeg's raw stdout byte stream into frames, filling each DIRECTLY
168
- * into its shm slot (zero-copy) — created once the output geometry is known.
169
- */
170
- splitter = null;
171
- /** Stdout chunks received BEFORE geometry is known — replayed into the
172
- * splitter once `frameSize` is learned. A short-lived list (no concat). */
173
- pendingStdout = [];
174
- /** The shm ring slot the splitter is currently filling, if any — aborted on
175
- * respawn / teardown so a half-filled slot never publishes. */
176
- openSlot = null;
177
- stderrAccum = "";
178
- destroyed = false;
179
- pixel;
180
- channels;
181
- /** The cap-facing raw format label (`'rgb'` / `'gray'`) stamped into frames. */
182
- frameFormat;
183
- outWidth = 0;
184
- outHeight = 0;
185
- frameSize = 0;
186
- frameRingSink = null;
187
- frameCallbacks = /* @__PURE__ */ new Set();
188
- handleCallbacks = /* @__PURE__ */ new Set();
189
- frameDropper;
190
- inputPackets = 0;
191
- /**
192
- * Current-GOP Annex-B packet bytes (push mode), from the most recent keyframe
193
- * forward. Replayed keyframe-first into every freshly-spawned child so a
194
- * hwaccel cascade re-spawn (`vaapi → qsv → software`) or a config restart
195
- * resumes on a keyframe instead of the live mid-GOP P-frame the dead child had
196
- * already consumed — which the fallback child would otherwise choke on
197
- * (`non-existing PPS 0`) until the next live keyframe. Mirrors the reference
198
- * NVR libav decoder, which replays its buffered packet list to each hwaccel
199
- * candidate. Reset on each keyframe; unused in pull mode.
200
- */
201
- gopBuffer = [];
202
- gopBufferBytes = 0;
203
- outputFrames = 0;
204
- droppedFrames = 0;
205
- startTime = Date.now();
206
- constructor(config, logger = noopLogger, options) {
207
- this.config = { ...config };
208
- const sessionTags = {};
209
- if (typeof config.deviceId === "number") sessionTags["deviceId"] = config.deviceId;
210
- if (typeof config.tag === "string" && config.tag.length > 0) sessionTags["tag"] = config.tag;
211
- this.logger = Object.keys(sessionTags).length > 0 ? logger.withTags(sessionTags) : logger;
212
- this.frameSink = options?.frameSink ?? "callback";
213
- this.nodeId = options?.nodeId ?? "local";
214
- this.renderDevice = options?.renderDevice;
215
- this.ffmpegPath = options?.ffmpegPath ?? "ffmpeg";
216
- const seededChain = options?.hwaccelChain ?? (options?.hwaccel != null && !isSoftwareHwAccel(options.hwaccel) ? [options.hwaccel] : []);
217
- this.hwaccelChain = seededChain.filter((m) => !isSoftwareHwAccel(m));
218
- this.hwaccel = this.currentHwAccel();
219
- this.availableGpuScaleFilters = options?.availableGpuScaleFilters ?? /* @__PURE__ */ new Set();
220
- this.onGpuScaleUnsupported = options?.onGpuScaleUnsupported;
221
- this.pixel = rawPixelForFormat(config.outputFormat);
222
- this.channels = channelsForPixel(this.pixel);
223
- this.frameFormat = this.pixel === "gray" ? "gray" : "rgb";
224
- this.frameDropper = new FrameDropper(config.maxFps);
225
- }
226
- /** Exposed so the owning addon can surface ring stats via `getShmStats`. */
227
- get frameRingSinkOrNull() {
228
- return this.frameRingSink;
229
- }
230
- get isPullMode() {
231
- return this.pullUrl !== null;
232
- }
233
- /**
234
- * Switch the session to PULL MODE: (re)spawn ffmpeg reading `url` directly
235
- * (`-rtsp_transport tcp -i <url>` for rtsp) instead of stdin. The whole
236
- * output path (filtergraph, rawvideo pipe, shm ring sink) is unchanged.
237
- *
238
- * - Calling it again re-dials with the FRESH url (a hub restream bounce
239
- * mints a new token URL — never cache the old one).
240
- * - Calling it on a session already fed via `pushPacket` throws: a session
241
- * is push-mode or pull-mode, never both (mixing inputs would corrupt the
242
- * decode).
243
- */
244
- async openStream(url) {
245
- if (this.destroyed) return;
246
- if (this.pullUrl === null && this.inputPackets > 0) throw new Error("ffmpeg decoder: openStream on a push-fed session — a session is push-mode or pull-mode, never both");
247
- this.pullUrl = url;
248
- this.logger.info("ffmpeg decoder: pull-mode openStream — dialing input URL", { meta: { url } });
249
- this.spawnFfmpeg();
250
- }
251
- ensureFrameRingSink() {
252
- if (this.frameRingSink === null) {
253
- const seedParts = [];
254
- if (typeof this.config.deviceId === "number") seedParts.push(String(this.config.deviceId));
255
- if (typeof this.config.tag === "string" && this.config.tag.length > 0) seedParts.push(this.config.tag);
256
- const seed = seedParts.length > 0 ? seedParts.join(":") : "anon";
257
- this.frameRingSink = new DecoderFrameRingSink({
258
- seed,
259
- logger: this.logger,
260
- nodeId: this.nodeId
261
- });
262
- }
263
- return this.frameRingSink;
264
- }
265
- spawnFfmpeg() {
266
- if (this.destroyed) return;
267
- this.spawnAttempted = true;
268
- this.clearPullRedial();
269
- this.killFfmpeg();
270
- this.abortOpenSlot();
271
- this.splitter = null;
272
- this.pendingStdout = [];
273
- this.stderrAccum = "";
274
- this.outWidth = 0;
275
- this.outHeight = 0;
276
- this.frameSize = 0;
277
- this.activeGpuScaleFilter = this.gpuScaleEnabled ? resolveGpuScaleFilter(this.hwaccel, this.availableGpuScaleFilters) : null;
278
- const args = buildFfmpegDecodeArgs({
279
- codec: this.config.codec,
280
- scale: this.config.scale,
281
- outputFormat: this.config.outputFormat
282
- }, {
283
- hwaccel: this.hwaccel,
284
- renderDevice: this.renderDevice,
285
- gpuScaleFilter: this.activeGpuScaleFilter,
286
- ...this.pullUrl !== null ? { inputUrl: this.pullUrl } : {}
287
- });
288
- let child;
289
- try {
290
- child = spawn(this.ffmpegPath, args);
291
- } catch (err) {
292
- this.logger.error("ffmpeg decoder: spawn threw", { meta: { error: errMsg(err) } });
293
- return;
294
- }
295
- this.process = child;
296
- this.spawnedAtMs = Date.now();
297
- child.stdin?.on("error", () => {});
298
- this.replayGopBuffer(child);
299
- child.stdout?.on("data", (chunk) => {
300
- if (child === this.process) this.handleStdout(chunk);
301
- });
302
- child.stderr?.on("data", (data) => {
303
- if (child === this.process) this.handleStderr(data);
304
- });
305
- child.on("error", (err) => {
306
- if (child !== this.process) return;
307
- this.logger.error("ffmpeg decoder: process error", { meta: { error: err.message } });
308
- });
309
- child.on("exit", (code, signal) => {
310
- if (child === this.process) this.handleExit(code, signal);
311
- });
312
- this.logger.info("ffmpeg decoder: spawned", { meta: {
313
- hwaccel: this.hwaccel,
314
- gpuScale: this.activeGpuScaleFilter ?? "cpu",
315
- codec: this.config.codec,
316
- format: this.frameFormat,
317
- sink: this.frameSink
318
- } });
319
- }
320
- killFfmpeg() {
321
- const child = this.process;
322
- if (!child) return;
323
- this.process = null;
324
- child.stdout?.removeAllListeners("data");
325
- child.stderr?.removeAllListeners("data");
326
- child.removeAllListeners("exit");
327
- try {
328
- child.stdin?.end();
329
- } catch {}
330
- try {
331
- child.kill("SIGTERM");
332
- } catch {}
333
- const pid = child.pid;
334
- setTimeout(() => {
335
- if (pid !== void 0 && child.exitCode === null && child.signalCode === null) try {
336
- child.kill("SIGKILL");
337
- } catch {}
338
- }, 500).unref?.();
339
- }
340
- /** The decode path for the current attempt — `hwaccelChain[index]`, or `null`
341
- * (software) once the chain is exhausted. */
342
- currentHwAccel() {
343
- return this.hwaccelChain[this.hwaccelIndex] ?? null;
344
- }
345
- handleExit(code, signal) {
346
- if (this.destroyed) return;
347
- const diedFast = Date.now() - this.spawnedAtMs < HWACCEL_FALLBACK_WINDOW_MS;
348
- if (!isSoftwareHwAccel(this.hwaccel) && this.outputFrames === 0 && diedFast) {
349
- if (this.activeGpuScaleFilter !== null) {
350
- const brokenFilter = this.activeGpuScaleFilter;
351
- this.gpuScaleEnabled = false;
352
- this.onGpuScaleUnsupported?.(brokenFilter);
353
- this.logger.warn("ffmpeg decoder: GPU-scale child exited early with no frames — retrying same backend with CPU scale", { meta: {
354
- hwaccel: this.hwaccel,
355
- gpuScaleFilter: brokenFilter,
356
- code,
357
- signal
358
- } });
359
- this.process = null;
360
- this.spawnFfmpeg();
361
- return;
362
- }
363
- const failed = this.hwaccel;
364
- this.hwaccelIndex += 1;
365
- this.hwaccel = this.currentHwAccel();
366
- this.gpuScaleEnabled = true;
367
- const next = isSoftwareHwAccel(this.hwaccel) ? "software" : this.hwaccel;
368
- this.logger.warn("ffmpeg decoder: hwaccel child exited early with no frames — trying next decode path", { meta: {
369
- failed,
370
- next,
371
- code,
372
- signal
373
- } });
374
- this.process = null;
375
- this.spawnFfmpeg();
376
- return;
377
- }
378
- this.process = null;
379
- this.logger.warn("ffmpeg decoder: child exited", { meta: {
380
- code,
381
- signal,
382
- outputFrames: this.outputFrames
383
- } });
384
- if (this.pullUrl !== null) {
385
- this.clearPullRedial();
386
- const timer = setTimeout(() => {
387
- this.pullRedialTimer = null;
388
- this.spawnFfmpeg();
389
- }, PULL_REDIAL_DELAY_MS);
390
- timer.unref?.();
391
- this.pullRedialTimer = timer;
392
- this.logger.info("ffmpeg decoder: pull-mode child exited — re-dial scheduled", { meta: {
393
- code,
394
- signal,
395
- redialInMs: PULL_REDIAL_DELAY_MS
396
- } });
397
- }
398
- }
399
- /** Cancel a pending pull-mode re-dial, if armed. */
400
- clearPullRedial() {
401
- if (this.pullRedialTimer !== null) {
402
- clearTimeout(this.pullRedialTimer);
403
- this.pullRedialTimer = null;
404
- }
405
- }
406
- handleStderr(data) {
407
- const text = data.toString();
408
- if (this.frameSize === 0) {
409
- this.stderrAccum = (this.stderrAccum + text).slice(-16384);
410
- this.tryLearnGeometry();
411
- }
412
- const line = text.trim();
413
- if (line) this.logger.debug("ffmpeg stderr", { meta: { line } });
414
- }
415
- /** Parse the muxer's Output block once to size the raw frames. */
416
- tryLearnGeometry() {
417
- const idx = this.stderrAccum.indexOf("Output #");
418
- if (idx < 0) return;
419
- const match = this.stderrAccum.slice(idx).match(/(\d{2,5})x(\d{2,5})/);
420
- if (!match) return;
421
- const width = Number(match[1]);
422
- const height = Number(match[2]);
423
- if (width <= 0 || height <= 0) return;
424
- this.outWidth = width;
425
- this.outHeight = height;
426
- this.frameSize = width * height * this.channels;
427
- this.logger.info("ffmpeg decoder: learned output geometry", { meta: {
428
- width,
429
- height,
430
- channels: this.channels,
431
- frameSize: this.frameSize
432
- } });
433
- this.splitter = new FrameStreamSplitter(this.frameSize, this.makeSlotController());
434
- const pending = this.pendingStdout;
435
- this.pendingStdout = [];
436
- for (const chunk of pending) this.splitter.push(chunk);
437
- }
438
- handleStdout(chunk) {
439
- if (this.splitter === null) {
440
- this.pendingStdout.push(chunk);
441
- return;
442
- }
443
- this.splitter.push(chunk);
444
- }
445
- /**
446
- * The frame lifecycle the {@link FrameStreamSplitter} drives. `startFrame`
447
- * reserves a shm slot (zero-copy: the splitter fills the slot view in place)
448
- * or, for the callback sink, allocates one frame buffer; `finishFrame`
449
- * publishes the filled frame; `dropFrame` accounts an FPS-skipped one.
450
- */
451
- makeSlotController() {
452
- return {
453
- startFrame: () => {
454
- if (!this.frameDropper.shouldKeep()) return null;
455
- if (this.frameSink === "shm") {
456
- const reserved = this.ensureFrameRingSink().beginFrame(this.outWidth, this.outHeight, this.frameFormat);
457
- if (reserved === null) return null;
458
- this.openSlot = reserved.slot;
459
- return reserved.buffer;
460
- }
461
- return Buffer.allocUnsafe(this.frameSize);
462
- },
463
- finishFrame: (target) => {
464
- this.outputFrames++;
465
- if (this.frameSink === "shm") {
466
- const slot = this.openSlot;
467
- this.openSlot = null;
468
- const sink = this.frameRingSink;
469
- if (slot === null || sink === null) return;
470
- const handle = sink.commitFrame(slot, {
471
- width: this.outWidth,
472
- height: this.outHeight,
473
- format: this.frameFormat,
474
- pts: performance.now(),
475
- byteLength: this.frameSize
476
- });
477
- if (handle === null) return;
478
- const delivered = {
479
- handle,
480
- timestamp: Date.now()
481
- };
482
- for (const cb of this.handleCallbacks) cb(delivered);
483
- return;
484
- }
485
- const frame = {
486
- data: target,
487
- width: this.outWidth,
488
- height: this.outHeight,
489
- format: this.frameFormat,
490
- timestamp: Date.now()
491
- };
492
- for (const cb of this.frameCallbacks) cb(frame);
493
- },
494
- dropFrame: () => {
495
- this.droppedFrames++;
496
- }
497
- };
498
- }
499
- /** Abandon a shm slot the splitter left open (respawn / teardown mid-frame),
500
- * so a half-filled slot is never published. */
501
- abortOpenSlot() {
502
- const slot = this.openSlot;
503
- if (slot === null) return;
504
- this.openSlot = null;
505
- this.frameRingSink?.abortFrame(slot);
506
- }
507
- pushPacket(packet) {
508
- if (this.destroyed) return;
509
- if (this.pullUrl !== null) return;
510
- this.recordGop(packet);
511
- this.inputPackets++;
512
- if (this.process === null && !this.spawnAttempted) {
513
- this.spawnFfmpeg();
514
- return;
515
- }
516
- const stdin = this.process?.stdin;
517
- if (!stdin) return;
518
- try {
519
- stdin.write(packet.data);
520
- } catch {}
521
- }
522
- /**
523
- * Accumulate the current GOP for the keyframe-first re-spawn replay. Reset on
524
- * each keyframe; P-frames before the first keyframe are ignored (useless to
525
- * prime a decoder). A pathological long/no-keyframe GOP is capped to the
526
- * keyframe alone (enough to re-prime SPS/PPS).
527
- */
528
- recordGop(packet) {
529
- if (packet.keyframe) {
530
- this.gopBuffer = [packet.data];
531
- this.gopBufferBytes = packet.data.length;
532
- return;
533
- }
534
- if (this.gopBuffer.length === 0) return;
535
- this.gopBuffer.push(packet.data);
536
- this.gopBufferBytes += packet.data.length;
537
- if (this.gopBufferBytes > MAX_GOP_BUFFER_BYTES) {
538
- const keyframe = this.gopBuffer[0];
539
- this.gopBuffer = [keyframe];
540
- this.gopBufferBytes = keyframe.length;
541
- }
542
- }
543
- /** Replay the buffered GOP (keyframe-first) into a freshly-spawned push child. */
544
- replayGopBuffer(child) {
545
- if (this.pullUrl !== null || this.gopBuffer.length === 0) return;
546
- const stdin = child.stdin;
547
- if (!stdin) return;
548
- for (const buf of this.gopBuffer) try {
549
- stdin.write(buf);
550
- } catch {
551
- return;
552
- }
553
- }
554
- onFrame(callback) {
555
- this.frameCallbacks.add(callback);
556
- return () => {
557
- this.frameCallbacks.delete(callback);
558
- };
559
- }
560
- /** Subscribe to shared-memory frame handles — fires only in `'shm'` mode. */
561
- onFrameHandle(callback) {
562
- this.handleCallbacks.add(callback);
563
- return () => {
564
- this.handleCallbacks.delete(callback);
565
- };
566
- }
567
- updateConfig(update) {
568
- const needsRestart = update.scale !== void 0 && update.scale !== this.config.scale || update.outputFormat !== void 0 && update.outputFormat !== this.config.outputFormat || update.codec !== void 0 && update.codec !== this.config.codec;
569
- this.config = {
570
- ...this.config,
571
- ...update
572
- };
573
- if (update.maxFps !== void 0) this.frameDropper.setMaxFps(update.maxFps);
574
- if (needsRestart && this.spawnAttempted) this.spawnFfmpeg();
575
- }
576
- async destroy() {
577
- if (this.destroyed) return;
578
- this.destroyed = true;
579
- this.clearPullRedial();
580
- this.killFfmpeg();
581
- this.abortOpenSlot();
582
- this.splitter = null;
583
- this.frameRingSink?.destroy();
584
- this.frameRingSink = null;
585
- this.frameCallbacks.clear();
586
- this.handleCallbacks.clear();
587
- }
588
- getStats() {
589
- const uptimeSec = Math.max((Date.now() - this.startTime) / 1e3, 1);
590
- return {
591
- inputFps: this.inputPackets / uptimeSec,
592
- outputFps: this.outputFrames / uptimeSec,
593
- avgDecodeTimeMs: 0,
594
- droppedFrames: this.droppedFrames
595
- };
596
- }
597
- };
598
- //#endregion
599
- //#region src/decoder-ffmpeg/ffmpeg-filter-probe.ts
600
- /**
601
- * One-shot probe of the GPU scale filters an ffmpeg build offers.
602
- *
603
- * The decoder runs the downscale on the GPU (`scale_vaapi` / `scale_qsv` /
604
- * `scale_vt`) instead of downloading the full-resolution frame and scaling it on
605
- * the CPU — a large saving on VA-API. Whether a given build even compiled those
606
- * filters in is build-specific, so the addon probes `ffmpeg -filters` ONCE at
607
- * init and passes the result into every session. The parse
608
- * ({@link parseAvailableGpuScaleFilters}) is a pure function unit-tested in
609
- * `ffmpeg-args.spec.ts`; only the spawn lives here.
610
- */
611
- /** How long to wait for `ffmpeg -filters` before giving up (→ empty set). */
612
- var PROBE_TIMEOUT_MS = 5e3;
613
- /**
614
- * Run `<ffmpegPath> -hide_banner -filters` and return the {@link GpuScaleFilter}s
615
- * the build lists. Never throws — a missing / slow / failing binary resolves to
616
- * an empty set, which makes every session fall back to the CPU-scale chain.
617
- */
618
- function probeGpuScaleFilters(ffmpegPath, logger) {
619
- return new Promise((resolve) => {
620
- let stdout = "";
621
- let settled = false;
622
- const done = (set) => {
623
- if (settled) return;
624
- settled = true;
625
- clearTimeout(timer);
626
- resolve(set);
627
- };
628
- let child;
629
- try {
630
- child = spawn(ffmpegPath, ["-hide_banner", "-filters"]);
631
- } catch (err) {
632
- logger.warn("decoder-ffmpeg: GPU-scale filter probe spawn failed — CPU scale", { meta: { error: errMsg(err) } });
633
- done(/* @__PURE__ */ new Set());
634
- return;
635
- }
636
- const timer = setTimeout(() => {
637
- try {
638
- child.kill("SIGKILL");
639
- } catch {}
640
- logger.warn("decoder-ffmpeg: GPU-scale filter probe timed out — CPU scale");
641
- done(/* @__PURE__ */ new Set());
642
- }, PROBE_TIMEOUT_MS);
643
- timer.unref?.();
644
- child.stdout?.on("data", (chunk) => {
645
- stdout += chunk.toString();
646
- });
647
- child.on("error", (err) => {
648
- logger.warn("decoder-ffmpeg: GPU-scale filter probe errored — CPU scale", { meta: { error: err.message } });
649
- done(/* @__PURE__ */ new Set());
650
- });
651
- child.on("exit", () => {
652
- done(parseAvailableGpuScaleFilters(stdout));
653
- });
654
- });
655
- }
656
- //#endregion
657
- //#region src/decoder-ffmpeg/addon/index.ts
658
- var FRAME_BUFFER_CAPACITY = 32;
659
- var DEFAULT_DECODER_FFMPEG_ADDON_CONFIG = {
660
- ...DEFAULT_DECODER_HWACCEL_CONFIG,
661
- backend: DEFAULT_DECODER_BACKEND,
662
- binaryPath: ""
663
- };
664
- /** The two selectable decoder backends, in default-first order. */
665
- var DECODER_BACKEND_OPTIONS = [{
666
- value: "nodeav",
667
- label: "node-av (in-process FFmpeg bindings — default)"
668
- }, {
669
- value: "ffmpeg",
670
- label: "ffmpeg (subprocess — crash-isolated fallback)"
671
- }];
672
- var DecoderFfmpegAddon = class extends BaseAddon {
673
- sessions = /* @__PURE__ */ new Map();
674
- frameBuffers = /* @__PURE__ */ new Map();
675
- handleBuffers = /* @__PURE__ */ new Map();
676
- unsubscribers = /* @__PURE__ */ new Map();
677
- sessionMeta = /* @__PURE__ */ new Map();
678
- frameReaders = null;
679
- getFrameHits = 0;
680
- getFrameMisses = 0;
681
- /**
682
- * The ffmpeg binary every session spawns — resolved once at init from the
683
- * cluster `ffmpeg` config section (`binaryPath`), defaulting to PATH
684
- * `ffmpeg`. MUST be the same binary the broker / recorder / probe use so the
685
- * decode-accel probe reports for the right build.
686
- */
687
- ffmpegPath = "ffmpeg";
688
- /**
689
- * GPU scale filters the resolved ffmpeg build offers (`scale_vaapi` /
690
- * `scale_qsv` / `scale_vt`), probed once at init. A session running one of
691
- * these downscales on the GPU instead of the CPU (a large VA-API saving).
692
- */
693
- probedGpuScaleFilters = /* @__PURE__ */ new Set();
694
- /**
695
- * GPU scalers the build LISTS but that proved non-functional at runtime (a
696
- * session's GPU-scale child died with no frames and self-healed to CPU scale).
697
- * Excluded from later sessions so the failed spawn happens at most once per
698
- * process — e.g. a videotoolbox build whose decode returns software frames.
699
- */
700
- unsupportedGpuScaleFilters = /* @__PURE__ */ new Set();
701
- constructor() {
702
- super(DEFAULT_DECODER_FFMPEG_ADDON_CONFIG);
703
- }
704
- globalSettingsSchema() {
705
- return this.schema({ sections: [{
706
- id: "backend",
707
- title: "Decoder backend",
708
- tab: "decoder",
709
- description: "Which decoder addon provides the decoder capability on this node — per-node selection, stored centrally on the hub. Selection is decided once at addon init (registration-time, race-free), so a change applies at the next restart of the decoder addons on the node.",
710
- fields: [this.field({
711
- type: "select",
712
- key: "backend",
713
- label: "Backend",
714
- options: [...DECODER_BACKEND_OPTIONS],
715
- default: DEFAULT_DECODER_BACKEND,
716
- immediate: true,
717
- perNode: true
718
- })]
719
- }, {
720
- id: "hwaccel",
721
- title: "Hardware acceleration",
722
- tab: "decoder",
723
- description: "Backend used by ffmpeg-subprocess decoder sessions. \"Auto\" defers to the probed best (VA-API on Intel); \"Off\" forces pure software. Only the VA-API hardware path is validated — other concrete backends degrade to software. Changes apply to NEW sessions.",
724
- fields: [
725
- this.field({
726
- type: "select",
727
- key: "hwaccel",
728
- label: "Preferred backend",
729
- options: [...HWACCEL_OPTIONS],
730
- default: "auto",
731
- immediate: true,
732
- perNode: true
733
- }),
734
- this.field({
735
- type: "text",
736
- key: "probedBestHwaccel",
737
- label: "Probed best",
738
- description: "Auto-detected best decoder backend on this host. Click the refresh icon to re-run the probe.",
739
- readonlyField: true,
740
- default: "",
741
- perNode: true,
742
- actions: [{
743
- action: "reprobe-hwaccel",
744
- icon: "refresh-cw",
745
- tooltip: "Re-probe hwaccel"
746
- }]
747
- }),
748
- this.field({
749
- type: "text",
750
- key: "binaryPath",
751
- label: "ffmpeg binary path (override)",
752
- description: "Optional per-node override for the ffmpeg binary. Leave empty to auto-provision a portable static ffmpeg on this node (recommended). Per-node because the path differs per OS/arch.",
753
- placeholder: "ffmpeg",
754
- default: "",
755
- perNode: true
756
- })
757
- ]
758
- }] });
759
- }
760
- async onInitialize() {
761
- if (!this.config.probedBestHwaccel) this.reprobeHwaccel().catch((err) => {
762
- this.ctx.logger.warn("ffmpeg: auto-reprobe hwaccel failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
763
- });
764
- let backend;
765
- try {
766
- backend = resolveOwnDecoderBackend(await this.resolveGlobalStore(this.resolveLocalNodeId()));
767
- } catch (err) {
768
- this.ctx.logger.warn("decoder-ffmpeg: own-store backend read failed — using default", { meta: {
769
- default: DEFAULT_DECODER_BACKEND,
770
- error: err instanceof Error ? err.message : String(err)
771
- } });
772
- backend = DEFAULT_DECODER_BACKEND;
773
- }
774
- if (backend !== "ffmpeg") {
775
- this.ctx.logger.info("ffmpeg decoder: this node selects a different decoder backend — standing down (no decoder provider registered)", { meta: { selectedBackend: backend } });
776
- return [];
777
- }
778
- this.ctx.logger.info("ffmpeg decoder addon initialized", { meta: { selectedBackend: backend } });
779
- this.frameReaders = new FrameRingReaderCache(this.ctx.logger);
780
- this.ffmpegPath = await this.resolveFfmpegBinaryPath();
781
- this.probedGpuScaleFilters = await probeGpuScaleFilters(this.ffmpegPath, this.ctx.logger);
782
- this.ctx.logger.info("decoder-ffmpeg: probed GPU scale filters", { meta: {
783
- filters: [...this.probedGpuScaleFilters],
784
- ffmpeg: this.ffmpegPath
785
- } });
786
- return [{
787
- capability: decoderCapability,
788
- provider: this
789
- }];
790
- }
791
- /**
792
- * Resolve the ffmpeg binary the sessions spawn.
793
- *
794
- * Precedence:
795
- * 1. An explicit operator override in the cluster `ffmpeg` config section
796
- * (`binaryPath`) — wins when set, so a hand-picked build can be forced.
797
- * 2. Otherwise `ctx.deps.ensureFfmpeg()` — provisions a portable static
798
- * ffmpeg into the node's deps dir when the system has none and returns its
799
- * absolute path. This GUARANTEES ffmpeg on every node (hub, agent, Mac),
800
- * so there is no missing-ffmpeg case and no node-av fallback.
801
- *
802
- * `ensureFfmpeg()` is single-flighted, so we resolve once at init and reuse
803
- * the path for every session. If it throws (e.g. a download failure) we log a
804
- * loud ERROR and fall back to PATH `ffmpeg`; the addon still registers the
805
- * decoder cap and a later session spawn re-runs the ensure.
806
- */
807
- async resolveFfmpegBinaryPath() {
808
- const override = this.config.binaryPath;
809
- if (typeof override === "string" && override.trim().length > 0) return override;
810
- try {
811
- return await this.ctx.deps.ensureFfmpeg();
812
- } catch (err) {
813
- this.ctx.logger.error("decoder-ffmpeg: ensureFfmpeg() failed to provision ffmpeg — falling back to PATH \"ffmpeg\"; a later session will retry", { meta: { error: err instanceof Error ? err.message : String(err) } });
814
- return "ffmpeg";
815
- }
816
- }
817
- /**
818
- * Resolve the ORDERED decode-hwaccel chain for a new session — NEVER
819
- * hardcoded. The session tries the chain best-first and only falls to
820
- * software once every hardware option is exhausted.
821
- *
822
- * - `'none'` → `[]` (pure software).
823
- * - explicit backend → `[<backend>]` (forced; the session still falls to
824
- * software if that one child fails).
825
- * - `'auto'` → the LOCAL node's preferred order from `ctx.kernel.hwaccel` —
826
- * the in-process resolver every forked child (hub / worker / agent) is
827
- * given, which derives the backend from `os.platform()` + a GPU-vendor probe
828
- * (darwin → `videotoolbox`; Intel → `qsv,vaapi`; NVIDIA → `cuda,nvdec`).
829
- * Ranked via {@link rankDecodeHwAccels} (vaapi ABOVE qsv, since qsv is broken
830
- * on the Intel stack while vaapi works). On a resolver error → `[]`
831
- * (software).
832
- *
833
- * Two things this deliberately does NOT do:
834
- * - It does NOT call the `platform-probe` singleton cap. Its per-node
835
- * `nodePin` does not survive a forked agent child's link chain, so a pinned
836
- * read resolves to the HUB's elected singleton and returns the hub's
837
- * hardware — a Mac decoder was handed the hub's Intel `vaapi/qsv/...` chain
838
- * (no `videotoolbox`) and fell all the way to software h265.
839
- * - It does NOT probe `ffmpeg -hwaccels` (nor node-av) to intersect the list.
840
- * The accelerator is deterministic from the host, which the kernel resolver
841
- * already knows in-process — no subprocess, no cross-process round-trip. The
842
- * rare case where the resolved backend is not usable by the actual ffmpeg
843
- * build is covered by the session's per-child runtime fallback (advance to
844
- * the next chain entry, else software). This mirrors how Scrypted resolves
845
- * hwaccel (static `os.platform()` map + try-then-fall-back), not a native
846
- * capability enumeration.
847
- *
848
- * macOS special-case: the resolved `videotoolbox` is emitted as `-hwaccel
849
- * auto`, NOT explicit `-hwaccel videotoolbox`. Explicit videotoolbox tears the
850
- * ffmpeg child down under MULTIPLE concurrent decode streams (documented by
851
- * Scrypted NVR's ffmpeg path; our Mac decodes many cameras at once — exactly
852
- * that case). `auto` lets ffmpeg pick VideoToolbox internally and auto-download
853
- * frames to software (no GPU surface to dispose, no `scale_vt`). The in-process
854
- * node-av decoder (`decoder-nodeav`) still uses explicit videotoolbox — this
855
- * substitution is specific to the ffmpeg-subprocess decode.
856
- */
857
- async resolveDecodeHwaccelChain() {
858
- const pref = this.config.hwaccel;
859
- if (pref === "none") return [];
860
- if (pref !== "auto") return [pref];
861
- try {
862
- const resolver = this.ctx.kernel.hwaccel;
863
- if (!resolver) {
864
- this.ctx.logger.warn("ffmpeg: no local kernel hwaccel resolver — software decode");
865
- return [];
866
- }
867
- const chain = rankDecodeHwAccels((await resolver.resolve(null)).preferred, []);
868
- if (process.platform === "darwin") return chain.map((method) => method === "videotoolbox" ? "auto" : method);
869
- return chain;
870
- } catch (err) {
871
- this.ctx.logger.warn("ffmpeg: decode-hwaccel probe failed — software decode", { meta: { error: err instanceof Error ? err.message : String(err) } });
872
- return [];
873
- }
874
- }
875
- async reprobeHwaccel() {
876
- const resolver = this.ctx.kernel.hwaccel;
877
- if (!resolver) {
878
- this.ctx.logger.warn("reprobeHwaccel: no kernel hwaccel resolver — returning none");
879
- await this.updateGlobalSettings({ probedBestHwaccel: "none" });
880
- return { backend: "none" };
881
- }
882
- try {
883
- const res = await resolver.resolve();
884
- const backend = rankDecodeHwAccels(res.preferred, [])[0] ?? "none";
885
- await this.updateGlobalSettings({ probedBestHwaccel: backend });
886
- this.ctx.logger.info("reprobeHwaccel: wrote probedBestHwaccel", { meta: {
887
- backend,
888
- rationale: res.rationale,
889
- preferred: res.preferred
890
- } });
891
- return { backend };
892
- } catch (err) {
893
- this.ctx.logger.warn("reprobeHwaccel failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
894
- await this.updateGlobalSettings({ probedBestHwaccel: "none" });
895
- return { backend: "none" };
896
- }
897
- }
898
- async supportsCodec(input) {
899
- return [
900
- "h264",
901
- "h265",
902
- "hevc"
903
- ].includes(input.codec.toLowerCase());
904
- }
905
- async getInfo() {
906
- return {
907
- id: "decoder-ffmpeg",
908
- name: "Decoder (ffmpeg)",
909
- isPullMode: true,
910
- priority: 20
911
- };
912
- }
913
- /**
914
- * The cluster node id of this decoder — stamped into session-owned
915
- * `FrameHandle`s so a downstream consumer routes `getFrame` to the node that
916
- * holds the shm ring. Mirrors `decoder-nodeav`.
917
- */
918
- resolveLocalNodeId() {
919
- const raw = this.ctx.kernel.localNodeId ?? this.ctx.id;
920
- return raw.includes("/") ? raw.split("/")[0] : raw;
921
- }
922
- async createSession(config) {
923
- const sessionId = randomUUID();
924
- const { frameSink } = config;
925
- const nodeId = this.resolveLocalNodeId();
926
- const hwaccelChain = await this.resolveDecodeHwaccelChain();
927
- const availableGpuScaleFilters = new Set([...this.probedGpuScaleFilters].filter((f) => !this.unsupportedGpuScaleFilters.has(f)));
928
- const session = new FfmpegDecoderSession(config, this.ctx.logger, {
929
- hwaccelChain,
930
- ffmpegPath: this.ffmpegPath,
931
- frameSink,
932
- nodeId,
933
- availableGpuScaleFilters,
934
- onGpuScaleUnsupported: (filter) => {
935
- if (this.unsupportedGpuScaleFilters.has(filter)) return;
936
- this.unsupportedGpuScaleFilters.add(filter);
937
- this.ctx.logger.warn("decoder-ffmpeg: GPU scaler non-functional on this build — future sessions use CPU scale", { meta: { filter } });
938
- }
939
- });
940
- const unsub = frameSink === "shm" ? this.wireShmSink(sessionId, session) : this.wireCallbackSink(sessionId, session);
941
- this.sessions.set(sessionId, session);
942
- this.unsubscribers.set(sessionId, unsub);
943
- this.sessionMeta.set(sessionId, {
944
- codec: config.codec,
945
- outputFormat: config.outputFormat,
946
- createdAtMs: Date.now()
947
- });
948
- this.ctx.logger.info("ffmpeg: created session", { meta: {
949
- sessionId,
950
- codec: config.codec,
951
- hwaccelChain,
952
- frameSink,
953
- nodeId
954
- } });
955
- return {
956
- sessionId,
957
- nodeId
958
- };
959
- }
960
- wireCallbackSink(sessionId, session) {
961
- const ringBuffer = new NotifyingRingBuffer(FRAME_BUFFER_CAPACITY);
962
- this.frameBuffers.set(sessionId, ringBuffer);
963
- return session.onFrame((frame) => {
964
- const { format } = frame;
965
- if (format !== "jpeg" && format !== "rgb" && format !== "bgr" && format !== "yuv420" && format !== "gray") return;
966
- const arrayBuf = new ArrayBuffer(frame.data.byteLength);
967
- new Uint8Array(arrayBuf).set(frame.data);
968
- const capFrame = {
969
- data: new Uint8Array(arrayBuf),
970
- width: frame.width,
971
- height: frame.height,
972
- format,
973
- timestamp: frame.timestamp
974
- };
975
- ringBuffer.push(capFrame);
976
- });
977
- }
978
- wireShmSink(sessionId, session) {
979
- const handleRing = new NotifyingRingBuffer(FRAME_BUFFER_CAPACITY);
980
- this.handleBuffers.set(sessionId, handleRing);
981
- return session.onFrameHandle((frame) => {
982
- handleRing.push(frame.handle);
983
- });
984
- }
985
- async destroySession(input) {
986
- const { sessionId } = input;
987
- const session = this.sessions.get(sessionId);
988
- if (!session) throw new Error(`decoder-ffmpeg: unknown sessionId ${sessionId}`);
989
- const unsub = this.unsubscribers.get(sessionId);
990
- if (unsub) unsub();
991
- this.frameBuffers.get(sessionId)?.cancel();
992
- this.handleBuffers.get(sessionId)?.cancel();
993
- await session.destroy();
994
- this.sessions.delete(sessionId);
995
- this.frameBuffers.delete(sessionId);
996
- this.handleBuffers.delete(sessionId);
997
- this.unsubscribers.delete(sessionId);
998
- this.sessionMeta.delete(sessionId);
999
- this.ctx.logger.info("ffmpeg: destroyed session", { meta: { sessionId } });
1000
- }
1001
- async listActiveSessions() {
1002
- const out = [];
1003
- for (const [sessionId, meta] of this.sessionMeta) out.push({
1004
- sessionId,
1005
- codec: meta.codec,
1006
- outputFormat: meta.outputFormat,
1007
- createdAtMs: meta.createdAtMs
1008
- });
1009
- return out;
1010
- }
1011
- async pushPacket(input) {
1012
- const session = this.sessions.get(input.sessionId);
1013
- if (!session) throw new Error(`decoder-ffmpeg: unknown sessionId ${input.sessionId}`);
1014
- const rawData = input.packet.data;
1015
- const data = Buffer.isBuffer(rawData) ? rawData : rawData instanceof Uint8Array ? Buffer.from(rawData.buffer, rawData.byteOffset, rawData.byteLength) : Buffer.from(rawData);
1016
- session.pushPacket({
1017
- ...input.packet,
1018
- data
1019
- });
1020
- }
1021
- async openStream(input) {
1022
- const session = this.sessions.get(input.sessionId);
1023
- if (!session) throw new Error(`decoder-ffmpeg: unknown sessionId ${input.sessionId}`);
1024
- await session.openStream(input.url);
1025
- }
1026
- async pullFrames(input) {
1027
- if (!this.sessions.has(input.sessionId)) throw new Error(`decoder-ffmpeg: unknown sessionId ${input.sessionId}`);
1028
- const ringBuffer = this.frameBuffers.get(input.sessionId);
1029
- if (!ringBuffer) return [];
1030
- if (ringBuffer.size === 0 && input.waitMs) await ringBuffer.waitForItem(input.waitMs);
1031
- return ringBuffer.drain(input.maxCount);
1032
- }
1033
- async pullHandles(input) {
1034
- if (!this.sessions.has(input.sessionId)) throw new Error(`decoder-ffmpeg: unknown sessionId ${input.sessionId}`);
1035
- const handleRing = this.handleBuffers.get(input.sessionId);
1036
- if (!handleRing) return [];
1037
- if (handleRing.size === 0 && input.waitMs) await handleRing.waitForItem(input.waitMs);
1038
- return handleRing.drain(input.maxCount);
1039
- }
1040
- async updateConfig(input) {
1041
- const session = this.sessions.get(input.sessionId);
1042
- if (!session) throw new Error(`decoder-ffmpeg: unknown sessionId ${input.sessionId}`);
1043
- session.updateConfig(input.config);
1044
- }
1045
- async getStats(input) {
1046
- const session = this.sessions.get(input.sessionId);
1047
- if (!session) throw new Error(`decoder-ffmpeg: unknown sessionId ${input.sessionId}`);
1048
- return session.getStats();
1049
- }
1050
- async getFrame(input) {
1051
- const frame = this.frameReaders?.read(input.handle) ?? null;
1052
- if (!frame) {
1053
- this.getFrameMisses += 1;
1054
- return null;
1055
- }
1056
- this.getFrameHits += 1;
1057
- const arrayBuf = new ArrayBuffer(frame.data.byteLength);
1058
- new Uint8Array(arrayBuf).set(frame.data);
1059
- return {
1060
- data: new Uint8Array(arrayBuf),
1061
- width: frame.width,
1062
- height: frame.height,
1063
- format: frame.format,
1064
- timestamp: frame.timestamp
1065
- };
1066
- }
1067
- async getShmStats(input) {
1068
- const stats = this.sessions.get(input.sessionId)?.frameRingSinkOrNull?.getShmStats() ?? null;
1069
- if (!stats) return null;
1070
- return {
1071
- sessionId: input.sessionId,
1072
- ...stats,
1073
- budgetMb: RING_BUDGET_MB,
1074
- getFrameHits: this.getFrameHits,
1075
- getFrameMisses: this.getFrameMisses
1076
- };
1077
- }
1078
- async onShutdown() {
1079
- this.ctx.logger.info("ffmpeg decoder addon shutdown — destroying all sessions");
1080
- const destroyPromises = [];
1081
- for (const [sessionId, session] of this.sessions) {
1082
- const unsub = this.unsubscribers.get(sessionId);
1083
- if (unsub) unsub();
1084
- destroyPromises.push(session.destroy());
1085
- }
1086
- await Promise.all(destroyPromises);
1087
- this.sessions.clear();
1088
- this.frameBuffers.clear();
1089
- this.handleBuffers.clear();
1090
- this.unsubscribers.clear();
1091
- this.sessionMeta.clear();
1092
- this.frameReaders?.close();
1093
- this.frameReaders = null;
1094
- }
1095
- };
1096
- //#endregion
1097
- export { DECODE_HWACCEL_RANK, DEFAULT_VAAPI_RENDER_DEVICE, DecoderFfmpegAddon, DecoderFfmpegAddon as default, FfmpegDecoderSession, buildFfmpegDecodeArgs, channelsForPixel, codecToInputFormat, computeOutputGeometry, maxDecodeWidth, parseFfmpegOutputDims, pickDecodeHwAccel, rankDecodeHwAccels, rawPixelForFormat };