@camstack/addon-pipeline 0.1.13 → 0.1.15

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 (76) hide show
  1. package/dist/audio-analyzer/index.js +2 -4
  2. package/dist/audio-analyzer/index.js.map +1 -1
  3. package/dist/audio-analyzer/index.mjs +2 -4
  4. package/dist/audio-analyzer/index.mjs.map +1 -1
  5. package/dist/audio-codec-nodeav/index.js +1 -1
  6. package/dist/audio-codec-nodeav/index.mjs +1 -1
  7. package/dist/decoder-nodeav/index.js +552 -18
  8. package/dist/decoder-nodeav/index.js.map +1 -1
  9. package/dist/decoder-nodeav/index.mjs +553 -19
  10. package/dist/decoder-nodeav/index.mjs.map +1 -1
  11. package/dist/detection-pipeline/index.js +2 -4
  12. package/dist/detection-pipeline/index.js.map +1 -1
  13. package/dist/detection-pipeline/index.mjs +2 -4
  14. package/dist/detection-pipeline/index.mjs.map +1 -1
  15. package/dist/{index-BwLnHesq.mjs → index-CVzLrojg.mjs} +567 -327
  16. package/dist/index-CVzLrojg.mjs.map +1 -0
  17. package/dist/{index-BBpVDiWL.js → index-p-6GfKOg.js} +567 -327
  18. package/dist/index-p-6GfKOg.js.map +1 -0
  19. package/dist/motion-wasm/index.js +2 -4
  20. package/dist/motion-wasm/index.js.map +1 -1
  21. package/dist/motion-wasm/index.mjs +2 -4
  22. package/dist/motion-wasm/index.mjs.map +1 -1
  23. package/dist/pipeline-runner/index.js +133 -54
  24. package/dist/pipeline-runner/index.js.map +1 -1
  25. package/dist/pipeline-runner/index.mjs +133 -54
  26. package/dist/pipeline-runner/index.mjs.map +1 -1
  27. package/dist/stream-broker/@mf-types.zip +0 -0
  28. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.mjs-d8PmLbO2.mjs +19 -0
  29. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.mjs-B4l8Nb2y.mjs +20 -0
  30. package/dist/stream-broker/{__mfe_internal__addon_stream_broker_widgets__loadShare__react__loadShare__.mjs-DePVYdid.mjs → __mfe_internal__addon_stream_broker_widgets__loadShare__react__loadShare__.mjs-DAssX3h0.mjs} +4 -2
  31. package/dist/stream-broker/{__mfe_internal__addon_stream_broker_widgets__loadShare__react__loadShare__.mjs_commonjs-proxy-CBlCGyx5.mjs → __mfe_internal__addon_stream_broker_widgets__loadShare__react__loadShare__.mjs_commonjs-proxy-DFoJJhpt.mjs} +1 -1
  32. package/dist/stream-broker/{__mfe_internal__addon_stream_broker_widgets__loadShare__react_mf_2_dom__loadShare__.mjs_commonjs-proxy-DZchZKbW.mjs → __mfe_internal__addon_stream_broker_widgets__loadShare__react_mf_2_dom__loadShare__.mjs_commonjs-proxy-x7XMEeuJ.mjs} +1 -1
  33. package/dist/stream-broker/_stub.js +2 -2
  34. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-B8d_i3jf.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CWHjxwIc.mjs} +6 -6
  35. package/dist/stream-broker/{client-BK73l2KT.mjs → client-CZXrddDR.mjs} +2990 -3217
  36. package/dist/stream-broker/{hostInit-oba_vMZE.mjs → hostInit-B86vUcFC.mjs} +12 -12
  37. package/dist/stream-broker/{index-COebxMhm.mjs → index-BCEx31Mh.mjs} +4032 -3582
  38. package/dist/stream-broker/{index-BAZcm437.mjs → index-BvV3RVTZ.mjs} +1 -1
  39. package/dist/stream-broker/{index-IUYKHbxX.mjs → index-C0BzaWmB.mjs} +1 -1
  40. package/dist/stream-broker/index-CWkKuNLr.mjs +232 -0
  41. package/dist/stream-broker/{index-ns1fRD30.mjs → index-CZNxa0ad.mjs} +1 -1
  42. package/dist/stream-broker/index-Kb4xa8FX.mjs +36403 -0
  43. package/dist/stream-broker/{index-BxHaCH3N.mjs → index-KtR7Pp0O.mjs} +1 -1
  44. package/dist/stream-broker/{index-Ss9m7Jum.mjs → index-cYW01SNH.mjs} +1 -1
  45. package/dist/stream-broker/index.js +805 -544
  46. package/dist/stream-broker/index.js.map +1 -1
  47. package/dist/stream-broker/index.mjs +805 -522
  48. package/dist/stream-broker/index.mjs.map +1 -1
  49. package/dist/stream-broker/{jsx-runtime-ZdY5pIZz.mjs → jsx-runtime-B_evVsXl.mjs} +1 -1
  50. package/dist/stream-broker/remoteEntry.js +1 -1
  51. package/package.json +23 -31
  52. package/dist/index-BBpVDiWL.js.map +0 -1
  53. package/dist/index-BwLnHesq.mjs.map +0 -1
  54. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.mjs-p-Z3JTk9.mjs +0 -19
  55. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.mjs-MYpjp-jd.mjs +0 -20
  56. package/dist/stream-broker/index-BkB3U-Tc.mjs +0 -20852
  57. package/python/__pycache__/inference_pool.cpython-313.pyc +0 -0
  58. package/python/postprocessors/__pycache__/__init__.cpython-312.pyc +0 -0
  59. package/python/postprocessors/__pycache__/__init__.cpython-313.pyc +0 -0
  60. package/python/postprocessors/__pycache__/_safety.cpython-313.pyc +0 -0
  61. package/python/postprocessors/__pycache__/arcface.cpython-312.pyc +0 -0
  62. package/python/postprocessors/__pycache__/arcface.cpython-313.pyc +0 -0
  63. package/python/postprocessors/__pycache__/ctc.cpython-312.pyc +0 -0
  64. package/python/postprocessors/__pycache__/ctc.cpython-313.pyc +0 -0
  65. package/python/postprocessors/__pycache__/saliency.cpython-312.pyc +0 -0
  66. package/python/postprocessors/__pycache__/saliency.cpython-313.pyc +0 -0
  67. package/python/postprocessors/__pycache__/scrfd.cpython-312.pyc +0 -0
  68. package/python/postprocessors/__pycache__/scrfd.cpython-313.pyc +0 -0
  69. package/python/postprocessors/__pycache__/softmax.cpython-312.pyc +0 -0
  70. package/python/postprocessors/__pycache__/softmax.cpython-313.pyc +0 -0
  71. package/python/postprocessors/__pycache__/yamnet.cpython-312.pyc +0 -0
  72. package/python/postprocessors/__pycache__/yamnet.cpython-313.pyc +0 -0
  73. package/python/postprocessors/__pycache__/yolo.cpython-312.pyc +0 -0
  74. package/python/postprocessors/__pycache__/yolo.cpython-313.pyc +0 -0
  75. package/python/postprocessors/__pycache__/yolo_seg.cpython-312.pyc +0 -0
  76. package/python/postprocessors/__pycache__/yolo_seg.cpython-313.pyc +0 -0
@@ -1,5 +1,227 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { e as errMsg, B as BaseAddon, u as DEFAULT_DECODER_HWACCEL_CONFIG, H as HWACCEL_OPTIONS, v as decoderCapability, R as RingBuffer } from "../index-BwLnHesq.mjs";
2
+ import { e as errMsg, B as BaseAddon, u as DEFAULT_DECODER_HWACCEL_CONFIG, H as HWACCEL_OPTIONS, v as decoderCapability, R as RingBuffer } from "../index-CVzLrojg.mjs";
3
+ import { computeSlotByteLength, computeSegmentSize, deriveSlotCount, MIN_RING_SLOTS, createSegment, FrameRingWriter, FrameRingReaderCache } from "@camstack/shm-ring";
4
+ const RING_BUDGET_MB = (() => {
5
+ const raw = Number(process.env["CAMSTACK_SHM_RING_BUDGET_MB"]);
6
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 128;
7
+ })();
8
+ const RING_BUDGET_BYTES = RING_BUDGET_MB * 1024 * 1024;
9
+ function makeSegmentName(seed, generation) {
10
+ let hash = 5381;
11
+ for (let i = 0; i < seed.length; i += 1) {
12
+ hash = (hash << 5) + hash + seed.charCodeAt(i) | 0;
13
+ }
14
+ const tag = (hash >>> 0).toString(36);
15
+ return `csf.${tag}.${generation}`;
16
+ }
17
+ class DecoderFrameRingSink {
18
+ seed;
19
+ logger;
20
+ nodeId;
21
+ segment = null;
22
+ writer = null;
23
+ segmentName = null;
24
+ slotByteLength = 0;
25
+ generation = 0;
26
+ destroyed = false;
27
+ /** Frames committed into the ring across this sink's lifetime (all generations). */
28
+ framesWritten = 0;
29
+ constructor(options) {
30
+ const salt = Math.random().toString(36).slice(2, 8);
31
+ this.seed = `${options.seed}.${salt}`;
32
+ this.logger = options.logger;
33
+ this.nodeId = options.nodeId;
34
+ }
35
+ /** Whether a segment has been created (i.e. at least one frame written). */
36
+ get isArmed() {
37
+ return this.writer !== null;
38
+ }
39
+ /** The current segment name, or `null` before the first frame. */
40
+ get currentSegmentName() {
41
+ return this.segmentName;
42
+ }
43
+ /**
44
+ * Write one decoded frame into the ring and return its `FrameHandle`.
45
+ *
46
+ * On the first call (or after a geometry change that overflows the current
47
+ * slot) the segment is created / re-created sized for this frame. Returns
48
+ * `null` only when the sink has been destroyed.
49
+ *
50
+ * This is the copy-in convenience form (it copies `pixels` into the slot).
51
+ * The decoder's hot path uses the zero-copy {@link beginFrame} /
52
+ * {@link commitFrame} scatter-write pair instead — the scaler produces its
53
+ * packed output directly into the slot, eliminating the write-side memcpy.
54
+ */
55
+ writeFrame(pixels, meta) {
56
+ if (this.destroyed) return null;
57
+ if (this.writer === null || computeSlotByteLength(meta.width, meta.height, meta.format) > this.slotByteLength) {
58
+ this.recreateSegment(
59
+ computeSlotByteLength(meta.width, meta.height, meta.format)
60
+ );
61
+ }
62
+ const writer = this.writer;
63
+ if (writer === null) {
64
+ return null;
65
+ }
66
+ const handle = writer.writeFrame(pixels, meta);
67
+ this.framesWritten += 1;
68
+ return handle;
69
+ }
70
+ /**
71
+ * Reserve a ring slot for a frame of the given geometry — the **zero-copy**
72
+ * scatter-write entry point (Phase 5 / D9 Task 7c).
73
+ *
74
+ * The segment is created / re-created here if this is the first frame or the
75
+ * geometry overflows the current slot capacity, so the slot is correctly
76
+ * sized before the caller fills it. The returned `buffer` is a writable view
77
+ * **directly over the mapped segment** — the node-av scaler scatters its
78
+ * packed output straight into it, with no intermediate copy. The caller MUST
79
+ * call {@link commitFrame} with the returned `slot` once the slot is filled.
80
+ *
81
+ * Returns `null` when the sink is destroyed or the segment cannot be created.
82
+ */
83
+ beginFrame(width, height, format) {
84
+ if (this.destroyed) return null;
85
+ const requiredSlotBytes = computeSlotByteLength(width, height, format);
86
+ if (this.writer === null || requiredSlotBytes > this.slotByteLength) {
87
+ this.recreateSegment(requiredSlotBytes);
88
+ }
89
+ const writer = this.writer;
90
+ if (writer === null) {
91
+ return null;
92
+ }
93
+ const { slot, buffer } = writer.beginFrame();
94
+ return { slot, buffer };
95
+ }
96
+ /**
97
+ * Publish the frame whose slot was reserved by {@link beginFrame} and filled
98
+ * in place by the caller. `slot` MUST be the value from the matching
99
+ * `beginFrame`. Returns the published `FrameHandle`, or `null` if the sink
100
+ * was destroyed (or the segment lost) between begin and commit.
101
+ */
102
+ commitFrame(slot, meta) {
103
+ if (this.destroyed) return null;
104
+ const writer = this.writer;
105
+ if (writer === null) return null;
106
+ const handle = writer.commitFrame(slot, meta);
107
+ this.framesWritten += 1;
108
+ return handle;
109
+ }
110
+ /**
111
+ * Current shm ring usage — `null` until the first frame arms the segment.
112
+ * Surfaced through `decoder.getShmStats` so a downstream consumer can
113
+ * observe ring pressure (slot depth, byte budget, frames written).
114
+ */
115
+ getShmStats() {
116
+ if (this.writer === null) return null;
117
+ return {
118
+ slotCount: this.writer.slotCount,
119
+ slotByteLength: this.slotByteLength,
120
+ segmentBytes: computeSegmentSize(this.writer.slotCount, this.slotByteLength),
121
+ framesWritten: this.framesWritten
122
+ };
123
+ }
124
+ /**
125
+ * Abandon a slot reserved by {@link beginFrame} **without publishing it** —
126
+ * the degenerate-path counterpart of {@link commitFrame}.
127
+ *
128
+ * A caller that reserved a slot but then could not produce valid pixels (no
129
+ * decoded source planes, or the scaler threw) MUST call this instead of
130
+ * `commitFrame`: it closes the open seqlock without advancing `writeIndex`,
131
+ * so no reader ever sees the slot's uninitialised bytes as a real frame, and
132
+ * no `FrameHandle` is handed downstream. `slot` MUST be the value from the
133
+ * matching `beginFrame`. A no-op if the sink was destroyed (or the segment
134
+ * lost) between begin and abort.
135
+ */
136
+ abortFrame(slot) {
137
+ if (this.destroyed) return;
138
+ const writer = this.writer;
139
+ if (writer === null) return;
140
+ writer.abortFrame(slot);
141
+ }
142
+ /** Close + unlink the segment. Idempotent. */
143
+ destroy() {
144
+ if (this.destroyed) return;
145
+ this.destroyed = true;
146
+ this.releaseSegment();
147
+ }
148
+ /**
149
+ * Create a fresh segment sized for at least `slotByteLength` bytes per slot,
150
+ * replacing any prior one. A re-create bumps the generation so the new
151
+ * segment has a distinct name — a consumer holding the old mapping is never
152
+ * silently handed a resized segment.
153
+ */
154
+ recreateSegment(slotByteLength) {
155
+ this.releaseSegment();
156
+ this.generation += 1;
157
+ const name = makeSegmentName(this.seed, this.generation);
158
+ const slotCount = deriveSlotCount(RING_BUDGET_BYTES, slotByteLength);
159
+ if (slotCount === MIN_RING_SLOTS && MIN_RING_SLOTS * slotByteLength > RING_BUDGET_BYTES) {
160
+ this.logger.warn(
161
+ "decoder shm ring: budget too small for resolution — using MIN slots",
162
+ { meta: { slotByteLength, budgetMb: RING_BUDGET_MB } }
163
+ );
164
+ }
165
+ const totalBytes = computeSegmentSize(slotCount, slotByteLength);
166
+ try {
167
+ const segment = createSegment(name, totalBytes);
168
+ this.segment = segment;
169
+ this.segmentName = name;
170
+ this.slotByteLength = slotByteLength;
171
+ this.writer = new FrameRingWriter(
172
+ segment.buffer,
173
+ name,
174
+ slotCount,
175
+ slotByteLength,
176
+ this.nodeId
177
+ );
178
+ this.logger.info("decoder shm ring: segment created", {
179
+ meta: {
180
+ segment: name,
181
+ slotCount,
182
+ slotByteLength,
183
+ totalBytes,
184
+ generation: this.generation
185
+ }
186
+ });
187
+ } catch (err) {
188
+ this.segment = null;
189
+ this.writer = null;
190
+ this.segmentName = null;
191
+ this.slotByteLength = 0;
192
+ this.logger.error("decoder shm ring: segment create failed", {
193
+ meta: {
194
+ segment: name,
195
+ slotByteLength,
196
+ error: err instanceof Error ? err.message : String(err)
197
+ }
198
+ });
199
+ }
200
+ }
201
+ /** Unmap + unlink the current segment, if any. */
202
+ releaseSegment() {
203
+ const segment = this.segment;
204
+ if (segment === null) return;
205
+ this.segment = null;
206
+ this.writer = null;
207
+ const name = this.segmentName;
208
+ this.segmentName = null;
209
+ try {
210
+ segment.close();
211
+ segment.unlink();
212
+ this.logger.info("decoder shm ring: segment released", {
213
+ meta: { segment: name }
214
+ });
215
+ } catch (err) {
216
+ this.logger.warn("decoder shm ring: segment release failed", {
217
+ meta: {
218
+ segment: name,
219
+ error: err instanceof Error ? err.message : String(err)
220
+ }
221
+ });
222
+ }
223
+ }
224
+ }
3
225
  function backendToHwDeviceConst(backend, consts) {
4
226
  switch (backend) {
5
227
  case "videotoolbox":
@@ -91,7 +313,19 @@ class NodeAvDecoderSession {
91
313
  config;
92
314
  logger;
93
315
  frameCallbacks = /* @__PURE__ */ new Set();
316
+ handleCallbacks = /* @__PURE__ */ new Set();
94
317
  destroyed = false;
318
+ /**
319
+ * Frame delivery mode (see {@link DecoderFrameSink}). `'shm'` lazily
320
+ * constructs `frameRingSink` and routes every decoded frame through it.
321
+ */
322
+ frameSink;
323
+ /**
324
+ * The shared-memory ring writer for this stream. Created lazily on the
325
+ * first decoded frame in `'shm'` mode (the segment cannot be sized until
326
+ * the output geometry is known) and torn down in `destroy`.
327
+ */
328
+ frameRingSink = null;
95
329
  // Low-level node-av objects (initialized on first keyframe)
96
330
  parser = null;
97
331
  codecCtx = null;
@@ -183,6 +417,8 @@ class NodeAvDecoderSession {
183
417
  startTime = Date.now();
184
418
  hwaccelPref;
185
419
  hwaccelResolver;
420
+ /** Cluster node id stamped into every `FrameHandle` the `'shm'` sink emits. */
421
+ nodeId;
186
422
  /** The backend that actually initialised successfully — `'none'` = software fallback. */
187
423
  activeHwAccel = "none";
188
424
  hwDevice = null;
@@ -197,6 +433,39 @@ class NodeAvDecoderSession {
197
433
  this.outputMode = NodeAvDecoderSession.resolveOutputMode(config.outputFormat);
198
434
  this.hwaccelPref = options?.hwaccel ?? "auto";
199
435
  this.hwaccelResolver = options?.hwaccelResolver ?? null;
436
+ this.frameSink = options?.frameSink ?? "callback";
437
+ this.nodeId = options?.nodeId ?? "local";
438
+ }
439
+ /**
440
+ * The shared-memory ring sink for this stream, or `null` before the first
441
+ * `'shm'`-mode frame lazily creates it. Exposed so the owning addon can
442
+ * surface ring stats via `decoder.getShmStats`.
443
+ */
444
+ get frameRingSinkOrNull() {
445
+ return this.frameRingSink;
446
+ }
447
+ /**
448
+ * Lazily build the shared-memory ring sink for this stream. The segment
449
+ * itself is still created lazily by the sink on its first `writeFrame`,
450
+ * once the decoded geometry is known.
451
+ */
452
+ ensureFrameRingSink() {
453
+ if (this.frameRingSink === null) {
454
+ const seedParts = [];
455
+ if (typeof this.config.deviceId === "number") {
456
+ seedParts.push(String(this.config.deviceId));
457
+ }
458
+ if (typeof this.config.tag === "string" && this.config.tag.length > 0) {
459
+ seedParts.push(this.config.tag);
460
+ }
461
+ const seed = seedParts.length > 0 ? seedParts.join(":") : "anon";
462
+ this.frameRingSink = new DecoderFrameRingSink({
463
+ seed,
464
+ logger: this.logger,
465
+ nodeId: this.nodeId
466
+ });
467
+ }
468
+ return this.frameRingSink;
200
469
  }
201
470
  /**
202
471
  * Resolve the backend preference list and try each one against
@@ -497,6 +766,10 @@ class NodeAvDecoderSession {
497
766
  }
498
767
  if (!this.dstFrame || !this.scaler) return;
499
768
  const decodeStart = performance.now();
769
+ if (this.frameSink === "shm" && this.outputMode !== "jpeg") {
770
+ this.scaleIntoRingSlot(frame, decodeStart);
771
+ return;
772
+ }
500
773
  try {
501
774
  this.dstFrame.makeWritable();
502
775
  this.scaler.scaleFrameSync(this.dstFrame, frame);
@@ -542,6 +815,104 @@ class NodeAvDecoderSession {
542
815
  this.emitRawFrame(rawBuf, "gray", decodeStart);
543
816
  }
544
817
  }
818
+ /**
819
+ * Scale a decoded frame **directly into a shared-memory ring slot** — the
820
+ * zero write-side copy path (Phase 5 / D9 Task 7c).
821
+ *
822
+ * `beginFrame` reserves the slot (its seqlock open / odd — readers skip it);
823
+ * `SoftwareScaleContext.scaleSync` (the low-level `sws_scale` mapping) writes
824
+ * the packed pixels straight into the slot buffer with `linesize = width ×
825
+ * bpp` — no linesize padding, so there is nothing to strip and nothing to
826
+ * memcpy; `commitFrame` writes the metadata, closes the seqlock and publishes
827
+ * the slot. The decoded source planes feed `scaleSync` as `srcSlice` +
828
+ * `srcStride` (`frame.data` / `frame.linesize`).
829
+ *
830
+ * Used only for raw output formats (rgb/bgr/gray). The jpeg path keeps the
831
+ * `dstFrame` + sharp encode route because sharp must consume an RGB buffer
832
+ * before the final (variable-length) jpeg bytes exist.
833
+ */
834
+ scaleIntoRingSlot(frame, decodeStart) {
835
+ if (!this.scaler) return;
836
+ const format = this.outputMode === "gray" ? "gray" : "rgb";
837
+ const channels = this.outputMode === "gray" ? 1 : 3;
838
+ const sink = this.ensureFrameRingSink();
839
+ const reserved = sink.beginFrame(this.outWidth, this.outHeight, format);
840
+ if (reserved === null) {
841
+ this.droppedFrames++;
842
+ return;
843
+ }
844
+ const srcPlanes = frame.data;
845
+ if (!srcPlanes || srcPlanes.length === 0) {
846
+ sink.abortFrame(reserved.slot);
847
+ this.droppedFrames++;
848
+ return;
849
+ }
850
+ const dstStride = this.outWidth * channels;
851
+ try {
852
+ const srcStrides = Array.from(frame.linesize).slice(0, srcPlanes.length);
853
+ this.scaler.scaleSync(
854
+ Array.from(srcPlanes),
855
+ srcStrides,
856
+ 0,
857
+ frame.height,
858
+ [reserved.buffer],
859
+ [dstStride]
860
+ );
861
+ } catch (err) {
862
+ this.logger.warn("node-av scale-into-slot error", {
863
+ meta: { error: errMsg(err) }
864
+ });
865
+ sink.abortFrame(reserved.slot);
866
+ this.droppedFrames++;
867
+ return;
868
+ }
869
+ const decodeMs = performance.now() - decodeStart;
870
+ this.totalDecodeTimeMs += decodeMs;
871
+ this.outputFrames++;
872
+ this.lastEmitTime = performance.now();
873
+ if (!this.firstFrameLogged) {
874
+ this.firstFrameLogged = true;
875
+ this.logger.info("node-av: scaled directly into shm ring slot", {
876
+ meta: {
877
+ phase: "frame-debug",
878
+ width: this.outWidth,
879
+ height: this.outHeight,
880
+ dstStride,
881
+ format
882
+ }
883
+ });
884
+ }
885
+ if (this.outputFrames === 1 || this.outputFrames % 500 === 0) {
886
+ this.logger.info("node-av frame emitted", {
887
+ meta: {
888
+ frameNumber: this.outputFrames,
889
+ width: this.outWidth,
890
+ height: this.outHeight,
891
+ format,
892
+ decodeMs,
893
+ sink: "shm",
894
+ subs: this.handleCallbacks.size
895
+ }
896
+ });
897
+ }
898
+ const handle = sink.commitFrame(reserved.slot, {
899
+ width: this.outWidth,
900
+ height: this.outHeight,
901
+ format,
902
+ // pts uses performance.now() (monotonic, process-relative) for
903
+ // ring-ordering; DecodedFrameHandle.timestamp carries the wall clock.
904
+ pts: performance.now(),
905
+ byteLength: dstStride * this.outHeight
906
+ });
907
+ if (handle === null) {
908
+ this.droppedFrames++;
909
+ return;
910
+ }
911
+ const delivered = { handle, timestamp: Date.now() };
912
+ for (const cb of this.handleCallbacks) {
913
+ cb(delivered);
914
+ }
915
+ }
545
916
  /**
546
917
  * Extract packed pixel buffer from a decoded frame.
547
918
  * FFmpeg's av_frame_get_buffer() may pad each row to alignment (32/64 bytes).
@@ -604,10 +975,15 @@ class NodeAvDecoderSession {
604
975
  height: this.outHeight,
605
976
  format,
606
977
  decodeMs,
607
- subs: this.frameCallbacks.size
978
+ sink: this.frameSink,
979
+ subs: this.frameSink === "shm" ? this.handleCallbacks.size : this.frameCallbacks.size
608
980
  }
609
981
  });
610
982
  }
983
+ if (this.frameSink === "shm") {
984
+ this.emitShmFrame(data, format);
985
+ return;
986
+ }
611
987
  const decodedFrame = {
612
988
  data,
613
989
  width: this.outWidth,
@@ -619,12 +995,53 @@ class NodeAvDecoderSession {
619
995
  cb(decodedFrame);
620
996
  }
621
997
  }
998
+ /**
999
+ * Write a decoded frame into this stream's shared-memory ring and deliver
1000
+ * the resulting `FrameHandle` to `onFrameHandle` subscribers. No pixel
1001
+ * `Buffer` is handed to callbacks — consumers open the segment and read the
1002
+ * pixels zero-copy via a `FrameRingReader`.
1003
+ */
1004
+ emitShmFrame(data, format) {
1005
+ const sink = this.ensureFrameRingSink();
1006
+ const handle = sink.writeFrame(data, {
1007
+ width: this.outWidth,
1008
+ height: this.outHeight,
1009
+ format,
1010
+ // The decoder does not carry a source PTS down to this point; use a
1011
+ // monotone-ish wall clock so a consumer can still order frames.
1012
+ // NOTE: pts uses performance.now() (monotonic, process-relative) while
1013
+ // DecodedFrameHandle.timestamp uses Date.now() (wall-clock epoch).
1014
+ // The two clocks are intentionally distinct — pts is for ring-ordering,
1015
+ // timestamp is for wall-clock correlation — and must not be compared.
1016
+ pts: performance.now(),
1017
+ byteLength: data.byteLength
1018
+ });
1019
+ if (handle === null) {
1020
+ this.droppedFrames++;
1021
+ return;
1022
+ }
1023
+ const frame = { handle, timestamp: Date.now() };
1024
+ for (const cb of this.handleCallbacks) {
1025
+ cb(frame);
1026
+ }
1027
+ }
622
1028
  onFrame(callback) {
623
1029
  this.frameCallbacks.add(callback);
624
1030
  return () => {
625
1031
  this.frameCallbacks.delete(callback);
626
1032
  };
627
1033
  }
1034
+ /**
1035
+ * Subscribe to shared-memory frame handles. Fires only when the session was
1036
+ * created with `frameSink: 'shm'`; in the legacy `'callback'` mode no
1037
+ * handles are produced and this subscription never fires.
1038
+ */
1039
+ onFrameHandle(callback) {
1040
+ this.handleCallbacks.add(callback);
1041
+ return () => {
1042
+ this.handleCallbacks.delete(callback);
1043
+ };
1044
+ }
628
1045
  updateConfig(update) {
629
1046
  const prevFormat = this.config.outputFormat;
630
1047
  this.config = { ...this.config, ...update };
@@ -659,6 +1076,9 @@ class NodeAvDecoderSession {
659
1076
  if (this.destroyed) return;
660
1077
  this.destroyed = true;
661
1078
  this.frameCallbacks.clear();
1079
+ this.handleCallbacks.clear();
1080
+ this.frameRingSink?.destroy();
1081
+ this.frameRingSink = null;
662
1082
  this.dstFrame?.[Symbol.dispose]?.();
663
1083
  this.avFrame?.[Symbol.dispose]?.();
664
1084
  this.avPacket?.[Symbol.dispose]?.();
@@ -691,10 +1111,28 @@ class NodeAvDecoderSession {
691
1111
  }
692
1112
  const FRAME_BUFFER_CAPACITY = 32;
693
1113
  class DecoderNodeAvAddon extends BaseAddon {
1114
+ /**
1115
+ * Sessions are stored as the concrete `NodeAvDecoderSession` (the only type
1116
+ * this addon ever creates) so `getShmStats` can reach the per-session shm
1117
+ * ring sink — `IDecoderSession` does not expose it.
1118
+ */
694
1119
  sessions = /* @__PURE__ */ new Map();
1120
+ /** Pixel-frame buffers — populated only for `frameSink: 'callback'` sessions. */
695
1121
  frameBuffers = /* @__PURE__ */ new Map();
1122
+ /** `FrameHandle` buffers — populated only for `frameSink: 'shm'` sessions. */
1123
+ handleBuffers = /* @__PURE__ */ new Map();
696
1124
  unsubscribers = /* @__PURE__ */ new Map();
697
1125
  sessionMeta = /* @__PURE__ */ new Map();
1126
+ /**
1127
+ * Per-shm-segment `FrameRingReader` cache backing `getFrame`. Opens each
1128
+ * named segment once and reuses the reader for every later handle on it;
1129
+ * built in `onInitialize` (it needs the scoped logger) and closed in
1130
+ * `onShutdown`.
1131
+ */
1132
+ frameReaders = null;
1133
+ /** Running `getFrame` hit/miss counters surfaced via `getShmStats`. */
1134
+ getFrameHits = 0;
1135
+ getFrameMisses = 0;
698
1136
  constructor() {
699
1137
  super(DEFAULT_DECODER_HWACCEL_CONFIG);
700
1138
  }
@@ -731,6 +1169,7 @@ class DecoderNodeAvAddon extends BaseAddon {
731
1169
  }
732
1170
  async onInitialize() {
733
1171
  this.ctx.logger.info("node-av decoder addon initialized");
1172
+ this.frameReaders = new FrameRingReaderCache(this.ctx.logger);
734
1173
  if (!this.config.probedBestHwaccel) {
735
1174
  this.reprobeHwaccel().catch((err) => {
736
1175
  this.ctx.logger.warn("nodeav: auto-reprobe hwaccel failed", {
@@ -789,15 +1228,48 @@ class DecoderNodeAvAddon extends BaseAddon {
789
1228
  priority: 10
790
1229
  };
791
1230
  }
1231
+ /**
1232
+ * The cluster node id of this decoder — the Moleculer nodeID (`hub`,
1233
+ * `dev-agent-0`, …), not the addon id. Stamped into session-owned
1234
+ * `FrameHandle`s so a downstream consumer routes `getFrame` to the node
1235
+ * that holds the shm ring. A hierarchical broker nodeID (`hub/...`) is
1236
+ * collapsed to the cluster-visible parent; in-process boot is left as-is.
1237
+ * Mirrors the resolution `PipelineRunnerAddon.onInitialize` performs.
1238
+ */
1239
+ resolveLocalNodeId() {
1240
+ const raw = this.ctx.kernel.localNodeId ?? this.ctx.id;
1241
+ return raw.includes("/") ? raw.split("/")[0] : raw;
1242
+ }
792
1243
  async createSession(config) {
793
1244
  const sessionId = randomUUID();
794
1245
  const hwaccel = this.resolveHwAccelPref();
1246
+ const { frameSink } = config;
1247
+ const nodeId = this.resolveLocalNodeId();
795
1248
  const session = new NodeAvDecoderSession(config, this.ctx.logger, {
796
1249
  hwaccel,
797
- hwaccelResolver: this.ctx.kernel.hwaccel
1250
+ hwaccelResolver: this.ctx.kernel.hwaccel,
1251
+ frameSink,
1252
+ nodeId
798
1253
  });
1254
+ const unsub = frameSink === "shm" ? this.wireShmSink(sessionId, session) : this.wireCallbackSink(sessionId, session);
1255
+ this.sessions.set(sessionId, session);
1256
+ this.unsubscribers.set(sessionId, unsub);
1257
+ this.sessionMeta.set(sessionId, {
1258
+ codec: config.codec,
1259
+ outputFormat: config.outputFormat,
1260
+ createdAtMs: Date.now()
1261
+ });
1262
+ this.ctx.logger.info("node-av: created session", { meta: { sessionId, codec: config.codec, hwaccelPref: hwaccel, frameSink, nodeId } });
1263
+ return { sessionId, nodeId };
1264
+ }
1265
+ /**
1266
+ * Subscribe a `'callback'` session's pixel frames into a per-session
1267
+ * ring buffer drained by `pullFrames`.
1268
+ */
1269
+ wireCallbackSink(sessionId, session) {
799
1270
  const ringBuffer = new RingBuffer(FRAME_BUFFER_CAPACITY);
800
- const unsub = session.onFrame((frame) => {
1271
+ this.frameBuffers.set(sessionId, ringBuffer);
1272
+ return session.onFrame((frame) => {
801
1273
  const { format } = frame;
802
1274
  if (format !== "jpeg" && format !== "rgb" && format !== "bgr" && format !== "yuv420" && format !== "gray") return;
803
1275
  const arrayBuf = new ArrayBuffer(frame.data.byteLength);
@@ -812,16 +1284,18 @@ class DecoderNodeAvAddon extends BaseAddon {
812
1284
  };
813
1285
  ringBuffer.push(capFrame);
814
1286
  });
815
- this.sessions.set(sessionId, session);
816
- this.frameBuffers.set(sessionId, ringBuffer);
817
- this.unsubscribers.set(sessionId, unsub);
818
- this.sessionMeta.set(sessionId, {
819
- codec: config.codec,
820
- outputFormat: config.outputFormat,
821
- createdAtMs: Date.now()
1287
+ }
1288
+ /**
1289
+ * Subscribe a `'shm'` session's `FrameHandle`s into a per-session ring
1290
+ * buffer drained by `pullHandles`. No pixel bytes cross the cap boundary
1291
+ * — the broker opens the named segment and reads the pixels zero-copy.
1292
+ */
1293
+ wireShmSink(sessionId, session) {
1294
+ const handleRing = new RingBuffer(FRAME_BUFFER_CAPACITY);
1295
+ this.handleBuffers.set(sessionId, handleRing);
1296
+ return session.onFrameHandle((frame) => {
1297
+ handleRing.push(frame.handle);
822
1298
  });
823
- this.ctx.logger.info("node-av: created session", { meta: { sessionId, codec: config.codec, hwaccelPref: hwaccel } });
824
- return { sessionId, nodeId: this.ctx.kernel.localNodeId ?? "local" };
825
1299
  }
826
1300
  async destroySession(input) {
827
1301
  const { sessionId } = input;
@@ -834,6 +1308,7 @@ class DecoderNodeAvAddon extends BaseAddon {
834
1308
  await session.destroy();
835
1309
  this.sessions.delete(sessionId);
836
1310
  this.frameBuffers.delete(sessionId);
1311
+ this.handleBuffers.delete(sessionId);
837
1312
  this.unsubscribers.delete(sessionId);
838
1313
  this.sessionMeta.delete(sessionId);
839
1314
  this.ctx.logger.info("node-av: destroyed session", { meta: { sessionId } });
@@ -859,17 +1334,24 @@ class DecoderNodeAvAddon extends BaseAddon {
859
1334
  if (!session) {
860
1335
  throw new Error(`decoder-nodeav: unknown sessionId ${input.sessionId}`);
861
1336
  }
862
- if (session.openStream) {
863
- await session.openStream(input.url);
864
- }
1337
+ void input.url;
865
1338
  }
866
1339
  async pullFrames(input) {
867
- const ringBuffer = this.frameBuffers.get(input.sessionId);
868
- if (!ringBuffer) {
1340
+ if (!this.sessions.has(input.sessionId)) {
869
1341
  throw new Error(`decoder-nodeav: unknown sessionId ${input.sessionId}`);
870
1342
  }
1343
+ const ringBuffer = this.frameBuffers.get(input.sessionId);
1344
+ if (!ringBuffer) return [];
871
1345
  return ringBuffer.drain(input.maxCount);
872
1346
  }
1347
+ async pullHandles(input) {
1348
+ if (!this.sessions.has(input.sessionId)) {
1349
+ throw new Error(`decoder-nodeav: unknown sessionId ${input.sessionId}`);
1350
+ }
1351
+ const handleRing = this.handleBuffers.get(input.sessionId);
1352
+ if (!handleRing) return [];
1353
+ return handleRing.drain(input.maxCount);
1354
+ }
873
1355
  async updateConfig(input) {
874
1356
  const session = this.sessions.get(input.sessionId);
875
1357
  if (!session) {
@@ -884,6 +1366,53 @@ class DecoderNodeAvAddon extends BaseAddon {
884
1366
  }
885
1367
  return session.getStats();
886
1368
  }
1369
+ /**
1370
+ * Read back the pixels a `FrameHandle` refers to from this node's shm ring
1371
+ * (Phase 5 / D9 downstream access). Returns `null` when the slot was
1372
+ * already recycled (latest-wins drop) or the segment could not be opened.
1373
+ * The reader cache opens each named segment once and reuses the reader.
1374
+ *
1375
+ * The reader hands back a `Buffer` view over its **reusable scratch
1376
+ * buffer**, only valid until the next `read` on the same reader; the
1377
+ * cap caller may keep the frame across reads, so the pixels are copied
1378
+ * into a fresh detached `ArrayBuffer` here. This matches the existing
1379
+ * callback-path copy in `wireCallbackSink`.
1380
+ */
1381
+ async getFrame(input) {
1382
+ const frame = this.frameReaders?.read(input.handle) ?? null;
1383
+ if (!frame) {
1384
+ this.getFrameMisses += 1;
1385
+ return null;
1386
+ }
1387
+ this.getFrameHits += 1;
1388
+ const arrayBuf = new ArrayBuffer(frame.data.byteLength);
1389
+ new Uint8Array(arrayBuf).set(frame.data);
1390
+ return {
1391
+ data: new Uint8Array(arrayBuf),
1392
+ width: frame.width,
1393
+ height: frame.height,
1394
+ format: frame.format,
1395
+ timestamp: frame.timestamp
1396
+ };
1397
+ }
1398
+ /**
1399
+ * shm ring usage stats for a `frameSink: 'shm'` session — slot geometry,
1400
+ * frames written, byte budget, plus this addon's running `getFrame`
1401
+ * hit/miss counters. Returns `null` for an unknown session or one whose
1402
+ * shm ring has not yet been armed by a first decoded frame.
1403
+ */
1404
+ async getShmStats(input) {
1405
+ const session = this.sessions.get(input.sessionId);
1406
+ const stats = session?.frameRingSinkOrNull?.getShmStats() ?? null;
1407
+ if (!stats) return null;
1408
+ return {
1409
+ sessionId: input.sessionId,
1410
+ ...stats,
1411
+ budgetMb: RING_BUDGET_MB,
1412
+ getFrameHits: this.getFrameHits,
1413
+ getFrameMisses: this.getFrameMisses
1414
+ };
1415
+ }
887
1416
  async onShutdown() {
888
1417
  this.ctx.logger.info("node-av decoder addon shutdown — destroying all sessions");
889
1418
  const destroyPromises = [];
@@ -895,13 +1424,18 @@ class DecoderNodeAvAddon extends BaseAddon {
895
1424
  await Promise.all(destroyPromises);
896
1425
  this.sessions.clear();
897
1426
  this.frameBuffers.clear();
1427
+ this.handleBuffers.clear();
898
1428
  this.unsubscribers.clear();
899
1429
  this.sessionMeta.clear();
1430
+ this.frameReaders?.close();
1431
+ this.frameReaders = null;
900
1432
  }
901
1433
  }
902
1434
  export {
1435
+ DecoderFrameRingSink,
903
1436
  DecoderNodeAvAddon,
904
1437
  NodeAvDecoderSession,
905
- DecoderNodeAvAddon as default
1438
+ DecoderNodeAvAddon as default,
1439
+ makeSegmentName
906
1440
  };
907
1441
  //# sourceMappingURL=index.mjs.map