@camstack/addon-pipeline 1.1.54 → 1.1.56

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 (39) hide show
  1. package/dist/audio-analyzer/index.js +1 -1
  2. package/dist/audio-analyzer/index.mjs +1 -1
  3. package/dist/detection-pipeline/index.js +159 -1471
  4. package/dist/detection-pipeline/index.mjs +139 -1451
  5. package/dist/{dist-BbaoC680.js → dist-ClE6_i0p.js} +1000 -40
  6. package/dist/{dist-CEcTeu1h.mjs → dist-CrjLGrkj.mjs} +1000 -40
  7. package/dist/motion-wasm/index.js +1 -1
  8. package/dist/motion-wasm/index.mjs +1 -1
  9. package/dist/pipeline-runner/index.js +777 -27
  10. package/dist/pipeline-runner/index.mjs +777 -28
  11. package/dist/recorder/index.js +1 -1
  12. package/dist/recorder/index.mjs +1 -1
  13. package/dist/{remote-source-plane-CNCqC_XG.mjs → remote-source-plane-B28iGi7h.mjs} +1 -1
  14. package/dist/{remote-source-plane-vN45KFSm.js → remote-source-plane-BAqEHSgm.js} +1 -1
  15. package/dist/session-decode/decode-worker-child.js +227 -6
  16. package/dist/session-decode/decode-worker-child.mjs +227 -6
  17. package/dist/step-definitions-C9kUXgC7.js +1514 -0
  18. package/dist/step-definitions-dCghpdRo.mjs +1479 -0
  19. package/dist/stream-broker/_stub.js +79 -79
  20. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-HS0tc6Sa.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-D4xH4Tm-.mjs} +3 -3
  21. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-D2z_vRMH.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-B_v5r5ya.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-DgohfEDA.mjs → hostInit-Di9WUNlQ.mjs} +3 -3
  24. package/dist/stream-broker/index.js +60 -3
  25. package/dist/stream-broker/index.mjs +60 -3
  26. package/dist/stream-broker/remoteEntry.js +1 -1
  27. package/dist/{worker-protocol-DGIt_waM.mjs → worker-protocol-CyVJTZEO.mjs} +1 -0
  28. package/dist/{worker-protocol-DkL6GDxe.js → worker-protocol-PP4jKHHJ.js} +1 -0
  29. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-CA2Hd6xx.js → MaskShapeCanvas-DI4BY7W2-Ca93F1js.js} +1 -1
  30. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-DUN1VeC7.js → MotionZonesSettings-NcxxQN8r-CDqgb-WA.js} +1 -1
  31. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-BqZIAU5s.js → PrivacyMaskSettings-APgPLF7p-BlxPhY_w.js} +1 -1
  32. package/embed-dist/assets/index-B23riUw8.js +81 -0
  33. package/embed-dist/assets/index-Bmpl9O1K.css +2 -0
  34. package/embed-dist/index.html +2 -2
  35. package/package.json +1 -1
  36. package/python/inference_pool.py +11 -1
  37. 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
  38. package/embed-dist/assets/index-CC06JBcl.css +0 -2
  39. package/embed-dist/assets/index-CMkYypxB.js +0 -81
@@ -1,7 +1,8 @@
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-CEcTeu1h.mjs";
2
- import { n as isRemoteRestream, r as profileForStreamId, t as RemoteSourcePlane } from "../remote-source-plane-CNCqC_XG.mjs";
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-CrjLGrkj.mjs";
2
+ import { n as isRemoteRestream, r as profileForStreamId, t as RemoteSourcePlane } from "../remote-source-plane-B28iGi7h.mjs";
3
3
  import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
4
- import { t as isWorkerReply } from "../worker-protocol-DGIt_waM.mjs";
4
+ import { t as isWorkerReply } from "../worker-protocol-CyVJTZEO.mjs";
5
+ import { a as getStepDefinition } from "../step-definitions-dCghpdRo.mjs";
5
6
  import { DecoderFrameRingSink, FrameRingReaderCache, unlinkSegment } from "@camstack/shm-ring";
6
7
  import { fork } from "node:child_process";
7
8
  import { fileURLToPath } from "node:url";
@@ -423,6 +424,36 @@ function startPolling(options, source, subscriptionId, resolvedMaxFps, lifecycle
423
424
  };
424
425
  }
425
426
  //#endregion
427
+ //#region src/pipeline-runner/frame-governor.ts
428
+ var FrameGovernor = class {
429
+ config;
430
+ intervals = /* @__PURE__ */ new Map();
431
+ throttled = 0;
432
+ constructor(config) {
433
+ this.config = config;
434
+ }
435
+ update(samples) {
436
+ this.intervals.clear();
437
+ this.throttled = 0;
438
+ if (samples.size === 0) return;
439
+ const shareMsPerSec = this.config.targetLoadPercent / 100 * this.config.maxConcurrentInferences * 1e3 / samples.size;
440
+ for (const [deviceId, s] of samples) {
441
+ const ceilingFps = s.configuredFps > 0 ? s.configuredFps : s.decodeFps;
442
+ const affordableFps = s.avgInferenceMs > 0 ? shareMsPerSec / s.avgInferenceMs : ceilingFps;
443
+ const targetFps = Math.min(ceilingFps, Math.max(this.config.minThrottledFps, affordableFps));
444
+ if (targetFps < ceilingFps) this.throttled++;
445
+ this.intervals.set(deviceId, targetFps > 0 ? 1e3 / targetFps : 0);
446
+ }
447
+ }
448
+ /** Minimum ms between admitted frames; 0 = no gate. */
449
+ minIntervalMs(deviceId) {
450
+ return this.intervals.get(deviceId) ?? 0;
451
+ }
452
+ throttledCount() {
453
+ return this.throttled;
454
+ }
455
+ };
456
+ //#endregion
426
457
  //#region src/pipeline-runner/frame-queue.ts
427
458
  /**
428
459
  * Latest-frame-only buffer. Keeps only the most recent item, dropping all
@@ -604,6 +635,24 @@ var PipelineTimingSampler = class {
604
635
  if (!this.detSamples.has(deviceId)) this.detSamples.set(deviceId, []);
605
636
  this.detSamples.get(deviceId).push(s);
606
637
  }
638
+ /**
639
+ * Moving average inference time (ms) per camera over the current
640
+ * accumulation window — the same `inf` samples the `pipeline stats` log
641
+ * line aggregates (see {@link report}'s `inf` computation). Consumed by
642
+ * the {@link FrameGovernor} recompute tick as `CameraLoadSample.avgInferenceMs`.
643
+ * The window resets on every {@link report} call (every REPORT_INTERVAL_MS),
644
+ * so a camera with zero completed inferences since the last report is
645
+ * simply absent from the returned map — callers supply their own default.
646
+ */
647
+ avgInferenceMsByCamera() {
648
+ const result = /* @__PURE__ */ new Map();
649
+ for (const [deviceId, samples] of this.detSamples) {
650
+ if (samples.length === 0) continue;
651
+ const total = samples.reduce((sum, s) => sum + s.inference, 0);
652
+ result.set(deviceId, total / samples.length);
653
+ }
654
+ return result;
655
+ }
607
656
  addMotionSample(deviceId, ms, frameAge = -1) {
608
657
  if (!this.motSamples.has(deviceId)) this.motSamples.set(deviceId, []);
609
658
  this.motSamples.get(deviceId).push({
@@ -781,6 +830,8 @@ function ownMotionEntry(entry) {
781
830
  */
782
831
  var HANDLE_PASSTHROUGH_MAX_QUEUE_DEPTH = 1;
783
832
  var DEFAULT_MOTION_COOLDOWN_MS = 3e4;
833
+ var GOVERNOR_RECOMPUTE_INTERVAL_MS = 2e3;
834
+ var DEFAULT_DECODE_FPS = 25;
784
835
  function toFrameInput$1(frame) {
785
836
  return {
786
837
  data: frame.data,
@@ -816,10 +867,20 @@ var PipelineRunner = class {
816
867
  detectionStreamHandler = null;
817
868
  logger;
818
869
  timingSampler = new PipelineTimingSampler();
870
+ governor;
871
+ lastAdmittedAt = /* @__PURE__ */ new Map();
872
+ arrivalCounts = /* @__PURE__ */ new Map();
873
+ lastGovernorRecomputeAt = Date.now();
874
+ governorIntervalHandle = null;
819
875
  constructor(config) {
820
876
  this.config = config;
821
877
  this.logger = config.logger;
822
878
  this.semaphore = new Semaphore(config.maxConcurrentInferences);
879
+ this.governor = new FrameGovernor({
880
+ targetLoadPercent: config.targetLoadPercent,
881
+ minThrottledFps: config.minThrottledFps,
882
+ maxConcurrentInferences: config.maxConcurrentInferences
883
+ });
823
884
  }
824
885
  /**
825
886
  * Hot-reload the four tuning fields without tearing down the runner.
@@ -830,8 +891,15 @@ var PipelineRunner = class {
830
891
  * (the FrameQueue implementation is latest-only and ignores maxSize
831
892
  * anyway — see `frame-queue.ts` — so the field is effectively a
832
893
  * metadata hint for observability).
833
- * - `targetLoadPercent` / `minThrottledFps`: stored for future
834
- * throttling logic (not yet consumed in the current runner body).
894
+ * - `targetLoadPercent` / `minThrottledFps`: stored on `this.config` (read
895
+ * by `getLimits()`), but NOT re-fed into the live `FrameGovernor` — the
896
+ * governor is built once, at construction, from the config snapshot
897
+ * passed to the constructor. A hot patch to either knob changes what
898
+ * `getLimits()` reports but does not change live admission pacing until
899
+ * the runner is reconstructed. (Rebuilding the governor live is a
900
+ * follow-up, not in scope here — `maxConcurrentInferences` has the same
901
+ * gap: the semaphore resizes live but the governor's budget calc keeps
902
+ * the construction-time value.)
835
903
  *
836
904
  * Only keys present in the patch are overwritten; unspecified keys
837
905
  * retain their current value. Any illegal combination (e.g.
@@ -877,13 +945,16 @@ var PipelineRunner = class {
877
945
  lastArmedSource: null,
878
946
  lastArmedRegions: void 0,
879
947
  occupancyTimer: null,
880
- motionInFlight: false
948
+ motionInFlight: false,
949
+ lastResultDetections: 0,
950
+ occupancyArmed: false,
951
+ emptyBurstStreak: 0
881
952
  };
882
953
  this.cameras.set(deviceId, state);
883
954
  this.timingSampler.setDroppedFramesSource(deviceId, () => detectionQueue.droppedFrames);
884
955
  if (registration.detectionMode === "on-motion") this.defaultRoundRobinKeys.push(deviceId);
885
956
  if (registration.detectionMode === "on-motion" && registration.occupancyRecheckEnabled === true && (registration.occupancyRecheckSec ?? 0) > 0) state.occupancyTimer = setInterval(() => {
886
- if (state.phase === "watching") this.config.onOccupancyRecheck?.(deviceId, registration.occupancyRecheckFrames ?? 4);
957
+ if (state.phase === "watching" && state.occupancyArmed) this.config.onOccupancyRecheck?.(deviceId, registration.occupancyRecheckFrames ?? 4);
887
958
  }, (registration.occupancyRecheckSec ?? 0) * 1e3);
888
959
  if (initialPhase === "active") {
889
960
  this.detectionStreamHandler?.(deviceId, "subscribe");
@@ -912,6 +983,8 @@ var PipelineRunner = class {
912
983
  state.detectionQueue.clear();
913
984
  this.timingSampler.clearDroppedFramesSource(deviceId);
914
985
  this.cameras.delete(deviceId);
986
+ this.lastAdmittedAt.delete(deviceId);
987
+ this.arrivalCounts.delete(deviceId);
915
988
  const idx = this.defaultRoundRobinKeys.indexOf(deviceId);
916
989
  if (idx !== -1) {
917
990
  this.defaultRoundRobinKeys.splice(idx, 1);
@@ -929,13 +1002,29 @@ var PipelineRunner = class {
929
1002
  enqueueDetectionFrame(deviceId, frame, handle) {
930
1003
  const state = this.cameras.get(deviceId);
931
1004
  if (!state) return;
1005
+ this.arrivalCounts.set(deviceId, (this.arrivalCounts.get(deviceId) ?? 0) + 1);
932
1006
  if (state.phase !== "active") return;
1007
+ const interval = this.governor.minIntervalMs(deviceId);
1008
+ if (interval > 0) {
1009
+ const last = this.lastAdmittedAt.get(deviceId) ?? 0;
1010
+ const now = Date.now();
1011
+ if (now - last < interval) return;
1012
+ this.lastAdmittedAt.set(deviceId, now);
1013
+ }
933
1014
  frame._enqueuedAt = Date.now();
934
1015
  state.detectionQueue.enqueue({
935
1016
  frame,
936
1017
  handle
937
1018
  });
938
1019
  }
1020
+ /**
1021
+ * `handle` is optional (mirrors {@link DetectionQueueEntry.handle}) — the
1022
+ * dial path (`runOccupancyBurst`'s `startFrameHandlePoller`) always has a
1023
+ * real shm handle, but Task 10's analyzer-tap path can feed a frame whose
1024
+ * motion subscription never produced one (e.g. the per-session motion
1025
+ * decode worker). `runInference` already falls back to the pixel path
1026
+ * when the handle is absent.
1027
+ */
939
1028
  enqueueOccupancyFrame(deviceId, frame, handle) {
940
1029
  const state = this.cameras.get(deviceId);
941
1030
  if (!state) return;
@@ -988,6 +1077,42 @@ var PipelineRunner = class {
988
1077
  getPhase(deviceId) {
989
1078
  return this.cameras.get(deviceId)?.phase;
990
1079
  }
1080
+ /**
1081
+ * Record the detection count from the runner's most recent inference
1082
+ * result for this camera. The addon wrapping the scheduler calls this
1083
+ * from its `onResult` callback — the runner itself stays event-agnostic
1084
+ * (it never inspects `FrameResult` shapes), it only remembers the raw
1085
+ * count that `transitionToWatching` reads to decide whether the
1086
+ * occupancy recheck timer arms for the upcoming `watching` phase.
1087
+ */
1088
+ noteDetectionResult(deviceId, detectionCount) {
1089
+ const state = this.cameras.get(deviceId);
1090
+ if (!state) return;
1091
+ state.lastResultDetections = detectionCount;
1092
+ }
1093
+ /**
1094
+ * Report the outcome of a completed occupancy recheck burst (see
1095
+ * `RunnerConfig.onOccupancyRecheck`). Called by the addon once it knows
1096
+ * whether any burst frame's inference result had detections.
1097
+ *
1098
+ * - `found: true` — resets the empty-burst streak. The timer stays armed;
1099
+ * a genuine occupant is a separate `reportMotion` event that reopens the
1100
+ * session through the existing motion/dispatch path — this method does
1101
+ * not itself flip the phase.
1102
+ * - `found: false` — increments the streak; after 2 consecutive empty
1103
+ * bursts the timer disarms (`occupancyArmed = false`) until the next
1104
+ * active→watching transition re-evaluates from scratch.
1105
+ */
1106
+ reportOccupancyBurstOutcome(deviceId, found) {
1107
+ const state = this.cameras.get(deviceId);
1108
+ if (!state) return;
1109
+ if (found) {
1110
+ state.emptyBurstStreak = 0;
1111
+ return;
1112
+ }
1113
+ state.emptyBurstStreak++;
1114
+ if (state.emptyBurstStreak >= 2) state.occupancyArmed = false;
1115
+ }
991
1116
  onResult(callback) {
992
1117
  this.resultCallbacks.push(callback);
993
1118
  }
@@ -995,12 +1120,19 @@ var PipelineRunner = class {
995
1120
  if (this.intervalHandle !== null) return;
996
1121
  this.intervalHandle = setInterval(() => this.tick(), 10);
997
1122
  this.timingSampler.start();
1123
+ this.lastGovernorRecomputeAt = Date.now();
1124
+ this.governorIntervalHandle = setInterval(() => this.recomputeGovernor(), GOVERNOR_RECOMPUTE_INTERVAL_MS);
1125
+ this.governorIntervalHandle?.unref?.();
998
1126
  }
999
1127
  stop() {
1000
1128
  if (this.intervalHandle !== null) {
1001
1129
  clearInterval(this.intervalHandle);
1002
1130
  this.intervalHandle = null;
1003
1131
  }
1132
+ if (this.governorIntervalHandle !== null) {
1133
+ clearInterval(this.governorIntervalHandle);
1134
+ this.governorIntervalHandle = null;
1135
+ }
1004
1136
  this.timingSampler.stop();
1005
1137
  for (const state of this.cameras.values()) {
1006
1138
  if (state.motionCooldownTimer !== null) {
@@ -1013,6 +1145,31 @@ var PipelineRunner = class {
1013
1145
  }
1014
1146
  }
1015
1147
  }
1148
+ /**
1149
+ * Recompute the {@link FrameGovernor}'s per-camera admission intervals
1150
+ * from fresh sampler + arrival data. Called on the {@link
1151
+ * GOVERNOR_RECOMPUTE_INTERVAL_MS} tick started by `start()`; also exposed
1152
+ * so tests can prime the governor deterministically without waiting on a
1153
+ * timer. `now` defaults to the wall clock but is accepted explicitly so a
1154
+ * caller can pass a fixed instant for reproducible elapsed-time math.
1155
+ */
1156
+ recomputeGovernor(now = Date.now()) {
1157
+ const elapsedSec = Math.max((now - this.lastGovernorRecomputeAt) / 1e3, .001);
1158
+ const avgInferenceMsByCamera = this.timingSampler.avgInferenceMsByCamera();
1159
+ const samples = /* @__PURE__ */ new Map();
1160
+ for (const [deviceId, state] of this.cameras) {
1161
+ if (state.phase !== "active") continue;
1162
+ const measuredDecodeFps = (this.arrivalCounts.get(deviceId) ?? 0) / elapsedSec;
1163
+ samples.set(deviceId, {
1164
+ avgInferenceMs: avgInferenceMsByCamera.get(deviceId) ?? 0,
1165
+ configuredFps: state.registration.fps,
1166
+ decodeFps: measuredDecodeFps > 0 ? measuredDecodeFps : DEFAULT_DECODE_FPS
1167
+ });
1168
+ }
1169
+ this.governor.update(samples);
1170
+ this.arrivalCounts.clear();
1171
+ this.lastGovernorRecomputeAt = now;
1172
+ }
1016
1173
  getMetrics() {
1017
1174
  let totalQueueDepth = 0;
1018
1175
  let totalInferenceTime = 0;
@@ -1026,7 +1183,7 @@ var PipelineRunner = class {
1026
1183
  }
1027
1184
  return {
1028
1185
  activeCameras: this.cameras.size,
1029
- throttledCameras: 0,
1186
+ throttledCameras: this.governor.throttledCount(),
1030
1187
  avgInferenceTimeMs: totalInferenceCount > 0 ? totalInferenceTime / totalInferenceCount : 0,
1031
1188
  queueDepth: totalQueueDepth
1032
1189
  };
@@ -1064,6 +1221,7 @@ var PipelineRunner = class {
1064
1221
  }
1065
1222
  transitionToActive(deviceId, state, source, regions, cooldownMs) {
1066
1223
  state.phase = "active";
1224
+ state.lastResultDetections = 0;
1067
1225
  this.logger?.info("motion gate opened — phase=active", {
1068
1226
  tags: { deviceId },
1069
1227
  meta: {
@@ -1082,9 +1240,14 @@ var PipelineRunner = class {
1082
1240
  transitionToWatching(deviceId, state, cooldownMs) {
1083
1241
  state.phase = "watching";
1084
1242
  state.detectionQueue.clear();
1243
+ state.occupancyArmed = state.lastResultDetections > 0;
1244
+ state.emptyBurstStreak = 0;
1085
1245
  this.logger?.info("motion gate closed — phase=watching", {
1086
1246
  tags: { deviceId },
1087
- meta: { lastSource: state.lastArmedSource }
1247
+ meta: {
1248
+ lastSource: state.lastArmedSource,
1249
+ occupancyArmed: state.occupancyArmed
1250
+ }
1088
1251
  });
1089
1252
  this.detectionStreamHandler?.(deviceId, "unsubscribe");
1090
1253
  const source = state.lastArmedSource ?? "analyzer";
@@ -1221,13 +1384,20 @@ var KILL_SIGNALS = {
1221
1384
  };
1222
1385
  /** Adapt a Node {@link ChildProcess} to the coordinator's {@link ForkedChildLike}. */
1223
1386
  function wrapChild(child) {
1387
+ child.on("error", () => void 0);
1224
1388
  return {
1225
1389
  send: (msg) => {
1226
- child.send(msg);
1390
+ if (!child.connected) return;
1391
+ child.send(msg, () => void 0);
1227
1392
  },
1228
1393
  on: (ev, cb) => {
1229
- if (ev === "exit") child.on("exit", () => cb(void 0));
1230
- else child.on("message", (m) => cb(m));
1394
+ if (ev === "exit") {
1395
+ const toInfo = (code, signal) => ({
1396
+ code,
1397
+ signal
1398
+ });
1399
+ child.on("exit", (code, signal) => cb(toInfo(code ?? null, signal ?? null)));
1400
+ } else child.on("message", (m) => cb(m));
1231
1401
  },
1232
1402
  kill: (signal) => {
1233
1403
  if (signal === void 0) {
@@ -1435,6 +1605,11 @@ var SIGKILL_FALLBACK_MS = 1e3;
1435
1605
  var DEFAULT_FIRST_FRAME_TIMEOUT_MS = 2e4;
1436
1606
  /** Default timeout for every `pull` round-trip AFTER the first frame arrives. */
1437
1607
  var DEFAULT_FRAME_TIMEOUT_MS = 1e4;
1608
+ /** Narrow an 'exit' payload to {@link WorkerExitInfo} (fakes may emit undefined). */
1609
+ function toWorkerExitInfo(v) {
1610
+ const isInfo = (x) => typeof x === "object" && x !== null && "code" in x && "signal" in x;
1611
+ return isInfo(v) ? v : void 0;
1612
+ }
1438
1613
  function toError(err) {
1439
1614
  return err instanceof Error ? err : new Error(String(err));
1440
1615
  }
@@ -1455,6 +1630,8 @@ var SessionDecodeSession = class {
1455
1630
  pendingNativeCrops = /* @__PURE__ */ new Map();
1456
1631
  nextNativeCropId = 1;
1457
1632
  exited = false;
1633
+ /** Set by teardown() so handleExit can tell an intentional stop from a death. */
1634
+ stopRequested = false;
1458
1635
  sigkillTimer = null;
1459
1636
  pullTimeoutTimer = null;
1460
1637
  firstFrameReceived = false;
@@ -1464,7 +1641,7 @@ var SessionDecodeSession = class {
1464
1641
  this.firstFrameTimeoutMs = firstFrameTimeoutMs;
1465
1642
  this.frameTimeoutMs = frameTimeoutMs;
1466
1643
  this.child.on("message", (m) => this.handleMessage(m));
1467
- this.child.on("exit", () => this.handleExit());
1644
+ this.child.on("exit", (m) => this.handleExit(toWorkerExitInfo(m)));
1468
1645
  }
1469
1646
  start(source, opts) {
1470
1647
  this.trySend({
@@ -1542,8 +1719,21 @@ var SessionDecodeSession = class {
1542
1719
  }
1543
1720
  });
1544
1721
  }
1722
+ /**
1723
+ * Prompt-release the worker's RAM native-frame lease for `frameId`. Fire and
1724
+ * forget (no reply): a lease also self-expires on its TTL, so a dropped
1725
+ * release only defers reclaim. No-op once the worker has exited.
1726
+ */
1727
+ releaseLease(frameId) {
1728
+ if (this.exited) return;
1729
+ this.trySend({
1730
+ kind: "releaseNativeLease",
1731
+ frameId
1732
+ });
1733
+ }
1545
1734
  /** Teardown: `stop`, `kill()`, arm the SIGKILL fallback unless already exited. */
1546
1735
  teardown() {
1736
+ this.stopRequested = true;
1547
1737
  this.clearPullTimeout();
1548
1738
  this.trySend({ kind: "stop" });
1549
1739
  this.tryKill(void 0);
@@ -1589,7 +1779,11 @@ var SessionDecodeSession = class {
1589
1779
  return;
1590
1780
  }
1591
1781
  }
1592
- handleExit() {
1782
+ handleExit(info) {
1783
+ if (!this.stopRequested && !this.exited) this.logger.warn("session-decode worker exited unexpectedly", { meta: {
1784
+ code: info?.code ?? null,
1785
+ signal: info?.signal ?? null
1786
+ } });
1593
1787
  this.exited = true;
1594
1788
  if (this.sigkillTimer) {
1595
1789
  clearTimeout(this.sigkillTimer);
@@ -1749,6 +1943,7 @@ function buildFrameImage(session, reply) {
1749
1943
  return session.requestBuffer(reply.frameId, opts);
1750
1944
  },
1751
1945
  nativeCrop: (bbox, maxWidth) => session.requestNativeCrop(reply.frameId, bbox, maxWidth),
1946
+ releaseNativeLease: () => session.releaseLease(reply.frameId),
1752
1947
  close: () => {
1753
1948
  closed = true;
1754
1949
  }
@@ -1890,7 +2085,8 @@ function startSessionDecodePump(deps) {
1890
2085
  try {
1891
2086
  const decoded = await toDecodedFrame(frame, format);
1892
2087
  const image = frame.image;
1893
- const nativeCrop = image.nativeCrop ? (bbox, maxWidth) => image.nativeCrop(bbox, maxWidth) : void 0;
2088
+ const cropFn = image.nativeCrop;
2089
+ const nativeCrop = cropFn ? Object.assign((bbox, maxWidth) => cropFn(bbox, maxWidth), { release: () => image.releaseNativeLease?.() }) : void 0;
1894
2090
  deps.onDecodedFrame(decoded, nativeCrop);
1895
2091
  } catch (err) {
1896
2092
  deps.logger.debug("session-decode pump: skipped a frame (decode/consume error)", {
@@ -1914,6 +2110,7 @@ function startSessionDecodePump(deps) {
1914
2110
  });
1915
2111
  } finally {
1916
2112
  releaseSpawnSlotOnce();
2113
+ if (!stopped) deps.onEnded?.();
1917
2114
  }
1918
2115
  };
1919
2116
  pump();
@@ -1924,6 +2121,12 @@ function startSessionDecodePump(deps) {
1924
2121
  iterator.return().catch(() => {});
1925
2122
  };
1926
2123
  }
2124
+ /** Invoke a requester's best-effort `release()`, swallowing any transport error. */
2125
+ function safeRelease(request) {
2126
+ try {
2127
+ request.release();
2128
+ } catch {}
2129
+ }
1927
2130
  var NativeCropRegistry = class {
1928
2131
  capacity;
1929
2132
  entries = /* @__PURE__ */ new Map();
@@ -1938,7 +2141,10 @@ var NativeCropRegistry = class {
1938
2141
  key(handle) {
1939
2142
  return `${handle.shmId}#${handle.slot}#${handle.seq}`;
1940
2143
  }
1941
- /** Bind `request` to `handle`; evicts the oldest entry past the cap. */
2144
+ /**
2145
+ * Bind `request` to `handle`; evicts the oldest entry past the cap, releasing
2146
+ * its worker-side RAM lease so a lease never outlives its registry entry.
2147
+ */
1942
2148
  register(handle, request) {
1943
2149
  const k = this.key(handle);
1944
2150
  this.entries.delete(k);
@@ -1946,19 +2152,207 @@ var NativeCropRegistry = class {
1946
2152
  while (this.entries.size > this.capacity) {
1947
2153
  const oldest = this.entries.keys().next().value;
1948
2154
  if (oldest === void 0) break;
1949
- this.entries.delete(oldest);
2155
+ this.releaseEntry(oldest);
1950
2156
  }
1951
2157
  }
1952
2158
  /** The requester for `handle`, or `null` if never registered / already evicted. */
1953
2159
  get(handle) {
1954
2160
  return this.entries.get(this.key(handle)) ?? null;
1955
2161
  }
1956
- /** Drop every entry (node/addon shutdown). */
2162
+ /**
2163
+ * Release the worker-side RAM native lease for `handle` and drop its entry —
2164
+ * the caller is done cutting native crops from that frame. Best-effort +
2165
+ * idempotent (an unknown handle is a no-op; the lease also self-expires).
2166
+ */
2167
+ release(handle) {
2168
+ this.releaseEntry(this.key(handle));
2169
+ }
2170
+ /** Drop every entry, releasing each frame's worker-side lease (node/addon shutdown). */
1957
2171
  clear() {
2172
+ for (const request of this.entries.values()) safeRelease(request);
1958
2173
  this.entries.clear();
1959
2174
  }
2175
+ /** Release + drop one entry by its map key. */
2176
+ releaseEntry(key) {
2177
+ const request = this.entries.get(key);
2178
+ if (!request) return;
2179
+ this.entries.delete(key);
2180
+ safeRelease(request);
2181
+ }
1960
2182
  };
1961
2183
  //#endregion
2184
+ //#region src/pipeline-runner/detail-subtree.ts
2185
+ /** Relative padding applied to the parent bbox before cutting the crop — more context for the child model. */
2186
+ var DETAIL_CROP_PADDING_RATIO = .15;
2187
+ /**
2188
+ * Pad `bbox` by {@link DETAIL_CROP_PADDING_RATIO} of its own size, then clamp
2189
+ * to `[0, frameWidth] x [0, frameHeight]`. Pure — always returns a new rect.
2190
+ */
2191
+ function padAndClampFrameBbox(bbox, frameWidth, frameHeight) {
2192
+ const padX = DETAIL_CROP_PADDING_RATIO * bbox.w;
2193
+ const padY = DETAIL_CROP_PADDING_RATIO * bbox.h;
2194
+ const x1 = Math.max(0, bbox.x - padX);
2195
+ const y1 = Math.max(0, bbox.y - padY);
2196
+ const x2 = Math.min(frameWidth, bbox.x + bbox.w + padX);
2197
+ const y2 = Math.min(frameHeight, bbox.y + bbox.h + padY);
2198
+ return {
2199
+ x: x1,
2200
+ y: y1,
2201
+ w: Math.max(0, x2 - x1),
2202
+ h: Math.max(0, y2 - y1)
2203
+ };
2204
+ }
2205
+ /** Normalize a frame-space pixel rect to `[0,1]` by the given frame dims. */
2206
+ function normalizeRect(rect, frameWidth, frameHeight) {
2207
+ return {
2208
+ x: rect.x / frameWidth,
2209
+ y: rect.y / frameHeight,
2210
+ w: rect.w / frameWidth,
2211
+ h: rect.h / frameHeight
2212
+ };
2213
+ }
2214
+ /** Inverse of {@link normalizeRect} — denormalize a `[0,1]` rect back to frame-space pixels. */
2215
+ function denormalizeRect(rect, frameWidth, frameHeight) {
2216
+ return {
2217
+ x: rect.x * frameWidth,
2218
+ y: rect.y * frameHeight,
2219
+ w: rect.w * frameWidth,
2220
+ h: rect.h * frameHeight
2221
+ };
2222
+ }
2223
+ /**
2224
+ * Walk `steps` (the attach config's tree) collecting every ENABLED step
2225
+ * whose catalog definition's `inputClasses` includes `parentClassName` —
2226
+ * these become the subtree's roots. A matched step's own nested `children`
2227
+ * ride along UNCHANGED here (transitively included: `face-detection` pulls
2228
+ * `face-embedding`, `plate-detection` pulls `plate-ocr`) — recursion stops
2229
+ * at a match rather than also searching inside it for further matches.
2230
+ * `filter`, when given, keeps only the TOP-LEVEL matches whose `addonId` is
2231
+ * in the list — it does NOT prune a match's descendants. Strict per-`steps`
2232
+ * pruning of the descendant chain happens later in {@link runDetailSubtree}
2233
+ * (see {@link pruneChildStepsToRequested}); this function only selects roots.
2234
+ */
2235
+ function findMatchingDetailSteps(steps, parentClassName, lookupStepDefinition, filter) {
2236
+ const matches = [];
2237
+ const walk = (list) => {
2238
+ for (const step of list) {
2239
+ if (!step.enabled) continue;
2240
+ const definition = lookupStepDefinition(step.addonId);
2241
+ if (definition !== null && definition.inputClasses !== null && definition.inputClasses.includes(parentClassName)) {
2242
+ if (!filter || filter.includes(step.addonId)) matches.push(step);
2243
+ continue;
2244
+ }
2245
+ if (step.children) walk(step.children);
2246
+ }
2247
+ };
2248
+ walk(steps);
2249
+ return matches;
2250
+ }
2251
+ /**
2252
+ * Prune a matched step's descendant tree to the caller's requested `steps`
2253
+ * set: keep a child ONLY when its `addonId` is in `requested`, recursing into
2254
+ * every kept child. Pure — returns a new step, never mutates the input.
2255
+ *
2256
+ * Applied ONLY on the DETAIL plane (see {@link runDetailSubtree}); the
2257
+ * per-frame executor path is untouched. This is what makes the dispatcher's
2258
+ * `steps` array authoritative: `['face-detection']` runs the detector geometry
2259
+ * ALONE (the `face-embedding` child is dropped — no ArcFace inference), while
2260
+ * `['face-detection','face-embedding']` keeps the child and reproduces the
2261
+ * full detect→embed chain exactly.
2262
+ */
2263
+ function pruneChildStepsToRequested(step, requested) {
2264
+ if (!step.children || step.children.length === 0) return step;
2265
+ const children = step.children.filter((child) => requested.includes(child.addonId)).map((child) => pruneChildStepsToRequested(child, requested));
2266
+ return {
2267
+ ...step,
2268
+ children
2269
+ };
2270
+ }
2271
+ /** Map a detection's CROP-pixel bbox back to FRAME-space pixels. */
2272
+ function mapBboxToFrameSpace(bbox, crop) {
2273
+ const scaleX = crop.frameSpace.w / crop.width;
2274
+ const scaleY = crop.frameSpace.h / crop.height;
2275
+ return {
2276
+ x: crop.frameSpace.x + bbox.x * scaleX,
2277
+ y: crop.frameSpace.y + bbox.y * scaleY,
2278
+ w: bbox.width * scaleX,
2279
+ h: bbox.height * scaleY
2280
+ };
2281
+ }
2282
+ /** Base64-encode a normalized float32 embedding vector. */
2283
+ function encodeEmbeddingBase64(embedding) {
2284
+ return Buffer.from(Float32Array.from(embedding).buffer).toString("base64");
2285
+ }
2286
+ /** First user-visible label — skips the opaque `embedding:*` marker (see `result-assembler.ts`). */
2287
+ function pickLabel(detection) {
2288
+ return detection.labels.find((l) => !l.label.startsWith("embedding:"))?.label;
2289
+ }
2290
+ function toDetailResult(detection, stepId, crop) {
2291
+ const label = pickLabel(detection);
2292
+ return {
2293
+ stepId,
2294
+ className: detection.macroClass,
2295
+ score: detection.score,
2296
+ bbox: mapBboxToFrameSpace(detection.bbox, crop),
2297
+ ...detection.embedding !== void 0 ? { embedding: encodeEmbeddingBase64(detection.embedding) } : {},
2298
+ ...label !== void 0 ? { label } : {},
2299
+ ...detection.faceAlignedCrop !== void 0 ? { alignedCropJpeg: detection.faceAlignedCrop } : {}
2300
+ };
2301
+ }
2302
+ async function resolveCrop(deps, input) {
2303
+ if (input.frameHandle) {
2304
+ const normalized = normalizeRect(padAndClampFrameBbox(input.parent.bbox, input.frameHandle.width, input.frameHandle.height), input.frameHandle.width, input.frameHandle.height);
2305
+ const native = await deps.getNativeCrop(input.deviceId, input.frameHandle, normalized);
2306
+ if (native) return native;
2307
+ }
2308
+ if (input.cropJpeg) {
2309
+ const jpeg = Buffer.from(input.cropJpeg, "base64");
2310
+ const sharp = (await import("sharp")).default;
2311
+ const meta = await sharp(jpeg).metadata();
2312
+ const width = meta.width ?? 0;
2313
+ const height = meta.height ?? 0;
2314
+ if (width <= 0 || height <= 0) return null;
2315
+ return {
2316
+ jpeg,
2317
+ width,
2318
+ height,
2319
+ frameSpace: input.parent.bbox
2320
+ };
2321
+ }
2322
+ return null;
2323
+ }
2324
+ /**
2325
+ * Run the DETAIL subtree for a single tracked detection: resolve a crop
2326
+ * (native lease → `cropJpeg` fallback), run each matched top-level child
2327
+ * step's chain against it, and map every result bbox back to frame space.
2328
+ * Returns `null` when the camera isn't attached, no configured child
2329
+ * matches `input.parent.className`, or no crop source resolves.
2330
+ *
2331
+ * STRICT `steps` semantics: when the caller names an explicit `input.steps`
2332
+ * set, each matched root's descendant chain is pruned to exactly those ids
2333
+ * (see {@link pruneChildStepsToRequested}) — `resolveChildSteps`/`filter`
2334
+ * only selects TOP-LEVEL roots, so without this a matched `face-detection`
2335
+ * would always drag its `face-embedding` child along regardless of what was
2336
+ * asked for. When `input.steps` is omitted, the full configured chain runs
2337
+ * (today's behavior). This is the knob the dispatcher uses to run a periodic
2338
+ * geometry-only face refresh (`['face-detection']`) without re-running ArcFace.
2339
+ */
2340
+ async function runDetailSubtree(deps, input) {
2341
+ const matched = deps.resolveChildSteps(input.deviceId, input.parent.className, input.steps);
2342
+ if (!matched || matched.length === 0) return null;
2343
+ const requested = input.steps;
2344
+ const steps = requested === void 0 ? matched : matched.map((step) => pruneChildStepsToRequested(step, requested));
2345
+ const crop = await resolveCrop(deps, input);
2346
+ if (!crop) return null;
2347
+ const details = [];
2348
+ for (const step of steps) {
2349
+ const result = await deps.runPipeline([step], crop.jpeg, input.deviceId);
2350
+ if (!result) continue;
2351
+ for (const detection of result.detections) details.push(toDetailResult(detection, step.addonId, crop));
2352
+ }
2353
+ return { details };
2354
+ }
2355
+ //#endregion
1962
2356
  //#region src/pipeline-runner/index.ts
1963
2357
  var DEFAULT_CONFIG = {
1964
2358
  maxQueueDepth: 30,
@@ -1983,6 +2377,31 @@ var DEFAULT_CONFIG = {
1983
2377
  */
1984
2378
  var DETECTION_KEEPWARM_MS = 15e3;
1985
2379
  /**
2380
+ * Occupancy-burst safety timeout (both the dial path and the analyzer-tap
2381
+ * path — Task 10). Bounds worst-case burst latency when frames stop
2382
+ * arriving mid-burst — a dial that never gets a keyframe, or a tap whose
2383
+ * motion subscription closes before N frames arrive.
2384
+ */
2385
+ var OCCUPANCY_BURST_SAFETY_TIMEOUT_MS = 8e3;
2386
+ /**
2387
+ * Freshness window for `lastMotionFrameAt` (Task 10 hardening). A non-null
2388
+ * `attachment.motionUnsubscribe` only proves a subscription handle exists —
2389
+ * NOT that frames are actually flowing (guarded-session-decode can return
2390
+ * its teardown synchronously, and a failed `acquireSessionDecodeRestreamFrom`
2391
+ * acquire never retries, leaving the handle permanently non-null with zero
2392
+ * frames arriving). `runOccupancyBurst` additionally requires a motion frame
2393
+ * to have been teed within this window before it will route to the tap path.
2394
+ */
2395
+ var MOTION_FRESHNESS_WINDOW_MS = 5e3;
2396
+ /** Delay before re-dialing a detection decode whose worker died unexpectedly. */
2397
+ var DETECTION_REDIAL_DELAY_MS = 2e3;
2398
+ /**
2399
+ * Two unexpected decode deaths within this window → stop re-dialing and detach
2400
+ * (the orchestrator re-dispatches on the next motion signal). Bounds the loop
2401
+ * when the camera/source itself is broken.
2402
+ */
2403
+ var DETECTION_REDIAL_GIVEUP_WINDOW_MS = 6e4;
2404
+ /**
1986
2405
  * Pure decision helper: returns `true` when a dynamic frame-diff analyzer
1987
2406
  * subscription should be opened for an onboard-motion camera.
1988
2407
  *
@@ -1999,6 +2418,62 @@ function shouldStartOnboardAnalyzer(config) {
1999
2418
  if (config.motionSources.includes("analyzer")) return false;
2000
2419
  return true;
2001
2420
  }
2421
+ /** Recursively collects the `addonId` of every ENABLED step in the tree. */
2422
+ function collectEnabledStepIds(steps, acc) {
2423
+ if (!steps) return;
2424
+ for (const step of steps) {
2425
+ if (!step.enabled) continue;
2426
+ acc.add(step.addonId);
2427
+ collectEnabledStepIds(step.children, acc);
2428
+ }
2429
+ }
2430
+ /**
2431
+ * Looks up a catalog step definition, tolerating an unknown id. A stale
2432
+ * config entry (removed/custom addon) must degrade to "not announced"
2433
+ * rather than fail the attach — `getStepDefinition` throws on a miss.
2434
+ */
2435
+ function tryGetStepDefinition(stepId) {
2436
+ try {
2437
+ return getStepDefinition(stepId);
2438
+ } catch {
2439
+ return null;
2440
+ }
2441
+ }
2442
+ /**
2443
+ * Precomputes the `detailSteps` announce (two-plane design) for a camera's
2444
+ * configured step tree: every ENABLED step whose catalog definition is a
2445
+ * detail/child step (`inputClasses !== null` — a root step like
2446
+ * `object-detection` runs on every frame on the `'frame'` plane and has
2447
+ * nothing to announce) is surfaced as `{ stepId, inputClasses, cadence }`
2448
+ * so a track-level consumer knows what it can dispatch via
2449
+ * `pipelineRunner.runDetailSubtree` and on what cadence, without a
2450
+ * separate round-trip to the executor's schema/config caps.
2451
+ *
2452
+ * Unknown step ids (stale config referencing a removed/custom addon) are
2453
+ * skipped rather than thrown — the catalog is the single source of truth
2454
+ * for cadence and a stale entry must not fail the attach.
2455
+ *
2456
+ * Extracted as a pure function so the unit suite can exercise the
2457
+ * derivation without instantiating the full addon.
2458
+ */
2459
+ function deriveDetailSteps(steps) {
2460
+ const stepIds = /* @__PURE__ */ new Set();
2461
+ collectEnabledStepIds(steps, stepIds);
2462
+ const announces = [];
2463
+ for (const stepId of stepIds) {
2464
+ const def = tryGetStepDefinition(stepId);
2465
+ if (!def || def.inputClasses === null) continue;
2466
+ announces.push({
2467
+ stepId: def.id,
2468
+ inputClasses: def.inputClasses,
2469
+ cadence: def.cadence ?? {
2470
+ trigger: "once",
2471
+ maxPerTrack: 3
2472
+ }
2473
+ });
2474
+ }
2475
+ return announces;
2476
+ }
2002
2477
  function toFrameInput(frame) {
2003
2478
  return {
2004
2479
  data: frame.data,
@@ -2260,6 +2735,13 @@ var PipelineRunnerAddon = class extends BaseAddon {
2260
2735
  /** Per-camera single-flight: a camera never runs two occupancy bursts at once. */
2261
2736
  occupancyBurstInFlight = /* @__PURE__ */ new Set();
2262
2737
  /**
2738
+ * Devices whose in-flight occupancy burst has seen at least one inference
2739
+ * result with detections>0 (set from the `onResult` handler above, cleared
2740
+ * per-burst in `runOccupancyBurst`). Read at burst cleanup and reported to
2741
+ * `PipelineRunner.reportOccupancyBurstOutcome` — Task 9's arm/disarm signal.
2742
+ */
2743
+ occupancyBurstFound = /* @__PURE__ */ new Set();
2744
+ /**
2263
2745
  * GLOBAL occupancy admission queue. Each occupancy burst SUBSCRIBES a detection
2264
2746
  * stream (rgb) which, during the `watching` phase, has no existing consumer and
2265
2747
  * therefore makes the broker DIAL a fresh decoder. Without a global cap, N
@@ -2276,6 +2758,23 @@ var PipelineRunnerAddon = class extends BaseAddon {
2276
2758
  occupancyActiveCount = 0;
2277
2759
  /** Max concurrent occupancy bursts (decoder dials) across all cameras. */
2278
2760
  occupancyMaxConcurrent = 3;
2761
+ /**
2762
+ * TAP-based occupancy bursts in flight, keyed by deviceId (Task 10). See
2763
+ * {@link OccupancyTapState}. A device has an entry here XOR is dialing via
2764
+ * `startFrameHandlePoller` in `runOccupancyBurst` — never both; the choice
2765
+ * is made once at burst start based on `attachment.motionUnsubscribe`.
2766
+ */
2767
+ occupancyTaps = /* @__PURE__ */ new Map();
2768
+ /**
2769
+ * Timestamp (`Date.now()`) of the last motion frame teed for each device
2770
+ * — set in `runMotionAnalysis` at the same call site as
2771
+ * `teeOccupancyTapFrame` (Task 10 hardening). This is the TRUTHFUL "is
2772
+ * the analyzer subscription actually delivering frames" signal;
2773
+ * `attachment.motionUnsubscribe !== null` alone only proves a
2774
+ * subscription handle was assigned, not that it's live. See
2775
+ * {@link MOTION_FRESHNESS_WINDOW_MS}.
2776
+ */
2777
+ lastMotionFrameAt = /* @__PURE__ */ new Map();
2279
2778
  constructor() {
2280
2779
  super({ ...DEFAULT_CONFIG });
2281
2780
  }
@@ -2312,6 +2811,9 @@ var PipelineRunnerAddon = class extends BaseAddon {
2312
2811
  });
2313
2812
  this.runner.onResult(async (deviceId, frame, result, _streamType, handle) => {
2314
2813
  this.emitInferenceResult(deviceId, frame, result, handle);
2814
+ const detectionCount = result.detections?.length ?? 0;
2815
+ this.runner?.noteDetectionResult(deviceId, detectionCount);
2816
+ if (detectionCount > 0 && this.occupancyBurstInFlight.has(deviceId)) this.occupancyBurstFound.add(deviceId);
2315
2817
  });
2316
2818
  this.runner.start();
2317
2819
  this.ctx.logger.info("Pipeline runner started", {
@@ -2377,6 +2879,7 @@ var PipelineRunnerAddon = class extends BaseAddon {
2377
2879
  attachment.detectionUnsubscribe?.();
2378
2880
  }
2379
2881
  this.attached.clear();
2882
+ this.lastMotionFrameAt.clear();
2380
2883
  }
2381
2884
  async cacheBenchFrame(input) {
2382
2885
  const sharp = (await import("sharp")).default;
@@ -2722,7 +3225,8 @@ var PipelineRunnerAddon = class extends BaseAddon {
2722
3225
  config,
2723
3226
  motionUnsubscribe: null,
2724
3227
  detectionUnsubscribe: null,
2725
- detectionKeepWarmTimer: null
3228
+ detectionKeepWarmTimer: null,
3229
+ detailSteps: deriveDetailSteps(config.steps)
2726
3230
  };
2727
3231
  this.attached.set(config.deviceId, attachment);
2728
3232
  runner.registerCamera(config.deviceId, {
@@ -2775,6 +3279,124 @@ var PipelineRunnerAddon = class extends BaseAddon {
2775
3279
  height: result.height
2776
3280
  } : null;
2777
3281
  }
3282
+ /**
3283
+ * Two-plane design: run the DETAIL subtree (crop children) for a single
3284
+ * tracked detection. Delegates the algorithm to the deps-injected pure
3285
+ * function in {@link runDetailSubtreeImpl} — this method only wires the
3286
+ * real deps: the attach config's step tree (+ the catalog, for the
3287
+ * `inputClasses` match), the SAME internal native-crop lease path
3288
+ * `getNativeCrop` uses above (no `ctx.api` round-trip — we're already in
3289
+ * the frame's owning process), and the node-local `pipelineExecutor`.
3290
+ */
3291
+ async runDetailSubtree(input) {
3292
+ return runDetailSubtree({
3293
+ resolveChildSteps: (deviceId, parentClassName, filter) => this.resolveDetailChildSteps(deviceId, parentClassName, filter),
3294
+ getNativeCrop: (deviceId, handle, paddedBbox) => this.getNativeCropForDetail(deviceId, handle, paddedBbox),
3295
+ runPipeline: (steps, imageJpeg, deviceId) => this.runDetailPipeline(steps, imageJpeg, deviceId)
3296
+ }, input);
3297
+ }
3298
+ /**
3299
+ * Real `resolveChildSteps` dep: `null` when the camera isn't attached
3300
+ * (matches the cap doc — "camera not attached" is a distinct case from
3301
+ * "attached but no matching child steps", which returns an empty array
3302
+ * and short-circuits inside {@link runDetailSubtreeImpl} the same way).
3303
+ */
3304
+ resolveDetailChildSteps(deviceId, parentClassName, filter) {
3305
+ const attachment = this.attached.get(deviceId);
3306
+ if (!attachment) return null;
3307
+ const steps = attachment.config.steps;
3308
+ if (!steps) return [];
3309
+ return findMatchingDetailSteps(steps, parentClassName, tryGetStepDefinition, filter);
3310
+ }
3311
+ /**
3312
+ * Real `getNativeCrop` dep: reuses the node-local {@link nativeCropRegistry}
3313
+ * lease (same path the `getNativeCrop` cap method above uses), encodes the
3314
+ * raw RGB crop to JPEG, and denormalizes `paddedBbox` back to frame-space
3315
+ * pixels for the `frameSpace` the caller maps result bboxes against.
3316
+ */
3317
+ async getNativeCropForDetail(_deviceId, handle, paddedBbox) {
3318
+ const native = await this.getNativeCrop({
3319
+ handle,
3320
+ bbox: paddedBbox
3321
+ });
3322
+ if (!native) return null;
3323
+ const sharp = (await import("sharp")).default;
3324
+ return {
3325
+ jpeg: await sharp(Buffer.from(native.bytes), { raw: {
3326
+ width: native.width,
3327
+ height: native.height,
3328
+ channels: 3
3329
+ } }).jpeg({ quality: 90 }).toBuffer(),
3330
+ width: native.width,
3331
+ height: native.height,
3332
+ frameSpace: denormalizeRect(paddedBbox, handle.width, handle.height)
3333
+ };
3334
+ }
3335
+ /** Real `runPipeline` dep: node-local `pipelineExecutor.runPipeline` on the `'full'` plane. */
3336
+ async runDetailPipeline(steps, imageJpeg, deviceId) {
3337
+ const api = this.ctxIfReady?.api;
3338
+ if (!api) return null;
3339
+ try {
3340
+ return await api.pipelineExecutor.runPipeline.mutate({
3341
+ steps: [...steps],
3342
+ image: new Uint8Array(imageJpeg),
3343
+ deviceId,
3344
+ plane: "full"
3345
+ });
3346
+ } catch (err) {
3347
+ this.ctxIfReady?.logger.warn("runDetailSubtree: runPipeline failed", {
3348
+ tags: { deviceId },
3349
+ meta: { error: errMsg(err) }
3350
+ });
3351
+ return null;
3352
+ }
3353
+ }
3354
+ /** Last unexpected detection-decode end per device — bounds the re-dial. */
3355
+ lastDecodeUnexpectedEndAt = /* @__PURE__ */ new Map();
3356
+ /**
3357
+ * The detection decode pump ended WITHOUT a teardown (worker death /
3358
+ * generator error) while the camera may still be attached. Previously this
3359
+ * zombied the session — phase stayed `active`, frames stopped, and nothing
3360
+ * re-dialed (the pre-fix Channel-closed runner crash used to mask it by
3361
+ * respawning the whole runner). Recovery, bounded:
3362
+ * - first death: clean up the dead subscription and re-dial once (2s);
3363
+ * - second death within 60s: give up re-dialing and DETACH — the
3364
+ * orchestrator re-dispatches cleanly on the next motion/occupancy signal
3365
+ * (the same recovery path validated manually on device 592).
3366
+ */
3367
+ handleDetectionDecodeEnded(deviceId) {
3368
+ const attachment = this.attached.get(deviceId);
3369
+ if (!attachment) return;
3370
+ const log = this.ctx.logger.withTags({ deviceId });
3371
+ attachment.detectionUnsubscribe?.();
3372
+ attachment.detectionUnsubscribe = null;
3373
+ if (attachment.detectionKeepWarmTimer !== null) {
3374
+ clearTimeout(attachment.detectionKeepWarmTimer);
3375
+ attachment.detectionKeepWarmTimer = null;
3376
+ log.warn("detection decode worker died while kept warm — next open re-forks cold");
3377
+ return;
3378
+ }
3379
+ const now = Date.now();
3380
+ const last = this.lastDecodeUnexpectedEndAt.get(deviceId) ?? 0;
3381
+ this.lastDecodeUnexpectedEndAt.set(deviceId, now);
3382
+ if (now - last < DETECTION_REDIAL_GIVEUP_WINDOW_MS) {
3383
+ log.warn("detection decode died twice within the re-dial window — detaching for orchestrator re-dispatch");
3384
+ this.detachInternal(deviceId);
3385
+ return;
3386
+ }
3387
+ log.warn("detection decode ended unexpectedly while attached — re-dialing once");
3388
+ setTimeout(() => {
3389
+ const still = this.attached.get(deviceId);
3390
+ if (!still || still.detectionUnsubscribe !== null) return;
3391
+ this.subscribeDetectionFrames(still.config).then((unsub) => {
3392
+ if (!this.attached.has(deviceId)) {
3393
+ unsub?.();
3394
+ return;
3395
+ }
3396
+ still.detectionUnsubscribe = unsub;
3397
+ });
3398
+ }, DETECTION_REDIAL_DELAY_MS).unref?.();
3399
+ }
2778
3400
  detachInternal(deviceId) {
2779
3401
  const attachment = this.attached.get(deviceId);
2780
3402
  if (!attachment) return;
@@ -2785,6 +3407,8 @@ var PipelineRunnerAddon = class extends BaseAddon {
2785
3407
  }
2786
3408
  attachment.motionUnsubscribe?.();
2787
3409
  attachment.detectionUnsubscribe?.();
3410
+ this.lastMotionFrameAt.delete(deviceId);
3411
+ this.lastDecodeUnexpectedEndAt.delete(deviceId);
2788
3412
  const remotePlane = this.remotePlanes.get(deviceId);
2789
3413
  if (remotePlane) {
2790
3414
  this.remotePlanes.delete(deviceId);
@@ -3094,6 +3718,7 @@ var PipelineRunnerAddon = class extends BaseAddon {
3094
3718
  format: "rgb"
3095
3719
  },
3096
3720
  acquireSpawnSlot: this.buildDecodeSpawnAcquire(log, "detect"),
3721
+ onEnded: () => this.handleDetectionDecodeEnded(config.deviceId),
3097
3722
  onDecodedFrame: (frame, nativeCrop) => {
3098
3723
  const handle = retentionSink.write(frame) ?? void 0;
3099
3724
  if (handle && nativeCrop) this.nativeCropRegistry.register(handle, nativeCrop);
@@ -3317,7 +3942,8 @@ var PipelineRunnerAddon = class extends BaseAddon {
3317
3942
  return await api.pipelineExecutor.runPipeline.mutate({
3318
3943
  steps: [...steps],
3319
3944
  ...useHandle ? { frameHandle: handle } : { frame },
3320
- deviceId
3945
+ deviceId,
3946
+ plane: "frame"
3321
3947
  });
3322
3948
  } catch (err) {
3323
3949
  const msg = errMsg(err);
@@ -3329,6 +3955,8 @@ var PipelineRunnerAddon = class extends BaseAddon {
3329
3955
  const ctx = this.ctx;
3330
3956
  const runner = this.runner;
3331
3957
  if (!ctx || !runner) return;
3958
+ this.lastMotionFrameAt.set(deviceId, Date.now());
3959
+ this.teeOccupancyTapFrame(deviceId, frame, handle);
3332
3960
  const log = this.ctx.logger.withTags({ deviceId });
3333
3961
  const motionStart = Date.now();
3334
3962
  try {
@@ -3443,11 +4071,98 @@ var PipelineRunnerAddon = class extends BaseAddon {
3443
4071
  });
3444
4072
  }
3445
4073
  }
4074
+ /**
4075
+ * Shared burst-completion path (Task 9 report + Task 10 dial/tap
4076
+ * consolidation): clears the in-flight flag, reads the `found` flag the
4077
+ * `onResult` handler set while the burst was live, reports it to the
4078
+ * runner's arm/disarm state machine, and logs. Both the dial path
4079
+ * (`runOccupancyBurst`'s `cleanup`) and the tap path
4080
+ * (`finishOccupancyTap`) fold into this single implementation so the
4081
+ * found/report semantics can't drift between the two admission
4082
+ * mechanisms.
4083
+ */
4084
+ finishOccupancyBurst(deviceId, log, collected) {
4085
+ this.occupancyBurstInFlight.delete(deviceId);
4086
+ const found = this.occupancyBurstFound.has(deviceId);
4087
+ this.occupancyBurstFound.delete(deviceId);
4088
+ this.runner?.reportOccupancyBurstOutcome(deviceId, found);
4089
+ log.debug("occupancy re-check: burst complete", { meta: {
4090
+ collected,
4091
+ found
4092
+ } });
4093
+ }
4094
+ /**
4095
+ * Task 10: arm a TAP-based occupancy burst for a device whose analyzer
4096
+ * motion subscription is already flowing (`attachment.motionUnsubscribe
4097
+ * !== null` — see `runOccupancyBurst`). Frames feed in via
4098
+ * `teeOccupancyTapFrame`, called from `runMotionAnalysis` on every
4099
+ * arriving substream frame; NO new main-stream poller/decode session is
4100
+ * started. A safety timer (mirrors the dial path's) completes the burst
4101
+ * even if the motion subscription stops producing frames mid-burst.
4102
+ *
4103
+ * Returns a promise that resolves once the tap settles (budget drained or
4104
+ * safety timeout) — `runOccupancyBurst` awaits it so the global
4105
+ * `occupancyActiveCount` slot stays held for the tap's full duration, the
4106
+ * same as the dial path.
4107
+ */
4108
+ /**
4109
+ * Returns a promise that resolves with the tap's final `collected` count
4110
+ * once it settles (budget drained or safety timeout). The caller
4111
+ * (`runOccupancyBurst`) decides what to do with that count — report the
4112
+ * outcome via `finishOccupancyBurst` when frames were actually collected,
4113
+ * or fall back to a dial burst when the tap collected zero (Task 10
4114
+ * hardening #2). Resolving with the count rather than reporting from
4115
+ * inside the tap itself is what makes that fallback possible without
4116
+ * double-reporting.
4117
+ */
4118
+ startOccupancyTap(deviceId, frames, log) {
4119
+ this.occupancyBurstInFlight.add(deviceId);
4120
+ this.occupancyBurstFound.delete(deviceId);
4121
+ log.debug("occupancy re-check: tapping live analyzer motion frames (no dial)", { meta: { frames } });
4122
+ return new Promise((resolve) => {
4123
+ const safetyTimer = setTimeout(() => {
4124
+ const collected = this.occupancyTaps.get(deviceId)?.collected ?? 0;
4125
+ log.debug("occupancy re-check: tap safety timeout reached", { meta: { collected } });
4126
+ this.finishOccupancyTap(deviceId);
4127
+ }, OCCUPANCY_BURST_SAFETY_TIMEOUT_MS);
4128
+ this.occupancyTaps.set(deviceId, {
4129
+ remaining: frames,
4130
+ collected: 0,
4131
+ safetyTimer,
4132
+ resolve
4133
+ });
4134
+ });
4135
+ }
4136
+ /** Idempotent: a no-op once the tap has already settled (timer fired, or the budget already drained). */
4137
+ finishOccupancyTap(deviceId) {
4138
+ const tap = this.occupancyTaps.get(deviceId);
4139
+ if (!tap) return;
4140
+ clearTimeout(tap.safetyTimer);
4141
+ this.occupancyTaps.delete(deviceId);
4142
+ tap.resolve(tap.collected);
4143
+ }
4144
+ /**
4145
+ * Tee an arriving analyzer motion frame into occupancy-burst detection
4146
+ * admission (Task 10). Called from `runMotionAnalysis` on EVERY
4147
+ * substream frame; a no-op unless `startOccupancyTap` armed a budget for
4148
+ * this device. The frame always feeds `enqueueOccupancyFrame` — the
4149
+ * handle may be `undefined` (e.g. the Epic-C per-session motion decode
4150
+ * worker never produces a shm handle) and `runInference` already falls
4151
+ * back to the pixel path in that case, so every arriving frame counts
4152
+ * toward the budget regardless of whether it carried a zero-copy handle.
4153
+ */
4154
+ teeOccupancyTapFrame(deviceId, frame, handle) {
4155
+ const tap = this.occupancyTaps.get(deviceId);
4156
+ if (!tap) return;
4157
+ this.runner?.enqueueOccupancyFrame(deviceId, frame, handle);
4158
+ tap.collected++;
4159
+ tap.remaining--;
4160
+ if (tap.remaining <= 0) this.finishOccupancyTap(deviceId);
4161
+ }
3446
4162
  async runOccupancyBurst(deviceId, frames) {
3447
4163
  if (this.occupancyBurstInFlight.has(deviceId)) return;
3448
4164
  const attachment = this.attached.get(deviceId);
3449
4165
  if (!attachment) return;
3450
- const config = attachment.config;
3451
4166
  const runner = this.runner;
3452
4167
  if (!runner) return;
3453
4168
  const log = this.ctx.logger.withTags({ deviceId });
@@ -3456,8 +4171,40 @@ var PipelineRunnerAddon = class extends BaseAddon {
3456
4171
  log.debug("runOccupancyBurst: ctx.api not available");
3457
4172
  return;
3458
4173
  }
3459
- this.occupancyBurstInFlight.add(deviceId);
3460
- log.debug("occupancy re-check: starting burst", { meta: { frames } });
4174
+ const lastFrameAt = this.lastMotionFrameAt.get(deviceId);
4175
+ const motionFresh = lastFrameAt !== void 0 && Date.now() - lastFrameAt < MOTION_FRESHNESS_WINDOW_MS;
4176
+ if (attachment.motionUnsubscribe !== null && motionFresh) {
4177
+ const collected = await this.startOccupancyTap(deviceId, frames, log);
4178
+ if (collected > 0) {
4179
+ this.finishOccupancyBurst(deviceId, log, collected);
4180
+ return;
4181
+ }
4182
+ log.debug("occupancy re-check: tap collected 0 frames, falling back to dial", { meta: { frames } });
4183
+ await this.runOccupancyDial(deviceId, frames, attachment, runner, api, log, true);
4184
+ return;
4185
+ }
4186
+ await this.runOccupancyDial(deviceId, frames, attachment, runner, api, log, false);
4187
+ }
4188
+ /**
4189
+ * Dial-path occupancy burst: subscribes a fresh main-stream decode session
4190
+ * via `startFrameHandlePoller` and collects `frames` handles (or times out
4191
+ * at {@link OCCUPANCY_BURST_SAFETY_TIMEOUT_MS}). Called either directly
4192
+ * from `runOccupancyBurst` (no live/fresh motion tap available,
4193
+ * `alreadyInFlight: false`) or as the Task 10 hardening #2 fallback after
4194
+ * a zero-collected tap (`alreadyInFlight: true` — the tap already claimed
4195
+ * the `occupancyBurstInFlight`/`occupancyBurstFound` slot, so this must
4196
+ * NOT re-add it).
4197
+ */
4198
+ async runOccupancyDial(deviceId, frames, attachment, runner, api, log, alreadyInFlight) {
4199
+ const config = attachment.config;
4200
+ if (!alreadyInFlight) {
4201
+ this.occupancyBurstInFlight.add(deviceId);
4202
+ this.occupancyBurstFound.delete(deviceId);
4203
+ }
4204
+ log.debug("occupancy re-check: starting burst", { meta: {
4205
+ frames,
4206
+ fallback: alreadyInFlight
4207
+ } });
3461
4208
  let count = 0;
3462
4209
  let unsubscribe = null;
3463
4210
  let unsubbed = false;
@@ -3472,13 +4219,12 @@ var PipelineRunnerAddon = class extends BaseAddon {
3472
4219
  if (settled) return;
3473
4220
  settled = true;
3474
4221
  doUnsub();
3475
- this.occupancyBurstInFlight.delete(deviceId);
3476
- log.debug("occupancy re-check: burst complete", { meta: { collected: count } });
4222
+ this.finishOccupancyBurst(deviceId, log, count);
3477
4223
  };
3478
4224
  const safetyTimer = setTimeout(() => {
3479
4225
  log.debug("occupancy re-check: safety timeout reached", { meta: { collected: count } });
3480
4226
  cleanup();
3481
- }, 8e3);
4227
+ }, OCCUPANCY_BURST_SAFETY_TIMEOUT_MS);
3482
4228
  try {
3483
4229
  unsubscribe = await startFrameHandlePoller({
3484
4230
  api,
@@ -3502,6 +4248,7 @@ var PipelineRunnerAddon = class extends BaseAddon {
3502
4248
  } catch (err) {
3503
4249
  clearTimeout(safetyTimer);
3504
4250
  this.occupancyBurstInFlight.delete(deviceId);
4251
+ this.occupancyBurstFound.delete(deviceId);
3505
4252
  log.debug("occupancy re-check: poller start failed", { meta: { error: errMsg(err) } });
3506
4253
  return;
3507
4254
  }
@@ -3513,12 +4260,14 @@ var PipelineRunnerAddon = class extends BaseAddon {
3513
4260
  emitInferenceResult(deviceId, frame, result, handle) {
3514
4261
  if (!this.ctx?.eventBus) return;
3515
4262
  const capturedAt = frame.capturedAt;
4263
+ const detailSteps = this.attached.get(deviceId)?.detailSteps;
3516
4264
  const payload = {
3517
4265
  deviceId,
3518
4266
  frame: result,
3519
4267
  nodeId: this.nodeId,
3520
4268
  frameHandle: handle,
3521
- ...typeof capturedAt === "number" && capturedAt > 0 ? { capturedAt } : {}
4269
+ ...typeof capturedAt === "number" && capturedAt > 0 ? { capturedAt } : {},
4270
+ ...detailSteps && detailSteps.length > 0 ? { detailSteps } : {}
3522
4271
  };
3523
4272
  this.ctx.eventBus.emit(createEvent(EventCategory.PipelineInferenceResult, {
3524
4273
  type: "device",
@@ -3675,4 +4424,4 @@ var PipelineRunnerAddon = class extends BaseAddon {
3675
4424
  }
3676
4425
  };
3677
4426
  //#endregion
3678
- export { DEFAULT_CONFIG, FrameQueue, PipelineRunner, PipelineTimingSampler, Semaphore, acquireSessionDecodeRestreamFrom, pipelineRunnerBenchActions as customActions, PipelineRunnerAddon as default, resolveMotionFrameSource, resolveSessionDecodeHostname, resolveSessionDecodeOwnerNodeId, rgbFrameToGrayFrame, shouldStartOnboardAnalyzer };
4427
+ export { DEFAULT_CONFIG, FrameQueue, PipelineRunner, PipelineTimingSampler, Semaphore, acquireSessionDecodeRestreamFrom, pipelineRunnerBenchActions as customActions, PipelineRunnerAddon as default, deriveDetailSteps, resolveMotionFrameSource, resolveSessionDecodeHostname, resolveSessionDecodeOwnerNodeId, rgbFrameToGrayFrame, shouldStartOnboardAnalyzer };