@camstack/addon-pipeline 1.1.34 → 1.1.36

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