@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
@@ -1,12 +1,11 @@
1
- import { $ as boolean, D as nodePin, H as createEvent, I as errMsg, K as makeSourceBrokerId, L as BaseAddon, Q as array, V as EventCategory, Z as _enum, _ as customAction, it as object, k as pipelineRunnerCapability, ot as string, rt as number, tt as lazy, y as defineCustomActions } from "../dist-RWGGPwVx.mjs";
2
- import { n as isRemoteRestream, r as profileForStreamId, t as RemoteSourcePlane } from "../remote-source-plane-DU0aRSPv.mjs";
1
+ import { $ as boolean, D as nodePin, H as createEvent, I as errMsg, L as BaseAddon, Q as array, V as EventCategory, Z as _enum, _ as customAction, it as object, k as pipelineRunnerCapability, ot as string, rt as number, tt as lazy, y as defineCustomActions } from "../dist-CL531uVA.mjs";
2
+ import { n as profileForStreamId, t as isRemoteRestream } from "../remote-restream-BeHi78PZ.mjs";
3
3
  import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
4
4
  import { t as isWorkerReply } from "../worker-protocol-CyVJTZEO.mjs";
5
- import { a as getStepDefinition } from "../step-definitions-CP9kVSml.mjs";
6
- import { DecoderFrameRingSink, FrameRingReaderCache, unlinkSegment } from "@camstack/shm-ring";
5
+ import { a as getStepDefinition } from "../step-definitions-1d3_vQ6S.mjs";
7
6
  import { fork } from "node:child_process";
8
7
  import { fileURLToPath } from "node:url";
9
- import { readdirSync } from "node:fs";
8
+ import sharp from "sharp";
10
9
  //#region src/pipeline-runner/bench-actions.ts
11
10
  /**
12
11
  * Synthetic-bench custom actions for pipeline-runner.
@@ -101,329 +100,6 @@ var pipelineRunnerBenchActions = defineCustomActions({
101
100
  runSyntheticBench: customAction(RunSyntheticBenchInputSchema, RunSyntheticBenchResultSchema, { kind: "mutation" })
102
101
  });
103
102
  //#endregion
104
- //#region src/pipeline-runner/foreign-handle-throttle.ts
105
- var ForeignHandleThrottle = class {
106
- minFetchIntervalMs;
107
- warnIntervalMs;
108
- lastFetchStartedAt = Number.NEGATIVE_INFINITY;
109
- inFlight = false;
110
- lastWarnAt;
111
- _foreignHandles = 0;
112
- _remoteFetches = 0;
113
- _remoteFailures = 0;
114
- _dropped = 0;
115
- constructor(options = {}) {
116
- const maxRemoteFps = options.maxRemoteFps ?? 1;
117
- this.minFetchIntervalMs = maxRemoteFps > 0 ? 1e3 / maxRemoteFps : Number.POSITIVE_INFINITY;
118
- this.warnIntervalMs = options.warnIntervalMs ?? 6e4;
119
- }
120
- /**
121
- * Decide whether the current foreign handle should trigger a remote fetch.
122
- * `'fetch'` only when no fetch is in flight AND at least `1000/maxRemoteFps`
123
- * ms have elapsed since the last fetch started; otherwise `'drop'`.
124
- */
125
- plan(now) {
126
- this._foreignHandles += 1;
127
- const rateOpen = now - this.lastFetchStartedAt >= this.minFetchIntervalMs;
128
- if (this.inFlight || !rateOpen) {
129
- this._dropped += 1;
130
- return "drop";
131
- }
132
- return "fetch";
133
- }
134
- /** Mark a remote fetch as begun (arms single-flight + the rate window). */
135
- noteFetchStart(now) {
136
- this.inFlight = true;
137
- this.lastFetchStartedAt = now;
138
- this._remoteFetches += 1;
139
- }
140
- /** Mark the in-flight remote fetch as finished; records a failure outcome. */
141
- noteFetchEnd(result = "ok") {
142
- this.inFlight = false;
143
- if (result === "failed") this._remoteFailures += 1;
144
- }
145
- /** `true` at most once per `warnIntervalMs`; stamps the window when it fires. */
146
- shouldWarn(now) {
147
- if (this.lastWarnAt !== void 0 && now - this.lastWarnAt < this.warnIntervalMs) return false;
148
- this.lastWarnAt = now;
149
- return true;
150
- }
151
- get counters() {
152
- return {
153
- foreignHandles: this._foreignHandles,
154
- remoteFetches: this._remoteFetches,
155
- remoteFailures: this._remoteFailures,
156
- dropped: this._dropped
157
- };
158
- }
159
- };
160
- //#endregion
161
- //#region src/pipeline-runner/resolve-polled-frame.ts
162
- async function resolvePolledFrame(handle, deps) {
163
- const { ownNodeId, readers, throttle, getRemoteFrame, logger, tag } = deps;
164
- if (handle.nodeId === ownNodeId) return readers.read(handle);
165
- const now = Date.now();
166
- const decision = throttle.plan(now);
167
- if (throttle.shouldWarn(now)) logger.warn("frame poller: foreign handle — decoder session is on another node; using bounded decoder.getFrame fallback", { meta: {
168
- tag,
169
- producerNode: handle.nodeId,
170
- ownNodeId,
171
- ...throttle.counters
172
- } });
173
- if (decision === "drop") return null;
174
- throttle.noteFetchStart(now);
175
- try {
176
- const remote = await getRemoteFrame(handle);
177
- throttle.noteFetchEnd("ok");
178
- return remote;
179
- } catch (err) {
180
- throttle.noteFetchEnd("failed");
181
- logger.debug("frame poller: remote getFrame failed", { meta: {
182
- tag,
183
- producerNode: handle.nodeId,
184
- error: errMsg(err)
185
- } });
186
- return null;
187
- }
188
- }
189
- //#endregion
190
- //#region src/pipeline-runner/frame-handle-poller.ts
191
- /**
192
- * `FrameHandlePoller` — the consumer-side poll loop of the shared-memory frame
193
- * plane (Phase 5 / D9).
194
- *
195
- * Replaces the live-object `IStreamBroker.onDecodedFrame(cb)` callback path.
196
- * A consumer (motion, detection):
197
- *
198
- * 1. `subscribeFrames({ brokerId, format, maxFps, tag })` over tRPC — the
199
- * broker spins up a `frameSink: 'shm'` decoder session for that format
200
- * and returns a `subscriptionId`;
201
- * 2. polls `pullFrameHandles({ subscriptionId, maxCount })` at the cadence
202
- * `maxFps` implies, draining zero-pixel `FrameHandle`s;
203
- * 3. feeds each handle to a `FrameRingReaderCache` which opens the named shm
204
- * segment and reads the pixels back zero-copy;
205
- * 4. on teardown, `unsubscribeFrames` + closes the cached readers.
206
- *
207
- * The loop tolerates an initially-empty stream — `pullFrameHandles` returns
208
- * `[]` until the broker has a source, and a recycled ring slot yields a `null`
209
- * pixel read (a dropped frame, the correct latest-wins behaviour).
210
- *
211
- * Foreign-handle path: a `FrameHandle` carries the `nodeId` of the node whose
212
- * shared memory holds its segment. A LOCAL handle is read back zero-copy; a
213
- * FOREIGN handle (produced on another node — e.g. a camera decoded on the hub
214
- * whose detection was placed on an agent) cannot be `shm_open`ed here. Reading
215
- * it unconditionally floods `openSegment failed shm_open ENOENT` at frame rate,
216
- * so the poll delegates every handle to `resolvePolledFrame`, which gates on
217
- * `handle.nodeId`: local → shm read; foreign → a BOUNDED, single-flight
218
- * `getRemoteFrame` (`decoder.getFrame`) fallback (≤ `maxRemoteFps`) plus a
219
- * rate-limited WARN naming the topology mismatch. `ownNodeId` + `getRemoteFrame`
220
- * are REQUIRED options — every in-repo call site supplies them, so there is no
221
- * silent default that re-creates the unconditional read.
222
- *
223
- * Boot-race tolerance: the broker for a given camStream may not be registered
224
- * yet when a consumer attempts to subscribe (the pipeline-runner spawns before
225
- * provider addons publish their cameraStreams). `subscribeFrames` retries with
226
- * exponential backoff (capped at `MAX_SUBSCRIBE_RETRY_BACKOFF_MS`) until it
227
- * succeeds or the returned teardown closure is invoked. The closure always
228
- * resolves to a function, even when the broker is missing; the function
229
- * cancels pending retries and releases any subscription acquired in flight.
230
- */
231
- /** How many handles to drain per poll — a small burst absorbs jitter. */
232
- var PULL_MAX_COUNT = 4;
233
- /** Floor on the poll period so a 0/absurd `maxFps` can't busy-spin. */
234
- var MIN_POLL_INTERVAL_MS = 20;
235
- /** Poll period when the broker reports a non-positive cadence hint. */
236
- var FALLBACK_POLL_INTERVAL_MS = 200;
237
- /**
238
- * Ceiling on the poll period — we never poll SLOWER than this even when the
239
- * subscriber's `maxFps` implies a longer interval (e.g. detection at 10 fps →
240
- * 100 ms). A decoded frame otherwise sits in the ring up to a full poll period
241
- * before it is picked, and that wait lands directly in `frameAge` (the analyzed
242
- * frame's staleness). Polling faster grabs the freshest frame sooner; because
243
- * the loop reads ONLY the newest handle per tick and the decoder emits at its
244
- * own rate, the extra ticks that find no new frame are near-free (an empty
245
- * `pullHandles` + no pixel read), so this lowers latency without a proportional
246
- * read-cost increase. `maxFps` still bounds the DELIVERED rate downstream via
247
- * the latest-wins queue — this only decouples poll cadence from it.
248
- */
249
- var MAX_POLL_INTERVAL_MS = 50;
250
- /** First subscribe-retry delay, doubled on every subsequent failure. */
251
- var INITIAL_SUBSCRIBE_RETRY_BACKOFF_MS = 250;
252
- /**
253
- * Subscribe-retry backoff ceiling. 5 s is fast enough to recover within a
254
- * single roster refresh of the consuming UI and slow enough that a
255
- * misconfigured camStream costs ~12 op/min, not 50/s.
256
- */
257
- var MAX_SUBSCRIBE_RETRY_BACKOFF_MS = 5e3;
258
- /**
259
- * The default source: the broker's shm plane over tRPC — today's path. The
260
- * methods return the underlying client promises DIRECTLY (no async wrapper)
261
- * so the poller's scheduling is microtask-identical to the pre-seam code.
262
- */
263
- function brokerFrameHandleSource(api, brokerId) {
264
- return {
265
- subscribe: (input) => api.streamBroker.subscribeFrames.mutate({
266
- brokerId,
267
- format: input.format,
268
- maxFps: input.maxFps,
269
- tag: input.tag
270
- }),
271
- pullHandles: (input) => api.streamBroker.pullFrameHandles.query({
272
- subscriptionId: input.subscriptionId,
273
- maxCount: input.maxCount
274
- }),
275
- unsubscribe: (input) => api.streamBroker.unsubscribeFrames.mutate({ subscriptionId: input.subscriptionId }).then(() => void 0)
276
- };
277
- }
278
- /**
279
- * Subscribe to a broker's shm frame stream and start the poll loop. Always
280
- * resolves to a teardown closure — when the broker is not yet registered the
281
- * closure cancels the ongoing retry loop; when polling is active it stops the
282
- * loop, releases the broker subscription, and closes every shm segment the
283
- * reader cache opened.
284
- */
285
- async function startFrameHandlePoller(options) {
286
- const lifecycle = {
287
- stopped: false,
288
- retryTimer: void 0,
289
- activeTeardown: null
290
- };
291
- const teardown = () => {
292
- if (lifecycle.stopped) return;
293
- lifecycle.stopped = true;
294
- if (lifecycle.retryTimer) {
295
- clearTimeout(lifecycle.retryTimer);
296
- lifecycle.retryTimer = void 0;
297
- }
298
- lifecycle.activeTeardown?.();
299
- };
300
- subscribeWithRetry(options, lifecycle);
301
- return teardown;
302
- }
303
- /**
304
- * Run the subscribe → poll handshake with exponential backoff on subscribe
305
- * failures. Resolves once the subscription is acquired (and the poll loop has
306
- * been started) or once `lifecycle.stopped` flips, whichever comes first.
307
- */
308
- async function subscribeWithRetry(options, lifecycle) {
309
- const { api, brokerId, format, maxFps, tag, logger } = options;
310
- const source = options.source ?? brokerFrameHandleSource(api, brokerId);
311
- let backoffMs = INITIAL_SUBSCRIBE_RETRY_BACKOFF_MS;
312
- let attempt = 0;
313
- while (!lifecycle.stopped) {
314
- attempt += 1;
315
- try {
316
- const result = await source.subscribe({
317
- format,
318
- maxFps,
319
- tag
320
- });
321
- if (lifecycle.stopped) {
322
- await source.unsubscribe({ subscriptionId: result.subscriptionId }).catch((err) => {
323
- logger.warn("frame-handle poller: late unsubscribe failed", { meta: {
324
- brokerId,
325
- subscriptionId: result.subscriptionId,
326
- error: errMsg(err)
327
- } });
328
- });
329
- return;
330
- }
331
- lifecycle.activeTeardown = startPolling(options, source, result.subscriptionId, result.maxFps, lifecycle);
332
- return;
333
- } catch (err) {
334
- if (lifecycle.stopped) return;
335
- if (attempt === 1) logger.warn("frame-handle poller: subscribeFrames failed, retrying", { meta: {
336
- brokerId,
337
- format,
338
- tag,
339
- error: errMsg(err),
340
- nextRetryInMs: backoffMs
341
- } });
342
- else logger.debug("frame-handle poller: subscribeFrames still failing", { meta: {
343
- brokerId,
344
- format,
345
- tag,
346
- attempt,
347
- error: errMsg(err),
348
- nextRetryInMs: backoffMs
349
- } });
350
- await sleep(backoffMs, lifecycle);
351
- backoffMs = Math.min(MAX_SUBSCRIBE_RETRY_BACKOFF_MS, backoffMs * 2);
352
- }
353
- }
354
- }
355
- /**
356
- * Sleep `ms` milliseconds, but resolve early when `lifecycle.stopped` flips so
357
- * a teardown during a retry pause doesn't have to wait out the full backoff.
358
- */
359
- function sleep(ms, lifecycle) {
360
- return new Promise((resolve) => {
361
- lifecycle.retryTimer = setTimeout(() => {
362
- lifecycle.retryTimer = void 0;
363
- resolve();
364
- }, ms);
365
- });
366
- }
367
- /**
368
- * Run the steady-state poll loop for a successfully-acquired subscription.
369
- * Returns the teardown closure that stops the loop and releases the
370
- * subscription.
371
- */
372
- function startPolling(options, source, subscriptionId, resolvedMaxFps, lifecycle) {
373
- const { brokerId, onFrame, logger, ownNodeId, getRemoteFrame, tag } = options;
374
- const readers = new FrameRingReaderCache(logger);
375
- const throttle = new ForeignHandleThrottle();
376
- const pollIntervalMs = Math.min(MAX_POLL_INTERVAL_MS, resolvedMaxFps > 0 ? Math.max(MIN_POLL_INTERVAL_MS, Math.round(1e3 / resolvedMaxFps)) : FALLBACK_POLL_INTERVAL_MS);
377
- let timer;
378
- const tick = async () => {
379
- if (lifecycle.stopped) return;
380
- try {
381
- const handles = await source.pullHandles({
382
- subscriptionId,
383
- maxCount: PULL_MAX_COUNT
384
- });
385
- for (let i = handles.length - 1; i >= 0; i -= 1) {
386
- if (lifecycle.stopped) break;
387
- const handle = handles[i];
388
- const frame = await resolvePolledFrame(handle, {
389
- ownNodeId,
390
- readers,
391
- throttle,
392
- getRemoteFrame,
393
- logger,
394
- tag
395
- });
396
- if (frame) {
397
- onFrame(frame, handle);
398
- break;
399
- }
400
- }
401
- } catch (err) {
402
- logger.warn("frame-handle poller: pullFrameHandles failed", { meta: {
403
- brokerId,
404
- subscriptionId,
405
- error: errMsg(err)
406
- } });
407
- }
408
- if (!lifecycle.stopped) timer = setTimeout(() => void tick(), pollIntervalMs);
409
- };
410
- tick();
411
- return () => {
412
- if (timer) {
413
- clearTimeout(timer);
414
- timer = void 0;
415
- }
416
- readers.close();
417
- source.unsubscribe({ subscriptionId }).catch((err) => {
418
- logger.warn("frame-handle poller: unsubscribe failed", { meta: {
419
- brokerId,
420
- subscriptionId,
421
- error: errMsg(err)
422
- } });
423
- });
424
- };
425
- }
426
- //#endregion
427
103
  //#region src/pipeline-runner/frame-governor.ts
428
104
  var FrameGovernor = class {
429
105
  config;
@@ -1391,8 +1067,13 @@ function wrapChild(child) {
1391
1067
  child.send(msg, () => void 0);
1392
1068
  },
1393
1069
  on: (ev, cb) => {
1394
- if (ev === "exit") child.on("exit", () => cb(void 0));
1395
- else child.on("message", (m) => cb(m));
1070
+ if (ev === "exit") {
1071
+ const toInfo = (code, signal) => ({
1072
+ code,
1073
+ signal
1074
+ });
1075
+ child.on("exit", (code, signal) => cb(toInfo(code ?? null, signal ?? null)));
1076
+ } else child.on("message", (m) => cb(m));
1396
1077
  },
1397
1078
  kill: (signal) => {
1398
1079
  if (signal === void 0) {
@@ -1455,137 +1136,141 @@ function startGuardedSessionDecode(deps) {
1455
1136
  if (release) release();
1456
1137
  };
1457
1138
  }
1458
- //#endregion
1459
- //#region src/session-decode/retention-sink.ts
1460
- /**
1461
- * Session-decode frame retention ring (Shape-2, Slice 1).
1462
- *
1463
- * The default detection path is **session-decode** (`useSessionDecode` true):
1464
- * a per-session forked decode worker hands the runner decoded rgb pixels over
1465
- * IPC — the frames never touch shared memory, so the emitted
1466
- * `PipelineInferenceResult` carried NO `FrameHandle`, and hub-side post-analysis
1467
- * (which gates ALL event/face/track crop media on a present handle) produced no
1468
- * media. This ring restores that: every decoded detection frame is copied into
1469
- * a short shared-memory retention ring on the runner's node, and the resulting
1470
- * `FrameHandle` rides the inference-result event so post-analysis can resolve
1471
- * the exact frame zero-copy and cut crops from it.
1472
- *
1473
- * ## Why a distinct prefix (`csr.`)
1474
- *
1475
- * The decoder addon runs `purgeOrphanSegments('csf.')` at startup and unlinks
1476
- * EVERY `csf.*` segment on the node. A retention ring under the `csf.` prefix
1477
- * would be reclaimed out from under a live session on every decoder redeploy.
1478
- * The retention ring therefore uses its own prefix `csr.` and its own startup
1479
- * orphan purge (`shm-retention-purge.ts`).
1480
- *
1481
- * ## Sizing
1482
- *
1483
- * Retention is ring depth: `slots ≈ detectionFps × retainSeconds`. At the
1484
- * Slice-1 ≤640-wide detection geometry a slot is ~0.69 MB, so the 32 MB default
1485
- * budget yields ~46 latest-wins slots (~4.6 s @10 fps). Budget is per active
1486
- * session; the sink is armed lazily on the first rgb frame and destroyed on
1487
- * session teardown, so idle cameras hold no shared memory.
1488
- */
1489
- /** Segment name prefix for session-decode retention rings — isolated from the
1490
- * decoder's `csf.` orphan purge so a decoder redeploy never reclaims a live
1491
- * retention ring. */
1492
- var RETENTION_SEGMENT_PREFIX = "csr.";
1493
- /** {@link RETAIN_BUDGET_MB} in bytes. */
1494
- var RETAIN_BUDGET_BYTES = (() => {
1139
+ /** {@link RETAINED_FRAME_BUDGET_MB} in bytes. */
1140
+ var RETAINED_FRAME_BUDGET_BYTES = (() => {
1495
1141
  const raw = Number(process.env["CAMSTACK_SESSION_RETAIN_MB"]);
1496
1142
  return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 32;
1497
1143
  })() * 1024 * 1024;
1498
- /**
1499
- * Create a session-decode retention ring for one device. The underlying
1500
- * shared-memory segment is created lazily on the first rgb frame.
1501
- */
1502
- function createSessionRetentionSink(options) {
1503
- const { deviceId, nodeId, logger } = options;
1504
- const sink = new DecoderFrameRingSink({
1505
- seed: `retain:${deviceId}`,
1506
- logger,
1507
- nodeId,
1508
- segmentPrefix: RETENTION_SEGMENT_PREFIX,
1509
- budgetBytes: RETAIN_BUDGET_BYTES
1510
- });
1511
- return {
1512
- write(frame) {
1513
- if (frame.format !== "rgb") return null;
1514
- return sink.writeFrame(frame.data, {
1515
- width: frame.width,
1516
- height: frame.height,
1517
- format: frame.format,
1518
- pts: performance.now(),
1519
- byteLength: frame.data.byteLength
1520
- });
1521
- },
1522
- destroy() {
1523
- sink.destroy();
1524
- },
1525
- get currentSegmentName() {
1526
- return sink.currentSegmentName;
1527
- }
1528
- };
1144
+ /** Opaque RAM-key prefix stamped into a retained frame's `FrameHandle.shmId`. */
1145
+ var RETAINED_FRAME_KEY_PREFIX = "ram:";
1146
+ /** A retention frame is uniquely identified by its key + slot + committed seq. */
1147
+ function keyOf(handle) {
1148
+ return `${handle.shmId}#${handle.slot}#${handle.seq}`;
1529
1149
  }
1530
- //#endregion
1531
- //#region src/pipeline-runner/shm-retention-purge.ts
1532
1150
  /**
1533
- * Startup reclamation of orphaned session-decode retention segments.
1534
- *
1535
- * The retention ring (`retention-sink.ts`) writes decoded detection frames into
1536
- * named `/dev/shm` segments under the `csr.` prefix and unlinks each on graceful
1537
- * session teardown. When the runner process dies ungracefully (SIGBUS, OOM-kill,
1538
- * SIGKILL during redeploy) that teardown never runs and the segment is orphaned.
1539
- * Across crashes/redeploys these accumulate until `/dev/shm` fills, at which
1540
- * point the next `mmap` write faults with an uncatchable SIGBUS.
1541
- *
1542
- * This is the retention-ring analogue of the decoder addon's
1543
- * `purgeOrphanSegments('csf.')`: a fresh pipeline-runner owns no live sessions,
1544
- * so every pre-existing `csr.*` segment is by definition an orphan from a dead
1545
- * instance. It MUST use the `csr.` prefix (never `csf.`): the decoder purge owns
1546
- * `csf.` and unlinking a live retention ring would break media mid-session.
1547
- *
1548
- * `shm_unlink` only removes the name — a consumer still holding a mapping keeps
1549
- * reading valid memory until it closes (POSIX deferred reclaim + the ring
1550
- * seqlock), so unlinking is safe even if a stale reader is momentarily attached.
1551
- *
1552
- * POSIX-only: segments surface as files under `/dev/shm` on Linux. On platforms
1553
- * without that directory (Windows, macOS) the scan finds nothing — no-op.
1554
- */
1555
- /** Default tmpfs directory where POSIX shared-memory segments appear on Linux. */
1556
- var DEFAULT_SHM_DIR = "/dev/shm";
1557
- /**
1558
- * Unlink every session-decode retention segment whose name starts with
1559
- * `prefix`. Intended to run ONCE at pipeline-runner startup, before any session
1560
- * is created, to reclaim segments orphaned by a previously-crashed instance. A
1561
- * per-file unlink failure is swallowed so one stuck segment cannot block
1562
- * reclaiming the rest.
1151
+ * Node-level RAM store of retained rgb detection frames, keyed by
1152
+ * `FrameHandle` identity. Latest-wins with a byte budget.
1563
1153
  */
1564
- function purgeOrphanSegments(prefix, options = {}) {
1565
- const dir = options.dir ?? DEFAULT_SHM_DIR;
1566
- const unlink = options.unlink ?? unlinkSegment;
1567
- let entries;
1568
- try {
1569
- entries = readdirSync(dir);
1570
- } catch {
1571
- return {
1572
- scanned: 0,
1573
- removed: 0,
1574
- names: []
1154
+ var RetainedFrameStore = class RetainedFrameStore {
1155
+ nodeId;
1156
+ budgetBytes;
1157
+ /** Insertion-ordered (Map) → oldest is `keys().next()`. */
1158
+ entries = /* @__PURE__ */ new Map();
1159
+ bytes = 0;
1160
+ seq = 0;
1161
+ /** Ring width used only to spread the `slot` field; the map key is authoritative. */
1162
+ static SLOT_SPREAD = 1024;
1163
+ constructor(options) {
1164
+ this.nodeId = options.nodeId;
1165
+ this.budgetBytes = options.budgetBytes ?? RETAINED_FRAME_BUDGET_BYTES;
1166
+ }
1167
+ /** Number of retained frames (tests / metrics). */
1168
+ get size() {
1169
+ return this.entries.size;
1170
+ }
1171
+ /** Total retained bytes (tests / metrics). */
1172
+ get retainedBytes() {
1173
+ return this.bytes;
1174
+ }
1175
+ /**
1176
+ * Retain one decoded rgb frame and return its `FrameHandle`. Returns `null`
1177
+ * for a non-rgb frame (motion gray is never retained — post-analysis crops
1178
+ * from the rgb frame). The frame buffer is retained by reference: the
1179
+ * decode-worker IPC delivers a fresh Buffer per frame, so no defensive copy
1180
+ * is needed.
1181
+ */
1182
+ write(deviceId, frame) {
1183
+ if (frame.format !== "rgb") return null;
1184
+ const seq = ++this.seq;
1185
+ const byteLength = frame.data.byteLength;
1186
+ const handle = {
1187
+ shmId: `${RETAINED_FRAME_KEY_PREFIX}${this.nodeId}:${deviceId}`,
1188
+ slot: seq % RetainedFrameStore.SLOT_SPREAD,
1189
+ seq,
1190
+ width: frame.width,
1191
+ height: frame.height,
1192
+ format: "rgb",
1193
+ pts: performance.now(),
1194
+ byteLength,
1195
+ nodeId: this.nodeId,
1196
+ slotCount: RetainedFrameStore.SLOT_SPREAD
1575
1197
  };
1198
+ this.entries.set(keyOf(handle), frame);
1199
+ this.bytes += byteLength;
1200
+ this.evictToBudget();
1201
+ return handle;
1576
1202
  }
1577
- const names = [];
1578
- for (const name of entries) {
1579
- if (!name.startsWith(prefix)) continue;
1580
- try {
1581
- unlink(name);
1582
- names.push(name);
1583
- } catch {}
1203
+ /** The retained frame for `handle`, or `null` if never retained / already evicted. */
1204
+ getFrame(handle) {
1205
+ return this.entries.get(keyOf(handle)) ?? null;
1206
+ }
1207
+ /**
1208
+ * Crop (and optionally downscale) the retained rgb frame for `handle` to the
1209
+ * normalized `bbox`. A full-frame `bbox` ({x:0,y:0,w:1,h:1}) with no
1210
+ * `maxWidth` returns the whole frame. Returns `null` when the frame is no
1211
+ * longer retained. Nearest-neighbour scale — this is the best-effort
1212
+ * post-analysis fallback path; native-resolution ROI crops come from the
1213
+ * decode worker's surface (`getNativeCrop`), not here.
1214
+ */
1215
+ crop(handle, bbox, maxWidth) {
1216
+ const frame = this.entries.get(keyOf(handle));
1217
+ if (!frame || frame.format !== "rgb") return null;
1218
+ return cropRgb(frame, bbox, maxWidth);
1219
+ }
1220
+ /** Drop every retained frame (node/addon shutdown). */
1221
+ clear() {
1222
+ this.entries.clear();
1223
+ this.bytes = 0;
1224
+ }
1225
+ /** Evict oldest frames until the retained bytes fit the budget (keep ≥1). */
1226
+ evictToBudget() {
1227
+ while (this.bytes > this.budgetBytes && this.entries.size > 1) {
1228
+ const oldest = this.entries.keys().next().value;
1229
+ if (oldest === void 0) break;
1230
+ const evicted = this.entries.get(oldest);
1231
+ this.entries.delete(oldest);
1232
+ if (evicted) this.bytes -= evicted.data.byteLength;
1233
+ }
1234
+ if (this.bytes < 0) this.bytes = 0;
1235
+ }
1236
+ };
1237
+ /**
1238
+ * Nearest-neighbour crop + optional downscale of a packed RGB24 frame to a
1239
+ * normalized `bbox`. Exported for unit coverage. Clamps the bbox into the frame
1240
+ * and never allocates more than the output size.
1241
+ */
1242
+ function cropRgb(frame, bbox, maxWidth) {
1243
+ const srcW = frame.width;
1244
+ const srcH = frame.height;
1245
+ const nx = Math.min(Math.max(bbox.x, 0), 1);
1246
+ const ny = Math.min(Math.max(bbox.y, 0), 1);
1247
+ const nw = Math.min(Math.max(bbox.w, 0), 1 - nx);
1248
+ const nh = Math.min(Math.max(bbox.h, 0), 1 - ny);
1249
+ const cropX = Math.floor(nx * srcW);
1250
+ const cropY = Math.floor(ny * srcH);
1251
+ const cropW = Math.max(1, Math.round(nw * srcW));
1252
+ const cropH = Math.max(1, Math.round(nh * srcH));
1253
+ const clampedW = Math.min(cropW, srcW - cropX);
1254
+ const clampedH = Math.min(cropH, srcH - cropY);
1255
+ const outW = maxWidth && maxWidth > 0 && maxWidth < clampedW ? maxWidth : clampedW;
1256
+ const outH = outW === clampedW ? clampedH : Math.max(1, Math.round(outW * clampedH / clampedW));
1257
+ const src = frame.data;
1258
+ const out = Buffer.allocUnsafe(outW * outH * 3);
1259
+ for (let oy = 0; oy < outH; oy += 1) {
1260
+ const srcRow = (cropY + Math.min(clampedH - 1, Math.floor(oy * clampedH / outH))) * srcW * 3;
1261
+ const outRow = oy * outW * 3;
1262
+ for (let ox = 0; ox < outW; ox += 1) {
1263
+ const s = srcRow + (cropX + Math.min(clampedW - 1, Math.floor(ox * clampedW / outW))) * 3;
1264
+ const o = outRow + ox * 3;
1265
+ out[o] = src[s];
1266
+ out[o + 1] = src[s + 1];
1267
+ out[o + 2] = src[s + 2];
1268
+ }
1584
1269
  }
1585
1270
  return {
1586
- scanned: entries.length,
1587
- removed: names.length,
1588
- names
1271
+ bytes: out,
1272
+ width: outW,
1273
+ height: outH
1589
1274
  };
1590
1275
  }
1591
1276
  //#endregion
@@ -1600,6 +1285,11 @@ var SIGKILL_FALLBACK_MS = 1e3;
1600
1285
  var DEFAULT_FIRST_FRAME_TIMEOUT_MS = 2e4;
1601
1286
  /** Default timeout for every `pull` round-trip AFTER the first frame arrives. */
1602
1287
  var DEFAULT_FRAME_TIMEOUT_MS = 1e4;
1288
+ /** Narrow an 'exit' payload to {@link WorkerExitInfo} (fakes may emit undefined). */
1289
+ function toWorkerExitInfo(v) {
1290
+ const isInfo = (x) => typeof x === "object" && x !== null && "code" in x && "signal" in x;
1291
+ return isInfo(v) ? v : void 0;
1292
+ }
1603
1293
  function toError(err) {
1604
1294
  return err instanceof Error ? err : new Error(String(err));
1605
1295
  }
@@ -1620,6 +1310,8 @@ var SessionDecodeSession = class {
1620
1310
  pendingNativeCrops = /* @__PURE__ */ new Map();
1621
1311
  nextNativeCropId = 1;
1622
1312
  exited = false;
1313
+ /** Set by teardown() so handleExit can tell an intentional stop from a death. */
1314
+ stopRequested = false;
1623
1315
  sigkillTimer = null;
1624
1316
  pullTimeoutTimer = null;
1625
1317
  firstFrameReceived = false;
@@ -1629,7 +1321,7 @@ var SessionDecodeSession = class {
1629
1321
  this.firstFrameTimeoutMs = firstFrameTimeoutMs;
1630
1322
  this.frameTimeoutMs = frameTimeoutMs;
1631
1323
  this.child.on("message", (m) => this.handleMessage(m));
1632
- this.child.on("exit", () => this.handleExit());
1324
+ this.child.on("exit", (m) => this.handleExit(toWorkerExitInfo(m)));
1633
1325
  }
1634
1326
  start(source, opts) {
1635
1327
  this.trySend({
@@ -1721,6 +1413,7 @@ var SessionDecodeSession = class {
1721
1413
  }
1722
1414
  /** Teardown: `stop`, `kill()`, arm the SIGKILL fallback unless already exited. */
1723
1415
  teardown() {
1416
+ this.stopRequested = true;
1724
1417
  this.clearPullTimeout();
1725
1418
  this.trySend({ kind: "stop" });
1726
1419
  this.tryKill(void 0);
@@ -1766,7 +1459,11 @@ var SessionDecodeSession = class {
1766
1459
  return;
1767
1460
  }
1768
1461
  }
1769
- handleExit() {
1462
+ handleExit(info) {
1463
+ if (!this.stopRequested && !this.exited) this.logger.warn("session-decode worker exited unexpectedly", { meta: {
1464
+ code: info?.code ?? null,
1465
+ signal: info?.signal ?? null
1466
+ } });
1770
1467
  this.exited = true;
1771
1468
  if (this.sigkillTimer) {
1772
1469
  clearTimeout(this.sigkillTimer);
@@ -1963,19 +1660,6 @@ function createSessionDecodeCoordinator(deps) {
1963
1660
  //#endregion
1964
1661
  //#region src/session-decode/session-decode-pump.ts
1965
1662
  /**
1966
- * Pure gate: does this runner route camera decode through a per-session
1967
- * decode worker (Epic C) instead of the shm frame-handle poller? This is now
1968
- * a RUNNER-LEVEL setting (the pipeline-runner addon's own config,
1969
- * `this.config.useSessionDecode`) rather than a per-camera one — every
1970
- * camera attached to this node shares the same decode path. DEFAULT TRUE:
1971
- * the caller passes the runner's own `useSessionDecode` config value;
1972
- * an operator unchecks it (node-wide) to fall back to the shared shm
1973
- * frame-handle poller for every camera on this node.
1974
- */
1975
- function shouldUseSessionDecode(enabled) {
1976
- return enabled === true;
1977
- }
1978
- /**
1979
1663
  * Detection working width cap — mirrors the shm decoder's
1980
1664
  * `initScaler` (`nodeav-decoder-session.ts`: `maxW = floor(640 / scale)`,
1981
1665
  * `scale = 1` default). The worker decodes full source resolution (e.g. a 4K
@@ -2093,6 +1777,7 @@ function startSessionDecodePump(deps) {
2093
1777
  });
2094
1778
  } finally {
2095
1779
  releaseSpawnSlotOnce();
1780
+ if (!stopped) deps.onEnded?.();
2096
1781
  }
2097
1782
  };
2098
1783
  pump();
@@ -2206,10 +1891,13 @@ function denormalizeRect(rect, frameWidth, frameHeight) {
2206
1891
  * Walk `steps` (the attach config's tree) collecting every ENABLED step
2207
1892
  * whose catalog definition's `inputClasses` includes `parentClassName` —
2208
1893
  * these become the subtree's roots. A matched step's own nested `children`
2209
- * ride along UNCHANGED (transitively included: `face-detection` pulls
1894
+ * ride along UNCHANGED here (transitively included: `face-detection` pulls
2210
1895
  * `face-embedding`, `plate-detection` pulls `plate-ocr`) — recursion stops
2211
1896
  * at a match rather than also searching inside it for further matches.
2212
- * `filter`, when given, keeps only matches whose `addonId` is in the list.
1897
+ * `filter`, when given, keeps only the TOP-LEVEL matches whose `addonId` is
1898
+ * in the list — it does NOT prune a match's descendants. Strict per-`steps`
1899
+ * pruning of the descendant chain happens later in {@link runDetailSubtree}
1900
+ * (see {@link pruneChildStepsToRequested}); this function only selects roots.
2213
1901
  */
2214
1902
  function findMatchingDetailSteps(steps, parentClassName, lookupStepDefinition, filter) {
2215
1903
  const matches = [];
@@ -2227,6 +1915,26 @@ function findMatchingDetailSteps(steps, parentClassName, lookupStepDefinition, f
2227
1915
  walk(steps);
2228
1916
  return matches;
2229
1917
  }
1918
+ /**
1919
+ * Prune a matched step's descendant tree to the caller's requested `steps`
1920
+ * set: keep a child ONLY when its `addonId` is in `requested`, recursing into
1921
+ * every kept child. Pure — returns a new step, never mutates the input.
1922
+ *
1923
+ * Applied ONLY on the DETAIL plane (see {@link runDetailSubtree}); the
1924
+ * per-frame executor path is untouched. This is what makes the dispatcher's
1925
+ * `steps` array authoritative: `['face-detection']` runs the detector geometry
1926
+ * ALONE (the `face-embedding` child is dropped — no ArcFace inference), while
1927
+ * `['face-detection','face-embedding']` keeps the child and reproduces the
1928
+ * full detect→embed chain exactly.
1929
+ */
1930
+ function pruneChildStepsToRequested(step, requested) {
1931
+ if (!step.children || step.children.length === 0) return step;
1932
+ const children = step.children.filter((child) => requested.includes(child.addonId)).map((child) => pruneChildStepsToRequested(child, requested));
1933
+ return {
1934
+ ...step,
1935
+ children
1936
+ };
1937
+ }
2230
1938
  /** Map a detection's CROP-pixel bbox back to FRAME-space pixels. */
2231
1939
  function mapBboxToFrameSpace(bbox, crop) {
2232
1940
  const scaleX = crop.frameSpace.w / crop.width;
@@ -2286,10 +1994,21 @@ async function resolveCrop(deps, input) {
2286
1994
  * step's chain against it, and map every result bbox back to frame space.
2287
1995
  * Returns `null` when the camera isn't attached, no configured child
2288
1996
  * matches `input.parent.className`, or no crop source resolves.
1997
+ *
1998
+ * STRICT `steps` semantics: when the caller names an explicit `input.steps`
1999
+ * set, each matched root's descendant chain is pruned to exactly those ids
2000
+ * (see {@link pruneChildStepsToRequested}) — `resolveChildSteps`/`filter`
2001
+ * only selects TOP-LEVEL roots, so without this a matched `face-detection`
2002
+ * would always drag its `face-embedding` child along regardless of what was
2003
+ * asked for. When `input.steps` is omitted, the full configured chain runs
2004
+ * (today's behavior). This is the knob the dispatcher uses to run a periodic
2005
+ * geometry-only face refresh (`['face-detection']`) without re-running ArcFace.
2289
2006
  */
2290
2007
  async function runDetailSubtree(deps, input) {
2291
- const steps = deps.resolveChildSteps(input.deviceId, input.parent.className, input.steps);
2292
- if (!steps || steps.length === 0) return null;
2008
+ const matched = deps.resolveChildSteps(input.deviceId, input.parent.className, input.steps);
2009
+ if (!matched || matched.length === 0) return null;
2010
+ const requested = input.steps;
2011
+ const steps = requested === void 0 ? matched : matched.map((step) => pruneChildStepsToRequested(step, requested));
2293
2012
  const crop = await resolveCrop(deps, input);
2294
2013
  if (!crop) return null;
2295
2014
  const details = [];
@@ -2307,7 +2026,6 @@ var DEFAULT_CONFIG = {
2307
2026
  maxConcurrentInferences: 16,
2308
2027
  targetLoadPercent: 80,
2309
2028
  minThrottledFps: 1,
2310
- useSessionDecode: true,
2311
2029
  maxConcurrentDecodeSpawns: 3
2312
2030
  };
2313
2031
  /**
@@ -2323,6 +2041,11 @@ var DEFAULT_CONFIG = {
2323
2041
  * cameras produced when every gate open/close forked and killed a worker.
2324
2042
  * Mirrors Scrypted's decode-fork ~15s idle-timeout.
2325
2043
  */
2044
+ /** JPEG quality for the runner→executor detection-frame hop. q82 keeps
2045
+ * detector-relevant detail (source is h264 anyway) at ~10-15× less payload
2046
+ * than raw RGB — the hop transits the hub main process (UDS child↔child
2047
+ * routing), which OOM'd under raw frames. */
2048
+ var DETECTION_HOP_JPEG_QUALITY = 82;
2326
2049
  var DETECTION_KEEPWARM_MS = 15e3;
2327
2050
  /**
2328
2051
  * Occupancy-burst safety timeout (both the dial path and the analyzer-tap
@@ -2341,6 +2064,14 @@ var OCCUPANCY_BURST_SAFETY_TIMEOUT_MS = 8e3;
2341
2064
  * to have been teed within this window before it will route to the tap path.
2342
2065
  */
2343
2066
  var MOTION_FRESHNESS_WINDOW_MS = 5e3;
2067
+ /** Delay before re-dialing a detection decode whose worker died unexpectedly. */
2068
+ var DETECTION_REDIAL_DELAY_MS = 2e3;
2069
+ /**
2070
+ * Two unexpected decode deaths within this window → stop re-dialing and detach
2071
+ * (the orchestrator re-dispatches on the next motion signal). Bounds the loop
2072
+ * when the camera/source itself is broken.
2073
+ */
2074
+ var DETECTION_REDIAL_GIVEUP_WINDOW_MS = 6e4;
2344
2075
  /**
2345
2076
  * Pure decision helper: returns `true` when a dynamic frame-diff analyzer
2346
2077
  * subscription should be opened for an onboard-motion camera.
@@ -2604,6 +2335,15 @@ var PipelineRunnerAddon = class extends BaseAddon {
2604
2335
  * to the detection-frame crop). See {@link NativeCropRegistry}.
2605
2336
  */
2606
2337
  nativeCropRegistry = new NativeCropRegistry();
2338
+ /**
2339
+ * Node-level RAM store of retained rgb detection frames, keyed by the
2340
+ * `FrameHandle` that rides the inference-result event (shm-ring removal
2341
+ * 2026-07-16 — replaces the `csr.` shm retention ring). Backs the
2342
+ * FULL-FRAME branch of `getNativeCrop` so hub-side post-analysis resolves a
2343
+ * frame's pixels over tRPC (routed by `handle.nodeId`) with no `/dev/shm`.
2344
+ * Initialised in {@link onInitialize} once `nodeId` is known.
2345
+ */
2346
+ retainedFrames = null;
2607
2347
  attached = /* @__PURE__ */ new Map();
2608
2348
  nodeId = "unknown";
2609
2349
  metricsSnapshotTimer = null;
@@ -2640,7 +2380,6 @@ var PipelineRunnerAddon = class extends BaseAddon {
2640
2380
  * payloads (`local-broker` / absent) — the map stays empty and every code
2641
2381
  * path is identical to pre-Phase-2. Torn down on detach / shutdown.
2642
2382
  */
2643
- remotePlanes = /* @__PURE__ */ new Map();
2644
2383
  /**
2645
2384
  * Devices whose motion currently rides the detection session-decode worker
2646
2385
  * (P4 co-active sharing) instead of a dedicated motion worker. Membership →
@@ -2700,8 +2439,8 @@ var PipelineRunnerAddon = class extends BaseAddon {
2700
2439
  occupancyMaxConcurrent = 3;
2701
2440
  /**
2702
2441
  * TAP-based occupancy bursts in flight, keyed by deviceId (Task 10). See
2703
- * {@link OccupancyTapState}. A device has an entry here XOR is dialing via
2704
- * `startFrameHandlePoller` in `runOccupancyBurst` — never both; the choice
2442
+ * {@link OccupancyTapState}. A device has an entry here XOR is dialing a
2443
+ * session-decode burst in `runOccupancyBurst` — never both; the choice
2705
2444
  * is made once at burst start based on `attachment.motionUnsubscribe`.
2706
2445
  */
2707
2446
  occupancyTaps = /* @__PURE__ */ new Map();
@@ -2721,16 +2460,10 @@ var PipelineRunnerAddon = class extends BaseAddon {
2721
2460
  async onInitialize() {
2722
2461
  const raw = this.ctx.kernel.localNodeId ?? this.ctx.id;
2723
2462
  this.nodeId = raw.includes("/") ? raw.split("/")[0] : raw;
2724
- try {
2725
- const purged = purgeOrphanSegments(RETENTION_SEGMENT_PREFIX);
2726
- if (purged.removed > 0) this.ctx.logger.info("session-decode retention: reclaimed orphaned shm segments", { meta: {
2727
- removed: purged.removed,
2728
- scanned: purged.scanned,
2729
- prefix: RETENTION_SEGMENT_PREFIX
2730
- } });
2731
- } catch (err) {
2732
- this.ctx.logger.warn("session-decode retention: orphan purge failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
2733
- }
2463
+ this.retainedFrames = new RetainedFrameStore({
2464
+ nodeId: this.nodeId,
2465
+ logger: this.ctx.logger.child("retained-frames")
2466
+ });
2734
2467
  this.runner = new PipelineRunner({
2735
2468
  maxQueueDepth: this.config.maxQueueDepth,
2736
2469
  maxConcurrentInferences: this.config.maxConcurrentInferences,
@@ -2803,9 +2536,6 @@ var PipelineRunnerAddon = class extends BaseAddon {
2803
2536
  }
2804
2537
  this.lastAnalyzerDetected.clear();
2805
2538
  for (const deviceId of [...this.onboardAnalyzerTeardownTimers.keys(), ...this.onboardAnalyzerSubs.keys()]) this.clearOnboardAnalyzer(deviceId);
2806
- const remotePlanes = [...this.remotePlanes.values()];
2807
- this.remotePlanes.clear();
2808
- await Promise.all(remotePlanes.map((plane) => plane.dispose().catch(() => {})));
2809
2539
  if (this.runner) {
2810
2540
  this.runner.stop();
2811
2541
  this.runner = null;
@@ -2820,6 +2550,9 @@ var PipelineRunnerAddon = class extends BaseAddon {
2820
2550
  }
2821
2551
  this.attached.clear();
2822
2552
  this.lastMotionFrameAt.clear();
2553
+ this.nativeCropRegistry.clear();
2554
+ this.retainedFrames?.clear();
2555
+ this.retainedFrames = null;
2823
2556
  }
2824
2557
  async cacheBenchFrame(input) {
2825
2558
  const sharp = (await import("sharp")).default;
@@ -3210,13 +2943,23 @@ var PipelineRunnerAddon = class extends BaseAddon {
3210
2943
  * worker downloads ONLY the ROI — a full native frame never travels the wire.
3211
2944
  */
3212
2945
  async getNativeCrop(input) {
3213
- const requester = this.nativeCropRegistry.get(input.handle);
3214
- if (!requester) return null;
3215
- const result = await requester(input.bbox, input.maxWidth);
3216
- return result ? {
3217
- bytes: result.bytes,
3218
- width: result.width,
3219
- height: result.height
2946
+ const b = input.bbox;
2947
+ if (!(b.x <= .001 && b.y <= .001 && b.w >= .999 && b.h >= .999)) {
2948
+ const requester = this.nativeCropRegistry.get(input.handle);
2949
+ if (requester) {
2950
+ const result = await requester(input.bbox, input.maxWidth);
2951
+ if (result) return {
2952
+ bytes: result.bytes,
2953
+ width: result.width,
2954
+ height: result.height
2955
+ };
2956
+ }
2957
+ }
2958
+ const crop = this.retainedFrames?.crop(input.handle, input.bbox, input.maxWidth);
2959
+ return crop ? {
2960
+ bytes: crop.bytes,
2961
+ width: crop.width,
2962
+ height: crop.height
3220
2963
  } : null;
3221
2964
  }
3222
2965
  /**
@@ -3291,6 +3034,52 @@ var PipelineRunnerAddon = class extends BaseAddon {
3291
3034
  return null;
3292
3035
  }
3293
3036
  }
3037
+ /** Last unexpected detection-decode end per device — bounds the re-dial. */
3038
+ lastDecodeUnexpectedEndAt = /* @__PURE__ */ new Map();
3039
+ /**
3040
+ * The detection decode pump ended WITHOUT a teardown (worker death /
3041
+ * generator error) while the camera may still be attached. Previously this
3042
+ * zombied the session — phase stayed `active`, frames stopped, and nothing
3043
+ * re-dialed (the pre-fix Channel-closed runner crash used to mask it by
3044
+ * respawning the whole runner). Recovery, bounded:
3045
+ * - first death: clean up the dead subscription and re-dial once (2s);
3046
+ * - second death within 60s: give up re-dialing and DETACH — the
3047
+ * orchestrator re-dispatches cleanly on the next motion/occupancy signal
3048
+ * (the same recovery path validated manually on device 592).
3049
+ */
3050
+ handleDetectionDecodeEnded(deviceId) {
3051
+ const attachment = this.attached.get(deviceId);
3052
+ if (!attachment) return;
3053
+ const log = this.ctx.logger.withTags({ deviceId });
3054
+ attachment.detectionUnsubscribe?.();
3055
+ attachment.detectionUnsubscribe = null;
3056
+ if (attachment.detectionKeepWarmTimer !== null) {
3057
+ clearTimeout(attachment.detectionKeepWarmTimer);
3058
+ attachment.detectionKeepWarmTimer = null;
3059
+ log.warn("detection decode worker died while kept warm — next open re-forks cold");
3060
+ return;
3061
+ }
3062
+ const now = Date.now();
3063
+ const last = this.lastDecodeUnexpectedEndAt.get(deviceId) ?? 0;
3064
+ this.lastDecodeUnexpectedEndAt.set(deviceId, now);
3065
+ if (now - last < DETECTION_REDIAL_GIVEUP_WINDOW_MS) {
3066
+ log.warn("detection decode died twice within the re-dial window — detaching for orchestrator re-dispatch");
3067
+ this.detachInternal(deviceId);
3068
+ return;
3069
+ }
3070
+ log.warn("detection decode ended unexpectedly while attached — re-dialing once");
3071
+ setTimeout(() => {
3072
+ const still = this.attached.get(deviceId);
3073
+ if (!still || still.detectionUnsubscribe !== null) return;
3074
+ this.subscribeDetectionFrames(still.config).then((unsub) => {
3075
+ if (!this.attached.has(deviceId)) {
3076
+ unsub?.();
3077
+ return;
3078
+ }
3079
+ still.detectionUnsubscribe = unsub;
3080
+ });
3081
+ }, DETECTION_REDIAL_DELAY_MS).unref?.();
3082
+ }
3294
3083
  detachInternal(deviceId) {
3295
3084
  const attachment = this.attached.get(deviceId);
3296
3085
  if (!attachment) return;
@@ -3302,16 +3091,7 @@ var PipelineRunnerAddon = class extends BaseAddon {
3302
3091
  attachment.motionUnsubscribe?.();
3303
3092
  attachment.detectionUnsubscribe?.();
3304
3093
  this.lastMotionFrameAt.delete(deviceId);
3305
- const remotePlane = this.remotePlanes.get(deviceId);
3306
- if (remotePlane) {
3307
- this.remotePlanes.delete(deviceId);
3308
- remotePlane.dispose().catch((err) => {
3309
- this.ctx?.logger.debug("remote-source plane dispose failed on detach", {
3310
- tags: { deviceId },
3311
- meta: { error: errMsg(err) }
3312
- });
3313
- });
3314
- }
3094
+ this.lastDecodeUnexpectedEndAt.delete(deviceId);
3315
3095
  this.attached.delete(deviceId);
3316
3096
  this.lastMotionAt.delete(deviceId);
3317
3097
  this.lastEmittedCameraMetrics.delete(deviceId);
@@ -3429,65 +3209,6 @@ var PipelineRunnerAddon = class extends BaseAddon {
3429
3209
  return [...this.attached.keys()];
3430
3210
  }
3431
3211
  /**
3432
- * Cross-node frame fetch for FOREIGN handles (segment on another node). Wired
3433
- * into every `startFrameHandlePoller` call so `resolvePolledFrame` can take
3434
- * the bounded `decoder.getFrame` fallback instead of an unmappable local shm
3435
- * read. The `nodeId` selector routes the call to the producing node (the
3436
- * cap's `nodeIdMode: 'routing'` default; agent→hub via `$hub-cap-fwd`). The
3437
- * wire schema returns `data: Uint8Array`; downstream inference expects a Node
3438
- * `Buffer`, so we adapt it here exactly as post-analysis does.
3439
- */
3440
- async getRemoteFrame(handle) {
3441
- const api = this.ctx.api;
3442
- if (!api) return null;
3443
- const remote = await api.decoder.getFrame.query({
3444
- handle,
3445
- nodeId: handle.nodeId
3446
- });
3447
- if (!remote) return null;
3448
- return {
3449
- data: Buffer.from(remote.data),
3450
- width: remote.width,
3451
- height: remote.height,
3452
- format: remote.format,
3453
- timestamp: remote.timestamp
3454
- };
3455
- }
3456
- /**
3457
- * The remote-source leg for a camera, or `null` for today's co-located
3458
- * broker path (P2d). Created lazily on the FIRST subscription of a
3459
- * `remote-restream` camera and shared by its motion + detection pollers;
3460
- * `detachInternal` / `onShutdown` dispose it. With rollout OFF every
3461
- * attach payload is `local-broker` (or absent) → always `null` → the
3462
- * subscribe paths below are byte-identical to pre-Phase-2.
3463
- */
3464
- remoteSourceFor(config) {
3465
- const frameSource = config.frameSource;
3466
- if (!isRemoteRestream(frameSource)) return null;
3467
- const existing = this.remotePlanes.get(config.deviceId);
3468
- if (existing) return existing;
3469
- const api = this.ctx.api;
3470
- if (!api) return null;
3471
- const hostname = resolveOwnerRestreamHost(frameSource, process.env["CAMSTACK_HUB_URL"]);
3472
- const plane = new RemoteSourcePlane({
3473
- api,
3474
- logger: this.ctx.logger,
3475
- deviceId: config.deviceId,
3476
- ownNodeId: this.nodeId,
3477
- ownerNodeId: frameSource.ownerNodeId,
3478
- hostname
3479
- });
3480
- this.ctx.logger.info("remote-source leg armed for camera", {
3481
- tags: { deviceId: config.deviceId },
3482
- meta: {
3483
- ownerNodeId: frameSource.ownerNodeId,
3484
- hostname: hostname ?? "(unresolved — acquires will fail until set)"
3485
- }
3486
- });
3487
- this.remotePlanes.set(config.deviceId, plane);
3488
- return plane;
3489
- }
3490
- /**
3491
3212
  * Thin delegator to the module-level {@link acquireSessionDecodeRestreamFrom}
3492
3213
  * (see its doc for the acquire behavior) — supplies this addon's `ctx.logger`.
3493
3214
  * Kept as a method so the two call sites need no change.
@@ -3589,11 +3310,7 @@ var PipelineRunnerAddon = class extends BaseAddon {
3589
3310
  const runner = this.runner;
3590
3311
  const log = this.ctx.logger.withTags({ deviceId: config.deviceId });
3591
3312
  if (!runner) return () => {};
3592
- const retentionSink = createSessionRetentionSink({
3593
- deviceId: config.deviceId,
3594
- nodeId: this.nodeId,
3595
- logger: log
3596
- });
3313
+ const retainedFrames = this.retainedFrames;
3597
3314
  const teardown = startGuardedSessionDecode({
3598
3315
  logger: log,
3599
3316
  acquire: () => this.acquireSessionDecodeRestream(api, config.deviceId, config.detectionStreamId, `session-decode:detect:${config.deviceId}`, resolveSessionDecodeHostname(config, process.env["CAMSTACK_HUB_URL"]), resolveSessionDecodeOwnerNodeId(config)),
@@ -3611,8 +3328,9 @@ var PipelineRunnerAddon = class extends BaseAddon {
3611
3328
  format: "rgb"
3612
3329
  },
3613
3330
  acquireSpawnSlot: this.buildDecodeSpawnAcquire(log, "detect"),
3331
+ onEnded: () => this.handleDetectionDecodeEnded(config.deviceId),
3614
3332
  onDecodedFrame: (frame, nativeCrop) => {
3615
- const handle = retentionSink.write(frame) ?? void 0;
3333
+ const handle = retainedFrames?.write(config.deviceId, frame) ?? void 0;
3616
3334
  if (handle && nativeCrop) this.nativeCropRegistry.register(handle, nativeCrop);
3617
3335
  this.enqueueSharedDetectionFrame(config.deviceId, frame, handle);
3618
3336
  }
@@ -3621,7 +3339,6 @@ var PipelineRunnerAddon = class extends BaseAddon {
3621
3339
  });
3622
3340
  return () => {
3623
3341
  teardown();
3624
- retentionSink.destroy();
3625
3342
  };
3626
3343
  }
3627
3344
  /**
@@ -3687,30 +3404,8 @@ var PipelineRunnerAddon = class extends BaseAddon {
3687
3404
  log.warn("subscribeMotionFrames: this.ctx.api not available");
3688
3405
  return null;
3689
3406
  }
3690
- if (shouldUseSessionDecode(this.config.useSessionDecode)) {
3691
- if (resolveMotionFrameSource(config, opts?.sharedWithDetection === true).shared) return this.registerSharedMotionSink(config.deviceId, config.motionFps);
3692
- return this.startMotionSessionDecode(config, api);
3693
- }
3694
- const src = resolveMotionFrameSource(config, opts?.sharedWithDetection === true);
3695
- const shared = src.shared;
3696
- const streamId = src.streamId;
3697
- const remote = this.remoteSourceFor(config);
3698
- return startFrameHandlePoller({
3699
- api,
3700
- brokerId: makeSourceBrokerId(config.deviceId, streamId),
3701
- format: src.format,
3702
- maxFps: config.motionFps,
3703
- tag: shared ? "motion-shared" : "motion",
3704
- logger: log,
3705
- ownNodeId: this.nodeId,
3706
- getRemoteFrame: (handle) => this.getRemoteFrame(handle),
3707
- ...remote ? { source: remote.makeSource(streamId) } : {},
3708
- onFrame: shared ? (frame) => {
3709
- runner.enqueueMotionFrame(config.deviceId, rgbFrameToGrayFrame(frame), void 0);
3710
- } : (frame, handle) => {
3711
- runner.enqueueMotionFrame(config.deviceId, frame, handle);
3712
- }
3713
- });
3407
+ if (resolveMotionFrameSource(config, opts?.sharedWithDetection === true).shared) return this.registerSharedMotionSink(config.deviceId, config.motionFps);
3408
+ return this.startMotionSessionDecode(config, api);
3714
3409
  }
3715
3410
  handleDetectionStreamChange(deviceId, action) {
3716
3411
  const attachment = this.attached.get(deviceId);
@@ -3798,24 +3493,9 @@ var PipelineRunnerAddon = class extends BaseAddon {
3798
3493
  log.warn("subscribeDetectionFrames: this.ctx.api not available");
3799
3494
  return null;
3800
3495
  }
3801
- if (shouldUseSessionDecode(this.config.useSessionDecode)) return this.startDetectionSessionDecode(config, api);
3802
- const remote = this.remoteSourceFor(config);
3803
- return startFrameHandlePoller({
3804
- api,
3805
- brokerId: makeSourceBrokerId(config.deviceId, config.detectionStreamId),
3806
- format: "rgb",
3807
- maxFps: config.detectionFps,
3808
- tag: "detection",
3809
- logger: log,
3810
- ownNodeId: this.nodeId,
3811
- getRemoteFrame: (handle) => this.getRemoteFrame(handle),
3812
- ...remote ? { source: remote.makeSource(config.detectionStreamId) } : {},
3813
- onFrame: (frame, handle) => {
3814
- runner.enqueueDetectionFrame(config.deviceId, frame, handle);
3815
- }
3816
- });
3496
+ return this.startDetectionSessionDecode(config, api);
3817
3497
  }
3818
- async runInference(deviceId, frame, handle) {
3498
+ async runInference(deviceId, frame, _handle) {
3819
3499
  if (!this.ctx) return null;
3820
3500
  const log = this.ctx.logger.withTags({ deviceId });
3821
3501
  const api = this.ctx.api;
@@ -3829,11 +3509,20 @@ var PipelineRunnerAddon = class extends BaseAddon {
3829
3509
  return null;
3830
3510
  }
3831
3511
  if (steps.length === 0) return null;
3832
- const useHandle = handle !== void 0 && handle.nodeId === this.nodeId && handle.width === frame.width && handle.height === frame.height;
3512
+ let image;
3513
+ if (frame.format === "rgb") try {
3514
+ image = await sharp(frame.data, { raw: {
3515
+ width: frame.width,
3516
+ height: frame.height,
3517
+ channels: 3
3518
+ } }).jpeg({ quality: DETECTION_HOP_JPEG_QUALITY }).toBuffer();
3519
+ } catch (err) {
3520
+ log.debug("runInference: frame JPEG encode failed — falling back to raw pixels", { meta: { error: errMsg(err) } });
3521
+ }
3833
3522
  try {
3834
3523
  return await api.pipelineExecutor.runPipeline.mutate({
3835
3524
  steps: [...steps],
3836
- ...useHandle ? { frameHandle: handle } : { frame },
3525
+ ...image !== void 0 ? { image } : { frame },
3837
3526
  deviceId,
3838
3527
  plane: "frame"
3839
3528
  });
@@ -3857,10 +3546,9 @@ var PipelineRunnerAddon = class extends BaseAddon {
3857
3546
  log.warn("runMotionAnalysis: this.ctx.api not available");
3858
3547
  return;
3859
3548
  }
3860
- const useHandle = handle !== void 0 && handle.nodeId === this.nodeId;
3861
3549
  const result = await api.motionDetection.analyze.mutate({
3862
3550
  deviceId,
3863
- ...useHandle ? { frameHandle: handle } : { frame: toFrameInput(frame) }
3551
+ frame: toFrameInput(frame)
3864
3552
  });
3865
3553
  if (!result) return;
3866
3554
  const detected = result.regions.length > 0;
@@ -4078,9 +3766,9 @@ var PipelineRunnerAddon = class extends BaseAddon {
4078
3766
  await this.runOccupancyDial(deviceId, frames, attachment, runner, api, log, false);
4079
3767
  }
4080
3768
  /**
4081
- * Dial-path occupancy burst: subscribes a fresh main-stream decode session
4082
- * via `startFrameHandlePoller` and collects `frames` handles (or times out
4083
- * at {@link OCCUPANCY_BURST_SAFETY_TIMEOUT_MS}). Called either directly
3769
+ * Dial-path occupancy burst: spins up a fresh per-session decode worker
3770
+ * (session-decode) over the main stream and collects `frames` decoded frames
3771
+ * (or times out at {@link OCCUPANCY_BURST_SAFETY_TIMEOUT_MS}). Called either directly
4084
3772
  * from `runOccupancyBurst` (no live/fresh motion tap available,
4085
3773
  * `alreadyInFlight: false`) or as the Task 10 hardening #2 fallback after
4086
3774
  * a zero-collected tap (`alreadyInFlight: true` — the tap already claimed
@@ -4089,6 +3777,7 @@ var PipelineRunnerAddon = class extends BaseAddon {
4089
3777
  */
4090
3778
  async runOccupancyDial(deviceId, frames, attachment, runner, api, log, alreadyInFlight) {
4091
3779
  const config = attachment.config;
3780
+ const retainedFrames = this.retainedFrames;
4092
3781
  if (!alreadyInFlight) {
4093
3782
  this.occupancyBurstInFlight.add(deviceId);
4094
3783
  this.occupancyBurstFound.delete(deviceId);
@@ -4098,37 +3787,35 @@ var PipelineRunnerAddon = class extends BaseAddon {
4098
3787
  fallback: alreadyInFlight
4099
3788
  } });
4100
3789
  let count = 0;
4101
- let unsubscribe = null;
4102
- let unsubbed = false;
4103
3790
  let settled = false;
4104
- const doUnsub = () => {
4105
- if (!unsubbed && unsubscribe) {
4106
- unsubbed = true;
4107
- unsubscribe();
4108
- }
4109
- };
3791
+ let teardown = null;
4110
3792
  const cleanup = () => {
4111
3793
  if (settled) return;
4112
3794
  settled = true;
4113
- doUnsub();
3795
+ teardown?.();
4114
3796
  this.finishOccupancyBurst(deviceId, log, count);
4115
3797
  };
4116
3798
  const safetyTimer = setTimeout(() => {
4117
3799
  log.debug("occupancy re-check: safety timeout reached", { meta: { collected: count } });
4118
3800
  cleanup();
4119
3801
  }, OCCUPANCY_BURST_SAFETY_TIMEOUT_MS);
4120
- try {
4121
- unsubscribe = await startFrameHandlePoller({
4122
- api,
4123
- brokerId: makeSourceBrokerId(config.deviceId, config.detectionStreamId),
4124
- format: "rgb",
4125
- maxFps: config.detectionFps,
4126
- tag: "occupancy",
3802
+ teardown = startGuardedSessionDecode({
3803
+ logger: log,
3804
+ acquire: () => this.acquireSessionDecodeRestream(api, config.deviceId, config.detectionStreamId, `session-decode:occupancy:${config.deviceId}`, resolveSessionDecodeHostname(config, process.env["CAMSTACK_HUB_URL"]), resolveSessionDecodeOwnerNodeId(config)),
3805
+ startPump: (source) => startSessionDecodePump({
3806
+ fork: forkDecodeWorker,
4127
3807
  logger: log,
4128
- ownNodeId: this.nodeId,
4129
- getRemoteFrame: (handle) => this.getRemoteFrame(handle),
4130
- onFrame: (frame, handle) => {
3808
+ source,
3809
+ opts: {
3810
+ fps: config.detectionFps,
3811
+ format: "rgb"
3812
+ },
3813
+ acquireSpawnSlot: this.buildDecodeSpawnAcquire(log, "occupancy"),
3814
+ onEnded: () => cleanup(),
3815
+ onDecodedFrame: (frame, nativeCrop) => {
4131
3816
  if (settled) return;
3817
+ const handle = retainedFrames?.write(config.deviceId, frame) ?? void 0;
3818
+ if (handle && nativeCrop) this.nativeCropRegistry.register(handle, nativeCrop);
4132
3819
  runner.enqueueOccupancyFrame(deviceId, frame, handle);
4133
3820
  count++;
4134
3821
  if (count >= frames) {
@@ -4136,17 +3823,11 @@ var PipelineRunnerAddon = class extends BaseAddon {
4136
3823
  cleanup();
4137
3824
  }
4138
3825
  }
4139
- });
4140
- } catch (err) {
4141
- clearTimeout(safetyTimer);
4142
- this.occupancyBurstInFlight.delete(deviceId);
4143
- this.occupancyBurstFound.delete(deviceId);
4144
- log.debug("occupancy re-check: poller start failed", { meta: { error: errMsg(err) } });
4145
- return;
4146
- }
3826
+ })
3827
+ });
4147
3828
  if (settled) {
4148
3829
  clearTimeout(safetyTimer);
4149
- doUnsub();
3830
+ teardown();
4150
3831
  }
4151
3832
  }
4152
3833
  emitInferenceResult(deviceId, frame, result, handle) {
@@ -4280,14 +3961,6 @@ var PipelineRunnerAddon = class extends BaseAddon {
4280
3961
  default: DEFAULT_CONFIG.minThrottledFps,
4281
3962
  showValue: true
4282
3963
  },
4283
- {
4284
- type: "boolean",
4285
- key: "useSessionDecode",
4286
- style: "checkbox",
4287
- label: "Use per-session decode workers",
4288
- 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.",
4289
- default: DEFAULT_CONFIG.useSessionDecode
4290
- },
4291
3964
  {
4292
3965
  type: "slider",
4293
3966
  key: "maxConcurrentDecodeSpawns",
@@ -4310,7 +3983,6 @@ var PipelineRunnerAddon = class extends BaseAddon {
4310
3983
  maxConcurrentInferences: this.config.maxConcurrentInferences,
4311
3984
  targetLoadPercent: this.config.targetLoadPercent,
4312
3985
  minThrottledFps: this.config.minThrottledFps,
4313
- useSessionDecode: this.config.useSessionDecode,
4314
3986
  maxConcurrentDecodeSpawns: this.config.maxConcurrentDecodeSpawns
4315
3987
  } });
4316
3988
  }