@camstack/addon-pipeline 1.1.55 → 1.1.57

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 (46) hide show
  1. package/dist/audio-analyzer/index.js +2 -2
  2. package/dist/audio-analyzer/index.mjs +2 -2
  3. package/dist/detection-pipeline/index.js +92 -88
  4. package/dist/detection-pipeline/index.mjs +91 -87
  5. package/dist/{dist-DI57FC8K.js → dist-CK8Ur-OS.js} +1045 -43
  6. package/dist/{dist-RWGGPwVx.mjs → dist-CL531uVA.mjs} +1045 -43
  7. package/dist/motion-wasm/index.js +9 -41
  8. package/dist/motion-wasm/index.mjs +9 -41
  9. package/dist/pipeline-runner/index.js +338 -664
  10. package/dist/pipeline-runner/index.mjs +327 -655
  11. package/dist/recorder/index.js +2 -2
  12. package/dist/recorder/index.mjs +2 -2
  13. package/dist/remote-restream-BeHi78PZ.mjs +25 -0
  14. package/dist/remote-restream-CO36Sr30.js +36 -0
  15. package/dist/session-decode/decode-worker-child.js +5 -3
  16. package/dist/session-decode/decode-worker-child.mjs +5 -3
  17. package/dist/{step-definitions-CP9kVSml.mjs → step-definitions-1d3_vQ6S.mjs} +45 -3
  18. package/dist/{step-definitions-CNBFKjZe.js → step-definitions-DHAxruAQ.js} +45 -3
  19. package/dist/stream-broker/_stub.js +351 -291
  20. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-mDCPzmT3.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-Cm_B-hQT.mjs} +2 -2
  21. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Ba7Eyd47.mjs +26 -0
  22. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-Dn_pxzP-.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-TWCUWhCm.mjs} +2 -2
  23. package/dist/stream-broker/{hostInit-GM_CI22k.mjs → hostInit-DEaqJpGA.mjs} +2 -2
  24. package/dist/stream-broker/index.js +935 -38
  25. package/dist/stream-broker/index.mjs +932 -35
  26. package/dist/stream-broker/remoteEntry.js +1 -1
  27. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-BChW0ntM.js → MaskShapeCanvas-DI4BY7W2-CZUzu3MF.js} +1 -1
  28. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-DDzqEbSe.js → MotionZonesSettings-NcxxQN8r-CmZbzLQi.js} +1 -1
  29. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-B879GXaf.js → PrivacyMaskSettings-APgPLF7p-BOKDrH0N.js} +1 -1
  30. package/embed-dist/assets/index-Bmpl9O1K.css +2 -0
  31. package/embed-dist/assets/index-DOsK8CZb.js +81 -0
  32. package/embed-dist/index.html +2 -2
  33. package/package.json +7 -5
  34. package/python/inference_pool.py +183 -29
  35. package/python/postprocessors/__init__.py +2 -0
  36. package/python/postprocessors/ssd.py +128 -0
  37. package/python/requirements-edgetpu.txt +23 -0
  38. package/python/test_inference_pool_layout.py +16 -1
  39. package/python/test_inference_pool_preprocess.py +65 -0
  40. package/dist/remote-source-plane-CHgvzzA6.js +0 -1030
  41. package/dist/remote-source-plane-DU0aRSPv.mjs +0 -1001
  42. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-B3gTdHEh.mjs +0 -26
  43. package/embed-dist/assets/index-CC06JBcl.css +0 -2
  44. package/embed-dist/assets/index-CNjQ5rAE.js +0 -81
  45. package/dist/{model-download-service-C-IHWnXx-BPy6aoAx.mjs → model-download-service-C-IHWnXx-3Mmeob3l.mjs} +1 -1
  46. package/dist/{model-download-service-C-IHWnXx-BLXzxpRB.js → model-download-service-C-IHWnXx-D326YNnt.js} +1 -1
@@ -2,15 +2,16 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-DI57FC8K.js");
6
- const require_remote_source_plane = require("../remote-source-plane-CHgvzzA6.js");
5
+ const require_chunk = require("../chunk-D6vf50IK.js");
6
+ const require_dist = require("../dist-CK8Ur-OS.js");
7
+ const require_remote_restream = require("../remote-restream-CO36Sr30.js");
7
8
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
8
9
  const require_worker_protocol = require("../worker-protocol-PP4jKHHJ.js");
9
- const require_step_definitions = require("../step-definitions-CNBFKjZe.js");
10
- let _camstack_shm_ring = require("@camstack/shm-ring");
10
+ const require_step_definitions = require("../step-definitions-DHAxruAQ.js");
11
11
  let node_child_process = require("node:child_process");
12
12
  let node_url = require("node:url");
13
- let node_fs = require("node:fs");
13
+ let sharp = require("sharp");
14
+ sharp = require_chunk.__toESM(sharp);
14
15
  //#region src/pipeline-runner/bench-actions.ts
15
16
  /**
16
17
  * Synthetic-bench custom actions for pipeline-runner.
@@ -105,329 +106,6 @@ var pipelineRunnerBenchActions = require_dist.defineCustomActions({
105
106
  runSyntheticBench: require_dist.customAction(RunSyntheticBenchInputSchema, RunSyntheticBenchResultSchema, { kind: "mutation" })
106
107
  });
107
108
  //#endregion
108
- //#region src/pipeline-runner/foreign-handle-throttle.ts
109
- var ForeignHandleThrottle = class {
110
- minFetchIntervalMs;
111
- warnIntervalMs;
112
- lastFetchStartedAt = Number.NEGATIVE_INFINITY;
113
- inFlight = false;
114
- lastWarnAt;
115
- _foreignHandles = 0;
116
- _remoteFetches = 0;
117
- _remoteFailures = 0;
118
- _dropped = 0;
119
- constructor(options = {}) {
120
- const maxRemoteFps = options.maxRemoteFps ?? 1;
121
- this.minFetchIntervalMs = maxRemoteFps > 0 ? 1e3 / maxRemoteFps : Number.POSITIVE_INFINITY;
122
- this.warnIntervalMs = options.warnIntervalMs ?? 6e4;
123
- }
124
- /**
125
- * Decide whether the current foreign handle should trigger a remote fetch.
126
- * `'fetch'` only when no fetch is in flight AND at least `1000/maxRemoteFps`
127
- * ms have elapsed since the last fetch started; otherwise `'drop'`.
128
- */
129
- plan(now) {
130
- this._foreignHandles += 1;
131
- const rateOpen = now - this.lastFetchStartedAt >= this.minFetchIntervalMs;
132
- if (this.inFlight || !rateOpen) {
133
- this._dropped += 1;
134
- return "drop";
135
- }
136
- return "fetch";
137
- }
138
- /** Mark a remote fetch as begun (arms single-flight + the rate window). */
139
- noteFetchStart(now) {
140
- this.inFlight = true;
141
- this.lastFetchStartedAt = now;
142
- this._remoteFetches += 1;
143
- }
144
- /** Mark the in-flight remote fetch as finished; records a failure outcome. */
145
- noteFetchEnd(result = "ok") {
146
- this.inFlight = false;
147
- if (result === "failed") this._remoteFailures += 1;
148
- }
149
- /** `true` at most once per `warnIntervalMs`; stamps the window when it fires. */
150
- shouldWarn(now) {
151
- if (this.lastWarnAt !== void 0 && now - this.lastWarnAt < this.warnIntervalMs) return false;
152
- this.lastWarnAt = now;
153
- return true;
154
- }
155
- get counters() {
156
- return {
157
- foreignHandles: this._foreignHandles,
158
- remoteFetches: this._remoteFetches,
159
- remoteFailures: this._remoteFailures,
160
- dropped: this._dropped
161
- };
162
- }
163
- };
164
- //#endregion
165
- //#region src/pipeline-runner/resolve-polled-frame.ts
166
- async function resolvePolledFrame(handle, deps) {
167
- const { ownNodeId, readers, throttle, getRemoteFrame, logger, tag } = deps;
168
- if (handle.nodeId === ownNodeId) return readers.read(handle);
169
- const now = Date.now();
170
- const decision = throttle.plan(now);
171
- if (throttle.shouldWarn(now)) logger.warn("frame poller: foreign handle — decoder session is on another node; using bounded decoder.getFrame fallback", { meta: {
172
- tag,
173
- producerNode: handle.nodeId,
174
- ownNodeId,
175
- ...throttle.counters
176
- } });
177
- if (decision === "drop") return null;
178
- throttle.noteFetchStart(now);
179
- try {
180
- const remote = await getRemoteFrame(handle);
181
- throttle.noteFetchEnd("ok");
182
- return remote;
183
- } catch (err) {
184
- throttle.noteFetchEnd("failed");
185
- logger.debug("frame poller: remote getFrame failed", { meta: {
186
- tag,
187
- producerNode: handle.nodeId,
188
- error: require_dist.errMsg(err)
189
- } });
190
- return null;
191
- }
192
- }
193
- //#endregion
194
- //#region src/pipeline-runner/frame-handle-poller.ts
195
- /**
196
- * `FrameHandlePoller` — the consumer-side poll loop of the shared-memory frame
197
- * plane (Phase 5 / D9).
198
- *
199
- * Replaces the live-object `IStreamBroker.onDecodedFrame(cb)` callback path.
200
- * A consumer (motion, detection):
201
- *
202
- * 1. `subscribeFrames({ brokerId, format, maxFps, tag })` over tRPC — the
203
- * broker spins up a `frameSink: 'shm'` decoder session for that format
204
- * and returns a `subscriptionId`;
205
- * 2. polls `pullFrameHandles({ subscriptionId, maxCount })` at the cadence
206
- * `maxFps` implies, draining zero-pixel `FrameHandle`s;
207
- * 3. feeds each handle to a `FrameRingReaderCache` which opens the named shm
208
- * segment and reads the pixels back zero-copy;
209
- * 4. on teardown, `unsubscribeFrames` + closes the cached readers.
210
- *
211
- * The loop tolerates an initially-empty stream — `pullFrameHandles` returns
212
- * `[]` until the broker has a source, and a recycled ring slot yields a `null`
213
- * pixel read (a dropped frame, the correct latest-wins behaviour).
214
- *
215
- * Foreign-handle path: a `FrameHandle` carries the `nodeId` of the node whose
216
- * shared memory holds its segment. A LOCAL handle is read back zero-copy; a
217
- * FOREIGN handle (produced on another node — e.g. a camera decoded on the hub
218
- * whose detection was placed on an agent) cannot be `shm_open`ed here. Reading
219
- * it unconditionally floods `openSegment failed shm_open ENOENT` at frame rate,
220
- * so the poll delegates every handle to `resolvePolledFrame`, which gates on
221
- * `handle.nodeId`: local → shm read; foreign → a BOUNDED, single-flight
222
- * `getRemoteFrame` (`decoder.getFrame`) fallback (≤ `maxRemoteFps`) plus a
223
- * rate-limited WARN naming the topology mismatch. `ownNodeId` + `getRemoteFrame`
224
- * are REQUIRED options — every in-repo call site supplies them, so there is no
225
- * silent default that re-creates the unconditional read.
226
- *
227
- * Boot-race tolerance: the broker for a given camStream may not be registered
228
- * yet when a consumer attempts to subscribe (the pipeline-runner spawns before
229
- * provider addons publish their cameraStreams). `subscribeFrames` retries with
230
- * exponential backoff (capped at `MAX_SUBSCRIBE_RETRY_BACKOFF_MS`) until it
231
- * succeeds or the returned teardown closure is invoked. The closure always
232
- * resolves to a function, even when the broker is missing; the function
233
- * cancels pending retries and releases any subscription acquired in flight.
234
- */
235
- /** How many handles to drain per poll — a small burst absorbs jitter. */
236
- var PULL_MAX_COUNT = 4;
237
- /** Floor on the poll period so a 0/absurd `maxFps` can't busy-spin. */
238
- var MIN_POLL_INTERVAL_MS = 20;
239
- /** Poll period when the broker reports a non-positive cadence hint. */
240
- var FALLBACK_POLL_INTERVAL_MS = 200;
241
- /**
242
- * Ceiling on the poll period — we never poll SLOWER than this even when the
243
- * subscriber's `maxFps` implies a longer interval (e.g. detection at 10 fps →
244
- * 100 ms). A decoded frame otherwise sits in the ring up to a full poll period
245
- * before it is picked, and that wait lands directly in `frameAge` (the analyzed
246
- * frame's staleness). Polling faster grabs the freshest frame sooner; because
247
- * the loop reads ONLY the newest handle per tick and the decoder emits at its
248
- * own rate, the extra ticks that find no new frame are near-free (an empty
249
- * `pullHandles` + no pixel read), so this lowers latency without a proportional
250
- * read-cost increase. `maxFps` still bounds the DELIVERED rate downstream via
251
- * the latest-wins queue — this only decouples poll cadence from it.
252
- */
253
- var MAX_POLL_INTERVAL_MS = 50;
254
- /** First subscribe-retry delay, doubled on every subsequent failure. */
255
- var INITIAL_SUBSCRIBE_RETRY_BACKOFF_MS = 250;
256
- /**
257
- * Subscribe-retry backoff ceiling. 5 s is fast enough to recover within a
258
- * single roster refresh of the consuming UI and slow enough that a
259
- * misconfigured camStream costs ~12 op/min, not 50/s.
260
- */
261
- var MAX_SUBSCRIBE_RETRY_BACKOFF_MS = 5e3;
262
- /**
263
- * The default source: the broker's shm plane over tRPC — today's path. The
264
- * methods return the underlying client promises DIRECTLY (no async wrapper)
265
- * so the poller's scheduling is microtask-identical to the pre-seam code.
266
- */
267
- function brokerFrameHandleSource(api, brokerId) {
268
- return {
269
- subscribe: (input) => api.streamBroker.subscribeFrames.mutate({
270
- brokerId,
271
- format: input.format,
272
- maxFps: input.maxFps,
273
- tag: input.tag
274
- }),
275
- pullHandles: (input) => api.streamBroker.pullFrameHandles.query({
276
- subscriptionId: input.subscriptionId,
277
- maxCount: input.maxCount
278
- }),
279
- unsubscribe: (input) => api.streamBroker.unsubscribeFrames.mutate({ subscriptionId: input.subscriptionId }).then(() => void 0)
280
- };
281
- }
282
- /**
283
- * Subscribe to a broker's shm frame stream and start the poll loop. Always
284
- * resolves to a teardown closure — when the broker is not yet registered the
285
- * closure cancels the ongoing retry loop; when polling is active it stops the
286
- * loop, releases the broker subscription, and closes every shm segment the
287
- * reader cache opened.
288
- */
289
- async function startFrameHandlePoller(options) {
290
- const lifecycle = {
291
- stopped: false,
292
- retryTimer: void 0,
293
- activeTeardown: null
294
- };
295
- const teardown = () => {
296
- if (lifecycle.stopped) return;
297
- lifecycle.stopped = true;
298
- if (lifecycle.retryTimer) {
299
- clearTimeout(lifecycle.retryTimer);
300
- lifecycle.retryTimer = void 0;
301
- }
302
- lifecycle.activeTeardown?.();
303
- };
304
- subscribeWithRetry(options, lifecycle);
305
- return teardown;
306
- }
307
- /**
308
- * Run the subscribe → poll handshake with exponential backoff on subscribe
309
- * failures. Resolves once the subscription is acquired (and the poll loop has
310
- * been started) or once `lifecycle.stopped` flips, whichever comes first.
311
- */
312
- async function subscribeWithRetry(options, lifecycle) {
313
- const { api, brokerId, format, maxFps, tag, logger } = options;
314
- const source = options.source ?? brokerFrameHandleSource(api, brokerId);
315
- let backoffMs = INITIAL_SUBSCRIBE_RETRY_BACKOFF_MS;
316
- let attempt = 0;
317
- while (!lifecycle.stopped) {
318
- attempt += 1;
319
- try {
320
- const result = await source.subscribe({
321
- format,
322
- maxFps,
323
- tag
324
- });
325
- if (lifecycle.stopped) {
326
- await source.unsubscribe({ subscriptionId: result.subscriptionId }).catch((err) => {
327
- logger.warn("frame-handle poller: late unsubscribe failed", { meta: {
328
- brokerId,
329
- subscriptionId: result.subscriptionId,
330
- error: require_dist.errMsg(err)
331
- } });
332
- });
333
- return;
334
- }
335
- lifecycle.activeTeardown = startPolling(options, source, result.subscriptionId, result.maxFps, lifecycle);
336
- return;
337
- } catch (err) {
338
- if (lifecycle.stopped) return;
339
- if (attempt === 1) logger.warn("frame-handle poller: subscribeFrames failed, retrying", { meta: {
340
- brokerId,
341
- format,
342
- tag,
343
- error: require_dist.errMsg(err),
344
- nextRetryInMs: backoffMs
345
- } });
346
- else logger.debug("frame-handle poller: subscribeFrames still failing", { meta: {
347
- brokerId,
348
- format,
349
- tag,
350
- attempt,
351
- error: require_dist.errMsg(err),
352
- nextRetryInMs: backoffMs
353
- } });
354
- await sleep(backoffMs, lifecycle);
355
- backoffMs = Math.min(MAX_SUBSCRIBE_RETRY_BACKOFF_MS, backoffMs * 2);
356
- }
357
- }
358
- }
359
- /**
360
- * Sleep `ms` milliseconds, but resolve early when `lifecycle.stopped` flips so
361
- * a teardown during a retry pause doesn't have to wait out the full backoff.
362
- */
363
- function sleep(ms, lifecycle) {
364
- return new Promise((resolve) => {
365
- lifecycle.retryTimer = setTimeout(() => {
366
- lifecycle.retryTimer = void 0;
367
- resolve();
368
- }, ms);
369
- });
370
- }
371
- /**
372
- * Run the steady-state poll loop for a successfully-acquired subscription.
373
- * Returns the teardown closure that stops the loop and releases the
374
- * subscription.
375
- */
376
- function startPolling(options, source, subscriptionId, resolvedMaxFps, lifecycle) {
377
- const { brokerId, onFrame, logger, ownNodeId, getRemoteFrame, tag } = options;
378
- const readers = new _camstack_shm_ring.FrameRingReaderCache(logger);
379
- const throttle = new ForeignHandleThrottle();
380
- const pollIntervalMs = Math.min(MAX_POLL_INTERVAL_MS, resolvedMaxFps > 0 ? Math.max(MIN_POLL_INTERVAL_MS, Math.round(1e3 / resolvedMaxFps)) : FALLBACK_POLL_INTERVAL_MS);
381
- let timer;
382
- const tick = async () => {
383
- if (lifecycle.stopped) return;
384
- try {
385
- const handles = await source.pullHandles({
386
- subscriptionId,
387
- maxCount: PULL_MAX_COUNT
388
- });
389
- for (let i = handles.length - 1; i >= 0; i -= 1) {
390
- if (lifecycle.stopped) break;
391
- const handle = handles[i];
392
- const frame = await resolvePolledFrame(handle, {
393
- ownNodeId,
394
- readers,
395
- throttle,
396
- getRemoteFrame,
397
- logger,
398
- tag
399
- });
400
- if (frame) {
401
- onFrame(frame, handle);
402
- break;
403
- }
404
- }
405
- } catch (err) {
406
- logger.warn("frame-handle poller: pullFrameHandles failed", { meta: {
407
- brokerId,
408
- subscriptionId,
409
- error: require_dist.errMsg(err)
410
- } });
411
- }
412
- if (!lifecycle.stopped) timer = setTimeout(() => void tick(), pollIntervalMs);
413
- };
414
- tick();
415
- return () => {
416
- if (timer) {
417
- clearTimeout(timer);
418
- timer = void 0;
419
- }
420
- readers.close();
421
- source.unsubscribe({ subscriptionId }).catch((err) => {
422
- logger.warn("frame-handle poller: unsubscribe failed", { meta: {
423
- brokerId,
424
- subscriptionId,
425
- error: require_dist.errMsg(err)
426
- } });
427
- });
428
- };
429
- }
430
- //#endregion
431
109
  //#region src/pipeline-runner/frame-governor.ts
432
110
  var FrameGovernor = class {
433
111
  config;
@@ -1395,8 +1073,13 @@ function wrapChild(child) {
1395
1073
  child.send(msg, () => void 0);
1396
1074
  },
1397
1075
  on: (ev, cb) => {
1398
- if (ev === "exit") child.on("exit", () => cb(void 0));
1399
- else child.on("message", (m) => cb(m));
1076
+ if (ev === "exit") {
1077
+ const toInfo = (code, signal) => ({
1078
+ code,
1079
+ signal
1080
+ });
1081
+ child.on("exit", (code, signal) => cb(toInfo(code ?? null, signal ?? null)));
1082
+ } else child.on("message", (m) => cb(m));
1400
1083
  },
1401
1084
  kill: (signal) => {
1402
1085
  if (signal === void 0) {
@@ -1459,137 +1142,141 @@ function startGuardedSessionDecode(deps) {
1459
1142
  if (release) release();
1460
1143
  };
1461
1144
  }
1462
- //#endregion
1463
- //#region src/session-decode/retention-sink.ts
1464
- /**
1465
- * Session-decode frame retention ring (Shape-2, Slice 1).
1466
- *
1467
- * The default detection path is **session-decode** (`useSessionDecode` true):
1468
- * a per-session forked decode worker hands the runner decoded rgb pixels over
1469
- * IPC — the frames never touch shared memory, so the emitted
1470
- * `PipelineInferenceResult` carried NO `FrameHandle`, and hub-side post-analysis
1471
- * (which gates ALL event/face/track crop media on a present handle) produced no
1472
- * media. This ring restores that: every decoded detection frame is copied into
1473
- * a short shared-memory retention ring on the runner's node, and the resulting
1474
- * `FrameHandle` rides the inference-result event so post-analysis can resolve
1475
- * the exact frame zero-copy and cut crops from it.
1476
- *
1477
- * ## Why a distinct prefix (`csr.`)
1478
- *
1479
- * The decoder addon runs `purgeOrphanSegments('csf.')` at startup and unlinks
1480
- * EVERY `csf.*` segment on the node. A retention ring under the `csf.` prefix
1481
- * would be reclaimed out from under a live session on every decoder redeploy.
1482
- * The retention ring therefore uses its own prefix `csr.` and its own startup
1483
- * orphan purge (`shm-retention-purge.ts`).
1484
- *
1485
- * ## Sizing
1486
- *
1487
- * Retention is ring depth: `slots ≈ detectionFps × retainSeconds`. At the
1488
- * Slice-1 ≤640-wide detection geometry a slot is ~0.69 MB, so the 32 MB default
1489
- * budget yields ~46 latest-wins slots (~4.6 s @10 fps). Budget is per active
1490
- * session; the sink is armed lazily on the first rgb frame and destroyed on
1491
- * session teardown, so idle cameras hold no shared memory.
1492
- */
1493
- /** Segment name prefix for session-decode retention rings — isolated from the
1494
- * decoder's `csf.` orphan purge so a decoder redeploy never reclaims a live
1495
- * retention ring. */
1496
- var RETENTION_SEGMENT_PREFIX = "csr.";
1497
- /** {@link RETAIN_BUDGET_MB} in bytes. */
1498
- var RETAIN_BUDGET_BYTES = (() => {
1145
+ /** {@link RETAINED_FRAME_BUDGET_MB} in bytes. */
1146
+ var RETAINED_FRAME_BUDGET_BYTES = (() => {
1499
1147
  const raw = Number(process.env["CAMSTACK_SESSION_RETAIN_MB"]);
1500
1148
  return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 32;
1501
1149
  })() * 1024 * 1024;
1502
- /**
1503
- * Create a session-decode retention ring for one device. The underlying
1504
- * shared-memory segment is created lazily on the first rgb frame.
1505
- */
1506
- function createSessionRetentionSink(options) {
1507
- const { deviceId, nodeId, logger } = options;
1508
- const sink = new _camstack_shm_ring.DecoderFrameRingSink({
1509
- seed: `retain:${deviceId}`,
1510
- logger,
1511
- nodeId,
1512
- segmentPrefix: RETENTION_SEGMENT_PREFIX,
1513
- budgetBytes: RETAIN_BUDGET_BYTES
1514
- });
1515
- return {
1516
- write(frame) {
1517
- if (frame.format !== "rgb") return null;
1518
- return sink.writeFrame(frame.data, {
1519
- width: frame.width,
1520
- height: frame.height,
1521
- format: frame.format,
1522
- pts: performance.now(),
1523
- byteLength: frame.data.byteLength
1524
- });
1525
- },
1526
- destroy() {
1527
- sink.destroy();
1528
- },
1529
- get currentSegmentName() {
1530
- return sink.currentSegmentName;
1531
- }
1532
- };
1150
+ /** Opaque RAM-key prefix stamped into a retained frame's `FrameHandle.shmId`. */
1151
+ var RETAINED_FRAME_KEY_PREFIX = "ram:";
1152
+ /** A retention frame is uniquely identified by its key + slot + committed seq. */
1153
+ function keyOf(handle) {
1154
+ return `${handle.shmId}#${handle.slot}#${handle.seq}`;
1533
1155
  }
1534
- //#endregion
1535
- //#region src/pipeline-runner/shm-retention-purge.ts
1536
1156
  /**
1537
- * Startup reclamation of orphaned session-decode retention segments.
1538
- *
1539
- * The retention ring (`retention-sink.ts`) writes decoded detection frames into
1540
- * named `/dev/shm` segments under the `csr.` prefix and unlinks each on graceful
1541
- * session teardown. When the runner process dies ungracefully (SIGBUS, OOM-kill,
1542
- * SIGKILL during redeploy) that teardown never runs and the segment is orphaned.
1543
- * Across crashes/redeploys these accumulate until `/dev/shm` fills, at which
1544
- * point the next `mmap` write faults with an uncatchable SIGBUS.
1545
- *
1546
- * This is the retention-ring analogue of the decoder addon's
1547
- * `purgeOrphanSegments('csf.')`: a fresh pipeline-runner owns no live sessions,
1548
- * so every pre-existing `csr.*` segment is by definition an orphan from a dead
1549
- * instance. It MUST use the `csr.` prefix (never `csf.`): the decoder purge owns
1550
- * `csf.` and unlinking a live retention ring would break media mid-session.
1551
- *
1552
- * `shm_unlink` only removes the name — a consumer still holding a mapping keeps
1553
- * reading valid memory until it closes (POSIX deferred reclaim + the ring
1554
- * seqlock), so unlinking is safe even if a stale reader is momentarily attached.
1555
- *
1556
- * POSIX-only: segments surface as files under `/dev/shm` on Linux. On platforms
1557
- * without that directory (Windows, macOS) the scan finds nothing — no-op.
1558
- */
1559
- /** Default tmpfs directory where POSIX shared-memory segments appear on Linux. */
1560
- var DEFAULT_SHM_DIR = "/dev/shm";
1561
- /**
1562
- * Unlink every session-decode retention segment whose name starts with
1563
- * `prefix`. Intended to run ONCE at pipeline-runner startup, before any session
1564
- * is created, to reclaim segments orphaned by a previously-crashed instance. A
1565
- * per-file unlink failure is swallowed so one stuck segment cannot block
1566
- * reclaiming the rest.
1157
+ * Node-level RAM store of retained rgb detection frames, keyed by
1158
+ * `FrameHandle` identity. Latest-wins with a byte budget.
1567
1159
  */
1568
- function purgeOrphanSegments(prefix, options = {}) {
1569
- const dir = options.dir ?? DEFAULT_SHM_DIR;
1570
- const unlink = options.unlink ?? _camstack_shm_ring.unlinkSegment;
1571
- let entries;
1572
- try {
1573
- entries = (0, node_fs.readdirSync)(dir);
1574
- } catch {
1575
- return {
1576
- scanned: 0,
1577
- removed: 0,
1578
- names: []
1160
+ var RetainedFrameStore = class RetainedFrameStore {
1161
+ nodeId;
1162
+ budgetBytes;
1163
+ /** Insertion-ordered (Map) → oldest is `keys().next()`. */
1164
+ entries = /* @__PURE__ */ new Map();
1165
+ bytes = 0;
1166
+ seq = 0;
1167
+ /** Ring width used only to spread the `slot` field; the map key is authoritative. */
1168
+ static SLOT_SPREAD = 1024;
1169
+ constructor(options) {
1170
+ this.nodeId = options.nodeId;
1171
+ this.budgetBytes = options.budgetBytes ?? RETAINED_FRAME_BUDGET_BYTES;
1172
+ }
1173
+ /** Number of retained frames (tests / metrics). */
1174
+ get size() {
1175
+ return this.entries.size;
1176
+ }
1177
+ /** Total retained bytes (tests / metrics). */
1178
+ get retainedBytes() {
1179
+ return this.bytes;
1180
+ }
1181
+ /**
1182
+ * Retain one decoded rgb frame and return its `FrameHandle`. Returns `null`
1183
+ * for a non-rgb frame (motion gray is never retained — post-analysis crops
1184
+ * from the rgb frame). The frame buffer is retained by reference: the
1185
+ * decode-worker IPC delivers a fresh Buffer per frame, so no defensive copy
1186
+ * is needed.
1187
+ */
1188
+ write(deviceId, frame) {
1189
+ if (frame.format !== "rgb") return null;
1190
+ const seq = ++this.seq;
1191
+ const byteLength = frame.data.byteLength;
1192
+ const handle = {
1193
+ shmId: `${RETAINED_FRAME_KEY_PREFIX}${this.nodeId}:${deviceId}`,
1194
+ slot: seq % RetainedFrameStore.SLOT_SPREAD,
1195
+ seq,
1196
+ width: frame.width,
1197
+ height: frame.height,
1198
+ format: "rgb",
1199
+ pts: performance.now(),
1200
+ byteLength,
1201
+ nodeId: this.nodeId,
1202
+ slotCount: RetainedFrameStore.SLOT_SPREAD
1579
1203
  };
1204
+ this.entries.set(keyOf(handle), frame);
1205
+ this.bytes += byteLength;
1206
+ this.evictToBudget();
1207
+ return handle;
1580
1208
  }
1581
- const names = [];
1582
- for (const name of entries) {
1583
- if (!name.startsWith(prefix)) continue;
1584
- try {
1585
- unlink(name);
1586
- names.push(name);
1587
- } catch {}
1209
+ /** The retained frame for `handle`, or `null` if never retained / already evicted. */
1210
+ getFrame(handle) {
1211
+ return this.entries.get(keyOf(handle)) ?? null;
1212
+ }
1213
+ /**
1214
+ * Crop (and optionally downscale) the retained rgb frame for `handle` to the
1215
+ * normalized `bbox`. A full-frame `bbox` ({x:0,y:0,w:1,h:1}) with no
1216
+ * `maxWidth` returns the whole frame. Returns `null` when the frame is no
1217
+ * longer retained. Nearest-neighbour scale — this is the best-effort
1218
+ * post-analysis fallback path; native-resolution ROI crops come from the
1219
+ * decode worker's surface (`getNativeCrop`), not here.
1220
+ */
1221
+ crop(handle, bbox, maxWidth) {
1222
+ const frame = this.entries.get(keyOf(handle));
1223
+ if (!frame || frame.format !== "rgb") return null;
1224
+ return cropRgb(frame, bbox, maxWidth);
1225
+ }
1226
+ /** Drop every retained frame (node/addon shutdown). */
1227
+ clear() {
1228
+ this.entries.clear();
1229
+ this.bytes = 0;
1230
+ }
1231
+ /** Evict oldest frames until the retained bytes fit the budget (keep ≥1). */
1232
+ evictToBudget() {
1233
+ while (this.bytes > this.budgetBytes && this.entries.size > 1) {
1234
+ const oldest = this.entries.keys().next().value;
1235
+ if (oldest === void 0) break;
1236
+ const evicted = this.entries.get(oldest);
1237
+ this.entries.delete(oldest);
1238
+ if (evicted) this.bytes -= evicted.data.byteLength;
1239
+ }
1240
+ if (this.bytes < 0) this.bytes = 0;
1241
+ }
1242
+ };
1243
+ /**
1244
+ * Nearest-neighbour crop + optional downscale of a packed RGB24 frame to a
1245
+ * normalized `bbox`. Exported for unit coverage. Clamps the bbox into the frame
1246
+ * and never allocates more than the output size.
1247
+ */
1248
+ function cropRgb(frame, bbox, maxWidth) {
1249
+ const srcW = frame.width;
1250
+ const srcH = frame.height;
1251
+ const nx = Math.min(Math.max(bbox.x, 0), 1);
1252
+ const ny = Math.min(Math.max(bbox.y, 0), 1);
1253
+ const nw = Math.min(Math.max(bbox.w, 0), 1 - nx);
1254
+ const nh = Math.min(Math.max(bbox.h, 0), 1 - ny);
1255
+ const cropX = Math.floor(nx * srcW);
1256
+ const cropY = Math.floor(ny * srcH);
1257
+ const cropW = Math.max(1, Math.round(nw * srcW));
1258
+ const cropH = Math.max(1, Math.round(nh * srcH));
1259
+ const clampedW = Math.min(cropW, srcW - cropX);
1260
+ const clampedH = Math.min(cropH, srcH - cropY);
1261
+ const outW = maxWidth && maxWidth > 0 && maxWidth < clampedW ? maxWidth : clampedW;
1262
+ const outH = outW === clampedW ? clampedH : Math.max(1, Math.round(outW * clampedH / clampedW));
1263
+ const src = frame.data;
1264
+ const out = Buffer.allocUnsafe(outW * outH * 3);
1265
+ for (let oy = 0; oy < outH; oy += 1) {
1266
+ const srcRow = (cropY + Math.min(clampedH - 1, Math.floor(oy * clampedH / outH))) * srcW * 3;
1267
+ const outRow = oy * outW * 3;
1268
+ for (let ox = 0; ox < outW; ox += 1) {
1269
+ const s = srcRow + (cropX + Math.min(clampedW - 1, Math.floor(ox * clampedW / outW))) * 3;
1270
+ const o = outRow + ox * 3;
1271
+ out[o] = src[s];
1272
+ out[o + 1] = src[s + 1];
1273
+ out[o + 2] = src[s + 2];
1274
+ }
1588
1275
  }
1589
1276
  return {
1590
- scanned: entries.length,
1591
- removed: names.length,
1592
- names
1277
+ bytes: out,
1278
+ width: outW,
1279
+ height: outH
1593
1280
  };
1594
1281
  }
1595
1282
  //#endregion
@@ -1604,6 +1291,11 @@ var SIGKILL_FALLBACK_MS = 1e3;
1604
1291
  var DEFAULT_FIRST_FRAME_TIMEOUT_MS = 2e4;
1605
1292
  /** Default timeout for every `pull` round-trip AFTER the first frame arrives. */
1606
1293
  var DEFAULT_FRAME_TIMEOUT_MS = 1e4;
1294
+ /** Narrow an 'exit' payload to {@link WorkerExitInfo} (fakes may emit undefined). */
1295
+ function toWorkerExitInfo(v) {
1296
+ const isInfo = (x) => typeof x === "object" && x !== null && "code" in x && "signal" in x;
1297
+ return isInfo(v) ? v : void 0;
1298
+ }
1607
1299
  function toError(err) {
1608
1300
  return err instanceof Error ? err : new Error(String(err));
1609
1301
  }
@@ -1624,6 +1316,8 @@ var SessionDecodeSession = class {
1624
1316
  pendingNativeCrops = /* @__PURE__ */ new Map();
1625
1317
  nextNativeCropId = 1;
1626
1318
  exited = false;
1319
+ /** Set by teardown() so handleExit can tell an intentional stop from a death. */
1320
+ stopRequested = false;
1627
1321
  sigkillTimer = null;
1628
1322
  pullTimeoutTimer = null;
1629
1323
  firstFrameReceived = false;
@@ -1633,7 +1327,7 @@ var SessionDecodeSession = class {
1633
1327
  this.firstFrameTimeoutMs = firstFrameTimeoutMs;
1634
1328
  this.frameTimeoutMs = frameTimeoutMs;
1635
1329
  this.child.on("message", (m) => this.handleMessage(m));
1636
- this.child.on("exit", () => this.handleExit());
1330
+ this.child.on("exit", (m) => this.handleExit(toWorkerExitInfo(m)));
1637
1331
  }
1638
1332
  start(source, opts) {
1639
1333
  this.trySend({
@@ -1725,6 +1419,7 @@ var SessionDecodeSession = class {
1725
1419
  }
1726
1420
  /** Teardown: `stop`, `kill()`, arm the SIGKILL fallback unless already exited. */
1727
1421
  teardown() {
1422
+ this.stopRequested = true;
1728
1423
  this.clearPullTimeout();
1729
1424
  this.trySend({ kind: "stop" });
1730
1425
  this.tryKill(void 0);
@@ -1770,7 +1465,11 @@ var SessionDecodeSession = class {
1770
1465
  return;
1771
1466
  }
1772
1467
  }
1773
- handleExit() {
1468
+ handleExit(info) {
1469
+ if (!this.stopRequested && !this.exited) this.logger.warn("session-decode worker exited unexpectedly", { meta: {
1470
+ code: info?.code ?? null,
1471
+ signal: info?.signal ?? null
1472
+ } });
1774
1473
  this.exited = true;
1775
1474
  if (this.sigkillTimer) {
1776
1475
  clearTimeout(this.sigkillTimer);
@@ -1967,19 +1666,6 @@ function createSessionDecodeCoordinator(deps) {
1967
1666
  //#endregion
1968
1667
  //#region src/session-decode/session-decode-pump.ts
1969
1668
  /**
1970
- * Pure gate: does this runner route camera decode through a per-session
1971
- * decode worker (Epic C) instead of the shm frame-handle poller? This is now
1972
- * a RUNNER-LEVEL setting (the pipeline-runner addon's own config,
1973
- * `this.config.useSessionDecode`) rather than a per-camera one — every
1974
- * camera attached to this node shares the same decode path. DEFAULT TRUE:
1975
- * the caller passes the runner's own `useSessionDecode` config value;
1976
- * an operator unchecks it (node-wide) to fall back to the shared shm
1977
- * frame-handle poller for every camera on this node.
1978
- */
1979
- function shouldUseSessionDecode(enabled) {
1980
- return enabled === true;
1981
- }
1982
- /**
1983
1669
  * Detection working width cap — mirrors the shm decoder's
1984
1670
  * `initScaler` (`nodeav-decoder-session.ts`: `maxW = floor(640 / scale)`,
1985
1671
  * `scale = 1` default). The worker decodes full source resolution (e.g. a 4K
@@ -2097,6 +1783,7 @@ function startSessionDecodePump(deps) {
2097
1783
  });
2098
1784
  } finally {
2099
1785
  releaseSpawnSlotOnce();
1786
+ if (!stopped) deps.onEnded?.();
2100
1787
  }
2101
1788
  };
2102
1789
  pump();
@@ -2210,10 +1897,13 @@ function denormalizeRect(rect, frameWidth, frameHeight) {
2210
1897
  * Walk `steps` (the attach config's tree) collecting every ENABLED step
2211
1898
  * whose catalog definition's `inputClasses` includes `parentClassName` —
2212
1899
  * these become the subtree's roots. A matched step's own nested `children`
2213
- * ride along UNCHANGED (transitively included: `face-detection` pulls
1900
+ * ride along UNCHANGED here (transitively included: `face-detection` pulls
2214
1901
  * `face-embedding`, `plate-detection` pulls `plate-ocr`) — recursion stops
2215
1902
  * at a match rather than also searching inside it for further matches.
2216
- * `filter`, when given, keeps only matches whose `addonId` is in the list.
1903
+ * `filter`, when given, keeps only the TOP-LEVEL matches whose `addonId` is
1904
+ * in the list — it does NOT prune a match's descendants. Strict per-`steps`
1905
+ * pruning of the descendant chain happens later in {@link runDetailSubtree}
1906
+ * (see {@link pruneChildStepsToRequested}); this function only selects roots.
2217
1907
  */
2218
1908
  function findMatchingDetailSteps(steps, parentClassName, lookupStepDefinition, filter) {
2219
1909
  const matches = [];
@@ -2231,6 +1921,26 @@ function findMatchingDetailSteps(steps, parentClassName, lookupStepDefinition, f
2231
1921
  walk(steps);
2232
1922
  return matches;
2233
1923
  }
1924
+ /**
1925
+ * Prune a matched step's descendant tree to the caller's requested `steps`
1926
+ * set: keep a child ONLY when its `addonId` is in `requested`, recursing into
1927
+ * every kept child. Pure — returns a new step, never mutates the input.
1928
+ *
1929
+ * Applied ONLY on the DETAIL plane (see {@link runDetailSubtree}); the
1930
+ * per-frame executor path is untouched. This is what makes the dispatcher's
1931
+ * `steps` array authoritative: `['face-detection']` runs the detector geometry
1932
+ * ALONE (the `face-embedding` child is dropped — no ArcFace inference), while
1933
+ * `['face-detection','face-embedding']` keeps the child and reproduces the
1934
+ * full detect→embed chain exactly.
1935
+ */
1936
+ function pruneChildStepsToRequested(step, requested) {
1937
+ if (!step.children || step.children.length === 0) return step;
1938
+ const children = step.children.filter((child) => requested.includes(child.addonId)).map((child) => pruneChildStepsToRequested(child, requested));
1939
+ return {
1940
+ ...step,
1941
+ children
1942
+ };
1943
+ }
2234
1944
  /** Map a detection's CROP-pixel bbox back to FRAME-space pixels. */
2235
1945
  function mapBboxToFrameSpace(bbox, crop) {
2236
1946
  const scaleX = crop.frameSpace.w / crop.width;
@@ -2270,8 +1980,8 @@ async function resolveCrop(deps, input) {
2270
1980
  }
2271
1981
  if (input.cropJpeg) {
2272
1982
  const jpeg = Buffer.from(input.cropJpeg, "base64");
2273
- const sharp = (await import("sharp")).default;
2274
- const meta = await sharp(jpeg).metadata();
1983
+ const sharp$4 = (await import("sharp")).default;
1984
+ const meta = await sharp$4(jpeg).metadata();
2275
1985
  const width = meta.width ?? 0;
2276
1986
  const height = meta.height ?? 0;
2277
1987
  if (width <= 0 || height <= 0) return null;
@@ -2290,10 +2000,21 @@ async function resolveCrop(deps, input) {
2290
2000
  * step's chain against it, and map every result bbox back to frame space.
2291
2001
  * Returns `null` when the camera isn't attached, no configured child
2292
2002
  * matches `input.parent.className`, or no crop source resolves.
2003
+ *
2004
+ * STRICT `steps` semantics: when the caller names an explicit `input.steps`
2005
+ * set, each matched root's descendant chain is pruned to exactly those ids
2006
+ * (see {@link pruneChildStepsToRequested}) — `resolveChildSteps`/`filter`
2007
+ * only selects TOP-LEVEL roots, so without this a matched `face-detection`
2008
+ * would always drag its `face-embedding` child along regardless of what was
2009
+ * asked for. When `input.steps` is omitted, the full configured chain runs
2010
+ * (today's behavior). This is the knob the dispatcher uses to run a periodic
2011
+ * geometry-only face refresh (`['face-detection']`) without re-running ArcFace.
2293
2012
  */
2294
2013
  async function runDetailSubtree(deps, input) {
2295
- const steps = deps.resolveChildSteps(input.deviceId, input.parent.className, input.steps);
2296
- if (!steps || steps.length === 0) return null;
2014
+ const matched = deps.resolveChildSteps(input.deviceId, input.parent.className, input.steps);
2015
+ if (!matched || matched.length === 0) return null;
2016
+ const requested = input.steps;
2017
+ const steps = requested === void 0 ? matched : matched.map((step) => pruneChildStepsToRequested(step, requested));
2297
2018
  const crop = await resolveCrop(deps, input);
2298
2019
  if (!crop) return null;
2299
2020
  const details = [];
@@ -2311,7 +2032,6 @@ var DEFAULT_CONFIG = {
2311
2032
  maxConcurrentInferences: 16,
2312
2033
  targetLoadPercent: 80,
2313
2034
  minThrottledFps: 1,
2314
- useSessionDecode: true,
2315
2035
  maxConcurrentDecodeSpawns: 3
2316
2036
  };
2317
2037
  /**
@@ -2327,6 +2047,11 @@ var DEFAULT_CONFIG = {
2327
2047
  * cameras produced when every gate open/close forked and killed a worker.
2328
2048
  * Mirrors Scrypted's decode-fork ~15s idle-timeout.
2329
2049
  */
2050
+ /** JPEG quality for the runner→executor detection-frame hop. q82 keeps
2051
+ * detector-relevant detail (source is h264 anyway) at ~10-15× less payload
2052
+ * than raw RGB — the hop transits the hub main process (UDS child↔child
2053
+ * routing), which OOM'd under raw frames. */
2054
+ var DETECTION_HOP_JPEG_QUALITY = 82;
2330
2055
  var DETECTION_KEEPWARM_MS = 15e3;
2331
2056
  /**
2332
2057
  * Occupancy-burst safety timeout (both the dial path and the analyzer-tap
@@ -2345,6 +2070,14 @@ var OCCUPANCY_BURST_SAFETY_TIMEOUT_MS = 8e3;
2345
2070
  * to have been teed within this window before it will route to the tap path.
2346
2071
  */
2347
2072
  var MOTION_FRESHNESS_WINDOW_MS = 5e3;
2073
+ /** Delay before re-dialing a detection decode whose worker died unexpectedly. */
2074
+ var DETECTION_REDIAL_DELAY_MS = 2e3;
2075
+ /**
2076
+ * Two unexpected decode deaths within this window → stop re-dialing and detach
2077
+ * (the orchestrator re-dispatches on the next motion signal). Bounds the loop
2078
+ * when the camera/source itself is broken.
2079
+ */
2080
+ var DETECTION_REDIAL_GIVEUP_WINDOW_MS = 6e4;
2348
2081
  /**
2349
2082
  * Pure decision helper: returns `true` when a dynamic frame-diff analyzer
2350
2083
  * subscription should be opened for an onboard-motion camera.
@@ -2486,7 +2219,7 @@ function resolveMotionFrameSource(config, sharedWithDetection) {
2486
2219
  */
2487
2220
  function resolveSessionDecodeHostname(config, hubUrl) {
2488
2221
  const frameSource = config.frameSource;
2489
- if (!require_remote_source_plane.isRemoteRestream(frameSource)) return void 0;
2222
+ if (!require_remote_restream.isRemoteRestream(frameSource)) return void 0;
2490
2223
  return resolveOwnerRestreamHost(frameSource, hubUrl);
2491
2224
  }
2492
2225
  /**
@@ -2497,7 +2230,7 @@ function resolveSessionDecodeHostname(config, hubUrl) {
2497
2230
  */
2498
2231
  function resolveSessionDecodeOwnerNodeId(config) {
2499
2232
  const frameSource = config.frameSource;
2500
- if (!require_remote_source_plane.isRemoteRestream(frameSource)) return void 0;
2233
+ if (!require_remote_restream.isRemoteRestream(frameSource)) return void 0;
2501
2234
  return frameSource.ownerNodeId;
2502
2235
  }
2503
2236
  /**
@@ -2533,7 +2266,7 @@ function resolveOwnerRestreamHost(frameSource, hubUrl) {
2533
2266
  async function acquireSessionDecodeRestreamFrom(api, logger, params) {
2534
2267
  const { deviceId, streamId, tag, hostname, ownerNodeId } = params;
2535
2268
  const pin = ownerNodeId !== void 0 ? require_dist.nodePin(ownerNodeId) : void 0;
2536
- const profile = require_remote_source_plane.profileForStreamId(await api.streamBroker.listAllProfileSlots.query(void 0, pin), deviceId, streamId);
2269
+ const profile = require_remote_restream.profileForStreamId(await api.streamBroker.listAllProfileSlots.query(void 0, pin), deviceId, streamId);
2537
2270
  if (profile === null) {
2538
2271
  logger.error("session-decode: no broker profile feeds camStream — cannot acquire restream", {
2539
2272
  tags: { deviceId },
@@ -2608,6 +2341,15 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
2608
2341
  * to the detection-frame crop). See {@link NativeCropRegistry}.
2609
2342
  */
2610
2343
  nativeCropRegistry = new NativeCropRegistry();
2344
+ /**
2345
+ * Node-level RAM store of retained rgb detection frames, keyed by the
2346
+ * `FrameHandle` that rides the inference-result event (shm-ring removal
2347
+ * 2026-07-16 — replaces the `csr.` shm retention ring). Backs the
2348
+ * FULL-FRAME branch of `getNativeCrop` so hub-side post-analysis resolves a
2349
+ * frame's pixels over tRPC (routed by `handle.nodeId`) with no `/dev/shm`.
2350
+ * Initialised in {@link onInitialize} once `nodeId` is known.
2351
+ */
2352
+ retainedFrames = null;
2611
2353
  attached = /* @__PURE__ */ new Map();
2612
2354
  nodeId = "unknown";
2613
2355
  metricsSnapshotTimer = null;
@@ -2644,7 +2386,6 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
2644
2386
  * payloads (`local-broker` / absent) — the map stays empty and every code
2645
2387
  * path is identical to pre-Phase-2. Torn down on detach / shutdown.
2646
2388
  */
2647
- remotePlanes = /* @__PURE__ */ new Map();
2648
2389
  /**
2649
2390
  * Devices whose motion currently rides the detection session-decode worker
2650
2391
  * (P4 co-active sharing) instead of a dedicated motion worker. Membership →
@@ -2704,8 +2445,8 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
2704
2445
  occupancyMaxConcurrent = 3;
2705
2446
  /**
2706
2447
  * TAP-based occupancy bursts in flight, keyed by deviceId (Task 10). See
2707
- * {@link OccupancyTapState}. A device has an entry here XOR is dialing via
2708
- * `startFrameHandlePoller` in `runOccupancyBurst` — never both; the choice
2448
+ * {@link OccupancyTapState}. A device has an entry here XOR is dialing a
2449
+ * session-decode burst in `runOccupancyBurst` — never both; the choice
2709
2450
  * is made once at burst start based on `attachment.motionUnsubscribe`.
2710
2451
  */
2711
2452
  occupancyTaps = /* @__PURE__ */ new Map();
@@ -2725,16 +2466,10 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
2725
2466
  async onInitialize() {
2726
2467
  const raw = this.ctx.kernel.localNodeId ?? this.ctx.id;
2727
2468
  this.nodeId = raw.includes("/") ? raw.split("/")[0] : raw;
2728
- try {
2729
- const purged = purgeOrphanSegments(RETENTION_SEGMENT_PREFIX);
2730
- if (purged.removed > 0) this.ctx.logger.info("session-decode retention: reclaimed orphaned shm segments", { meta: {
2731
- removed: purged.removed,
2732
- scanned: purged.scanned,
2733
- prefix: RETENTION_SEGMENT_PREFIX
2734
- } });
2735
- } catch (err) {
2736
- this.ctx.logger.warn("session-decode retention: orphan purge failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
2737
- }
2469
+ this.retainedFrames = new RetainedFrameStore({
2470
+ nodeId: this.nodeId,
2471
+ logger: this.ctx.logger.child("retained-frames")
2472
+ });
2738
2473
  this.runner = new PipelineRunner({
2739
2474
  maxQueueDepth: this.config.maxQueueDepth,
2740
2475
  maxConcurrentInferences: this.config.maxConcurrentInferences,
@@ -2807,9 +2542,6 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
2807
2542
  }
2808
2543
  this.lastAnalyzerDetected.clear();
2809
2544
  for (const deviceId of [...this.onboardAnalyzerTeardownTimers.keys(), ...this.onboardAnalyzerSubs.keys()]) this.clearOnboardAnalyzer(deviceId);
2810
- const remotePlanes = [...this.remotePlanes.values()];
2811
- this.remotePlanes.clear();
2812
- await Promise.all(remotePlanes.map((plane) => plane.dispose().catch(() => {})));
2813
2545
  if (this.runner) {
2814
2546
  this.runner.stop();
2815
2547
  this.runner = null;
@@ -2824,10 +2556,13 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
2824
2556
  }
2825
2557
  this.attached.clear();
2826
2558
  this.lastMotionFrameAt.clear();
2559
+ this.nativeCropRegistry.clear();
2560
+ this.retainedFrames?.clear();
2561
+ this.retainedFrames = null;
2827
2562
  }
2828
2563
  async cacheBenchFrame(input) {
2829
- const sharp = (await import("sharp")).default;
2830
- const { data, info } = await sharp(Buffer.from(input.imageBase64, "base64")).raw().toBuffer({ resolveWithObject: true });
2564
+ const sharp$2 = (await import("sharp")).default;
2565
+ const { data, info } = await sharp$2(Buffer.from(input.imageBase64, "base64")).raw().toBuffer({ resolveWithObject: true });
2831
2566
  if (info.channels !== 3) throw new Error(`cacheBenchFrame: expected 3 channels (rgb), got ${info.channels}`);
2832
2567
  const rgb = new Uint8Array(data);
2833
2568
  const ttlMs = Math.max(6e4, (input.ttlSeconds ?? 600) * 1e3);
@@ -3214,13 +2949,23 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
3214
2949
  * worker downloads ONLY the ROI — a full native frame never travels the wire.
3215
2950
  */
3216
2951
  async getNativeCrop(input) {
3217
- const requester = this.nativeCropRegistry.get(input.handle);
3218
- if (!requester) return null;
3219
- const result = await requester(input.bbox, input.maxWidth);
3220
- return result ? {
3221
- bytes: result.bytes,
3222
- width: result.width,
3223
- height: result.height
2952
+ const b = input.bbox;
2953
+ if (!(b.x <= .001 && b.y <= .001 && b.w >= .999 && b.h >= .999)) {
2954
+ const requester = this.nativeCropRegistry.get(input.handle);
2955
+ if (requester) {
2956
+ const result = await requester(input.bbox, input.maxWidth);
2957
+ if (result) return {
2958
+ bytes: result.bytes,
2959
+ width: result.width,
2960
+ height: result.height
2961
+ };
2962
+ }
2963
+ }
2964
+ const crop = this.retainedFrames?.crop(input.handle, input.bbox, input.maxWidth);
2965
+ return crop ? {
2966
+ bytes: crop.bytes,
2967
+ width: crop.width,
2968
+ height: crop.height
3224
2969
  } : null;
3225
2970
  }
3226
2971
  /**
@@ -3264,9 +3009,9 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
3264
3009
  bbox: paddedBbox
3265
3010
  });
3266
3011
  if (!native) return null;
3267
- const sharp = (await import("sharp")).default;
3012
+ const sharp$3 = (await import("sharp")).default;
3268
3013
  return {
3269
- jpeg: await sharp(Buffer.from(native.bytes), { raw: {
3014
+ jpeg: await sharp$3(Buffer.from(native.bytes), { raw: {
3270
3015
  width: native.width,
3271
3016
  height: native.height,
3272
3017
  channels: 3
@@ -3295,6 +3040,52 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
3295
3040
  return null;
3296
3041
  }
3297
3042
  }
3043
+ /** Last unexpected detection-decode end per device — bounds the re-dial. */
3044
+ lastDecodeUnexpectedEndAt = /* @__PURE__ */ new Map();
3045
+ /**
3046
+ * The detection decode pump ended WITHOUT a teardown (worker death /
3047
+ * generator error) while the camera may still be attached. Previously this
3048
+ * zombied the session — phase stayed `active`, frames stopped, and nothing
3049
+ * re-dialed (the pre-fix Channel-closed runner crash used to mask it by
3050
+ * respawning the whole runner). Recovery, bounded:
3051
+ * - first death: clean up the dead subscription and re-dial once (2s);
3052
+ * - second death within 60s: give up re-dialing and DETACH — the
3053
+ * orchestrator re-dispatches cleanly on the next motion/occupancy signal
3054
+ * (the same recovery path validated manually on device 592).
3055
+ */
3056
+ handleDetectionDecodeEnded(deviceId) {
3057
+ const attachment = this.attached.get(deviceId);
3058
+ if (!attachment) return;
3059
+ const log = this.ctx.logger.withTags({ deviceId });
3060
+ attachment.detectionUnsubscribe?.();
3061
+ attachment.detectionUnsubscribe = null;
3062
+ if (attachment.detectionKeepWarmTimer !== null) {
3063
+ clearTimeout(attachment.detectionKeepWarmTimer);
3064
+ attachment.detectionKeepWarmTimer = null;
3065
+ log.warn("detection decode worker died while kept warm — next open re-forks cold");
3066
+ return;
3067
+ }
3068
+ const now = Date.now();
3069
+ const last = this.lastDecodeUnexpectedEndAt.get(deviceId) ?? 0;
3070
+ this.lastDecodeUnexpectedEndAt.set(deviceId, now);
3071
+ if (now - last < DETECTION_REDIAL_GIVEUP_WINDOW_MS) {
3072
+ log.warn("detection decode died twice within the re-dial window — detaching for orchestrator re-dispatch");
3073
+ this.detachInternal(deviceId);
3074
+ return;
3075
+ }
3076
+ log.warn("detection decode ended unexpectedly while attached — re-dialing once");
3077
+ setTimeout(() => {
3078
+ const still = this.attached.get(deviceId);
3079
+ if (!still || still.detectionUnsubscribe !== null) return;
3080
+ this.subscribeDetectionFrames(still.config).then((unsub) => {
3081
+ if (!this.attached.has(deviceId)) {
3082
+ unsub?.();
3083
+ return;
3084
+ }
3085
+ still.detectionUnsubscribe = unsub;
3086
+ });
3087
+ }, DETECTION_REDIAL_DELAY_MS).unref?.();
3088
+ }
3298
3089
  detachInternal(deviceId) {
3299
3090
  const attachment = this.attached.get(deviceId);
3300
3091
  if (!attachment) return;
@@ -3306,16 +3097,7 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
3306
3097
  attachment.motionUnsubscribe?.();
3307
3098
  attachment.detectionUnsubscribe?.();
3308
3099
  this.lastMotionFrameAt.delete(deviceId);
3309
- const remotePlane = this.remotePlanes.get(deviceId);
3310
- if (remotePlane) {
3311
- this.remotePlanes.delete(deviceId);
3312
- remotePlane.dispose().catch((err) => {
3313
- this.ctx?.logger.debug("remote-source plane dispose failed on detach", {
3314
- tags: { deviceId },
3315
- meta: { error: require_dist.errMsg(err) }
3316
- });
3317
- });
3318
- }
3100
+ this.lastDecodeUnexpectedEndAt.delete(deviceId);
3319
3101
  this.attached.delete(deviceId);
3320
3102
  this.lastMotionAt.delete(deviceId);
3321
3103
  this.lastEmittedCameraMetrics.delete(deviceId);
@@ -3433,65 +3215,6 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
3433
3215
  return [...this.attached.keys()];
3434
3216
  }
3435
3217
  /**
3436
- * Cross-node frame fetch for FOREIGN handles (segment on another node). Wired
3437
- * into every `startFrameHandlePoller` call so `resolvePolledFrame` can take
3438
- * the bounded `decoder.getFrame` fallback instead of an unmappable local shm
3439
- * read. The `nodeId` selector routes the call to the producing node (the
3440
- * cap's `nodeIdMode: 'routing'` default; agent→hub via `$hub-cap-fwd`). The
3441
- * wire schema returns `data: Uint8Array`; downstream inference expects a Node
3442
- * `Buffer`, so we adapt it here exactly as post-analysis does.
3443
- */
3444
- async getRemoteFrame(handle) {
3445
- const api = this.ctx.api;
3446
- if (!api) return null;
3447
- const remote = await api.decoder.getFrame.query({
3448
- handle,
3449
- nodeId: handle.nodeId
3450
- });
3451
- if (!remote) return null;
3452
- return {
3453
- data: Buffer.from(remote.data),
3454
- width: remote.width,
3455
- height: remote.height,
3456
- format: remote.format,
3457
- timestamp: remote.timestamp
3458
- };
3459
- }
3460
- /**
3461
- * The remote-source leg for a camera, or `null` for today's co-located
3462
- * broker path (P2d). Created lazily on the FIRST subscription of a
3463
- * `remote-restream` camera and shared by its motion + detection pollers;
3464
- * `detachInternal` / `onShutdown` dispose it. With rollout OFF every
3465
- * attach payload is `local-broker` (or absent) → always `null` → the
3466
- * subscribe paths below are byte-identical to pre-Phase-2.
3467
- */
3468
- remoteSourceFor(config) {
3469
- const frameSource = config.frameSource;
3470
- if (!require_remote_source_plane.isRemoteRestream(frameSource)) return null;
3471
- const existing = this.remotePlanes.get(config.deviceId);
3472
- if (existing) return existing;
3473
- const api = this.ctx.api;
3474
- if (!api) return null;
3475
- const hostname = resolveOwnerRestreamHost(frameSource, process.env["CAMSTACK_HUB_URL"]);
3476
- const plane = new require_remote_source_plane.RemoteSourcePlane({
3477
- api,
3478
- logger: this.ctx.logger,
3479
- deviceId: config.deviceId,
3480
- ownNodeId: this.nodeId,
3481
- ownerNodeId: frameSource.ownerNodeId,
3482
- hostname
3483
- });
3484
- this.ctx.logger.info("remote-source leg armed for camera", {
3485
- tags: { deviceId: config.deviceId },
3486
- meta: {
3487
- ownerNodeId: frameSource.ownerNodeId,
3488
- hostname: hostname ?? "(unresolved — acquires will fail until set)"
3489
- }
3490
- });
3491
- this.remotePlanes.set(config.deviceId, plane);
3492
- return plane;
3493
- }
3494
- /**
3495
3218
  * Thin delegator to the module-level {@link acquireSessionDecodeRestreamFrom}
3496
3219
  * (see its doc for the acquire behavior) — supplies this addon's `ctx.logger`.
3497
3220
  * Kept as a method so the two call sites need no change.
@@ -3593,11 +3316,7 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
3593
3316
  const runner = this.runner;
3594
3317
  const log = this.ctx.logger.withTags({ deviceId: config.deviceId });
3595
3318
  if (!runner) return () => {};
3596
- const retentionSink = createSessionRetentionSink({
3597
- deviceId: config.deviceId,
3598
- nodeId: this.nodeId,
3599
- logger: log
3600
- });
3319
+ const retainedFrames = this.retainedFrames;
3601
3320
  const teardown = startGuardedSessionDecode({
3602
3321
  logger: log,
3603
3322
  acquire: () => this.acquireSessionDecodeRestream(api, config.deviceId, config.detectionStreamId, `session-decode:detect:${config.deviceId}`, resolveSessionDecodeHostname(config, process.env["CAMSTACK_HUB_URL"]), resolveSessionDecodeOwnerNodeId(config)),
@@ -3615,8 +3334,9 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
3615
3334
  format: "rgb"
3616
3335
  },
3617
3336
  acquireSpawnSlot: this.buildDecodeSpawnAcquire(log, "detect"),
3337
+ onEnded: () => this.handleDetectionDecodeEnded(config.deviceId),
3618
3338
  onDecodedFrame: (frame, nativeCrop) => {
3619
- const handle = retentionSink.write(frame) ?? void 0;
3339
+ const handle = retainedFrames?.write(config.deviceId, frame) ?? void 0;
3620
3340
  if (handle && nativeCrop) this.nativeCropRegistry.register(handle, nativeCrop);
3621
3341
  this.enqueueSharedDetectionFrame(config.deviceId, frame, handle);
3622
3342
  }
@@ -3625,7 +3345,6 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
3625
3345
  });
3626
3346
  return () => {
3627
3347
  teardown();
3628
- retentionSink.destroy();
3629
3348
  };
3630
3349
  }
3631
3350
  /**
@@ -3691,30 +3410,8 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
3691
3410
  log.warn("subscribeMotionFrames: this.ctx.api not available");
3692
3411
  return null;
3693
3412
  }
3694
- if (shouldUseSessionDecode(this.config.useSessionDecode)) {
3695
- if (resolveMotionFrameSource(config, opts?.sharedWithDetection === true).shared) return this.registerSharedMotionSink(config.deviceId, config.motionFps);
3696
- return this.startMotionSessionDecode(config, api);
3697
- }
3698
- const src = resolveMotionFrameSource(config, opts?.sharedWithDetection === true);
3699
- const shared = src.shared;
3700
- const streamId = src.streamId;
3701
- const remote = this.remoteSourceFor(config);
3702
- return startFrameHandlePoller({
3703
- api,
3704
- brokerId: require_dist.makeSourceBrokerId(config.deviceId, streamId),
3705
- format: src.format,
3706
- maxFps: config.motionFps,
3707
- tag: shared ? "motion-shared" : "motion",
3708
- logger: log,
3709
- ownNodeId: this.nodeId,
3710
- getRemoteFrame: (handle) => this.getRemoteFrame(handle),
3711
- ...remote ? { source: remote.makeSource(streamId) } : {},
3712
- onFrame: shared ? (frame) => {
3713
- runner.enqueueMotionFrame(config.deviceId, rgbFrameToGrayFrame(frame), void 0);
3714
- } : (frame, handle) => {
3715
- runner.enqueueMotionFrame(config.deviceId, frame, handle);
3716
- }
3717
- });
3413
+ if (resolveMotionFrameSource(config, opts?.sharedWithDetection === true).shared) return this.registerSharedMotionSink(config.deviceId, config.motionFps);
3414
+ return this.startMotionSessionDecode(config, api);
3718
3415
  }
3719
3416
  handleDetectionStreamChange(deviceId, action) {
3720
3417
  const attachment = this.attached.get(deviceId);
@@ -3802,24 +3499,9 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
3802
3499
  log.warn("subscribeDetectionFrames: this.ctx.api not available");
3803
3500
  return null;
3804
3501
  }
3805
- if (shouldUseSessionDecode(this.config.useSessionDecode)) return this.startDetectionSessionDecode(config, api);
3806
- const remote = this.remoteSourceFor(config);
3807
- return startFrameHandlePoller({
3808
- api,
3809
- brokerId: require_dist.makeSourceBrokerId(config.deviceId, config.detectionStreamId),
3810
- format: "rgb",
3811
- maxFps: config.detectionFps,
3812
- tag: "detection",
3813
- logger: log,
3814
- ownNodeId: this.nodeId,
3815
- getRemoteFrame: (handle) => this.getRemoteFrame(handle),
3816
- ...remote ? { source: remote.makeSource(config.detectionStreamId) } : {},
3817
- onFrame: (frame, handle) => {
3818
- runner.enqueueDetectionFrame(config.deviceId, frame, handle);
3819
- }
3820
- });
3502
+ return this.startDetectionSessionDecode(config, api);
3821
3503
  }
3822
- async runInference(deviceId, frame, handle) {
3504
+ async runInference(deviceId, frame, _handle) {
3823
3505
  if (!this.ctx) return null;
3824
3506
  const log = this.ctx.logger.withTags({ deviceId });
3825
3507
  const api = this.ctx.api;
@@ -3833,11 +3515,20 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
3833
3515
  return null;
3834
3516
  }
3835
3517
  if (steps.length === 0) return null;
3836
- const useHandle = handle !== void 0 && handle.nodeId === this.nodeId && handle.width === frame.width && handle.height === frame.height;
3518
+ let image;
3519
+ if (frame.format === "rgb") try {
3520
+ image = await (0, sharp.default)(frame.data, { raw: {
3521
+ width: frame.width,
3522
+ height: frame.height,
3523
+ channels: 3
3524
+ } }).jpeg({ quality: DETECTION_HOP_JPEG_QUALITY }).toBuffer();
3525
+ } catch (err) {
3526
+ log.debug("runInference: frame JPEG encode failed — falling back to raw pixels", { meta: { error: require_dist.errMsg(err) } });
3527
+ }
3837
3528
  try {
3838
3529
  return await api.pipelineExecutor.runPipeline.mutate({
3839
3530
  steps: [...steps],
3840
- ...useHandle ? { frameHandle: handle } : { frame },
3531
+ ...image !== void 0 ? { image } : { frame },
3841
3532
  deviceId,
3842
3533
  plane: "frame"
3843
3534
  });
@@ -3861,10 +3552,9 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
3861
3552
  log.warn("runMotionAnalysis: this.ctx.api not available");
3862
3553
  return;
3863
3554
  }
3864
- const useHandle = handle !== void 0 && handle.nodeId === this.nodeId;
3865
3555
  const result = await api.motionDetection.analyze.mutate({
3866
3556
  deviceId,
3867
- ...useHandle ? { frameHandle: handle } : { frame: toFrameInput(frame) }
3557
+ frame: toFrameInput(frame)
3868
3558
  });
3869
3559
  if (!result) return;
3870
3560
  const detected = result.regions.length > 0;
@@ -4082,9 +3772,9 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
4082
3772
  await this.runOccupancyDial(deviceId, frames, attachment, runner, api, log, false);
4083
3773
  }
4084
3774
  /**
4085
- * Dial-path occupancy burst: subscribes a fresh main-stream decode session
4086
- * via `startFrameHandlePoller` and collects `frames` handles (or times out
4087
- * at {@link OCCUPANCY_BURST_SAFETY_TIMEOUT_MS}). Called either directly
3775
+ * Dial-path occupancy burst: spins up a fresh per-session decode worker
3776
+ * (session-decode) over the main stream and collects `frames` decoded frames
3777
+ * (or times out at {@link OCCUPANCY_BURST_SAFETY_TIMEOUT_MS}). Called either directly
4088
3778
  * from `runOccupancyBurst` (no live/fresh motion tap available,
4089
3779
  * `alreadyInFlight: false`) or as the Task 10 hardening #2 fallback after
4090
3780
  * a zero-collected tap (`alreadyInFlight: true` — the tap already claimed
@@ -4093,6 +3783,7 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
4093
3783
  */
4094
3784
  async runOccupancyDial(deviceId, frames, attachment, runner, api, log, alreadyInFlight) {
4095
3785
  const config = attachment.config;
3786
+ const retainedFrames = this.retainedFrames;
4096
3787
  if (!alreadyInFlight) {
4097
3788
  this.occupancyBurstInFlight.add(deviceId);
4098
3789
  this.occupancyBurstFound.delete(deviceId);
@@ -4102,37 +3793,35 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
4102
3793
  fallback: alreadyInFlight
4103
3794
  } });
4104
3795
  let count = 0;
4105
- let unsubscribe = null;
4106
- let unsubbed = false;
4107
3796
  let settled = false;
4108
- const doUnsub = () => {
4109
- if (!unsubbed && unsubscribe) {
4110
- unsubbed = true;
4111
- unsubscribe();
4112
- }
4113
- };
3797
+ let teardown = null;
4114
3798
  const cleanup = () => {
4115
3799
  if (settled) return;
4116
3800
  settled = true;
4117
- doUnsub();
3801
+ teardown?.();
4118
3802
  this.finishOccupancyBurst(deviceId, log, count);
4119
3803
  };
4120
3804
  const safetyTimer = setTimeout(() => {
4121
3805
  log.debug("occupancy re-check: safety timeout reached", { meta: { collected: count } });
4122
3806
  cleanup();
4123
3807
  }, OCCUPANCY_BURST_SAFETY_TIMEOUT_MS);
4124
- try {
4125
- unsubscribe = await startFrameHandlePoller({
4126
- api,
4127
- brokerId: require_dist.makeSourceBrokerId(config.deviceId, config.detectionStreamId),
4128
- format: "rgb",
4129
- maxFps: config.detectionFps,
4130
- tag: "occupancy",
3808
+ teardown = startGuardedSessionDecode({
3809
+ logger: log,
3810
+ acquire: () => this.acquireSessionDecodeRestream(api, config.deviceId, config.detectionStreamId, `session-decode:occupancy:${config.deviceId}`, resolveSessionDecodeHostname(config, process.env["CAMSTACK_HUB_URL"]), resolveSessionDecodeOwnerNodeId(config)),
3811
+ startPump: (source) => startSessionDecodePump({
3812
+ fork: forkDecodeWorker,
4131
3813
  logger: log,
4132
- ownNodeId: this.nodeId,
4133
- getRemoteFrame: (handle) => this.getRemoteFrame(handle),
4134
- onFrame: (frame, handle) => {
3814
+ source,
3815
+ opts: {
3816
+ fps: config.detectionFps,
3817
+ format: "rgb"
3818
+ },
3819
+ acquireSpawnSlot: this.buildDecodeSpawnAcquire(log, "occupancy"),
3820
+ onEnded: () => cleanup(),
3821
+ onDecodedFrame: (frame, nativeCrop) => {
4135
3822
  if (settled) return;
3823
+ const handle = retainedFrames?.write(config.deviceId, frame) ?? void 0;
3824
+ if (handle && nativeCrop) this.nativeCropRegistry.register(handle, nativeCrop);
4136
3825
  runner.enqueueOccupancyFrame(deviceId, frame, handle);
4137
3826
  count++;
4138
3827
  if (count >= frames) {
@@ -4140,17 +3829,11 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
4140
3829
  cleanup();
4141
3830
  }
4142
3831
  }
4143
- });
4144
- } catch (err) {
4145
- clearTimeout(safetyTimer);
4146
- this.occupancyBurstInFlight.delete(deviceId);
4147
- this.occupancyBurstFound.delete(deviceId);
4148
- log.debug("occupancy re-check: poller start failed", { meta: { error: require_dist.errMsg(err) } });
4149
- return;
4150
- }
3832
+ })
3833
+ });
4151
3834
  if (settled) {
4152
3835
  clearTimeout(safetyTimer);
4153
- doUnsub();
3836
+ teardown();
4154
3837
  }
4155
3838
  }
4156
3839
  emitInferenceResult(deviceId, frame, result, handle) {
@@ -4284,14 +3967,6 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
4284
3967
  default: DEFAULT_CONFIG.minThrottledFps,
4285
3968
  showValue: true
4286
3969
  },
4287
- {
4288
- type: "boolean",
4289
- key: "useSessionDecode",
4290
- style: "checkbox",
4291
- label: "Use per-session decode workers",
4292
- description: "Default decode path for every camera on this runner/node: each attached camera decodes in a disposable per-session worker process instead of the shared shm frame-handle poller, freeing the VAAPI/GPU decode pool (and any /dev/shm ring segments) as soon as it goes idle. Uncheck to fall this node back to the shared shm frame-handle poller for ALL its cameras — no per-camera override.",
4293
- default: DEFAULT_CONFIG.useSessionDecode
4294
- },
4295
3970
  {
4296
3971
  type: "slider",
4297
3972
  key: "maxConcurrentDecodeSpawns",
@@ -4314,7 +3989,6 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
4314
3989
  maxConcurrentInferences: this.config.maxConcurrentInferences,
4315
3990
  targetLoadPercent: this.config.targetLoadPercent,
4316
3991
  minThrottledFps: this.config.minThrottledFps,
4317
- useSessionDecode: this.config.useSessionDecode,
4318
3992
  maxConcurrentDecodeSpawns: this.config.maxConcurrentDecodeSpawns
4319
3993
  } });
4320
3994
  }