@camstack/addon-pipeline 1.1.33 → 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 (91) 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-B-DxHelU.js +0 -597
  63. package/dist/frame-ring-sink-B_WjDX0x.mjs +0 -550
  64. package/python/__pycache__/inference_pool.cpython-313.pyc +0 -0
  65. package/python/__pycache__/inference_pool.cpython-314.pyc +0 -0
  66. package/python/__pycache__/test_inference_pool_backpressure.cpython-314.pyc +0 -0
  67. package/python/__pycache__/test_inference_pool_coreml_cache.cpython-314.pyc +0 -0
  68. package/python/__pycache__/test_inference_pool_device_selection.cpython-314.pyc +0 -0
  69. package/python/__pycache__/yamnet_audio.cpython-314.pyc +0 -0
  70. package/python/postprocessors/__pycache__/__init__.cpython-312.pyc +0 -0
  71. package/python/postprocessors/__pycache__/__init__.cpython-313.pyc +0 -0
  72. package/python/postprocessors/__pycache__/_safety.cpython-313.pyc +0 -0
  73. package/python/postprocessors/__pycache__/arcface.cpython-312.pyc +0 -0
  74. package/python/postprocessors/__pycache__/arcface.cpython-313.pyc +0 -0
  75. package/python/postprocessors/__pycache__/clip.cpython-313.pyc +0 -0
  76. package/python/postprocessors/__pycache__/ctc.cpython-312.pyc +0 -0
  77. package/python/postprocessors/__pycache__/ctc.cpython-313.pyc +0 -0
  78. package/python/postprocessors/__pycache__/saliency.cpython-312.pyc +0 -0
  79. package/python/postprocessors/__pycache__/saliency.cpython-313.pyc +0 -0
  80. package/python/postprocessors/__pycache__/scrfd.cpython-312.pyc +0 -0
  81. package/python/postprocessors/__pycache__/scrfd.cpython-313.pyc +0 -0
  82. package/python/postprocessors/__pycache__/softmax.cpython-312.pyc +0 -0
  83. package/python/postprocessors/__pycache__/softmax.cpython-313.pyc +0 -0
  84. package/python/postprocessors/__pycache__/test_clip.cpython-313-pytest-9.1.1.pyc +0 -0
  85. package/python/postprocessors/__pycache__/test_saliency.cpython-313-pytest-9.1.1.pyc +0 -0
  86. package/python/postprocessors/__pycache__/yamnet.cpython-312.pyc +0 -0
  87. package/python/postprocessors/__pycache__/yamnet.cpython-313.pyc +0 -0
  88. package/python/postprocessors/__pycache__/yolo.cpython-312.pyc +0 -0
  89. package/python/postprocessors/__pycache__/yolo.cpython-313.pyc +0 -0
  90. package/python/postprocessors/__pycache__/yolo_seg.cpython-312.pyc +0 -0
  91. package/python/postprocessors/__pycache__/yolo_seg.cpython-313.pyc +0 -0
@@ -1,597 +0,0 @@
1
- const require_dist = require("./dist-BrIE85BG.js");
2
- let _camstack_shm_ring = require("@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
- /**
22
- * Decoder backend selection — decides which decoder addon registers the
23
- * `decoder` cap on a node.
24
- *
25
- * Two decoder addons ship in this bundle: `decoder-ffmpeg` (subprocess
26
- * decode; a decode crash is isolated to the child) and `decoder-nodeav`
27
- * (in-process decode via FFmpeg native bindings; no subprocess lifecycle).
28
- * Both are installed on every node, but only ONE may provide the `decoder`
29
- * cap at a time.
30
- *
31
- * ## Why selection happens at REGISTRATION time (race-free by construction)
32
- *
33
- * The `decoder` cap is a singleton. Historically both decoder addons
34
- * registered a provider and the active slot was resolved AFTER the fact —
35
- * which exposed a boot-order race: whichever addon's runner initialised
36
- * first briefly held the active slot, and any consumer that resolved the
37
- * cap inside that window was dispatched to the wrong backend (the
38
- * cap-declared `preferredProvider` override only corrects the slot when
39
- * the preferred addon's registration eventually lands).
40
- *
41
- * This module removes the race by construction instead of arbitrating it:
42
- * each decoder addon calls {@link resolveDecoderBackend} at the START of
43
- * its `onInitialize` and registers its provider ONLY when it is the
44
- * selected backend — the other addon returns no registrations at all. At
45
- * most one `decoder` provider ever exists per node, so no resolution layer
46
- * (kernel `CapabilityRegistry`, route resolver, UDS child preference) can
47
- * pick a wrong one, regardless of boot order or timing.
48
- *
49
- * ## The setting
50
- *
51
- * The per-node selection lives in the `decoder-ffmpeg` OWNER addon's global
52
- * settings — the standard `addon-settings` surface, visible in the admin UI
53
- * as the owner's `backend` select field. The store is hub-central and the
54
- * value is persisted node-scoped as `backend@<nodeId>`
55
- * (`decoder-backend-keys.ts`); the owner's `getGlobalSettings` projects the
56
- * requested node's value onto the bare `backend` field.
57
- *
58
- * Both decoder addons read it the SAME way, placement-agnostically: via
59
- * `ctx.api.addonSettings.getGlobalSettings({ addonId: 'decoder-ffmpeg',
60
- * nodeId })`. The `addon-settings` cap is hub-routed at the system level
61
- * (`AddonCallGateway.classify` sends settings calls to the addon's base
62
- * node — the hub instance answers for every node), so an agent addon
63
- * transparently reads the hub-central store with no placement branching.
64
- * NOT used: `ctx.settings.getSection` (node-local — empty on an agent, the
65
- * original bug), the raw settings-store cap, or any custom side channel.
66
- *
67
- * Resolution: the node's scoped value → the built-in default
68
- * {@link DEFAULT_DECODER_BACKEND} (`'ffmpeg'`). There is deliberately NO
69
- * bare-key fallback (see `decoder-backend-keys.ts`), and any read failure
70
- * resolves to the default so the safe backend still comes up.
71
- *
72
- * ## Switching backends
73
- *
74
- * Because registration is decided once at addon init, changing the setting
75
- * takes effect on the next restart of the two decoder addons on the
76
- * affected node (addon restart / redeploy / node reboot). This is the
77
- * price of race-freedom: there is deliberately NO live re-arbitration
78
- * path — a live hand-off would reintroduce a window with two providers.
79
- */
80
- /** The addon whose global settings OWN the per-node `backend` field. */
81
- var DECODER_OWNER_ADDON_ID = "decoder-ffmpeg";
82
- /** Fail-safe when the owner's hwaccel can't be read: defer to the local probe. */
83
- var DEFAULT_DECODER_HWACCEL = "auto";
84
- /** Narrow an arbitrary stored value to a known {@link HwAccelChoice}, else null. */
85
- function parseDecoderHwAccel(raw) {
86
- if (typeof raw !== "string") return null;
87
- const match = require_dist.HWACCEL_OPTIONS.find((o) => o.value === raw);
88
- return match ? match.value : null;
89
- }
90
- function isHydratedField(entry) {
91
- return typeof entry === "object" && entry !== null && "key" in entry;
92
- }
93
- /**
94
- * Pure selection from an already-read hydrated settings payload: extract the
95
- * owner's `backend` field value (which the owner projected per-node from its
96
- * scoped store key) and narrow it. A missing/invalid field or a null payload
97
- * resolves to {@link DEFAULT_DECODER_BACKEND} — never a bare store key.
98
- */
99
- function pickDecoderBackendFromSettings(view) {
100
- if (view === null) return DEFAULT_DECODER_BACKEND;
101
- for (const section of view.sections) for (const entry of section.fields) {
102
- if (!isHydratedField(entry) || entry.key !== "backend") continue;
103
- return parseDecoderBackend(entry.value) ?? "ffmpeg";
104
- }
105
- return DEFAULT_DECODER_BACKEND;
106
- }
107
- /**
108
- * Resolve the OWNER addon's (`decoder-ffmpeg`) own per-node backend from its
109
- * ALREADY-PROJECTED global store — the store `BaseAddon.resolveGlobalStore`
110
- * returns, where the `perNode` `backend` field carries THIS node's value on
111
- * its bare key. The owner MUST use this instead of {@link resolveDecoderBackend}:
112
- * routing `addonSettings.getGlobalSettings({addonId:'decoder-ffmpeg'})` back to
113
- * itself during its OWN `onInitialize` DEADLOCKS (the addon is not "loaded" on
114
- * the node until init completes, but init is blocked on the read) → the read
115
- * throws `transport-failed (addon not loaded)` → it silently defaults to
116
- * `ffmpeg` and REGISTERS even when the node selected `nodeav`, producing a
117
- * two-provider decode storm. Reading its own store is local, race-free, and
118
- * has no deadlock.
119
- */
120
- function resolveOwnDecoderBackend(projectedGlobalStore) {
121
- return parseDecoderBackend(projectedGlobalStore["backend"]) ?? "ffmpeg";
122
- }
123
- /** Transient transport/settings-store fingerprints worth retrying on. */
124
- function isTransientSettingsError(message) {
125
- return /not routable/i.test(message) || /not loaded/i.test(message) || /transport-failed/i.test(message) || /not connected/i.test(message) || /provider not available/i.test(message) || /SqliteSettingsBackend not initialized/i.test(message);
126
- }
127
- /**
128
- * Resolve the decoder backend a NON-OWNER addon (`decoder-nodeav`) should run,
129
- * by reading the OWNER's (`decoder-ffmpeg`) hub-central per-node `backend`.
130
- *
131
- * The read routes to the owner addon's child runner; during a simultaneous
132
- * (re)start the owner may not be up yet → a transient `transport-failed (addon
133
- * not loaded)`. Immediately defaulting to `ffmpeg` here makes `decoder-nodeav`
134
- * stand down even though the node selected `nodeav` → NO decoder registers at
135
- * all. So retry on the transient fingerprints with a bounded budget (mirrors
136
- * `BaseAddon.readAddonStoreWithRetry`) until the owner answers; only a
137
- * persistent failure falls back to {@link DEFAULT_DECODER_BACKEND}.
138
- *
139
- * The OWNER addon must NOT call this (it would self-route + deadlock) — it uses
140
- * {@link resolveOwnDecoderBackend} against its own store instead.
141
- */
142
- async function resolveDecoderBackend(api, nodeId, logger) {
143
- if (!api) {
144
- logger.warn("decoder-backend: no api surface — using default backend", { meta: { default: DEFAULT_DECODER_BACKEND } });
145
- return DEFAULT_DECODER_BACKEND;
146
- }
147
- const normalized = normalizeDecoderNodeId(nodeId);
148
- const delaysMs = [
149
- 150,
150
- 350,
151
- 600,
152
- 900,
153
- 1200
154
- ];
155
- let lastErr;
156
- for (let attempt = 0; attempt <= delaysMs.length; attempt++) {
157
- try {
158
- const view = await api.addonSettings.getGlobalSettings.query({
159
- addonId: DECODER_OWNER_ADDON_ID,
160
- nodeId: normalized
161
- });
162
- if (view !== null) return pickDecoderBackendFromSettings(view);
163
- lastErr = /* @__PURE__ */ new Error("owner settings unavailable (null)");
164
- } catch (err) {
165
- lastErr = err;
166
- const msg = err instanceof Error ? err.message : String(err);
167
- if (!isTransientSettingsError(msg)) {
168
- logger.warn("decoder-backend: settings read failed — using default backend", { meta: {
169
- default: DEFAULT_DECODER_BACKEND,
170
- error: msg
171
- } });
172
- return DEFAULT_DECODER_BACKEND;
173
- }
174
- }
175
- if (attempt === delaysMs.length) break;
176
- await new Promise((resolve) => setTimeout(resolve, delaysMs[attempt]));
177
- }
178
- logger.warn("decoder-backend: owner settings unavailable after retries — using default backend", { meta: {
179
- default: DEFAULT_DECODER_BACKEND,
180
- owner: DECODER_OWNER_ADDON_ID,
181
- error: lastErr instanceof Error ? lastErr.message : String(lastErr)
182
- } });
183
- return DEFAULT_DECODER_BACKEND;
184
- }
185
- /**
186
- * Pure selection of the owner's `hwaccel` field from an already-read hydrated
187
- * settings payload. Missing/invalid/null → {@link DEFAULT_DECODER_HWACCEL}.
188
- */
189
- function pickDecoderHwAccelFromSettings(view) {
190
- if (view === null) return DEFAULT_DECODER_HWACCEL;
191
- for (const section of view.sections) for (const entry of section.fields) {
192
- if (!isHydratedField(entry) || entry.key !== "hwaccel") continue;
193
- return parseDecoderHwAccel(entry.value) ?? "auto";
194
- }
195
- return DEFAULT_DECODER_HWACCEL;
196
- }
197
- /**
198
- * Resolve the effective decoder hwaccel override for a node by reading the
199
- * `decoder-ffmpeg` OWNER's `hwaccel@<node>` setting — the exact same
200
- * hub-routed, placement-agnostic read as {@link resolveDecoderBackend}, so
201
- * node-av and ffmpeg honour ONE per-node value (the one the UI edits). Fails
202
- * safe to {@link DEFAULT_DECODER_HWACCEL} (`'auto'` → the session's local probe).
203
- */
204
- async function resolveDecoderHwAccel(api, nodeId, logger) {
205
- if (!api) return DEFAULT_DECODER_HWACCEL;
206
- try {
207
- return pickDecoderHwAccelFromSettings(await api.addonSettings.getGlobalSettings.query({
208
- addonId: DECODER_OWNER_ADDON_ID,
209
- nodeId: normalizeDecoderNodeId(nodeId)
210
- }));
211
- } catch (err) {
212
- logger.warn("decoder-hwaccel: owner settings read failed — deferring to local probe", { meta: { error: err instanceof Error ? err.message : String(err) } });
213
- return DEFAULT_DECODER_HWACCEL;
214
- }
215
- }
216
- //#endregion
217
- //#region src/shared/notifying-ring-buffer.ts
218
- /**
219
- * A {@link RingBuffer} that can notify a single blocked consumer the moment an
220
- * item commits — the server side of the `pullFrames`/`pullHandles` long-poll.
221
- *
222
- * The decoder's `onFrame`/`onFrameHandle` callback drives `push()`; a consumer
223
- * that finds the ring momentarily empty calls {@link waitForItem} and is woken
224
- * on the very next `push()` (a real commit signal) rather than spinning at
225
- * ~1kHz on `setTimeout(…,1)`. If nothing commits it resolves after `waitMs`,
226
- * and {@link cancel} (session teardown) resolves any in-flight wait promptly so
227
- * a blocked long-poll never waits out `waitMs` on destroy.
228
- *
229
- * Semantics are otherwise identical to `RingBuffer`: fixed capacity,
230
- * latest-wins overwrite when full, FIFO `drain`.
231
- */
232
- var NotifyingRingBuffer = class {
233
- ring;
234
- waiters = /* @__PURE__ */ new Set();
235
- constructor(capacity) {
236
- this.ring = new require_dist.RingBuffer(capacity);
237
- }
238
- get size() {
239
- return this.ring.size;
240
- }
241
- push(item) {
242
- this.ring.push(item);
243
- this.wakeAll();
244
- }
245
- drain(maxCount) {
246
- return this.ring.drain(maxCount);
247
- }
248
- /**
249
- * Resolve as soon as an item commits to the ring, or after `waitMs`,
250
- * whichever comes first. Returns immediately when items are already present
251
- * or `waitMs <= 0` (legacy immediate-return). {@link cancel} also resolves an
252
- * in-flight wait.
253
- */
254
- async waitForItem(waitMs) {
255
- if (this.ring.size > 0 || waitMs <= 0) return;
256
- await new Promise((resolve) => {
257
- let settled = false;
258
- const finish = () => {
259
- if (settled) return;
260
- settled = true;
261
- this.waiters.delete(wake);
262
- clearTimeout(timer);
263
- resolve();
264
- };
265
- const wake = () => finish();
266
- const timer = setTimeout(finish, waitMs);
267
- if (typeof timer.unref === "function") timer.unref();
268
- this.waiters.add(wake);
269
- });
270
- }
271
- /**
272
- * Cancel every in-flight {@link waitForItem} — called on session teardown so
273
- * a blocked long-poll returns promptly instead of waiting out `waitMs`.
274
- */
275
- cancel() {
276
- this.wakeAll();
277
- }
278
- wakeAll() {
279
- if (this.waiters.size === 0) return;
280
- const pending = [...this.waiters];
281
- this.waiters.clear();
282
- for (const wake of pending) wake();
283
- }
284
- };
285
- //#endregion
286
- //#region src/decoder-ffmpeg/frame-ring-sink.ts
287
- /**
288
- * `DecoderFrameRingSink` — the decoder's shared-memory write side (Phase 5 / D9).
289
- *
290
- * When a decoder session is configured with `frameSink: 'shm'`, the decoder
291
- * **owns** the shared-memory ring segment for that stream: it creates the
292
- * segment on the first decoded frame (when the output geometry is known),
293
- * writes every subsequent decoded frame into the ring via a `FrameRingWriter`,
294
- * and closes + unlinks the segment when the session is destroyed.
295
- *
296
- * What leaves the decoder is no longer the pixel `Buffer` — it is a tiny,
297
- * serialisable `FrameHandle` (`FrameRingWriter.writeFrame`'s return value).
298
- * Same-host consumers (motion, detection, the WebRTC encoder) open the same
299
- * segment with a `FrameRingReader` and read the pixels zero-copy.
300
- *
301
- * ## Lazy segment creation
302
- *
303
- * The segment cannot be sized until the first frame: `slotByteLength` is
304
- * `width × height × bytesPerPixel`, and the output dimensions are only known
305
- * once the scaler has produced its first `dstFrame`. So `writeFrame` is a
306
- * no-op-until-armed: the first call sizes + creates the segment, every later
307
- * call writes into it.
308
- *
309
- * ## Resolution-change decision
310
- *
311
- * A live camera stream can change resolution mid-stream (the decoder's scaler
312
- * is rebuilt on a config toggle, or the source renegotiates). The slot is
313
- * sized for the **first** frame's geometry. A later frame that no longer fits
314
- * the slot triggers a **segment re-create**: the old segment is closed +
315
- * unlinked and a fresh, larger segment is created under a new generation-tagged
316
- * name. This is simpler and leak-free versus over-allocating slots for a
317
- * worst-case 4K frame on every stream; resolution changes on a live camera are
318
- * rare, and a brief gap while consumers re-open the segment is acceptable
319
- * (latest-wins — a missed frame is correct behaviour).
320
- */
321
- /**
322
- * Per-ring shared-memory budget (MB) — the slot count is derived per-resolution
323
- * from this budget via {@link deriveSlotCount}, so a 360p stream gets many
324
- * slots and a 4K stream a few, both inside the same memory footprint.
325
- *
326
- * Read ONCE at module load from `CAMSTACK_SHM_RING_BUDGET_MB`; a non-finite or
327
- * non-positive value falls back to the 16 MB default.
328
- *
329
- * The default is deliberately small (16 MB) so many concurrent per-camera rings
330
- * fit inside a bounded `/dev/shm`. The decoder output is capped at 640px wide, so
331
- * a slot is ≤ ~920 KB and 16 MB still yields ~17–70 latest-wins slots. A ring
332
- * segment larger than the container's `/dev/shm` tmpfs backing (Docker default is
333
- * only 64 MB) faults an **uncatchable SIGBUS** on write past `st_size` — the old
334
- * 128 MB default overflowed a 64 MB `/dev/shm` and crashed the decoder on 8MP
335
- * streams. Pair this with a `--shm-size` that scales with concurrent-decode load.
336
- */
337
- var RING_BUDGET_MB = (() => {
338
- const raw = Number(process.env["CAMSTACK_SHM_RING_BUDGET_MB"]);
339
- return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 16;
340
- })();
341
- /** {@link RING_BUDGET_MB} in bytes — the budget passed to `deriveSlotCount`. */
342
- var RING_BUDGET_BYTES = RING_BUDGET_MB * 1024 * 1024;
343
- /** A unique, stable shared-memory segment name for a decoder stream.
344
- *
345
- * macOS POSIX shm names are capped at ~31 characters (`PSHMNAMLEN`). A
346
- * `camstack.frames.<deviceId>.<streamId>` scheme overflows that for realistic
347
- * ids, so the sink uses a short, collision-resistant scheme instead:
348
- * `csf.<base36 hash>.<gen>`. The hash folds the device id, the session tag
349
- * and a per-process random salt; the generation suffix makes a re-created
350
- * segment (resolution change) a distinct name so a stale consumer mapping is
351
- * never silently reused.
352
- */
353
- function makeSegmentName(seed, generation) {
354
- let hash = 5381;
355
- for (let i = 0; i < seed.length; i += 1) hash = (hash << 5) + hash + seed.charCodeAt(i) | 0;
356
- return `csf.${(hash >>> 0).toString(36)}.${generation}`;
357
- }
358
- /**
359
- * The decoder-side owner of one stream's shared-memory frame ring.
360
- *
361
- * Not constructed until a session actually uses the shm sink; the segment
362
- * itself is created lazily on the first `writeFrame`.
363
- */
364
- var DecoderFrameRingSink = class {
365
- seed;
366
- logger;
367
- nodeId;
368
- segment = null;
369
- writer = null;
370
- segmentName = null;
371
- slotByteLength = 0;
372
- generation = 0;
373
- destroyed = false;
374
- /** Frames committed into the ring across this sink's lifetime (all generations). */
375
- framesWritten = 0;
376
- constructor(options) {
377
- const salt = Math.random().toString(36).slice(2, 8);
378
- this.seed = `${options.seed}.${salt}`;
379
- this.logger = options.logger;
380
- this.nodeId = options.nodeId;
381
- }
382
- /** Whether a segment has been created (i.e. at least one frame written). */
383
- get isArmed() {
384
- return this.writer !== null;
385
- }
386
- /** The current segment name, or `null` before the first frame. */
387
- get currentSegmentName() {
388
- return this.segmentName;
389
- }
390
- /**
391
- * Write one decoded frame into the ring and return its `FrameHandle`.
392
- *
393
- * On the first call (or after a geometry change that overflows the current
394
- * slot) the segment is created / re-created sized for this frame. Returns
395
- * `null` only when the sink has been destroyed.
396
- *
397
- * This is the copy-in convenience form (it copies `pixels` into the slot).
398
- * The decoder's hot path uses the zero-copy {@link beginFrame} /
399
- * {@link commitFrame} scatter-write pair instead — the scaler produces its
400
- * packed output directly into the slot, eliminating the write-side memcpy.
401
- */
402
- writeFrame(pixels, meta) {
403
- if (this.destroyed) return null;
404
- if (this.writer === null || (0, _camstack_shm_ring.computeSlotByteLength)(meta.width, meta.height, meta.format) > this.slotByteLength) this.recreateSegment((0, _camstack_shm_ring.computeSlotByteLength)(meta.width, meta.height, meta.format));
405
- const writer = this.writer;
406
- if (writer === null) return null;
407
- const handle = writer.writeFrame(pixels, meta);
408
- this.framesWritten += 1;
409
- return handle;
410
- }
411
- /**
412
- * Reserve a ring slot for a frame of the given geometry — the **zero-copy**
413
- * scatter-write entry point (Phase 5 / D9 Task 7c).
414
- *
415
- * The segment is created / re-created here if this is the first frame or the
416
- * geometry overflows the current slot capacity, so the slot is correctly
417
- * sized before the caller fills it. The returned `buffer` is a writable view
418
- * **directly over the mapped segment** — the node-av scaler scatters its
419
- * packed output straight into it, with no intermediate copy. The caller MUST
420
- * call {@link commitFrame} with the returned `slot` once the slot is filled.
421
- *
422
- * Returns `null` when the sink is destroyed or the segment cannot be created.
423
- */
424
- beginFrame(width, height, format) {
425
- if (this.destroyed) return null;
426
- const requiredSlotBytes = (0, _camstack_shm_ring.computeSlotByteLength)(width, height, format);
427
- if (this.writer === null || requiredSlotBytes > this.slotByteLength) this.recreateSegment(requiredSlotBytes);
428
- const writer = this.writer;
429
- if (writer === null) return null;
430
- const { slot, buffer } = writer.beginFrame();
431
- return {
432
- slot,
433
- buffer
434
- };
435
- }
436
- /**
437
- * Publish the frame whose slot was reserved by {@link beginFrame} and filled
438
- * in place by the caller. `slot` MUST be the value from the matching
439
- * `beginFrame`. Returns the published `FrameHandle`, or `null` if the sink
440
- * was destroyed (or the segment lost) between begin and commit.
441
- */
442
- commitFrame(slot, meta) {
443
- if (this.destroyed) return null;
444
- const writer = this.writer;
445
- if (writer === null) return null;
446
- const handle = writer.commitFrame(slot, meta);
447
- this.framesWritten += 1;
448
- return handle;
449
- }
450
- /**
451
- * Current shm ring usage — `null` until the first frame arms the segment.
452
- * Surfaced through `decoder.getShmStats` so a downstream consumer can
453
- * observe ring pressure (slot depth, byte budget, frames written).
454
- */
455
- getShmStats() {
456
- if (this.writer === null) return null;
457
- return {
458
- slotCount: this.writer.slotCount,
459
- slotByteLength: this.slotByteLength,
460
- segmentBytes: (0, _camstack_shm_ring.computeSegmentSize)(this.writer.slotCount, this.slotByteLength),
461
- framesWritten: this.framesWritten
462
- };
463
- }
464
- /**
465
- * Abandon a slot reserved by {@link beginFrame} **without publishing it** —
466
- * the degenerate-path counterpart of {@link commitFrame}.
467
- *
468
- * A caller that reserved a slot but then could not produce valid pixels (no
469
- * decoded source planes, or the scaler threw) MUST call this instead of
470
- * `commitFrame`: it closes the open seqlock without advancing `writeIndex`,
471
- * so no reader ever sees the slot's uninitialised bytes as a real frame, and
472
- * no `FrameHandle` is handed downstream. `slot` MUST be the value from the
473
- * matching `beginFrame`. A no-op if the sink was destroyed (or the segment
474
- * lost) between begin and abort.
475
- */
476
- abortFrame(slot) {
477
- if (this.destroyed) return;
478
- const writer = this.writer;
479
- if (writer === null) return;
480
- writer.abortFrame(slot);
481
- }
482
- /** Close + unlink the segment. Idempotent. */
483
- destroy() {
484
- if (this.destroyed) return;
485
- this.destroyed = true;
486
- this.releaseSegment();
487
- }
488
- /**
489
- * Create a fresh segment sized for at least `slotByteLength` bytes per slot,
490
- * replacing any prior one. A re-create bumps the generation so the new
491
- * segment has a distinct name — a consumer holding the old mapping is never
492
- * silently handed a resized segment.
493
- */
494
- recreateSegment(slotByteLength) {
495
- this.releaseSegment();
496
- this.generation += 1;
497
- const name = makeSegmentName(this.seed, this.generation);
498
- const slotCount = (0, _camstack_shm_ring.deriveSlotCount)(RING_BUDGET_BYTES, slotByteLength);
499
- if (slotCount === _camstack_shm_ring.MIN_RING_SLOTS && _camstack_shm_ring.MIN_RING_SLOTS * slotByteLength > RING_BUDGET_BYTES) this.logger.warn("decoder shm ring: budget too small for resolution — using MIN slots", { meta: {
500
- slotByteLength,
501
- budgetMb: RING_BUDGET_MB
502
- } });
503
- const totalBytes = (0, _camstack_shm_ring.computeSegmentSize)(slotCount, slotByteLength);
504
- try {
505
- const segment = (0, _camstack_shm_ring.createSegment)(name, totalBytes);
506
- this.segment = segment;
507
- this.segmentName = name;
508
- this.slotByteLength = slotByteLength;
509
- this.writer = new _camstack_shm_ring.FrameRingWriter(segment.buffer, name, slotCount, slotByteLength, this.nodeId);
510
- this.logger.info("decoder shm ring: segment created", { meta: {
511
- segment: name,
512
- slotCount,
513
- slotByteLength,
514
- totalBytes,
515
- generation: this.generation
516
- } });
517
- } catch (err) {
518
- this.segment = null;
519
- this.writer = null;
520
- this.segmentName = null;
521
- this.slotByteLength = 0;
522
- this.logger.error("decoder shm ring: segment create failed", { meta: {
523
- segment: name,
524
- slotByteLength,
525
- error: err instanceof Error ? err.message : String(err)
526
- } });
527
- }
528
- }
529
- /** Unmap + unlink the current segment, if any. */
530
- releaseSegment() {
531
- const segment = this.segment;
532
- if (segment === null) return;
533
- this.segment = null;
534
- this.writer = null;
535
- const name = this.segmentName;
536
- this.segmentName = null;
537
- try {
538
- segment.close();
539
- segment.unlink();
540
- this.logger.info("decoder shm ring: segment released", { meta: { segment: name } });
541
- } catch (err) {
542
- this.logger.warn("decoder shm ring: segment release failed", { meta: {
543
- segment: name,
544
- error: err instanceof Error ? err.message : String(err)
545
- } });
546
- }
547
- }
548
- };
549
- //#endregion
550
- Object.defineProperty(exports, "DEFAULT_DECODER_BACKEND", {
551
- enumerable: true,
552
- get: function() {
553
- return DEFAULT_DECODER_BACKEND;
554
- }
555
- });
556
- Object.defineProperty(exports, "DecoderFrameRingSink", {
557
- enumerable: true,
558
- get: function() {
559
- return DecoderFrameRingSink;
560
- }
561
- });
562
- Object.defineProperty(exports, "NotifyingRingBuffer", {
563
- enumerable: true,
564
- get: function() {
565
- return NotifyingRingBuffer;
566
- }
567
- });
568
- Object.defineProperty(exports, "RING_BUDGET_MB", {
569
- enumerable: true,
570
- get: function() {
571
- return RING_BUDGET_MB;
572
- }
573
- });
574
- Object.defineProperty(exports, "makeSegmentName", {
575
- enumerable: true,
576
- get: function() {
577
- return makeSegmentName;
578
- }
579
- });
580
- Object.defineProperty(exports, "resolveDecoderBackend", {
581
- enumerable: true,
582
- get: function() {
583
- return resolveDecoderBackend;
584
- }
585
- });
586
- Object.defineProperty(exports, "resolveDecoderHwAccel", {
587
- enumerable: true,
588
- get: function() {
589
- return resolveDecoderHwAccel;
590
- }
591
- });
592
- Object.defineProperty(exports, "resolveOwnDecoderBackend", {
593
- enumerable: true,
594
- get: function() {
595
- return resolveOwnDecoderBackend;
596
- }
597
- });