@camstack/addon-pipeline 1.1.26 → 1.1.28

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 (48) hide show
  1. package/dist/audio-analyzer/index.js +7 -6
  2. package/dist/audio-analyzer/index.mjs +7 -6
  3. package/dist/audio-codec-ffmpeg/index.js +1 -1
  4. package/dist/audio-codec-ffmpeg/index.mjs +1 -1
  5. package/dist/constants-CAqYVigN.js +6615 -0
  6. package/dist/constants-oYgW36Ty.mjs +3587 -0
  7. package/dist/decoder-ffmpeg/index.js +74 -281
  8. package/dist/decoder-ffmpeg/index.mjs +73 -280
  9. package/dist/decoder-nodeav/index.js +1218 -0
  10. package/dist/decoder-nodeav/index.mjs +1210 -0
  11. package/dist/detection-pipeline/index.js +157 -219
  12. package/dist/detection-pipeline/index.mjs +157 -219
  13. package/dist/{dist-DAIlCdAx.js → dist-Biq62zt4.js} +372 -35
  14. package/dist/{dist-CgEP_0OL.mjs → dist-C6_wgXqF.mjs} +373 -24
  15. package/dist/dist-D3Ytt-_Y.js +41111 -0
  16. package/dist/dist-DluJuxV5.mjs +37976 -0
  17. package/dist/{frame-handle-plane-Dq20KtKL.mjs → frame-handle-plane-BIoY6nRV.mjs} +13 -5
  18. package/dist/{frame-handle-plane-DtTRX_0n.js → frame-handle-plane-D6BzyEgy.js} +13 -5
  19. package/dist/frame-ring-sink-9J0wCdLF.js +444 -0
  20. package/dist/frame-ring-sink-Cs9vby6v.mjs +409 -0
  21. package/dist/motion-wasm/index.js +55 -3
  22. package/dist/motion-wasm/index.mjs +55 -3
  23. package/dist/node-topology-platform-BkR_k6WT.mjs +15 -0
  24. package/dist/node-topology-platform-CFZ7F4xW.js +20 -0
  25. package/dist/pipeline-runner/index.js +50 -16
  26. package/dist/pipeline-runner/index.mjs +50 -16
  27. package/dist/recorder/index.js +36 -40
  28. package/dist/recorder/index.mjs +36 -40
  29. package/dist/stream-broker/_stub.js +2 -2
  30. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-5tQlh9h4.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DoReAb4y.mjs} +3 -3
  31. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DhY3MZ2C.mjs +26 -0
  32. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BJK0-svt.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DtoYGpTp.mjs} +1 -1
  33. package/dist/stream-broker/{hostInit-DyLqyJaS.mjs → hostInit-C0SuwQhL.mjs} +3 -3
  34. package/dist/stream-broker/index.js +81 -40
  35. package/dist/stream-broker/index.mjs +81 -40
  36. package/dist/stream-broker/remoteEntry.js +1 -1
  37. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-BDLNwJ_F.js → MaskShapeCanvas-DI4BY7W2-Br2yi8ah.js} +1 -1
  38. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-CoLjNiUN.js → MotionZonesSettings-NcxxQN8r-BSCcPdPf.js} +1 -1
  39. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-DJE3OU-q.js → PrivacyMaskSettings-APgPLF7p-ztxvxSjS.js} +1 -1
  40. package/embed-dist/assets/index-C5UpuPr8.css +2 -0
  41. package/embed-dist/assets/{index-C-pL8ETk.js → index-CeZV1B-2.js} +10 -10
  42. package/embed-dist/index.html +2 -2
  43. package/package.json +31 -3
  44. package/python/inference_pool.py +108 -121
  45. package/python/test_inference_pool_device_selection.py +47 -43
  46. package/python/yamnet_audio.py +1 -1
  47. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CkOPfV8r.mjs +0 -26
  48. package/embed-dist/assets/index-DrJ0ee3f.css +0 -2
@@ -0,0 +1,409 @@
1
+ import { p as RingBuffer } from "./dist-C6_wgXqF.mjs";
2
+ import { FrameRingWriter, MIN_RING_SLOTS, computeSegmentSize, computeSlotByteLength, createSegment, deriveSlotCount } from "@camstack/shm-ring";
3
+ //#region src/shared/decoder-backend-keys.ts
4
+ /** Built-in default when a node has no selection — the subprocess decoder. */
5
+ var DEFAULT_DECODER_BACKEND = "ffmpeg";
6
+ /** Narrow an unknown settings value to a {@link DecoderBackend}, else `null`. */
7
+ function parseDecoderBackend(value) {
8
+ return value === "ffmpeg" || value === "nodeav" ? value : null;
9
+ }
10
+ /**
11
+ * Normalise a raw kernel node id to the bare node id used for scoping.
12
+ * `localNodeId` can carry a `<node>/<addon>` suffix; the decoder selection is
13
+ * per-NODE, so strip the addon segment. Falls back to `hub`.
14
+ */
15
+ function normalizeDecoderNodeId(rawNodeId) {
16
+ const raw = rawNodeId ?? "hub";
17
+ return raw.includes("/") ? raw.split("/")[0] ?? "hub" : raw;
18
+ }
19
+ //#endregion
20
+ //#region src/shared/decoder-backend.ts
21
+ /** The addon whose global settings OWN the per-node `backend` field. */
22
+ var DECODER_OWNER_ADDON_ID = "decoder-ffmpeg";
23
+ function isHydratedField(entry) {
24
+ return typeof entry === "object" && entry !== null && "key" in entry;
25
+ }
26
+ /**
27
+ * Pure selection from an already-read hydrated settings payload: extract the
28
+ * owner's `backend` field value (which the owner projected per-node from its
29
+ * scoped store key) and narrow it. A missing/invalid field or a null payload
30
+ * resolves to {@link DEFAULT_DECODER_BACKEND} — never a bare store key.
31
+ */
32
+ function pickDecoderBackendFromSettings(view) {
33
+ if (view === null) return DEFAULT_DECODER_BACKEND;
34
+ for (const section of view.sections) for (const entry of section.fields) {
35
+ if (!isHydratedField(entry) || entry.key !== "backend") continue;
36
+ return parseDecoderBackend(entry.value) ?? "ffmpeg";
37
+ }
38
+ return DEFAULT_DECODER_BACKEND;
39
+ }
40
+ /**
41
+ * Resolve the decoder backend this node should run. Called by BOTH decoder
42
+ * addons at the start of `onInitialize`; the addon whose backend is NOT
43
+ * selected registers nothing.
44
+ *
45
+ * Fails safe: no api surface, a read error, or a null payload (owner not
46
+ * answering yet) resolves to {@link DEFAULT_DECODER_BACKEND} — the default
47
+ * backend still registers, and the non-default one stands down (never both).
48
+ */
49
+ async function resolveDecoderBackend(api, nodeId, logger) {
50
+ if (!api) {
51
+ logger.warn("decoder-backend: no api surface — using default backend", { meta: { default: DEFAULT_DECODER_BACKEND } });
52
+ return DEFAULT_DECODER_BACKEND;
53
+ }
54
+ try {
55
+ const view = await api.addonSettings.getGlobalSettings.query({
56
+ addonId: DECODER_OWNER_ADDON_ID,
57
+ nodeId: normalizeDecoderNodeId(nodeId)
58
+ });
59
+ if (view === null) {
60
+ logger.warn("decoder-backend: owner settings unavailable — using default backend", { meta: {
61
+ default: DEFAULT_DECODER_BACKEND,
62
+ owner: DECODER_OWNER_ADDON_ID
63
+ } });
64
+ return DEFAULT_DECODER_BACKEND;
65
+ }
66
+ return pickDecoderBackendFromSettings(view);
67
+ } catch (err) {
68
+ logger.warn("decoder-backend: settings read failed — using default backend", { meta: {
69
+ default: DEFAULT_DECODER_BACKEND,
70
+ error: err instanceof Error ? err.message : String(err)
71
+ } });
72
+ return DEFAULT_DECODER_BACKEND;
73
+ }
74
+ }
75
+ //#endregion
76
+ //#region src/shared/notifying-ring-buffer.ts
77
+ /**
78
+ * A {@link RingBuffer} that can notify a single blocked consumer the moment an
79
+ * item commits — the server side of the `pullFrames`/`pullHandles` long-poll.
80
+ *
81
+ * The decoder's `onFrame`/`onFrameHandle` callback drives `push()`; a consumer
82
+ * that finds the ring momentarily empty calls {@link waitForItem} and is woken
83
+ * on the very next `push()` (a real commit signal) rather than spinning at
84
+ * ~1kHz on `setTimeout(…,1)`. If nothing commits it resolves after `waitMs`,
85
+ * and {@link cancel} (session teardown) resolves any in-flight wait promptly so
86
+ * a blocked long-poll never waits out `waitMs` on destroy.
87
+ *
88
+ * Semantics are otherwise identical to `RingBuffer`: fixed capacity,
89
+ * latest-wins overwrite when full, FIFO `drain`.
90
+ */
91
+ var NotifyingRingBuffer = class {
92
+ ring;
93
+ waiters = /* @__PURE__ */ new Set();
94
+ constructor(capacity) {
95
+ this.ring = new RingBuffer(capacity);
96
+ }
97
+ get size() {
98
+ return this.ring.size;
99
+ }
100
+ push(item) {
101
+ this.ring.push(item);
102
+ this.wakeAll();
103
+ }
104
+ drain(maxCount) {
105
+ return this.ring.drain(maxCount);
106
+ }
107
+ /**
108
+ * Resolve as soon as an item commits to the ring, or after `waitMs`,
109
+ * whichever comes first. Returns immediately when items are already present
110
+ * or `waitMs <= 0` (legacy immediate-return). {@link cancel} also resolves an
111
+ * in-flight wait.
112
+ */
113
+ async waitForItem(waitMs) {
114
+ if (this.ring.size > 0 || waitMs <= 0) return;
115
+ await new Promise((resolve) => {
116
+ let settled = false;
117
+ const finish = () => {
118
+ if (settled) return;
119
+ settled = true;
120
+ this.waiters.delete(wake);
121
+ clearTimeout(timer);
122
+ resolve();
123
+ };
124
+ const wake = () => finish();
125
+ const timer = setTimeout(finish, waitMs);
126
+ if (typeof timer.unref === "function") timer.unref();
127
+ this.waiters.add(wake);
128
+ });
129
+ }
130
+ /**
131
+ * Cancel every in-flight {@link waitForItem} — called on session teardown so
132
+ * a blocked long-poll returns promptly instead of waiting out `waitMs`.
133
+ */
134
+ cancel() {
135
+ this.wakeAll();
136
+ }
137
+ wakeAll() {
138
+ if (this.waiters.size === 0) return;
139
+ const pending = [...this.waiters];
140
+ this.waiters.clear();
141
+ for (const wake of pending) wake();
142
+ }
143
+ };
144
+ //#endregion
145
+ //#region src/decoder-ffmpeg/frame-ring-sink.ts
146
+ /**
147
+ * `DecoderFrameRingSink` — the decoder's shared-memory write side (Phase 5 / D9).
148
+ *
149
+ * When a decoder session is configured with `frameSink: 'shm'`, the decoder
150
+ * **owns** the shared-memory ring segment for that stream: it creates the
151
+ * segment on the first decoded frame (when the output geometry is known),
152
+ * writes every subsequent decoded frame into the ring via a `FrameRingWriter`,
153
+ * and closes + unlinks the segment when the session is destroyed.
154
+ *
155
+ * What leaves the decoder is no longer the pixel `Buffer` — it is a tiny,
156
+ * serialisable `FrameHandle` (`FrameRingWriter.writeFrame`'s return value).
157
+ * Same-host consumers (motion, detection, the WebRTC encoder) open the same
158
+ * segment with a `FrameRingReader` and read the pixels zero-copy.
159
+ *
160
+ * ## Lazy segment creation
161
+ *
162
+ * The segment cannot be sized until the first frame: `slotByteLength` is
163
+ * `width × height × bytesPerPixel`, and the output dimensions are only known
164
+ * once the scaler has produced its first `dstFrame`. So `writeFrame` is a
165
+ * no-op-until-armed: the first call sizes + creates the segment, every later
166
+ * call writes into it.
167
+ *
168
+ * ## Resolution-change decision
169
+ *
170
+ * A live camera stream can change resolution mid-stream (the decoder's scaler
171
+ * is rebuilt on a config toggle, or the source renegotiates). The slot is
172
+ * sized for the **first** frame's geometry. A later frame that no longer fits
173
+ * the slot triggers a **segment re-create**: the old segment is closed +
174
+ * unlinked and a fresh, larger segment is created under a new generation-tagged
175
+ * name. This is simpler and leak-free versus over-allocating slots for a
176
+ * worst-case 4K frame on every stream; resolution changes on a live camera are
177
+ * rare, and a brief gap while consumers re-open the segment is acceptable
178
+ * (latest-wins — a missed frame is correct behaviour).
179
+ */
180
+ /**
181
+ * Per-ring shared-memory budget (MB) — the slot count is derived per-resolution
182
+ * from this budget via {@link deriveSlotCount}, so a 360p stream gets many
183
+ * slots and a 4K stream a few, both inside the same memory footprint.
184
+ *
185
+ * Read ONCE at module load from `CAMSTACK_SHM_RING_BUDGET_MB`; a non-finite or
186
+ * non-positive value falls back to the 16 MB default.
187
+ *
188
+ * The default is deliberately small (16 MB) so many concurrent per-camera rings
189
+ * fit inside a bounded `/dev/shm`. The decoder output is capped at 640px wide, so
190
+ * a slot is ≤ ~920 KB and 16 MB still yields ~17–70 latest-wins slots. A ring
191
+ * segment larger than the container's `/dev/shm` tmpfs backing (Docker default is
192
+ * only 64 MB) faults an **uncatchable SIGBUS** on write past `st_size` — the old
193
+ * 128 MB default overflowed a 64 MB `/dev/shm` and crashed the decoder on 8MP
194
+ * streams. Pair this with a `--shm-size` that scales with concurrent-decode load.
195
+ */
196
+ var RING_BUDGET_MB = (() => {
197
+ const raw = Number(process.env["CAMSTACK_SHM_RING_BUDGET_MB"]);
198
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 16;
199
+ })();
200
+ /** {@link RING_BUDGET_MB} in bytes — the budget passed to `deriveSlotCount`. */
201
+ var RING_BUDGET_BYTES = RING_BUDGET_MB * 1024 * 1024;
202
+ /** A unique, stable shared-memory segment name for a decoder stream.
203
+ *
204
+ * macOS POSIX shm names are capped at ~31 characters (`PSHMNAMLEN`). A
205
+ * `camstack.frames.<deviceId>.<streamId>` scheme overflows that for realistic
206
+ * ids, so the sink uses a short, collision-resistant scheme instead:
207
+ * `csf.<base36 hash>.<gen>`. The hash folds the device id, the session tag
208
+ * and a per-process random salt; the generation suffix makes a re-created
209
+ * segment (resolution change) a distinct name so a stale consumer mapping is
210
+ * never silently reused.
211
+ */
212
+ function makeSegmentName(seed, generation) {
213
+ let hash = 5381;
214
+ for (let i = 0; i < seed.length; i += 1) hash = (hash << 5) + hash + seed.charCodeAt(i) | 0;
215
+ return `csf.${(hash >>> 0).toString(36)}.${generation}`;
216
+ }
217
+ /**
218
+ * The decoder-side owner of one stream's shared-memory frame ring.
219
+ *
220
+ * Not constructed until a session actually uses the shm sink; the segment
221
+ * itself is created lazily on the first `writeFrame`.
222
+ */
223
+ var DecoderFrameRingSink = class {
224
+ seed;
225
+ logger;
226
+ nodeId;
227
+ segment = null;
228
+ writer = null;
229
+ segmentName = null;
230
+ slotByteLength = 0;
231
+ generation = 0;
232
+ destroyed = false;
233
+ /** Frames committed into the ring across this sink's lifetime (all generations). */
234
+ framesWritten = 0;
235
+ constructor(options) {
236
+ const salt = Math.random().toString(36).slice(2, 8);
237
+ this.seed = `${options.seed}.${salt}`;
238
+ this.logger = options.logger;
239
+ this.nodeId = options.nodeId;
240
+ }
241
+ /** Whether a segment has been created (i.e. at least one frame written). */
242
+ get isArmed() {
243
+ return this.writer !== null;
244
+ }
245
+ /** The current segment name, or `null` before the first frame. */
246
+ get currentSegmentName() {
247
+ return this.segmentName;
248
+ }
249
+ /**
250
+ * Write one decoded frame into the ring and return its `FrameHandle`.
251
+ *
252
+ * On the first call (or after a geometry change that overflows the current
253
+ * slot) the segment is created / re-created sized for this frame. Returns
254
+ * `null` only when the sink has been destroyed.
255
+ *
256
+ * This is the copy-in convenience form (it copies `pixels` into the slot).
257
+ * The decoder's hot path uses the zero-copy {@link beginFrame} /
258
+ * {@link commitFrame} scatter-write pair instead — the scaler produces its
259
+ * packed output directly into the slot, eliminating the write-side memcpy.
260
+ */
261
+ writeFrame(pixels, meta) {
262
+ if (this.destroyed) return null;
263
+ if (this.writer === null || computeSlotByteLength(meta.width, meta.height, meta.format) > this.slotByteLength) this.recreateSegment(computeSlotByteLength(meta.width, meta.height, meta.format));
264
+ const writer = this.writer;
265
+ if (writer === null) return null;
266
+ const handle = writer.writeFrame(pixels, meta);
267
+ this.framesWritten += 1;
268
+ return handle;
269
+ }
270
+ /**
271
+ * Reserve a ring slot for a frame of the given geometry — the **zero-copy**
272
+ * scatter-write entry point (Phase 5 / D9 Task 7c).
273
+ *
274
+ * The segment is created / re-created here if this is the first frame or the
275
+ * geometry overflows the current slot capacity, so the slot is correctly
276
+ * sized before the caller fills it. The returned `buffer` is a writable view
277
+ * **directly over the mapped segment** — the node-av scaler scatters its
278
+ * packed output straight into it, with no intermediate copy. The caller MUST
279
+ * call {@link commitFrame} with the returned `slot` once the slot is filled.
280
+ *
281
+ * Returns `null` when the sink is destroyed or the segment cannot be created.
282
+ */
283
+ beginFrame(width, height, format) {
284
+ if (this.destroyed) return null;
285
+ const requiredSlotBytes = computeSlotByteLength(width, height, format);
286
+ if (this.writer === null || requiredSlotBytes > this.slotByteLength) this.recreateSegment(requiredSlotBytes);
287
+ const writer = this.writer;
288
+ if (writer === null) return null;
289
+ const { slot, buffer } = writer.beginFrame();
290
+ return {
291
+ slot,
292
+ buffer
293
+ };
294
+ }
295
+ /**
296
+ * Publish the frame whose slot was reserved by {@link beginFrame} and filled
297
+ * in place by the caller. `slot` MUST be the value from the matching
298
+ * `beginFrame`. Returns the published `FrameHandle`, or `null` if the sink
299
+ * was destroyed (or the segment lost) between begin and commit.
300
+ */
301
+ commitFrame(slot, meta) {
302
+ if (this.destroyed) return null;
303
+ const writer = this.writer;
304
+ if (writer === null) return null;
305
+ const handle = writer.commitFrame(slot, meta);
306
+ this.framesWritten += 1;
307
+ return handle;
308
+ }
309
+ /**
310
+ * Current shm ring usage — `null` until the first frame arms the segment.
311
+ * Surfaced through `decoder.getShmStats` so a downstream consumer can
312
+ * observe ring pressure (slot depth, byte budget, frames written).
313
+ */
314
+ getShmStats() {
315
+ if (this.writer === null) return null;
316
+ return {
317
+ slotCount: this.writer.slotCount,
318
+ slotByteLength: this.slotByteLength,
319
+ segmentBytes: computeSegmentSize(this.writer.slotCount, this.slotByteLength),
320
+ framesWritten: this.framesWritten
321
+ };
322
+ }
323
+ /**
324
+ * Abandon a slot reserved by {@link beginFrame} **without publishing it** —
325
+ * the degenerate-path counterpart of {@link commitFrame}.
326
+ *
327
+ * A caller that reserved a slot but then could not produce valid pixels (no
328
+ * decoded source planes, or the scaler threw) MUST call this instead of
329
+ * `commitFrame`: it closes the open seqlock without advancing `writeIndex`,
330
+ * so no reader ever sees the slot's uninitialised bytes as a real frame, and
331
+ * no `FrameHandle` is handed downstream. `slot` MUST be the value from the
332
+ * matching `beginFrame`. A no-op if the sink was destroyed (or the segment
333
+ * lost) between begin and abort.
334
+ */
335
+ abortFrame(slot) {
336
+ if (this.destroyed) return;
337
+ const writer = this.writer;
338
+ if (writer === null) return;
339
+ writer.abortFrame(slot);
340
+ }
341
+ /** Close + unlink the segment. Idempotent. */
342
+ destroy() {
343
+ if (this.destroyed) return;
344
+ this.destroyed = true;
345
+ this.releaseSegment();
346
+ }
347
+ /**
348
+ * Create a fresh segment sized for at least `slotByteLength` bytes per slot,
349
+ * replacing any prior one. A re-create bumps the generation so the new
350
+ * segment has a distinct name — a consumer holding the old mapping is never
351
+ * silently handed a resized segment.
352
+ */
353
+ recreateSegment(slotByteLength) {
354
+ this.releaseSegment();
355
+ this.generation += 1;
356
+ const name = makeSegmentName(this.seed, this.generation);
357
+ const slotCount = deriveSlotCount(RING_BUDGET_BYTES, slotByteLength);
358
+ if (slotCount === MIN_RING_SLOTS && MIN_RING_SLOTS * slotByteLength > RING_BUDGET_BYTES) this.logger.warn("decoder shm ring: budget too small for resolution — using MIN slots", { meta: {
359
+ slotByteLength,
360
+ budgetMb: RING_BUDGET_MB
361
+ } });
362
+ const totalBytes = computeSegmentSize(slotCount, slotByteLength);
363
+ try {
364
+ const segment = createSegment(name, totalBytes);
365
+ this.segment = segment;
366
+ this.segmentName = name;
367
+ this.slotByteLength = slotByteLength;
368
+ this.writer = new FrameRingWriter(segment.buffer, name, slotCount, slotByteLength, this.nodeId);
369
+ this.logger.info("decoder shm ring: segment created", { meta: {
370
+ segment: name,
371
+ slotCount,
372
+ slotByteLength,
373
+ totalBytes,
374
+ generation: this.generation
375
+ } });
376
+ } catch (err) {
377
+ this.segment = null;
378
+ this.writer = null;
379
+ this.segmentName = null;
380
+ this.slotByteLength = 0;
381
+ this.logger.error("decoder shm ring: segment create failed", { meta: {
382
+ segment: name,
383
+ slotByteLength,
384
+ error: err instanceof Error ? err.message : String(err)
385
+ } });
386
+ }
387
+ }
388
+ /** Unmap + unlink the current segment, if any. */
389
+ releaseSegment() {
390
+ const segment = this.segment;
391
+ if (segment === null) return;
392
+ this.segment = null;
393
+ this.writer = null;
394
+ const name = this.segmentName;
395
+ this.segmentName = null;
396
+ try {
397
+ segment.close();
398
+ segment.unlink();
399
+ this.logger.info("decoder shm ring: segment released", { meta: { segment: name } });
400
+ } catch (err) {
401
+ this.logger.warn("decoder shm ring: segment release failed", { meta: {
402
+ segment: name,
403
+ error: err instanceof Error ? err.message : String(err)
404
+ } });
405
+ }
406
+ }
407
+ };
408
+ //#endregion
409
+ export { resolveDecoderBackend as a, NotifyingRingBuffer as i, RING_BUDGET_MB as n, DEFAULT_DECODER_BACKEND as o, makeSegmentName as r, DecoderFrameRingSink as t };
@@ -2,7 +2,8 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-DAIlCdAx.js");
5
+ const require_dist = require("../dist-Biq62zt4.js");
6
+ let _camstack_shm_ring = require("@camstack/shm-ring");
6
7
  let node_fs = require("node:fs");
7
8
  let node_path = require("node:path");
8
9
  //#region src/motion-wasm/wasm-motion-detector.ts
@@ -135,6 +136,13 @@ var DEFAULT_CONFIG = {
135
136
  };
136
137
  var MotionWasmAddon = class MotionWasmAddon extends require_dist.BaseAddon {
137
138
  detector = null;
139
+ /**
140
+ * CB5 shm passthrough — process-local, long-lived reader cache for
141
+ * `analyze({ frameHandle })`. Opens each named shm segment once and reuses
142
+ * the mapping; lazily created on first LOCAL handle, unmapped in
143
+ * {@link onShutdown}. Never opened/closed per-call.
144
+ */
145
+ frameHandleReaders = null;
138
146
  cameras = /* @__PURE__ */ new Map();
139
147
  deviceConfigCache = /* @__PURE__ */ new Map();
140
148
  static DEVICE_CONFIG_TTL_MS = 6e4;
@@ -187,9 +195,51 @@ var MotionWasmAddon = class MotionWasmAddon extends require_dist.BaseAddon {
187
195
  * motion-detection cap: analyze a frame, return regions + stats.
188
196
  * Per-device state keyed by the numeric deviceId (stringified internally
189
197
  * so the pipeline-step sentinel shares the same Map).
198
+ *
199
+ * CB5: pixels arrive either inline (`frame`) or as a zero-pixel shm
200
+ * `FrameHandle`. A LOCAL handle is read back zero-copy here; a foreign /
201
+ * recycled / unmappable handle yields a null read and an empty result (the
202
+ * runner ships `frame` as the fallback, queue-depth gated).
190
203
  */
191
- async analyze({ deviceId, frame }) {
192
- return this.analyzeInternal(String(deviceId), frame);
204
+ async analyze({ deviceId, frame, frameHandle }) {
205
+ const resolved = frame ?? (frameHandle ? this.resolveFrameHandle(frameHandle) : null);
206
+ if (!resolved) return this.emptyMotionResult(frameHandle);
207
+ return this.analyzeInternal(String(deviceId), resolved);
208
+ }
209
+ /** This node's cluster id (suffix-stripped), for local-vs-foreign gating. */
210
+ ownNodeId() {
211
+ const raw = this.ctx.kernel?.localNodeId ?? "hub";
212
+ return raw.includes("/") ? raw.split("/")[0] : raw;
213
+ }
214
+ /**
215
+ * CB5 — resolve a shm `FrameHandle` to gray pixels zero-copy. Returns null
216
+ * for a FOREIGN handle (segment on another node — the runner only sends
217
+ * local handles), a recycled ring slot, or an unmappable segment.
218
+ */
219
+ resolveFrameHandle(handle) {
220
+ if (handle.nodeId !== this.ownNodeId()) return null;
221
+ if (!this.frameHandleReaders) this.frameHandleReaders = new _camstack_shm_ring.FrameRingReaderCache(this.ctx.logger);
222
+ const decoded = this.frameHandleReaders.read(handle);
223
+ if (!decoded) return null;
224
+ return {
225
+ data: decoded.data,
226
+ width: decoded.width,
227
+ height: decoded.height,
228
+ format: decoded.format,
229
+ timestamp: decoded.timestamp
230
+ };
231
+ }
232
+ /** Zeroed motion result for a missed `frameHandle` read (dropped frame). */
233
+ emptyMotionResult(handle) {
234
+ return {
235
+ detected: false,
236
+ regionCount: 0,
237
+ regions: [],
238
+ rawRegions: [],
239
+ frameWidth: handle?.width ?? 0,
240
+ frameHeight: handle?.height ?? 0,
241
+ analysisMs: 0
242
+ };
193
243
  }
194
244
  async analyzeInternal(cameraId, frame) {
195
245
  if (!this.detector) return {
@@ -351,6 +401,8 @@ var MotionWasmAddon = class MotionWasmAddon extends require_dist.BaseAddon {
351
401
  this.deviceConfigCache.clear();
352
402
  for (const id of this.proxies.keys()) this.releaseProxy(id);
353
403
  this.detector = null;
404
+ this.frameHandleReaders?.close();
405
+ this.frameHandleReaders = null;
354
406
  }
355
407
  deviceSettingsSchema() {
356
408
  return this.schema({ sections: [{
@@ -1,4 +1,5 @@
1
- import { A as motionDetectionCapability, T as evaluateZoneRules, V as BaseAddon, W as DeviceType, Y as hydrateSchema } from "../dist-CgEP_0OL.mjs";
1
+ import { A as motionDetectionCapability, T as evaluateZoneRules, V as BaseAddon, W as DeviceType, q as hydrateSchema } from "../dist-C6_wgXqF.mjs";
2
+ import { FrameRingReaderCache } from "@camstack/shm-ring";
2
3
  import { readFileSync } from "node:fs";
3
4
  import { join } from "node:path";
4
5
  //#region src/motion-wasm/wasm-motion-detector.ts
@@ -131,6 +132,13 @@ var DEFAULT_CONFIG = {
131
132
  };
132
133
  var MotionWasmAddon = class MotionWasmAddon extends BaseAddon {
133
134
  detector = null;
135
+ /**
136
+ * CB5 shm passthrough — process-local, long-lived reader cache for
137
+ * `analyze({ frameHandle })`. Opens each named shm segment once and reuses
138
+ * the mapping; lazily created on first LOCAL handle, unmapped in
139
+ * {@link onShutdown}. Never opened/closed per-call.
140
+ */
141
+ frameHandleReaders = null;
134
142
  cameras = /* @__PURE__ */ new Map();
135
143
  deviceConfigCache = /* @__PURE__ */ new Map();
136
144
  static DEVICE_CONFIG_TTL_MS = 6e4;
@@ -183,9 +191,51 @@ var MotionWasmAddon = class MotionWasmAddon extends BaseAddon {
183
191
  * motion-detection cap: analyze a frame, return regions + stats.
184
192
  * Per-device state keyed by the numeric deviceId (stringified internally
185
193
  * so the pipeline-step sentinel shares the same Map).
194
+ *
195
+ * CB5: pixels arrive either inline (`frame`) or as a zero-pixel shm
196
+ * `FrameHandle`. A LOCAL handle is read back zero-copy here; a foreign /
197
+ * recycled / unmappable handle yields a null read and an empty result (the
198
+ * runner ships `frame` as the fallback, queue-depth gated).
186
199
  */
187
- async analyze({ deviceId, frame }) {
188
- return this.analyzeInternal(String(deviceId), frame);
200
+ async analyze({ deviceId, frame, frameHandle }) {
201
+ const resolved = frame ?? (frameHandle ? this.resolveFrameHandle(frameHandle) : null);
202
+ if (!resolved) return this.emptyMotionResult(frameHandle);
203
+ return this.analyzeInternal(String(deviceId), resolved);
204
+ }
205
+ /** This node's cluster id (suffix-stripped), for local-vs-foreign gating. */
206
+ ownNodeId() {
207
+ const raw = this.ctx.kernel?.localNodeId ?? "hub";
208
+ return raw.includes("/") ? raw.split("/")[0] : raw;
209
+ }
210
+ /**
211
+ * CB5 — resolve a shm `FrameHandle` to gray pixels zero-copy. Returns null
212
+ * for a FOREIGN handle (segment on another node — the runner only sends
213
+ * local handles), a recycled ring slot, or an unmappable segment.
214
+ */
215
+ resolveFrameHandle(handle) {
216
+ if (handle.nodeId !== this.ownNodeId()) return null;
217
+ if (!this.frameHandleReaders) this.frameHandleReaders = new FrameRingReaderCache(this.ctx.logger);
218
+ const decoded = this.frameHandleReaders.read(handle);
219
+ if (!decoded) return null;
220
+ return {
221
+ data: decoded.data,
222
+ width: decoded.width,
223
+ height: decoded.height,
224
+ format: decoded.format,
225
+ timestamp: decoded.timestamp
226
+ };
227
+ }
228
+ /** Zeroed motion result for a missed `frameHandle` read (dropped frame). */
229
+ emptyMotionResult(handle) {
230
+ return {
231
+ detected: false,
232
+ regionCount: 0,
233
+ regions: [],
234
+ rawRegions: [],
235
+ frameWidth: handle?.width ?? 0,
236
+ frameHeight: handle?.height ?? 0,
237
+ analysisMs: 0
238
+ };
189
239
  }
190
240
  async analyzeInternal(cameraId, frame) {
191
241
  if (!this.detector) return {
@@ -347,6 +397,8 @@ var MotionWasmAddon = class MotionWasmAddon extends BaseAddon {
347
397
  this.deviceConfigCache.clear();
348
398
  for (const id of this.proxies.keys()) this.releaseProxy(id);
349
399
  this.detector = null;
400
+ this.frameHandleReaders?.close();
401
+ this.frameHandleReaders = null;
350
402
  }
351
403
  deviceSettingsSchema() {
352
404
  return this.schema({ sections: [{
@@ -0,0 +1,15 @@
1
+ //#region src/shared/node-topology-platform.ts
2
+ /**
3
+ * Pick a node's `{ platform, arch }` from the cluster topology. Returns an empty
4
+ * object when the topology is missing or the node is absent (caller falls back).
5
+ */
6
+ function pickNodePlatformArch(topology, nodeId) {
7
+ const entry = topology?.find((n) => n.id === nodeId);
8
+ if (!entry) return {};
9
+ return {
10
+ platform: entry.platform,
11
+ arch: entry.arch
12
+ };
13
+ }
14
+ //#endregion
15
+ export { pickNodePlatformArch as t };
@@ -0,0 +1,20 @@
1
+ //#region src/shared/node-topology-platform.ts
2
+ /**
3
+ * Pick a node's `{ platform, arch }` from the cluster topology. Returns an empty
4
+ * object when the topology is missing or the node is absent (caller falls back).
5
+ */
6
+ function pickNodePlatformArch(topology, nodeId) {
7
+ const entry = topology?.find((n) => n.id === nodeId);
8
+ if (!entry) return {};
9
+ return {
10
+ platform: entry.platform,
11
+ arch: entry.arch
12
+ };
13
+ }
14
+ //#endregion
15
+ Object.defineProperty(exports, "pickNodePlatformArch", {
16
+ enumerable: true,
17
+ get: function() {
18
+ return pickNodePlatformArch;
19
+ }
20
+ });