@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,1422 +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
- let _camstack_shm_ring = require("@camstack/shm-ring");
9
- let node_crypto = require("node:crypto");
10
- //#region src/decoder-nodeav/scaler-geometry.ts
11
- /**
12
- * Decide the scaler action for an incoming frame's source geometry.
13
- *
14
- * @param current The scaler's configured source geometry, or `null` when no
15
- * scaler has been built yet.
16
- * @param incoming The source geometry of the frame about to be scaled.
17
- */
18
- function resolveScalerAction(current, incoming) {
19
- if (current === null) return "init";
20
- if (current.width === incoming.width && current.height === incoming.height && current.format === incoming.format) return "reuse";
21
- return "rebuild";
22
- }
23
- //#endregion
24
- //#region src/decoder-nodeav/nodeav-decoder-session.ts
25
- /**
26
- * Pull-mode re-dial backoff. On a pull input error/EOF the loop waits this long
27
- * before re-opening the URL — mirrors the ffmpeg pull decoder's 3s re-dial so a
28
- * transient RTSP restream blip (source suspended on idle, keyframe wait) does
29
- * not hot-loop `Demuxer.open`.
30
- */
31
- var PULL_REDIAL_MS = 3e3;
32
- /** Map our canonical backend name to the node-av `AV_HWDEVICE_TYPE_*` constant. */
33
- function backendToHwDeviceConst(backend, consts) {
34
- switch (backend) {
35
- case "videotoolbox": return consts.AV_HWDEVICE_TYPE_VIDEOTOOLBOX;
36
- case "cuda": return consts.AV_HWDEVICE_TYPE_CUDA;
37
- case "nvdec": return consts.AV_HWDEVICE_TYPE_CUDA;
38
- case "vaapi": return consts.AV_HWDEVICE_TYPE_VAAPI;
39
- case "qsv": return consts.AV_HWDEVICE_TYPE_QSV;
40
- case "d3d11va": return consts.AV_HWDEVICE_TYPE_D3D11VA;
41
- case "dxva2": return consts.AV_HWDEVICE_TYPE_DXVA2;
42
- case "amf": return consts.AV_HWDEVICE_TYPE_AMF;
43
- case "vdpau": return consts.AV_HWDEVICE_TYPE_VDPAU;
44
- case "drm": return consts.AV_HWDEVICE_TYPE_DRM;
45
- default: return null;
46
- }
47
- }
48
- /** Pick the hw pixel format for a given backend — used in get_format callback. */
49
- function backendToHwPixFmt(backend, consts) {
50
- switch (backend) {
51
- case "videotoolbox": return consts.AV_PIX_FMT_VIDEOTOOLBOX;
52
- case "cuda": return consts.AV_PIX_FMT_CUDA;
53
- case "nvdec": return consts.AV_PIX_FMT_CUDA;
54
- case "vaapi": return consts.AV_PIX_FMT_VAAPI;
55
- case "qsv": return consts.AV_PIX_FMT_QSV;
56
- case "d3d11va": return consts.AV_PIX_FMT_D3D11;
57
- case "dxva2": return consts.AV_PIX_FMT_DXVA2_VLD;
58
- case "amf": return consts.AV_PIX_FMT_D3D11;
59
- case "vdpau": return consts.AV_PIX_FMT_VDPAU;
60
- case "drm": return consts.AV_PIX_FMT_DRM_PRIME;
61
- default: return null;
62
- }
63
- }
64
- var _nav = null;
65
- var _consts = null;
66
- var _sharp = null;
67
- async function getNodeAv() {
68
- if (!_nav) _nav = await import("node-av");
69
- return _nav;
70
- }
71
- async function getConstants() {
72
- if (!_consts) _consts = await import("node-av/constants");
73
- return _consts;
74
- }
75
- async function getSharp() {
76
- if (!_sharp) _sharp = (await import("sharp")).default;
77
- return _sharp;
78
- }
79
- var noopLogger = {
80
- debug() {},
81
- info() {},
82
- warn() {},
83
- error() {},
84
- child() {
85
- return noopLogger;
86
- },
87
- withTags() {
88
- return noopLogger;
89
- }
90
- };
91
- var NodeAvDecoderSession = class NodeAvDecoderSession {
92
- config;
93
- logger;
94
- frameCallbacks = /* @__PURE__ */ new Set();
95
- handleCallbacks = /* @__PURE__ */ new Set();
96
- destroyed = false;
97
- /**
98
- * Frame delivery mode (see {@link DecoderFrameSink}). `'shm'` lazily
99
- * constructs `frameRingSink` and routes every decoded frame through it.
100
- */
101
- frameSink;
102
- /**
103
- * The shared-memory ring writer for this stream. Created lazily on the
104
- * first decoded frame in `'shm'` mode (the segment cannot be sized until
105
- * the output geometry is known) and torn down in `destroy`.
106
- */
107
- frameRingSink = null;
108
- parser = null;
109
- codecCtx = null;
110
- scaler = null;
111
- avPacket = null;
112
- avFrame = null;
113
- dstFrame = null;
114
- EAGAIN = -11;
115
- PIX_FMT_GRAY8 = 8;
116
- PIX_FMT_RGB24 = 2;
117
- SWS_FAST_BILINEAR = 1;
118
- /**
119
- * Decoder output mode. Drives both the scaler's destination pixel
120
- * format and whether sharp runs the JPEG encode at the end:
121
- *
122
- * - `'jpeg'` — scaler→RGB24 → sharp encode → emit JPEG bytes
123
- * - `'rgb'` — scaler→RGB24 → emit raw RGB24 (no sharp)
124
- * - `'gray'` — scaler→GRAY8 → emit raw GRAY8 (no sharp)
125
- *
126
- * The broker holds the policy decision on which mode to request based
127
- * on its active subscribers; on-the-fly conversion (e.g. RGB→JPEG for
128
- * a WebRTC consumer that joined while detection holds the decoder in
129
- * RGB mode) happens broker-side via the per-frame conversion cache.
130
- */
131
- outputMode;
132
- sharpFn = null;
133
- /**
134
- * Backpressure for the sharp JPEG encode pipeline. The broker
135
- * currently creates sessions with `maxFps: 0` (unlimited) and relies
136
- * on per-subscriber throttling, so without a bound the
137
- * fire-and-forget `sharp(...).toBuffer()` chain would accumulate
138
- * unboundedly whenever sharp falls behind the decoder. Cap at
139
- * `MAX_JPEG_INFLIGHT` pending encodes per session — any frame that
140
- * arrives while the cap is saturated is dropped and counted.
141
- */
142
- static MAX_JPEG_INFLIGHT = 2;
143
- jpegEncodeInFlight = 0;
144
- /**
145
- * Map a `DecoderSessionConfig.outputFormat` value to one of the three
146
- * native scaler/encoder modes the session understands. The cap-level
147
- * format vocabulary is broader (it accepts `bgr`, `yuv420`) than what
148
- * libav's scaler is wired for here — anything else degrades to RGB
149
- * (the canonical raw mode) and the broker is expected to convert
150
- * downstream if a subscriber needs a different shape.
151
- */
152
- static resolveOutputMode(format) {
153
- if (format === "jpeg" || format === void 0) return "jpeg";
154
- if (format === "gray") return "gray";
155
- return "rgb";
156
- }
157
- initialized = false;
158
- initializing = false;
159
- scalerInitializing = false;
160
- /**
161
- * Monotonic counter incremented by `updateConfig` whenever the
162
- * scaler + dstFrame get invalidated (e.g. output format toggle).
163
- * `initScaler` captures the current value at entry and aborts — or
164
- * disposes the locally-built scaler — if the epoch moved while
165
- * its async init was in flight. Without this, a toggle racing an
166
- * in-flight init could leave two scalers allocated natively while
167
- * `this.scaler` only holds a reference to one → libav leak.
168
- */
169
- scalerEpoch = 0;
170
- /**
171
- * One-shot guard for the "first frame" diagnostic log + raw frame
172
- * dump. Setting this synchronously inside `emitDecodedFrame`
173
- * prevents re-entry — without it we were using `outputFrames === 0`
174
- * which stays true until the async sharp encode callback runs, so
175
- * several decoded frames could trigger the dump before the first
176
- * JPEG landed.
177
- */
178
- firstFrameLogged = false;
179
- outWidth = 0;
180
- outHeight = 0;
181
- /**
182
- * The SOURCE geometry the current `scaler` was built for. `sws_scale` trusts
183
- * these dimensions blindly; if a later decoded frame arrives with different
184
- * width/height/format we MUST rebuild the scaler rather than feed it mismatched
185
- * planes (an out-of-bounds read/write → uncatchable SIGBUS). `-1` means "no
186
- * scaler built yet" (mirrors `this.scaler === null`).
187
- */
188
- scalerSrcW = -1;
189
- scalerSrcH = -1;
190
- scalerSrcFmt = -1;
191
- lastEmitTime = 0;
192
- minIntervalMs;
193
- inputPackets = 0;
194
- outputFrames = 0;
195
- droppedFrames = 0;
196
- totalDecodeTimeMs = 0;
197
- startTime = Date.now();
198
- hwaccelPref;
199
- hwaccelResolver;
200
- /** Cluster node id stamped into every `FrameHandle` the `'shm'` sink emits. */
201
- nodeId;
202
- /** The backend that actually initialised successfully — `'none'` = software fallback. */
203
- activeHwAccel = "none";
204
- hwDevice = null;
205
- swTransferFrame = null;
206
- /** True once `openStream` has armed the pull loop; drives `isPullMode`. */
207
- pullActive = false;
208
- /** One-shot guard so a stray push into a pull session logs once, not per-packet. */
209
- pullStrayPushLogged = false;
210
- /** The URL the pull loop (re)dials — updatable while active. */
211
- pullUrl = null;
212
- /** Pending re-dial timer + its resolver, so `destroy` can wake it instantly. */
213
- pullRedialTimer = null;
214
- pullRedialResolve = null;
215
- /** High-level demuxer/decoder for the CURRENT dial — recreated per re-dial. */
216
- pullDemuxer = null;
217
- pullDecoder = null;
218
- /** HW context — resolved once, reused across re-dials, freed in `destroy`. */
219
- pullHwContext = null;
220
- constructor(config, logger = noopLogger, options) {
221
- this.config = { ...config };
222
- const sessionTags = {};
223
- if (typeof config.deviceId === "number") sessionTags["deviceId"] = config.deviceId;
224
- if (typeof config.tag === "string" && config.tag.length > 0) sessionTags["tag"] = config.tag;
225
- this.logger = Object.keys(sessionTags).length > 0 ? logger.withTags(sessionTags) : logger;
226
- this.minIntervalMs = config.maxFps > 0 ? 1e3 / config.maxFps : 0;
227
- this.outputMode = NodeAvDecoderSession.resolveOutputMode(config.outputFormat);
228
- this.hwaccelPref = options?.hwaccel ?? "auto";
229
- this.hwaccelResolver = options?.hwaccelResolver ?? null;
230
- this.frameSink = options?.frameSink ?? "callback";
231
- this.nodeId = options?.nodeId ?? "local";
232
- }
233
- /**
234
- * The shared-memory ring sink for this stream, or `null` before the first
235
- * `'shm'`-mode frame lazily creates it. Exposed so the owning addon can
236
- * surface ring stats via `decoder.getShmStats`.
237
- */
238
- get frameRingSinkOrNull() {
239
- return this.frameRingSink;
240
- }
241
- /**
242
- * Lazily build the shared-memory ring sink for this stream. The segment
243
- * itself is still created lazily by the sink on its first `writeFrame`,
244
- * once the decoded geometry is known.
245
- */
246
- ensureFrameRingSink() {
247
- if (this.frameRingSink === null) {
248
- const seedParts = [];
249
- if (typeof this.config.deviceId === "number") seedParts.push(String(this.config.deviceId));
250
- if (typeof this.config.tag === "string" && this.config.tag.length > 0) seedParts.push(this.config.tag);
251
- const seed = seedParts.length > 0 ? seedParts.join(":") : "anon";
252
- this.frameRingSink = new require_frame_ring_sink.DecoderFrameRingSink({
253
- seed,
254
- logger: this.logger,
255
- nodeId: this.nodeId
256
- });
257
- }
258
- return this.frameRingSink;
259
- }
260
- /**
261
- * Resolve the backend preference list and try each one against
262
- * node-av's HW context APIs. The first backend whose
263
- * `HardwareDeviceContext.create()` succeeds gets attached to
264
- * `codecCtx.hwDeviceCtx` + its hw pixel format registered via
265
- * `setHardwarePixelFormat`. On any failure, falls through to the
266
- * next backend; if all fail, returns with `activeHwAccel='none'`
267
- * and the decoder runs in software on the same context.
268
- */
269
- async tryAttachHwAccel(nav, C) {
270
- if (!this.codecCtx) return;
271
- if (this.hwaccelPref === "none") {
272
- this.activeHwAccel = "none";
273
- return;
274
- }
275
- const explicit = this.hwaccelPref === "auto" ? null : this.hwaccelPref;
276
- const resolution = this.hwaccelResolver ? await this.hwaccelResolver.resolve(explicit) : explicit ? {
277
- preferred: [explicit],
278
- rationale: "explicit (no resolver)"
279
- } : {
280
- preferred: [],
281
- rationale: "auto + no resolver → software"
282
- };
283
- if (resolution.preferred.length === 0) {
284
- this.activeHwAccel = "none";
285
- return;
286
- }
287
- for (const backend of resolution.preferred) {
288
- const deviceType = backendToHwDeviceConst(backend, C);
289
- const hwPixFmt = backendToHwPixFmt(backend, C);
290
- if (!deviceType || !hwPixFmt) continue;
291
- const device = new nav.HardwareDeviceContext();
292
- const rc = device.create(deviceType);
293
- if (rc < 0) {
294
- this.logger.warn("node-av: hwaccel device create failed — trying next", { meta: {
295
- backend,
296
- rc
297
- } });
298
- device.free();
299
- continue;
300
- }
301
- try {
302
- this.codecCtx.hwDeviceCtx = device;
303
- this.codecCtx.setHardwarePixelFormat(hwPixFmt);
304
- } catch (err) {
305
- this.logger.warn("node-av: hwaccel context attach failed — trying next", { meta: {
306
- backend,
307
- error: require_dist.errMsg(err)
308
- } });
309
- device.free();
310
- continue;
311
- }
312
- this.hwDevice = device;
313
- this.activeHwAccel = backend;
314
- return;
315
- }
316
- this.logger.warn("node-av: no hwaccel backend initialised — using software", { meta: {
317
- attempted: resolution.preferred.join(","),
318
- rationale: resolution.rationale
319
- } });
320
- this.activeHwAccel = "none";
321
- }
322
- /**
323
- * Download a HW frame (format == hw pix fmt) into a SW frame so the
324
- * rest of the pipeline (scaler, JPEG encoder, grayscale passthrough)
325
- * handles it identically to the pure-software path. Uses the sync
326
- * variant so the synchronous receive loop below doesn't need to be
327
- * async-ified. Returns `null` on transfer failure, meaning the
328
- * caller should drop the frame.
329
- */
330
- transferHwFrame(hwFrame) {
331
- if (this.activeHwAccel === "none" || !this.swTransferFrame) return hwFrame;
332
- const rc = hwFrame.hwframeTransferDataSync(this.swTransferFrame, 0);
333
- if (rc < 0) {
334
- this.logger.warn("node-av: hwframeTransferData failed", { meta: { rc } });
335
- return null;
336
- }
337
- return this.swTransferFrame;
338
- }
339
- /**
340
- * Initialize the decoder pipeline on the first keyframe.
341
- * After this returns, all hot-path methods are fully synchronous (except JPEG encode).
342
- */
343
- async initDecoder() {
344
- if (this.initialized || this.initializing || this.destroyed) return;
345
- this.initializing = true;
346
- try {
347
- const nav = await getNodeAv();
348
- const C = await getConstants();
349
- this.EAGAIN = C.AVERROR_EAGAIN;
350
- this.PIX_FMT_GRAY8 = C.AV_PIX_FMT_GRAY8;
351
- this.PIX_FMT_RGB24 = C.AV_PIX_FMT_RGB24;
352
- this.SWS_FAST_BILINEAR = C.SWS_FAST_BILINEAR;
353
- if (this.outputMode === "jpeg") this.sharpFn = await getSharp();
354
- nav.Log.setLevel(C.AV_LOG_FATAL);
355
- const codecId = this.config.codec === "h265" || this.config.codec === "hevc" ? C.AV_CODEC_ID_HEVC : C.AV_CODEC_ID_H264;
356
- this.parser = new nav.CodecParser();
357
- this.parser.init(codecId);
358
- const codec = nav.Codec.findDecoder(codecId);
359
- if (!codec) {
360
- this.logger.error("node-av: no decoder found", { meta: { codec: this.config.codec } });
361
- return;
362
- }
363
- this.codecCtx = new nav.CodecContext();
364
- this.codecCtx.allocContext3(codec);
365
- if (this.config.width && this.config.height) {
366
- this.codecCtx.width = this.config.width;
367
- this.codecCtx.height = this.config.height;
368
- }
369
- this.codecCtx.threadCount = 1;
370
- await this.tryAttachHwAccel(nav, C);
371
- const ret = await this.codecCtx.open2(codec);
372
- if (ret < 0) if (this.activeHwAccel !== "none") {
373
- this.logger.warn("node-av: open2 failed with hwaccel — retrying in software", { meta: {
374
- ret,
375
- hwAccel: this.activeHwAccel
376
- } });
377
- this.hwDevice?.free();
378
- this.hwDevice = null;
379
- this.activeHwAccel = "none";
380
- this.codecCtx = new nav.CodecContext();
381
- this.codecCtx.allocContext3(codec);
382
- if (this.config.width && this.config.height) {
383
- this.codecCtx.width = this.config.width;
384
- this.codecCtx.height = this.config.height;
385
- }
386
- this.codecCtx.threadCount = 1;
387
- const retry = await this.codecCtx.open2(codec);
388
- if (retry < 0) {
389
- this.logger.error("node-av: failed to open decoder (sw fallback)", { meta: { ret: retry } });
390
- return;
391
- }
392
- } else {
393
- this.logger.error("node-av: failed to open decoder", { meta: { ret } });
394
- return;
395
- }
396
- if (this.activeHwAccel !== "none") {
397
- this.swTransferFrame = new nav.Frame();
398
- this.swTransferFrame.alloc();
399
- }
400
- this.avPacket = new nav.Packet();
401
- this.avPacket.alloc();
402
- this.avFrame = new nav.Frame();
403
- this.avFrame.alloc();
404
- this.initialized = true;
405
- this.logger.info("node-av push decoder initialized", { meta: {
406
- codec: this.config.codec,
407
- output: this.outputMode,
408
- hwAccel: this.activeHwAccel
409
- } });
410
- } catch (err) {
411
- this.logger.error("node-av init error", { meta: { error: require_dist.errMsg(err) } });
412
- } finally {
413
- this.initializing = false;
414
- }
415
- }
416
- /**
417
- * Initialize the scaler after the first frame tells us the actual
418
- * dimensions. Output pixel format: RGB24 for JPEG encoding, GRAY8
419
- * for raw motion.
420
- *
421
- * Builds `scaler` + `dstFrame` on local variables and publishes
422
- * them onto `this` in a single atomic step at the end. Captures
423
- * `scalerEpoch` at entry; if `updateConfig` invalidated the scaler
424
- * while this init was in flight (epoch mismatch), the locally
425
- * built pair is disposed and discarded so the later init wins.
426
- * Without the local-first approach, partial state (scaler set,
427
- * dstFrame still null) could be observed by a concurrent
428
- * `emitDecodedFrame` call.
429
- */
430
- async initScaler(srcW, srcH, srcFmt) {
431
- if (this.scalerInitializing) return;
432
- this.scalerInitializing = true;
433
- const myEpoch = this.scalerEpoch;
434
- let localScaler = null;
435
- let localDstFrame = null;
436
- try {
437
- const nav = await getNodeAv();
438
- if (this.destroyed || myEpoch !== this.scalerEpoch) return;
439
- const scale = this.config.scale > 1 ? this.config.scale : 1;
440
- const maxW = Math.floor(640 / scale);
441
- const outWidth = Math.min(srcW, maxW);
442
- const outHeight = Math.round(outWidth * srcH / srcW);
443
- const dstFmt = this.outputMode === "gray" ? this.PIX_FMT_GRAY8 : this.PIX_FMT_RGB24;
444
- const fmtName = this.outputMode === "gray" ? "gray8" : "rgb24";
445
- localScaler = new nav.SoftwareScaleContext();
446
- localScaler.getContext(srcW, srcH, srcFmt, outWidth, outHeight, dstFmt, this.SWS_FAST_BILINEAR);
447
- const ret = localScaler.initContext();
448
- if (ret < 0) {
449
- this.logger.error("node-av: sws_init_context failed", { meta: { ret } });
450
- return;
451
- }
452
- localDstFrame = new nav.Frame();
453
- localDstFrame.alloc();
454
- localDstFrame.width = outWidth;
455
- localDstFrame.height = outHeight;
456
- localDstFrame.format = dstFmt;
457
- const allocRet = localDstFrame.allocBuffer();
458
- if (allocRet < 0) {
459
- this.logger.error("node-av: dst frame allocBuffer failed", { meta: { ret: allocRet } });
460
- return;
461
- }
462
- if (this.destroyed || myEpoch !== this.scalerEpoch) return;
463
- this.scaler?.[Symbol.dispose]?.();
464
- this.dstFrame?.[Symbol.dispose]?.();
465
- this.scaler = localScaler;
466
- this.dstFrame = localDstFrame;
467
- this.outWidth = outWidth;
468
- this.outHeight = outHeight;
469
- this.scalerSrcW = srcW;
470
- this.scalerSrcH = srcH;
471
- this.scalerSrcFmt = srcFmt;
472
- localScaler = null;
473
- localDstFrame = null;
474
- this.logger.info("node-av scaler initialized", { meta: {
475
- srcWidth: srcW,
476
- srcHeight: srcH,
477
- outWidth,
478
- outHeight,
479
- format: fmtName
480
- } });
481
- } catch (err) {
482
- this.logger.error("Scaler init failed", { meta: { error: require_dist.errMsg(err) } });
483
- } finally {
484
- localScaler?.[Symbol.dispose]?.();
485
- localDstFrame?.[Symbol.dispose]?.();
486
- this.scalerInitializing = false;
487
- }
488
- }
489
- /**
490
- * Start PULL mode: libav dials `url` and demuxes it in-process, decoding
491
- * every frame through the same emit path as push mode. Idempotent while
492
- * active (updates the re-dial target). Throws if the session already took a
493
- * push packet — a session is push-mode or pull-mode for its whole life.
494
- */
495
- openStream(url) {
496
- if (this.destroyed) return Promise.resolve();
497
- if (this.inputPackets > 0 || this.initialized || this.initializing) throw new Error("node-av decoder: openStream on a push-fed session — a session is push-mode or pull-mode, never both");
498
- if (this.pullActive) {
499
- this.pullUrl = url;
500
- return Promise.resolve();
501
- }
502
- this.pullActive = true;
503
- this.pullUrl = url;
504
- this.logger.info("node-av decoder: pull-mode openStream — dialing input URL", { meta: { url } });
505
- this.runPullLoop().catch((err) => {
506
- this.logger.error("node-av decoder: pull loop crashed", { meta: { error: require_dist.errMsg(err) } });
507
- });
508
- return Promise.resolve();
509
- }
510
- /**
511
- * The pull-mode supervisor loop: resolve HW once, then repeatedly dial +
512
- * decode the URL, re-dialing after {@link PULL_REDIAL_MS} on any exit/error
513
- * until the session is destroyed. Each iteration rebuilds a fresh
514
- * demuxer/decoder (the previous one is disposed in `closePullInput`).
515
- */
516
- async runPullLoop() {
517
- const nav = await getNodeAv();
518
- const C = await getConstants();
519
- this.EAGAIN = C.AVERROR_EAGAIN;
520
- this.PIX_FMT_GRAY8 = C.AV_PIX_FMT_GRAY8;
521
- this.PIX_FMT_RGB24 = C.AV_PIX_FMT_RGB24;
522
- this.SWS_FAST_BILINEAR = C.SWS_FAST_BILINEAR;
523
- nav.Log.setLevel(C.AV_LOG_FATAL);
524
- if (this.outputMode === "jpeg" && !this.sharpFn) this.sharpFn = await getSharp();
525
- await this.ensurePullHwContext(nav, C);
526
- while (!this.destroyed && this.pullActive) {
527
- const url = this.pullUrl;
528
- if (!url) break;
529
- try {
530
- await this.pullDialAndDecode(nav, C, url);
531
- } catch (err) {
532
- if (this.destroyed) break;
533
- this.logger.warn("node-av decoder: pull-mode dial ended — re-dial scheduled", { meta: {
534
- error: require_dist.errMsg(err),
535
- redialInMs: PULL_REDIAL_MS
536
- } });
537
- } finally {
538
- this.closePullInput();
539
- }
540
- if (this.destroyed || !this.pullActive) break;
541
- await this.pullSleep(PULL_REDIAL_MS);
542
- }
543
- }
544
- /**
545
- * One dial: open the demuxer, pick the video stream, create a HW/SW decoder
546
- * that downloads + normalises frames to YUV420P (so the shared scaler in
547
- * `emitDecodedFrame` reads plain software planes regardless of hwaccel), and
548
- * pump `demuxer.packets(video) → decoder.frames → emitDecodedFrame` until the
549
- * stream ends or the session is torn down.
550
- */
551
- async pullDialAndDecode(nav, C, url) {
552
- const demuxer = await nav.Demuxer.open(url, { options: { rtsp_transport: "tcp" } });
553
- if (this.destroyed || !this.pullActive) {
554
- demuxer[Symbol.dispose]?.();
555
- return;
556
- }
557
- this.pullDemuxer = demuxer;
558
- const videoStream = demuxer.video();
559
- if (!videoStream) throw new Error("node-av decoder: pull input has no video stream");
560
- const decoder = await nav.Decoder.create(videoStream, {
561
- ...this.pullHwContext ? { hardware: this.pullHwContext } : {},
562
- rescale: { pixelFormat: C.AV_PIX_FMT_YUV420P }
563
- });
564
- if (this.destroyed || !this.pullActive) {
565
- decoder[Symbol.dispose]?.();
566
- return;
567
- }
568
- this.pullDecoder = decoder;
569
- this.logger.info("node-av pull decoder initialized", { meta: {
570
- codec: this.config.codec,
571
- output: this.outputMode,
572
- hwAccel: this.activeHwAccel,
573
- streamIndex: videoStream.index
574
- } });
575
- for await (const frame of decoder.frames(demuxer.packets(videoStream.index))) {
576
- if (this.destroyed || !this.pullActive) break;
577
- if (!frame) continue;
578
- this.inputPackets++;
579
- try {
580
- this.emitDecodedFrame(frame);
581
- } finally {
582
- frame.free();
583
- }
584
- }
585
- }
586
- /**
587
- * Resolve and build the HW context for pull mode ONCE. Explicit backend →
588
- * single try; `'auto'` → the kernel resolver's ordered list; no resolver →
589
- * software. The first `HardwareContext.create` that succeeds wins; all
590
- * failures fall through to software decode (`hardware: null`).
591
- */
592
- async ensurePullHwContext(nav, C) {
593
- if (this.hwaccelPref === "none") {
594
- this.activeHwAccel = "none";
595
- return;
596
- }
597
- const explicit = this.hwaccelPref === "auto" ? null : this.hwaccelPref;
598
- const resolution = this.hwaccelResolver ? await this.hwaccelResolver.resolve(explicit) : explicit ? {
599
- preferred: [explicit],
600
- rationale: "explicit (no resolver)"
601
- } : {
602
- preferred: [],
603
- rationale: "auto + no resolver → software"
604
- };
605
- for (const backend of resolution.preferred) {
606
- const deviceType = backendToHwDeviceConst(backend, C);
607
- if (!deviceType) continue;
608
- const hw = nav.HardwareContext.create(deviceType);
609
- if (!hw) {
610
- this.logger.warn("node-av: pull hwaccel context create failed — trying next", { meta: { backend } });
611
- continue;
612
- }
613
- this.pullHwContext = hw;
614
- this.activeHwAccel = backend;
615
- return;
616
- }
617
- this.logger.warn("node-av: no pull hwaccel backend initialised — using software", { meta: {
618
- attempted: resolution.preferred.join(","),
619
- rationale: resolution.rationale
620
- } });
621
- this.activeHwAccel = "none";
622
- }
623
- /** Dispose the CURRENT dial's demuxer + decoder (HW context is reused). */
624
- closePullInput() {
625
- if (this.pullDecoder) {
626
- this.pullDecoder[Symbol.dispose]?.();
627
- this.pullDecoder = null;
628
- }
629
- if (this.pullDemuxer) {
630
- this.pullDemuxer[Symbol.dispose]?.();
631
- this.pullDemuxer = null;
632
- }
633
- }
634
- /** Interruptible sleep for the re-dial backoff — `destroy` resolves it early. */
635
- pullSleep(ms) {
636
- return new Promise((resolve) => {
637
- this.pullRedialResolve = resolve;
638
- this.pullRedialTimer = setTimeout(() => {
639
- this.pullRedialTimer = null;
640
- this.pullRedialResolve = null;
641
- resolve();
642
- }, ms);
643
- });
644
- }
645
- pushPacket(packet) {
646
- if (this.destroyed) return;
647
- if (this.pullActive) {
648
- if (!this.pullStrayPushLogged) {
649
- this.pullStrayPushLogged = true;
650
- this.logger.debug("node-av decoder: pushPacket on a pull-mode session — ignored");
651
- }
652
- return;
653
- }
654
- this.inputPackets++;
655
- if (!this.initialized && !this.initializing && packet.keyframe) {
656
- this.initDecoder().then(() => {
657
- if (this.initialized) this.decodeRawData(packet.data, packet.pts ?? Date.now());
658
- }).catch((err) => {
659
- this.logger.error("node-av init failed", { meta: { error: require_dist.errMsg(err) } });
660
- });
661
- return;
662
- }
663
- if (!this.initialized) return;
664
- this.decodeRawData(packet.data, packet.pts ?? Date.now());
665
- }
666
- decodeRawData(data, pts) {
667
- if (!this.parser || !this.codecCtx || !this.avPacket || !this.avFrame) return;
668
- const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
669
- const bigPts = BigInt(pts);
670
- let offset = 0;
671
- while (offset < buf.length) {
672
- const remaining = buf.subarray(offset);
673
- const consumed = this.parser.parse2(this.codecCtx, this.avPacket, remaining, bigPts, bigPts, offset);
674
- if (consumed < 0) {
675
- this.logger.warn("node-av parser error", { meta: { ret: consumed } });
676
- break;
677
- }
678
- offset += consumed;
679
- if (this.avPacket.size > 0) {
680
- this.decodePacket();
681
- this.avPacket.unref();
682
- }
683
- }
684
- }
685
- decodePacket() {
686
- if (!this.codecCtx || !this.avFrame) return;
687
- const sendRet = this.codecCtx.sendPacketSync(this.avPacket);
688
- if (sendRet < 0 && sendRet !== this.EAGAIN) {
689
- this.logger.warn("node-av sendPacket error", { meta: { ret: sendRet } });
690
- return;
691
- }
692
- while (true) {
693
- if (this.codecCtx.receiveFrameSync(this.avFrame) < 0) break;
694
- const frame = this.transferHwFrame(this.avFrame);
695
- if (!frame) continue;
696
- this.emitDecodedFrame(frame);
697
- }
698
- }
699
- emitDecodedFrame(frame) {
700
- const now = performance.now();
701
- if (this.minIntervalMs > 0 && now - this.lastEmitTime < this.minIntervalMs) {
702
- this.droppedFrames++;
703
- return;
704
- }
705
- if (this.scalerInitializing) return;
706
- const incoming = {
707
- width: frame.width,
708
- height: frame.height,
709
- format: frame.format
710
- };
711
- const current = this.scaler !== null ? {
712
- width: this.scalerSrcW,
713
- height: this.scalerSrcH,
714
- format: this.scalerSrcFmt
715
- } : null;
716
- const action = resolveScalerAction(current, incoming);
717
- if (action === "init") {
718
- this.initScaler(incoming.width, incoming.height, incoming.format);
719
- return;
720
- }
721
- if (action === "rebuild") {
722
- this.logger.info("node-av: decoded source geometry changed — rebuilding scaler", { meta: {
723
- from: current,
724
- to: incoming
725
- } });
726
- this.scalerEpoch++;
727
- this.scaler?.[Symbol.dispose]?.();
728
- this.scaler = null;
729
- this.dstFrame?.[Symbol.dispose]?.();
730
- this.dstFrame = null;
731
- this.scalerSrcW = -1;
732
- this.scalerSrcH = -1;
733
- this.scalerSrcFmt = -1;
734
- this.initScaler(incoming.width, incoming.height, incoming.format);
735
- return;
736
- }
737
- if (!this.dstFrame || !this.scaler) return;
738
- const decodeStart = performance.now();
739
- if (this.frameSink === "shm" && this.outputMode !== "jpeg") {
740
- this.scaleIntoRingSlot(frame, decodeStart);
741
- return;
742
- }
743
- try {
744
- this.dstFrame.makeWritable();
745
- this.scaler.scaleFrameSync(this.dstFrame, frame);
746
- } catch (err) {
747
- this.logger.warn("node-av scale error", { meta: { error: require_dist.errMsg(err) } });
748
- return;
749
- }
750
- if (!this.firstFrameLogged) {
751
- this.firstFrameLogged = true;
752
- const channels = this.outputMode === "gray" ? 1 : 3;
753
- const ls = this.dstFrame.linesize;
754
- const buf = this.dstFrame.toBuffer();
755
- this.logger.info("dstFrame after scale", { meta: {
756
- phase: "frame-debug",
757
- width: this.dstFrame.width,
758
- height: this.dstFrame.height,
759
- linesize: [
760
- ls[0],
761
- ls[1],
762
- ls[2]
763
- ],
764
- expectedStride: this.dstFrame.width * channels,
765
- bufLen: buf.length,
766
- expectedPacked: this.dstFrame.width * channels * this.dstFrame.height,
767
- srcFormat: frame.format ?? "?"
768
- } });
769
- if (this.outputMode !== "gray") import("node:fs").then((fsModule) => {
770
- import("node:path").then((pathModule) => {
771
- const dumpPath = pathModule.join(process.cwd(), "camstack-data", "debug-frame-rgb24.raw");
772
- fsModule.writeFileSync(dumpPath, buf);
773
- this.logger.info("Dumped first RGB24 frame", { meta: {
774
- phase: "frame-debug",
775
- path: dumpPath,
776
- bytes: buf.length
777
- } });
778
- }).catch(() => {});
779
- }).catch(() => {});
780
- }
781
- const rawBuf = this.extractPackedBuffer(this.dstFrame);
782
- if (this.outputMode === "jpeg") this.encodeAndEmitJpeg(rawBuf, decodeStart);
783
- else if (this.outputMode === "rgb") this.emitRawFrame(rawBuf, "rgb", decodeStart);
784
- else this.emitRawFrame(rawBuf, "gray", decodeStart);
785
- }
786
- /**
787
- * Scale a decoded frame **directly into a shared-memory ring slot** — the
788
- * zero write-side copy path (Phase 5 / D9 Task 7c).
789
- *
790
- * `beginFrame` reserves the slot (its seqlock open / odd — readers skip it);
791
- * `SoftwareScaleContext.scaleSync` (the low-level `sws_scale` mapping) writes
792
- * the packed pixels straight into the slot buffer with `linesize = width ×
793
- * bpp` — no linesize padding, so there is nothing to strip and nothing to
794
- * memcpy; `commitFrame` writes the metadata, closes the seqlock and publishes
795
- * the slot. The decoded source planes feed `scaleSync` as `srcSlice` +
796
- * `srcStride` (`frame.data` / `frame.linesize`).
797
- *
798
- * Used only for raw output formats (rgb/bgr/gray). The jpeg path keeps the
799
- * `dstFrame` + sharp encode route because sharp must consume an RGB buffer
800
- * before the final (variable-length) jpeg bytes exist.
801
- */
802
- scaleIntoRingSlot(frame, decodeStart) {
803
- if (!this.scaler) return;
804
- const format = this.outputMode === "gray" ? "gray" : "rgb";
805
- const channels = this.outputMode === "gray" ? 1 : 3;
806
- const sink = this.ensureFrameRingSink();
807
- const reserved = sink.beginFrame(this.outWidth, this.outHeight, format);
808
- if (reserved === null) {
809
- this.droppedFrames++;
810
- return;
811
- }
812
- const srcPlanes = frame.data;
813
- if (!srcPlanes || srcPlanes.length === 0) {
814
- sink.abortFrame(reserved.slot);
815
- this.droppedFrames++;
816
- return;
817
- }
818
- const dstStride = this.outWidth * channels;
819
- const needed = dstStride * this.outHeight;
820
- if (reserved.buffer.length < needed) {
821
- this.logger.warn("node-av: ring slot too small for scaled frame — dropping", { meta: {
822
- needed,
823
- slotBytes: reserved.buffer.length,
824
- width: this.outWidth,
825
- height: this.outHeight
826
- } });
827
- sink.abortFrame(reserved.slot);
828
- this.droppedFrames++;
829
- return;
830
- }
831
- try {
832
- const srcStrides = Array.from(frame.linesize).slice(0, srcPlanes.length);
833
- this.scaler.scaleSync(Array.from(srcPlanes), srcStrides, 0, frame.height, [reserved.buffer], [dstStride]);
834
- } catch (err) {
835
- this.logger.warn("node-av scale-into-slot error", { meta: { error: require_dist.errMsg(err) } });
836
- sink.abortFrame(reserved.slot);
837
- this.droppedFrames++;
838
- return;
839
- }
840
- const decodeMs = performance.now() - decodeStart;
841
- this.totalDecodeTimeMs += decodeMs;
842
- this.outputFrames++;
843
- this.lastEmitTime = performance.now();
844
- if (!this.firstFrameLogged) {
845
- this.firstFrameLogged = true;
846
- this.logger.info("node-av: scaled directly into shm ring slot", { meta: {
847
- phase: "frame-debug",
848
- width: this.outWidth,
849
- height: this.outHeight,
850
- dstStride,
851
- format
852
- } });
853
- }
854
- if (this.outputFrames === 1 || this.outputFrames % 500 === 0) this.logger.info("node-av frame emitted", { meta: {
855
- frameNumber: this.outputFrames,
856
- width: this.outWidth,
857
- height: this.outHeight,
858
- format,
859
- decodeMs,
860
- sink: "shm",
861
- subs: this.handleCallbacks.size
862
- } });
863
- const handle = sink.commitFrame(reserved.slot, {
864
- width: this.outWidth,
865
- height: this.outHeight,
866
- format,
867
- pts: performance.now(),
868
- byteLength: dstStride * this.outHeight
869
- });
870
- if (handle === null) {
871
- this.droppedFrames++;
872
- return;
873
- }
874
- const delivered = {
875
- handle,
876
- timestamp: Date.now()
877
- };
878
- for (const cb of this.handleCallbacks) cb(delivered);
879
- }
880
- /**
881
- * Extract packed pixel buffer from a decoded frame.
882
- * FFmpeg's av_frame_get_buffer() may pad each row to alignment (32/64 bytes).
883
- * Sharp and WASM consumers expect tightly-packed rows (stride = width * channels).
884
- * If linesize matches expected stride, return the buffer directly (zero-copy).
885
- */
886
- extractPackedBuffer(frame) {
887
- const channels = this.outputMode === "gray" ? 1 : 3;
888
- const expectedStride = frame.width * channels;
889
- const actualStride = frame.linesize[0] ?? expectedStride;
890
- const src = frame.data?.[0];
891
- if (!src) return frame.toBuffer();
892
- if (actualStride === expectedStride) return Buffer.from(src.buffer, src.byteOffset, expectedStride * frame.height);
893
- const dst = Buffer.allocUnsafe(expectedStride * frame.height);
894
- for (let y = 0; y < frame.height; y++) src.copy(dst, y * expectedStride, y * actualStride, y * actualStride + expectedStride);
895
- return dst;
896
- }
897
- /**
898
- * Encode RGB24 raw buffer as JPEG and emit.
899
- *
900
- * Drops the frame (and counts it) when `MAX_JPEG_INFLIGHT` encodes
901
- * are already pending — prevents unbounded growth of the
902
- * fire-and-forget promise chain when sharp cannot keep up with the
903
- * decode rate.
904
- */
905
- encodeAndEmitJpeg(rgb, decodeStart) {
906
- if (!this.sharpFn) return;
907
- if (this.jpegEncodeInFlight >= NodeAvDecoderSession.MAX_JPEG_INFLIGHT) {
908
- this.droppedFrames++;
909
- return;
910
- }
911
- this.jpegEncodeInFlight++;
912
- this.sharpFn(rgb, { raw: {
913
- width: this.outWidth,
914
- height: this.outHeight,
915
- channels: 3
916
- } }).jpeg({
917
- quality: 80,
918
- mozjpeg: false
919
- }).toBuffer().then((jpegBuf) => {
920
- if (this.destroyed) return;
921
- this.emitRawFrame(jpegBuf, "jpeg", decodeStart);
922
- }).catch((err) => {
923
- this.logger.warn("sharp jpeg encode error", { meta: { error: require_dist.errMsg(err) } });
924
- }).finally(() => {
925
- this.jpegEncodeInFlight--;
926
- });
927
- }
928
- emitRawFrame(data, format, decodeStart) {
929
- const decodeMs = performance.now() - decodeStart;
930
- this.totalDecodeTimeMs += decodeMs;
931
- this.outputFrames++;
932
- this.lastEmitTime = performance.now();
933
- if (this.outputFrames === 1 || this.outputFrames % 500 === 0) this.logger.info("node-av frame emitted", { meta: {
934
- frameNumber: this.outputFrames,
935
- width: this.outWidth,
936
- height: this.outHeight,
937
- format,
938
- decodeMs,
939
- sink: this.frameSink,
940
- subs: this.frameSink === "shm" ? this.handleCallbacks.size : this.frameCallbacks.size
941
- } });
942
- if (this.frameSink === "shm") {
943
- this.emitShmFrame(data, format);
944
- return;
945
- }
946
- const decodedFrame = {
947
- data,
948
- width: this.outWidth,
949
- height: this.outHeight,
950
- format,
951
- timestamp: Date.now()
952
- };
953
- for (const cb of this.frameCallbacks) cb(decodedFrame);
954
- }
955
- /**
956
- * Write a decoded frame into this stream's shared-memory ring and deliver
957
- * the resulting `FrameHandle` to `onFrameHandle` subscribers. No pixel
958
- * `Buffer` is handed to callbacks — consumers open the segment and read the
959
- * pixels zero-copy via a `FrameRingReader`.
960
- */
961
- emitShmFrame(data, format) {
962
- const handle = this.ensureFrameRingSink().writeFrame(data, {
963
- width: this.outWidth,
964
- height: this.outHeight,
965
- format,
966
- pts: performance.now(),
967
- byteLength: data.byteLength
968
- });
969
- if (handle === null) {
970
- this.droppedFrames++;
971
- return;
972
- }
973
- const frame = {
974
- handle,
975
- timestamp: Date.now()
976
- };
977
- for (const cb of this.handleCallbacks) cb(frame);
978
- }
979
- onFrame(callback) {
980
- this.frameCallbacks.add(callback);
981
- return () => {
982
- this.frameCallbacks.delete(callback);
983
- };
984
- }
985
- /**
986
- * Subscribe to shared-memory frame handles. Fires only when the session was
987
- * created with `frameSink: 'shm'`; in the legacy `'callback'` mode no
988
- * handles are produced and this subscription never fires.
989
- */
990
- onFrameHandle(callback) {
991
- this.handleCallbacks.add(callback);
992
- return () => {
993
- this.handleCallbacks.delete(callback);
994
- };
995
- }
996
- updateConfig(update) {
997
- const prevFormat = this.config.outputFormat;
998
- this.config = {
999
- ...this.config,
1000
- ...update
1001
- };
1002
- if (update.maxFps !== void 0) this.minIntervalMs = update.maxFps > 0 ? 1e3 / update.maxFps : 0;
1003
- if (update.outputFormat !== void 0 && update.outputFormat !== prevFormat) {
1004
- const prevMode = this.outputMode;
1005
- this.outputMode = NodeAvDecoderSession.resolveOutputMode(update.outputFormat);
1006
- if (this.outputMode === prevMode) return;
1007
- this.scalerEpoch++;
1008
- if (this.scaler) {
1009
- this.scaler[Symbol.dispose]?.();
1010
- this.scaler = null;
1011
- }
1012
- if (this.dstFrame) {
1013
- this.dstFrame[Symbol.dispose]?.();
1014
- this.dstFrame = null;
1015
- }
1016
- this.scalerSrcW = -1;
1017
- this.scalerSrcH = -1;
1018
- this.scalerSrcFmt = -1;
1019
- if (this.outputMode === "jpeg" && !this.sharpFn) getSharp().then((fn) => {
1020
- this.sharpFn = fn;
1021
- }).catch(() => {});
1022
- this.logger.info("node-av: output format changed — scaler will reinit", { meta: {
1023
- from: prevFormat,
1024
- to: update.outputFormat,
1025
- mode: this.outputMode
1026
- } });
1027
- }
1028
- }
1029
- async destroy() {
1030
- if (this.destroyed) return;
1031
- this.destroyed = true;
1032
- this.frameCallbacks.clear();
1033
- this.handleCallbacks.clear();
1034
- this.pullActive = false;
1035
- if (this.pullRedialTimer) {
1036
- clearTimeout(this.pullRedialTimer);
1037
- this.pullRedialTimer = null;
1038
- }
1039
- if (this.pullRedialResolve) {
1040
- const resolve = this.pullRedialResolve;
1041
- this.pullRedialResolve = null;
1042
- resolve();
1043
- }
1044
- this.closePullInput();
1045
- if (this.pullHwContext) {
1046
- if (!this.pullHwContext.isDisposed) this.pullHwContext.dispose();
1047
- this.pullHwContext = null;
1048
- }
1049
- this.frameRingSink?.destroy();
1050
- this.frameRingSink = null;
1051
- this.dstFrame?.[Symbol.dispose]?.();
1052
- this.avFrame?.[Symbol.dispose]?.();
1053
- this.avPacket?.[Symbol.dispose]?.();
1054
- this.scaler?.[Symbol.dispose]?.();
1055
- this.parser?.[Symbol.dispose]?.();
1056
- this.swTransferFrame?.[Symbol.dispose]?.();
1057
- this.codecCtx?.[Symbol.dispose]?.();
1058
- this.hwDevice?.free();
1059
- this.dstFrame = null;
1060
- this.avFrame = null;
1061
- this.avPacket = null;
1062
- this.scaler = null;
1063
- this.parser = null;
1064
- this.codecCtx = null;
1065
- this.swTransferFrame = null;
1066
- this.hwDevice = null;
1067
- }
1068
- getStats() {
1069
- const uptimeSec = Math.max((Date.now() - this.startTime) / 1e3, 1);
1070
- return {
1071
- inputFps: this.inputPackets / uptimeSec,
1072
- outputFps: this.outputFrames / uptimeSec,
1073
- avgDecodeTimeMs: this.outputFrames > 0 ? this.totalDecodeTimeMs / this.outputFrames : 0,
1074
- droppedFrames: this.droppedFrames
1075
- };
1076
- }
1077
- get isPullMode() {
1078
- return this.pullActive;
1079
- }
1080
- };
1081
- //#endregion
1082
- //#region src/decoder-nodeav/addon/index.ts
1083
- var FRAME_BUFFER_CAPACITY = 32;
1084
- var DecoderNodeAvAddon = class extends require_dist.BaseAddon {
1085
- /**
1086
- * Sessions are stored as the concrete `NodeAvDecoderSession` (the only type
1087
- * this addon ever creates) so `getShmStats` can reach the per-session shm
1088
- * ring sink — `IDecoderSession` does not expose it.
1089
- */
1090
- sessions = /* @__PURE__ */ new Map();
1091
- /** Pixel-frame buffers — populated only for `frameSink: 'callback'` sessions. */
1092
- frameBuffers = /* @__PURE__ */ new Map();
1093
- /** `FrameHandle` buffers — populated only for `frameSink: 'shm'` sessions. */
1094
- handleBuffers = /* @__PURE__ */ new Map();
1095
- unsubscribers = /* @__PURE__ */ new Map();
1096
- sessionMeta = /* @__PURE__ */ new Map();
1097
- /**
1098
- * Per-shm-segment `FrameRingReader` cache backing `getFrame`. Opens each
1099
- * named segment once and reuses the reader for every later handle on it;
1100
- * built in `onInitialize` (it needs the scoped logger) and closed in
1101
- * `onShutdown`.
1102
- */
1103
- frameReaders = null;
1104
- /** Running `getFrame` hit/miss counters surfaced via `getShmStats`. */
1105
- getFrameHits = 0;
1106
- getFrameMisses = 0;
1107
- constructor() {
1108
- super(require_dist.DEFAULT_DECODER_HWACCEL_CONFIG);
1109
- }
1110
- globalSettingsSchema() {
1111
- return this.schema({ sections: [{
1112
- id: "hwaccel",
1113
- title: "Hardware acceleration",
1114
- tab: "decoder",
1115
- description: "Backend used by node-av decoder sessions. \"Auto\" defers to the probed best; concrete backends force it. Changes apply to NEW sessions — existing sessions keep the backend they were created with.",
1116
- fields: [this.field({
1117
- type: "select",
1118
- key: "hwaccel",
1119
- label: "Preferred backend",
1120
- options: [...require_dist.HWACCEL_OPTIONS],
1121
- default: "auto",
1122
- immediate: true,
1123
- perNode: true
1124
- }), this.field({
1125
- type: "text",
1126
- key: "probedBestHwaccel",
1127
- label: "Probed best",
1128
- description: "Auto-detected best decoder backend on this host. Click the refresh icon to re-run the probe.",
1129
- readonlyField: true,
1130
- default: "",
1131
- perNode: true,
1132
- actions: [{
1133
- action: "reprobe-hwaccel",
1134
- icon: "refresh-cw",
1135
- tooltip: "Re-probe hwaccel"
1136
- }]
1137
- })]
1138
- }] });
1139
- }
1140
- async onInitialize() {
1141
- const backend = await require_frame_ring_sink.resolveDecoderBackend(this.ctx.api, this.resolveLocalNodeId(), this.ctx.logger);
1142
- if (backend !== "nodeav") {
1143
- this.ctx.logger.info("node-av decoder: this node selects a different decoder backend — standing down (no decoder provider registered)", { meta: { selectedBackend: backend } });
1144
- return [];
1145
- }
1146
- this.ctx.logger.info("node-av decoder addon initialized", { meta: { selectedBackend: backend } });
1147
- this.frameReaders = new _camstack_shm_ring.FrameRingReaderCache(this.ctx.logger);
1148
- if (!this.config.probedBestHwaccel) this.reprobeHwaccel().catch((err) => {
1149
- this.ctx.logger.warn("nodeav: auto-reprobe hwaccel failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
1150
- });
1151
- return [{
1152
- capability: require_dist.decoderCapability,
1153
- provider: this
1154
- }];
1155
- }
1156
- /**
1157
- * Resolve the effective hwaccel backend for a new session. Reads the SHARED
1158
- * per-node override from the `decoder-ffmpeg` OWNER — the single value the
1159
- * Pipeline UI edits — exactly as `resolveDecoderBackend` reads the owner's
1160
- * `backend`. This makes a manual hwaccel override take effect even when
1161
- * node-av is the active backend (its OWN `hwaccel@node` is never surfaced by
1162
- * the UI). `'auto'` defers to the session's local resolver
1163
- * (`ctx.kernel.hwaccel`). Fails safe to `'auto'`.
1164
- */
1165
- resolveHwAccelPref() {
1166
- return require_frame_ring_sink.resolveDecoderHwAccel(this.ctx.api, this.resolveLocalNodeId(), this.ctx.logger);
1167
- }
1168
- /**
1169
- * Re-run the platform probe on this host and persist the detected
1170
- * backend as `probedBestHwaccel`. The operator's `hwaccel` setting
1171
- * is intentionally left alone — the probe only updates the hint.
1172
- */
1173
- async reprobeHwaccel() {
1174
- const resolver = this.ctx.kernel.hwaccel;
1175
- if (!resolver) {
1176
- this.ctx.logger.warn("reprobeHwaccel: no kernel hwaccel resolver — returning none");
1177
- await this.updateGlobalSettings({ probedBestHwaccel: "none" });
1178
- return { backend: "none" };
1179
- }
1180
- try {
1181
- const res = await resolver.resolve();
1182
- const backend = require_ffmpeg_args.rankDecodeHwAccels(res.preferred, [])[0] ?? "none";
1183
- await this.updateGlobalSettings({ probedBestHwaccel: backend });
1184
- this.ctx.logger.info("reprobeHwaccel: wrote probedBestHwaccel", { meta: {
1185
- backend,
1186
- rationale: res.rationale,
1187
- preferred: res.preferred
1188
- } });
1189
- return { backend };
1190
- } catch (err) {
1191
- this.ctx.logger.warn("reprobeHwaccel failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
1192
- await this.updateGlobalSettings({ probedBestHwaccel: "none" });
1193
- return { backend: "none" };
1194
- }
1195
- }
1196
- async supportsCodec(input) {
1197
- return [
1198
- "h264",
1199
- "h265",
1200
- "hevc"
1201
- ].includes(input.codec.toLowerCase());
1202
- }
1203
- async getInfo() {
1204
- return {
1205
- id: "decoder-nodeav",
1206
- name: "Decoder (node-av)",
1207
- isPullMode: true,
1208
- priority: 10
1209
- };
1210
- }
1211
- /**
1212
- * The cluster node id of this decoder — the Moleculer nodeID (`hub`,
1213
- * `dev-agent-0`, …), not the addon id. Stamped into session-owned
1214
- * `FrameHandle`s so a downstream consumer routes `getFrame` to the node
1215
- * that holds the shm ring. A hierarchical broker nodeID (`hub/...`) is
1216
- * collapsed to the cluster-visible parent; in-process boot is left as-is.
1217
- * Mirrors the resolution `PipelineRunnerAddon.onInitialize` performs.
1218
- */
1219
- resolveLocalNodeId() {
1220
- const raw = this.ctx.kernel.localNodeId ?? this.ctx.id;
1221
- return raw.includes("/") ? raw.split("/")[0] : raw;
1222
- }
1223
- async createSession(config) {
1224
- const sessionId = (0, node_crypto.randomUUID)();
1225
- const hwaccel = await this.resolveHwAccelPref();
1226
- const { frameSink } = config;
1227
- const nodeId = this.resolveLocalNodeId();
1228
- const session = new NodeAvDecoderSession(config, this.ctx.logger, {
1229
- hwaccel,
1230
- hwaccelResolver: this.ctx.kernel.hwaccel,
1231
- frameSink,
1232
- nodeId
1233
- });
1234
- const unsub = frameSink === "shm" ? this.wireShmSink(sessionId, session) : this.wireCallbackSink(sessionId, session);
1235
- this.sessions.set(sessionId, session);
1236
- this.unsubscribers.set(sessionId, unsub);
1237
- this.sessionMeta.set(sessionId, {
1238
- codec: config.codec,
1239
- outputFormat: config.outputFormat,
1240
- createdAtMs: Date.now()
1241
- });
1242
- this.ctx.logger.info("node-av: created session", { meta: {
1243
- sessionId,
1244
- codec: config.codec,
1245
- hwaccelPref: hwaccel,
1246
- frameSink,
1247
- nodeId
1248
- } });
1249
- return {
1250
- sessionId,
1251
- nodeId
1252
- };
1253
- }
1254
- /**
1255
- * Subscribe a `'callback'` session's pixel frames into a per-session
1256
- * ring buffer drained by `pullFrames`.
1257
- */
1258
- wireCallbackSink(sessionId, session) {
1259
- const ringBuffer = new require_frame_ring_sink.NotifyingRingBuffer(FRAME_BUFFER_CAPACITY);
1260
- this.frameBuffers.set(sessionId, ringBuffer);
1261
- return session.onFrame((frame) => {
1262
- const { format } = frame;
1263
- if (format !== "jpeg" && format !== "rgb" && format !== "bgr" && format !== "yuv420" && format !== "gray") return;
1264
- const arrayBuf = new ArrayBuffer(frame.data.byteLength);
1265
- new Uint8Array(arrayBuf).set(frame.data);
1266
- const capFrame = {
1267
- data: new Uint8Array(arrayBuf),
1268
- width: frame.width,
1269
- height: frame.height,
1270
- format,
1271
- timestamp: frame.timestamp
1272
- };
1273
- ringBuffer.push(capFrame);
1274
- });
1275
- }
1276
- /**
1277
- * Subscribe a `'shm'` session's `FrameHandle`s into a per-session ring
1278
- * buffer drained by `pullHandles`. No pixel bytes cross the cap boundary
1279
- * — the broker opens the named segment and reads the pixels zero-copy.
1280
- */
1281
- wireShmSink(sessionId, session) {
1282
- const handleRing = new require_frame_ring_sink.NotifyingRingBuffer(FRAME_BUFFER_CAPACITY);
1283
- this.handleBuffers.set(sessionId, handleRing);
1284
- return session.onFrameHandle((frame) => {
1285
- handleRing.push(frame.handle);
1286
- });
1287
- }
1288
- async destroySession(input) {
1289
- const { sessionId } = input;
1290
- const session = this.sessions.get(sessionId);
1291
- if (!session) throw new Error(`decoder-nodeav: unknown sessionId ${sessionId}`);
1292
- const unsub = this.unsubscribers.get(sessionId);
1293
- if (unsub) unsub();
1294
- this.frameBuffers.get(sessionId)?.cancel();
1295
- this.handleBuffers.get(sessionId)?.cancel();
1296
- await session.destroy();
1297
- this.sessions.delete(sessionId);
1298
- this.frameBuffers.delete(sessionId);
1299
- this.handleBuffers.delete(sessionId);
1300
- this.unsubscribers.delete(sessionId);
1301
- this.sessionMeta.delete(sessionId);
1302
- this.ctx.logger.info("node-av: destroyed session", { meta: { sessionId } });
1303
- }
1304
- async listActiveSessions() {
1305
- const out = [];
1306
- for (const [sessionId, meta] of this.sessionMeta) out.push({
1307
- sessionId,
1308
- codec: meta.codec,
1309
- outputFormat: meta.outputFormat,
1310
- createdAtMs: meta.createdAtMs
1311
- });
1312
- return out;
1313
- }
1314
- async pushPacket(input) {
1315
- const session = this.sessions.get(input.sessionId);
1316
- if (!session) throw new Error(`decoder-nodeav: unknown sessionId ${input.sessionId}`);
1317
- const rawData = input.packet.data;
1318
- const data = Buffer.isBuffer(rawData) ? rawData : rawData instanceof Uint8Array ? Buffer.from(rawData.buffer, rawData.byteOffset, rawData.byteLength) : Buffer.from(rawData);
1319
- session.pushPacket({
1320
- ...input.packet,
1321
- data
1322
- });
1323
- }
1324
- async openStream(input) {
1325
- const session = this.sessions.get(input.sessionId);
1326
- if (!session) throw new Error(`decoder-nodeav: unknown sessionId ${input.sessionId}`);
1327
- await session.openStream(input.url);
1328
- }
1329
- async pullFrames(input) {
1330
- if (!this.sessions.has(input.sessionId)) throw new Error(`decoder-nodeav: unknown sessionId ${input.sessionId}`);
1331
- const ringBuffer = this.frameBuffers.get(input.sessionId);
1332
- if (!ringBuffer) return [];
1333
- if (ringBuffer.size === 0 && input.waitMs) await ringBuffer.waitForItem(input.waitMs);
1334
- return ringBuffer.drain(input.maxCount);
1335
- }
1336
- async pullHandles(input) {
1337
- if (!this.sessions.has(input.sessionId)) throw new Error(`decoder-nodeav: unknown sessionId ${input.sessionId}`);
1338
- const handleRing = this.handleBuffers.get(input.sessionId);
1339
- if (!handleRing) return [];
1340
- if (handleRing.size === 0 && input.waitMs) await handleRing.waitForItem(input.waitMs);
1341
- return handleRing.drain(input.maxCount);
1342
- }
1343
- async updateConfig(input) {
1344
- const session = this.sessions.get(input.sessionId);
1345
- if (!session) throw new Error(`decoder-nodeav: unknown sessionId ${input.sessionId}`);
1346
- session.updateConfig(input.config);
1347
- }
1348
- async getStats(input) {
1349
- const session = this.sessions.get(input.sessionId);
1350
- if (!session) throw new Error(`decoder-nodeav: unknown sessionId ${input.sessionId}`);
1351
- return session.getStats();
1352
- }
1353
- /**
1354
- * Read back the pixels a `FrameHandle` refers to from this node's shm ring
1355
- * (Phase 5 / D9 downstream access). Returns `null` when the slot was
1356
- * already recycled (latest-wins drop) or the segment could not be opened.
1357
- * The reader cache opens each named segment once and reuses the reader.
1358
- *
1359
- * The reader hands back a `Buffer` view over its **reusable scratch
1360
- * buffer**, only valid until the next `read` on the same reader; the
1361
- * cap caller may keep the frame across reads, so the pixels are copied
1362
- * into a fresh detached `ArrayBuffer` here. This matches the existing
1363
- * callback-path copy in `wireCallbackSink`.
1364
- */
1365
- async getFrame(input) {
1366
- const frame = this.frameReaders?.read(input.handle) ?? null;
1367
- if (!frame) {
1368
- this.getFrameMisses += 1;
1369
- return null;
1370
- }
1371
- this.getFrameHits += 1;
1372
- const arrayBuf = new ArrayBuffer(frame.data.byteLength);
1373
- new Uint8Array(arrayBuf).set(frame.data);
1374
- return {
1375
- data: new Uint8Array(arrayBuf),
1376
- width: frame.width,
1377
- height: frame.height,
1378
- format: frame.format,
1379
- timestamp: frame.timestamp
1380
- };
1381
- }
1382
- /**
1383
- * shm ring usage stats for a `frameSink: 'shm'` session — slot geometry,
1384
- * frames written, byte budget, plus this addon's running `getFrame`
1385
- * hit/miss counters. Returns `null` for an unknown session or one whose
1386
- * shm ring has not yet been armed by a first decoded frame.
1387
- */
1388
- async getShmStats(input) {
1389
- const stats = this.sessions.get(input.sessionId)?.frameRingSinkOrNull?.getShmStats() ?? null;
1390
- if (!stats) return null;
1391
- return {
1392
- sessionId: input.sessionId,
1393
- ...stats,
1394
- budgetMb: require_frame_ring_sink.RING_BUDGET_MB,
1395
- getFrameHits: this.getFrameHits,
1396
- getFrameMisses: this.getFrameMisses
1397
- };
1398
- }
1399
- async onShutdown() {
1400
- this.ctx.logger.info("node-av decoder addon shutdown — destroying all sessions");
1401
- const destroyPromises = [];
1402
- for (const [sessionId, session] of this.sessions) {
1403
- const unsub = this.unsubscribers.get(sessionId);
1404
- if (unsub) unsub();
1405
- destroyPromises.push(session.destroy());
1406
- }
1407
- await Promise.all(destroyPromises);
1408
- this.sessions.clear();
1409
- this.frameBuffers.clear();
1410
- this.handleBuffers.clear();
1411
- this.unsubscribers.clear();
1412
- this.sessionMeta.clear();
1413
- this.frameReaders?.close();
1414
- this.frameReaders = null;
1415
- }
1416
- };
1417
- //#endregion
1418
- exports.DecoderFrameRingSink = require_frame_ring_sink.DecoderFrameRingSink;
1419
- exports.DecoderNodeAvAddon = DecoderNodeAvAddon;
1420
- exports.NodeAvDecoderSession = NodeAvDecoderSession;
1421
- exports.default = DecoderNodeAvAddon;
1422
- exports.makeSegmentName = require_frame_ring_sink.makeSegmentName;