@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
@@ -2,10 +2,11 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-BbaoC680.js");
6
- const require_remote_source_plane = require("../remote-source-plane-vN45KFSm.js");
5
+ const require_dist = require("../dist-ClE6_i0p.js");
6
+ const require_remote_source_plane = require("../remote-source-plane-BAqEHSgm.js");
7
7
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
8
- const require_worker_protocol = require("../worker-protocol-DkL6GDxe.js");
8
+ const require_worker_protocol = require("../worker-protocol-PP4jKHHJ.js");
9
+ const require_step_definitions = require("../step-definitions-C9kUXgC7.js");
9
10
  let _camstack_shm_ring = require("@camstack/shm-ring");
10
11
  let node_child_process = require("node:child_process");
11
12
  let node_url = require("node:url");
@@ -427,6 +428,36 @@ function startPolling(options, source, subscriptionId, resolvedMaxFps, lifecycle
427
428
  };
428
429
  }
429
430
  //#endregion
431
+ //#region src/pipeline-runner/frame-governor.ts
432
+ var FrameGovernor = class {
433
+ config;
434
+ intervals = /* @__PURE__ */ new Map();
435
+ throttled = 0;
436
+ constructor(config) {
437
+ this.config = config;
438
+ }
439
+ update(samples) {
440
+ this.intervals.clear();
441
+ this.throttled = 0;
442
+ if (samples.size === 0) return;
443
+ const shareMsPerSec = this.config.targetLoadPercent / 100 * this.config.maxConcurrentInferences * 1e3 / samples.size;
444
+ for (const [deviceId, s] of samples) {
445
+ const ceilingFps = s.configuredFps > 0 ? s.configuredFps : s.decodeFps;
446
+ const affordableFps = s.avgInferenceMs > 0 ? shareMsPerSec / s.avgInferenceMs : ceilingFps;
447
+ const targetFps = Math.min(ceilingFps, Math.max(this.config.minThrottledFps, affordableFps));
448
+ if (targetFps < ceilingFps) this.throttled++;
449
+ this.intervals.set(deviceId, targetFps > 0 ? 1e3 / targetFps : 0);
450
+ }
451
+ }
452
+ /** Minimum ms between admitted frames; 0 = no gate. */
453
+ minIntervalMs(deviceId) {
454
+ return this.intervals.get(deviceId) ?? 0;
455
+ }
456
+ throttledCount() {
457
+ return this.throttled;
458
+ }
459
+ };
460
+ //#endregion
430
461
  //#region src/pipeline-runner/frame-queue.ts
431
462
  /**
432
463
  * Latest-frame-only buffer. Keeps only the most recent item, dropping all
@@ -608,6 +639,24 @@ var PipelineTimingSampler = class {
608
639
  if (!this.detSamples.has(deviceId)) this.detSamples.set(deviceId, []);
609
640
  this.detSamples.get(deviceId).push(s);
610
641
  }
642
+ /**
643
+ * Moving average inference time (ms) per camera over the current
644
+ * accumulation window — the same `inf` samples the `pipeline stats` log
645
+ * line aggregates (see {@link report}'s `inf` computation). Consumed by
646
+ * the {@link FrameGovernor} recompute tick as `CameraLoadSample.avgInferenceMs`.
647
+ * The window resets on every {@link report} call (every REPORT_INTERVAL_MS),
648
+ * so a camera with zero completed inferences since the last report is
649
+ * simply absent from the returned map — callers supply their own default.
650
+ */
651
+ avgInferenceMsByCamera() {
652
+ const result = /* @__PURE__ */ new Map();
653
+ for (const [deviceId, samples] of this.detSamples) {
654
+ if (samples.length === 0) continue;
655
+ const total = samples.reduce((sum, s) => sum + s.inference, 0);
656
+ result.set(deviceId, total / samples.length);
657
+ }
658
+ return result;
659
+ }
611
660
  addMotionSample(deviceId, ms, frameAge = -1) {
612
661
  if (!this.motSamples.has(deviceId)) this.motSamples.set(deviceId, []);
613
662
  this.motSamples.get(deviceId).push({
@@ -785,6 +834,8 @@ function ownMotionEntry(entry) {
785
834
  */
786
835
  var HANDLE_PASSTHROUGH_MAX_QUEUE_DEPTH = 1;
787
836
  var DEFAULT_MOTION_COOLDOWN_MS = 3e4;
837
+ var GOVERNOR_RECOMPUTE_INTERVAL_MS = 2e3;
838
+ var DEFAULT_DECODE_FPS = 25;
788
839
  function toFrameInput$1(frame) {
789
840
  return {
790
841
  data: frame.data,
@@ -820,10 +871,20 @@ var PipelineRunner = class {
820
871
  detectionStreamHandler = null;
821
872
  logger;
822
873
  timingSampler = new PipelineTimingSampler();
874
+ governor;
875
+ lastAdmittedAt = /* @__PURE__ */ new Map();
876
+ arrivalCounts = /* @__PURE__ */ new Map();
877
+ lastGovernorRecomputeAt = Date.now();
878
+ governorIntervalHandle = null;
823
879
  constructor(config) {
824
880
  this.config = config;
825
881
  this.logger = config.logger;
826
882
  this.semaphore = new Semaphore(config.maxConcurrentInferences);
883
+ this.governor = new FrameGovernor({
884
+ targetLoadPercent: config.targetLoadPercent,
885
+ minThrottledFps: config.minThrottledFps,
886
+ maxConcurrentInferences: config.maxConcurrentInferences
887
+ });
827
888
  }
828
889
  /**
829
890
  * Hot-reload the four tuning fields without tearing down the runner.
@@ -834,8 +895,15 @@ var PipelineRunner = class {
834
895
  * (the FrameQueue implementation is latest-only and ignores maxSize
835
896
  * anyway — see `frame-queue.ts` — so the field is effectively a
836
897
  * metadata hint for observability).
837
- * - `targetLoadPercent` / `minThrottledFps`: stored for future
838
- * throttling logic (not yet consumed in the current runner body).
898
+ * - `targetLoadPercent` / `minThrottledFps`: stored on `this.config` (read
899
+ * by `getLimits()`), but NOT re-fed into the live `FrameGovernor` — the
900
+ * governor is built once, at construction, from the config snapshot
901
+ * passed to the constructor. A hot patch to either knob changes what
902
+ * `getLimits()` reports but does not change live admission pacing until
903
+ * the runner is reconstructed. (Rebuilding the governor live is a
904
+ * follow-up, not in scope here — `maxConcurrentInferences` has the same
905
+ * gap: the semaphore resizes live but the governor's budget calc keeps
906
+ * the construction-time value.)
839
907
  *
840
908
  * Only keys present in the patch are overwritten; unspecified keys
841
909
  * retain their current value. Any illegal combination (e.g.
@@ -881,13 +949,16 @@ var PipelineRunner = class {
881
949
  lastArmedSource: null,
882
950
  lastArmedRegions: void 0,
883
951
  occupancyTimer: null,
884
- motionInFlight: false
952
+ motionInFlight: false,
953
+ lastResultDetections: 0,
954
+ occupancyArmed: false,
955
+ emptyBurstStreak: 0
885
956
  };
886
957
  this.cameras.set(deviceId, state);
887
958
  this.timingSampler.setDroppedFramesSource(deviceId, () => detectionQueue.droppedFrames);
888
959
  if (registration.detectionMode === "on-motion") this.defaultRoundRobinKeys.push(deviceId);
889
960
  if (registration.detectionMode === "on-motion" && registration.occupancyRecheckEnabled === true && (registration.occupancyRecheckSec ?? 0) > 0) state.occupancyTimer = setInterval(() => {
890
- if (state.phase === "watching") this.config.onOccupancyRecheck?.(deviceId, registration.occupancyRecheckFrames ?? 4);
961
+ if (state.phase === "watching" && state.occupancyArmed) this.config.onOccupancyRecheck?.(deviceId, registration.occupancyRecheckFrames ?? 4);
891
962
  }, (registration.occupancyRecheckSec ?? 0) * 1e3);
892
963
  if (initialPhase === "active") {
893
964
  this.detectionStreamHandler?.(deviceId, "subscribe");
@@ -916,6 +987,8 @@ var PipelineRunner = class {
916
987
  state.detectionQueue.clear();
917
988
  this.timingSampler.clearDroppedFramesSource(deviceId);
918
989
  this.cameras.delete(deviceId);
990
+ this.lastAdmittedAt.delete(deviceId);
991
+ this.arrivalCounts.delete(deviceId);
919
992
  const idx = this.defaultRoundRobinKeys.indexOf(deviceId);
920
993
  if (idx !== -1) {
921
994
  this.defaultRoundRobinKeys.splice(idx, 1);
@@ -933,13 +1006,29 @@ var PipelineRunner = class {
933
1006
  enqueueDetectionFrame(deviceId, frame, handle) {
934
1007
  const state = this.cameras.get(deviceId);
935
1008
  if (!state) return;
1009
+ this.arrivalCounts.set(deviceId, (this.arrivalCounts.get(deviceId) ?? 0) + 1);
936
1010
  if (state.phase !== "active") return;
1011
+ const interval = this.governor.minIntervalMs(deviceId);
1012
+ if (interval > 0) {
1013
+ const last = this.lastAdmittedAt.get(deviceId) ?? 0;
1014
+ const now = Date.now();
1015
+ if (now - last < interval) return;
1016
+ this.lastAdmittedAt.set(deviceId, now);
1017
+ }
937
1018
  frame._enqueuedAt = Date.now();
938
1019
  state.detectionQueue.enqueue({
939
1020
  frame,
940
1021
  handle
941
1022
  });
942
1023
  }
1024
+ /**
1025
+ * `handle` is optional (mirrors {@link DetectionQueueEntry.handle}) — the
1026
+ * dial path (`runOccupancyBurst`'s `startFrameHandlePoller`) always has a
1027
+ * real shm handle, but Task 10's analyzer-tap path can feed a frame whose
1028
+ * motion subscription never produced one (e.g. the per-session motion
1029
+ * decode worker). `runInference` already falls back to the pixel path
1030
+ * when the handle is absent.
1031
+ */
943
1032
  enqueueOccupancyFrame(deviceId, frame, handle) {
944
1033
  const state = this.cameras.get(deviceId);
945
1034
  if (!state) return;
@@ -992,6 +1081,42 @@ var PipelineRunner = class {
992
1081
  getPhase(deviceId) {
993
1082
  return this.cameras.get(deviceId)?.phase;
994
1083
  }
1084
+ /**
1085
+ * Record the detection count from the runner's most recent inference
1086
+ * result for this camera. The addon wrapping the scheduler calls this
1087
+ * from its `onResult` callback — the runner itself stays event-agnostic
1088
+ * (it never inspects `FrameResult` shapes), it only remembers the raw
1089
+ * count that `transitionToWatching` reads to decide whether the
1090
+ * occupancy recheck timer arms for the upcoming `watching` phase.
1091
+ */
1092
+ noteDetectionResult(deviceId, detectionCount) {
1093
+ const state = this.cameras.get(deviceId);
1094
+ if (!state) return;
1095
+ state.lastResultDetections = detectionCount;
1096
+ }
1097
+ /**
1098
+ * Report the outcome of a completed occupancy recheck burst (see
1099
+ * `RunnerConfig.onOccupancyRecheck`). Called by the addon once it knows
1100
+ * whether any burst frame's inference result had detections.
1101
+ *
1102
+ * - `found: true` — resets the empty-burst streak. The timer stays armed;
1103
+ * a genuine occupant is a separate `reportMotion` event that reopens the
1104
+ * session through the existing motion/dispatch path — this method does
1105
+ * not itself flip the phase.
1106
+ * - `found: false` — increments the streak; after 2 consecutive empty
1107
+ * bursts the timer disarms (`occupancyArmed = false`) until the next
1108
+ * active→watching transition re-evaluates from scratch.
1109
+ */
1110
+ reportOccupancyBurstOutcome(deviceId, found) {
1111
+ const state = this.cameras.get(deviceId);
1112
+ if (!state) return;
1113
+ if (found) {
1114
+ state.emptyBurstStreak = 0;
1115
+ return;
1116
+ }
1117
+ state.emptyBurstStreak++;
1118
+ if (state.emptyBurstStreak >= 2) state.occupancyArmed = false;
1119
+ }
995
1120
  onResult(callback) {
996
1121
  this.resultCallbacks.push(callback);
997
1122
  }
@@ -999,12 +1124,19 @@ var PipelineRunner = class {
999
1124
  if (this.intervalHandle !== null) return;
1000
1125
  this.intervalHandle = setInterval(() => this.tick(), 10);
1001
1126
  this.timingSampler.start();
1127
+ this.lastGovernorRecomputeAt = Date.now();
1128
+ this.governorIntervalHandle = setInterval(() => this.recomputeGovernor(), GOVERNOR_RECOMPUTE_INTERVAL_MS);
1129
+ this.governorIntervalHandle?.unref?.();
1002
1130
  }
1003
1131
  stop() {
1004
1132
  if (this.intervalHandle !== null) {
1005
1133
  clearInterval(this.intervalHandle);
1006
1134
  this.intervalHandle = null;
1007
1135
  }
1136
+ if (this.governorIntervalHandle !== null) {
1137
+ clearInterval(this.governorIntervalHandle);
1138
+ this.governorIntervalHandle = null;
1139
+ }
1008
1140
  this.timingSampler.stop();
1009
1141
  for (const state of this.cameras.values()) {
1010
1142
  if (state.motionCooldownTimer !== null) {
@@ -1017,6 +1149,31 @@ var PipelineRunner = class {
1017
1149
  }
1018
1150
  }
1019
1151
  }
1152
+ /**
1153
+ * Recompute the {@link FrameGovernor}'s per-camera admission intervals
1154
+ * from fresh sampler + arrival data. Called on the {@link
1155
+ * GOVERNOR_RECOMPUTE_INTERVAL_MS} tick started by `start()`; also exposed
1156
+ * so tests can prime the governor deterministically without waiting on a
1157
+ * timer. `now` defaults to the wall clock but is accepted explicitly so a
1158
+ * caller can pass a fixed instant for reproducible elapsed-time math.
1159
+ */
1160
+ recomputeGovernor(now = Date.now()) {
1161
+ const elapsedSec = Math.max((now - this.lastGovernorRecomputeAt) / 1e3, .001);
1162
+ const avgInferenceMsByCamera = this.timingSampler.avgInferenceMsByCamera();
1163
+ const samples = /* @__PURE__ */ new Map();
1164
+ for (const [deviceId, state] of this.cameras) {
1165
+ if (state.phase !== "active") continue;
1166
+ const measuredDecodeFps = (this.arrivalCounts.get(deviceId) ?? 0) / elapsedSec;
1167
+ samples.set(deviceId, {
1168
+ avgInferenceMs: avgInferenceMsByCamera.get(deviceId) ?? 0,
1169
+ configuredFps: state.registration.fps,
1170
+ decodeFps: measuredDecodeFps > 0 ? measuredDecodeFps : DEFAULT_DECODE_FPS
1171
+ });
1172
+ }
1173
+ this.governor.update(samples);
1174
+ this.arrivalCounts.clear();
1175
+ this.lastGovernorRecomputeAt = now;
1176
+ }
1020
1177
  getMetrics() {
1021
1178
  let totalQueueDepth = 0;
1022
1179
  let totalInferenceTime = 0;
@@ -1030,7 +1187,7 @@ var PipelineRunner = class {
1030
1187
  }
1031
1188
  return {
1032
1189
  activeCameras: this.cameras.size,
1033
- throttledCameras: 0,
1190
+ throttledCameras: this.governor.throttledCount(),
1034
1191
  avgInferenceTimeMs: totalInferenceCount > 0 ? totalInferenceTime / totalInferenceCount : 0,
1035
1192
  queueDepth: totalQueueDepth
1036
1193
  };
@@ -1068,6 +1225,7 @@ var PipelineRunner = class {
1068
1225
  }
1069
1226
  transitionToActive(deviceId, state, source, regions, cooldownMs) {
1070
1227
  state.phase = "active";
1228
+ state.lastResultDetections = 0;
1071
1229
  this.logger?.info("motion gate opened — phase=active", {
1072
1230
  tags: { deviceId },
1073
1231
  meta: {
@@ -1086,9 +1244,14 @@ var PipelineRunner = class {
1086
1244
  transitionToWatching(deviceId, state, cooldownMs) {
1087
1245
  state.phase = "watching";
1088
1246
  state.detectionQueue.clear();
1247
+ state.occupancyArmed = state.lastResultDetections > 0;
1248
+ state.emptyBurstStreak = 0;
1089
1249
  this.logger?.info("motion gate closed — phase=watching", {
1090
1250
  tags: { deviceId },
1091
- meta: { lastSource: state.lastArmedSource }
1251
+ meta: {
1252
+ lastSource: state.lastArmedSource,
1253
+ occupancyArmed: state.occupancyArmed
1254
+ }
1092
1255
  });
1093
1256
  this.detectionStreamHandler?.(deviceId, "unsubscribe");
1094
1257
  const source = state.lastArmedSource ?? "analyzer";
@@ -1225,13 +1388,20 @@ var KILL_SIGNALS = {
1225
1388
  };
1226
1389
  /** Adapt a Node {@link ChildProcess} to the coordinator's {@link ForkedChildLike}. */
1227
1390
  function wrapChild(child) {
1391
+ child.on("error", () => void 0);
1228
1392
  return {
1229
1393
  send: (msg) => {
1230
- child.send(msg);
1394
+ if (!child.connected) return;
1395
+ child.send(msg, () => void 0);
1231
1396
  },
1232
1397
  on: (ev, cb) => {
1233
- if (ev === "exit") child.on("exit", () => cb(void 0));
1234
- else child.on("message", (m) => cb(m));
1398
+ if (ev === "exit") {
1399
+ const toInfo = (code, signal) => ({
1400
+ code,
1401
+ signal
1402
+ });
1403
+ child.on("exit", (code, signal) => cb(toInfo(code ?? null, signal ?? null)));
1404
+ } else child.on("message", (m) => cb(m));
1235
1405
  },
1236
1406
  kill: (signal) => {
1237
1407
  if (signal === void 0) {
@@ -1439,6 +1609,11 @@ var SIGKILL_FALLBACK_MS = 1e3;
1439
1609
  var DEFAULT_FIRST_FRAME_TIMEOUT_MS = 2e4;
1440
1610
  /** Default timeout for every `pull` round-trip AFTER the first frame arrives. */
1441
1611
  var DEFAULT_FRAME_TIMEOUT_MS = 1e4;
1612
+ /** Narrow an 'exit' payload to {@link WorkerExitInfo} (fakes may emit undefined). */
1613
+ function toWorkerExitInfo(v) {
1614
+ const isInfo = (x) => typeof x === "object" && x !== null && "code" in x && "signal" in x;
1615
+ return isInfo(v) ? v : void 0;
1616
+ }
1442
1617
  function toError(err) {
1443
1618
  return err instanceof Error ? err : new Error(String(err));
1444
1619
  }
@@ -1459,6 +1634,8 @@ var SessionDecodeSession = class {
1459
1634
  pendingNativeCrops = /* @__PURE__ */ new Map();
1460
1635
  nextNativeCropId = 1;
1461
1636
  exited = false;
1637
+ /** Set by teardown() so handleExit can tell an intentional stop from a death. */
1638
+ stopRequested = false;
1462
1639
  sigkillTimer = null;
1463
1640
  pullTimeoutTimer = null;
1464
1641
  firstFrameReceived = false;
@@ -1468,7 +1645,7 @@ var SessionDecodeSession = class {
1468
1645
  this.firstFrameTimeoutMs = firstFrameTimeoutMs;
1469
1646
  this.frameTimeoutMs = frameTimeoutMs;
1470
1647
  this.child.on("message", (m) => this.handleMessage(m));
1471
- this.child.on("exit", () => this.handleExit());
1648
+ this.child.on("exit", (m) => this.handleExit(toWorkerExitInfo(m)));
1472
1649
  }
1473
1650
  start(source, opts) {
1474
1651
  this.trySend({
@@ -1546,8 +1723,21 @@ var SessionDecodeSession = class {
1546
1723
  }
1547
1724
  });
1548
1725
  }
1726
+ /**
1727
+ * Prompt-release the worker's RAM native-frame lease for `frameId`. Fire and
1728
+ * forget (no reply): a lease also self-expires on its TTL, so a dropped
1729
+ * release only defers reclaim. No-op once the worker has exited.
1730
+ */
1731
+ releaseLease(frameId) {
1732
+ if (this.exited) return;
1733
+ this.trySend({
1734
+ kind: "releaseNativeLease",
1735
+ frameId
1736
+ });
1737
+ }
1549
1738
  /** Teardown: `stop`, `kill()`, arm the SIGKILL fallback unless already exited. */
1550
1739
  teardown() {
1740
+ this.stopRequested = true;
1551
1741
  this.clearPullTimeout();
1552
1742
  this.trySend({ kind: "stop" });
1553
1743
  this.tryKill(void 0);
@@ -1593,7 +1783,11 @@ var SessionDecodeSession = class {
1593
1783
  return;
1594
1784
  }
1595
1785
  }
1596
- handleExit() {
1786
+ handleExit(info) {
1787
+ if (!this.stopRequested && !this.exited) this.logger.warn("session-decode worker exited unexpectedly", { meta: {
1788
+ code: info?.code ?? null,
1789
+ signal: info?.signal ?? null
1790
+ } });
1597
1791
  this.exited = true;
1598
1792
  if (this.sigkillTimer) {
1599
1793
  clearTimeout(this.sigkillTimer);
@@ -1753,6 +1947,7 @@ function buildFrameImage(session, reply) {
1753
1947
  return session.requestBuffer(reply.frameId, opts);
1754
1948
  },
1755
1949
  nativeCrop: (bbox, maxWidth) => session.requestNativeCrop(reply.frameId, bbox, maxWidth),
1950
+ releaseNativeLease: () => session.releaseLease(reply.frameId),
1756
1951
  close: () => {
1757
1952
  closed = true;
1758
1953
  }
@@ -1894,7 +2089,8 @@ function startSessionDecodePump(deps) {
1894
2089
  try {
1895
2090
  const decoded = await toDecodedFrame(frame, format);
1896
2091
  const image = frame.image;
1897
- const nativeCrop = image.nativeCrop ? (bbox, maxWidth) => image.nativeCrop(bbox, maxWidth) : void 0;
2092
+ const cropFn = image.nativeCrop;
2093
+ const nativeCrop = cropFn ? Object.assign((bbox, maxWidth) => cropFn(bbox, maxWidth), { release: () => image.releaseNativeLease?.() }) : void 0;
1898
2094
  deps.onDecodedFrame(decoded, nativeCrop);
1899
2095
  } catch (err) {
1900
2096
  deps.logger.debug("session-decode pump: skipped a frame (decode/consume error)", {
@@ -1918,6 +2114,7 @@ function startSessionDecodePump(deps) {
1918
2114
  });
1919
2115
  } finally {
1920
2116
  releaseSpawnSlotOnce();
2117
+ if (!stopped) deps.onEnded?.();
1921
2118
  }
1922
2119
  };
1923
2120
  pump();
@@ -1928,6 +2125,12 @@ function startSessionDecodePump(deps) {
1928
2125
  iterator.return().catch(() => {});
1929
2126
  };
1930
2127
  }
2128
+ /** Invoke a requester's best-effort `release()`, swallowing any transport error. */
2129
+ function safeRelease(request) {
2130
+ try {
2131
+ request.release();
2132
+ } catch {}
2133
+ }
1931
2134
  var NativeCropRegistry = class {
1932
2135
  capacity;
1933
2136
  entries = /* @__PURE__ */ new Map();
@@ -1942,7 +2145,10 @@ var NativeCropRegistry = class {
1942
2145
  key(handle) {
1943
2146
  return `${handle.shmId}#${handle.slot}#${handle.seq}`;
1944
2147
  }
1945
- /** Bind `request` to `handle`; evicts the oldest entry past the cap. */
2148
+ /**
2149
+ * Bind `request` to `handle`; evicts the oldest entry past the cap, releasing
2150
+ * its worker-side RAM lease so a lease never outlives its registry entry.
2151
+ */
1946
2152
  register(handle, request) {
1947
2153
  const k = this.key(handle);
1948
2154
  this.entries.delete(k);
@@ -1950,19 +2156,207 @@ var NativeCropRegistry = class {
1950
2156
  while (this.entries.size > this.capacity) {
1951
2157
  const oldest = this.entries.keys().next().value;
1952
2158
  if (oldest === void 0) break;
1953
- this.entries.delete(oldest);
2159
+ this.releaseEntry(oldest);
1954
2160
  }
1955
2161
  }
1956
2162
  /** The requester for `handle`, or `null` if never registered / already evicted. */
1957
2163
  get(handle) {
1958
2164
  return this.entries.get(this.key(handle)) ?? null;
1959
2165
  }
1960
- /** Drop every entry (node/addon shutdown). */
2166
+ /**
2167
+ * Release the worker-side RAM native lease for `handle` and drop its entry —
2168
+ * the caller is done cutting native crops from that frame. Best-effort +
2169
+ * idempotent (an unknown handle is a no-op; the lease also self-expires).
2170
+ */
2171
+ release(handle) {
2172
+ this.releaseEntry(this.key(handle));
2173
+ }
2174
+ /** Drop every entry, releasing each frame's worker-side lease (node/addon shutdown). */
1961
2175
  clear() {
2176
+ for (const request of this.entries.values()) safeRelease(request);
1962
2177
  this.entries.clear();
1963
2178
  }
2179
+ /** Release + drop one entry by its map key. */
2180
+ releaseEntry(key) {
2181
+ const request = this.entries.get(key);
2182
+ if (!request) return;
2183
+ this.entries.delete(key);
2184
+ safeRelease(request);
2185
+ }
1964
2186
  };
1965
2187
  //#endregion
2188
+ //#region src/pipeline-runner/detail-subtree.ts
2189
+ /** Relative padding applied to the parent bbox before cutting the crop — more context for the child model. */
2190
+ var DETAIL_CROP_PADDING_RATIO = .15;
2191
+ /**
2192
+ * Pad `bbox` by {@link DETAIL_CROP_PADDING_RATIO} of its own size, then clamp
2193
+ * to `[0, frameWidth] x [0, frameHeight]`. Pure — always returns a new rect.
2194
+ */
2195
+ function padAndClampFrameBbox(bbox, frameWidth, frameHeight) {
2196
+ const padX = DETAIL_CROP_PADDING_RATIO * bbox.w;
2197
+ const padY = DETAIL_CROP_PADDING_RATIO * bbox.h;
2198
+ const x1 = Math.max(0, bbox.x - padX);
2199
+ const y1 = Math.max(0, bbox.y - padY);
2200
+ const x2 = Math.min(frameWidth, bbox.x + bbox.w + padX);
2201
+ const y2 = Math.min(frameHeight, bbox.y + bbox.h + padY);
2202
+ return {
2203
+ x: x1,
2204
+ y: y1,
2205
+ w: Math.max(0, x2 - x1),
2206
+ h: Math.max(0, y2 - y1)
2207
+ };
2208
+ }
2209
+ /** Normalize a frame-space pixel rect to `[0,1]` by the given frame dims. */
2210
+ function normalizeRect(rect, frameWidth, frameHeight) {
2211
+ return {
2212
+ x: rect.x / frameWidth,
2213
+ y: rect.y / frameHeight,
2214
+ w: rect.w / frameWidth,
2215
+ h: rect.h / frameHeight
2216
+ };
2217
+ }
2218
+ /** Inverse of {@link normalizeRect} — denormalize a `[0,1]` rect back to frame-space pixels. */
2219
+ function denormalizeRect(rect, frameWidth, frameHeight) {
2220
+ return {
2221
+ x: rect.x * frameWidth,
2222
+ y: rect.y * frameHeight,
2223
+ w: rect.w * frameWidth,
2224
+ h: rect.h * frameHeight
2225
+ };
2226
+ }
2227
+ /**
2228
+ * Walk `steps` (the attach config's tree) collecting every ENABLED step
2229
+ * whose catalog definition's `inputClasses` includes `parentClassName` —
2230
+ * these become the subtree's roots. A matched step's own nested `children`
2231
+ * ride along UNCHANGED here (transitively included: `face-detection` pulls
2232
+ * `face-embedding`, `plate-detection` pulls `plate-ocr`) — recursion stops
2233
+ * at a match rather than also searching inside it for further matches.
2234
+ * `filter`, when given, keeps only the TOP-LEVEL matches whose `addonId` is
2235
+ * in the list — it does NOT prune a match's descendants. Strict per-`steps`
2236
+ * pruning of the descendant chain happens later in {@link runDetailSubtree}
2237
+ * (see {@link pruneChildStepsToRequested}); this function only selects roots.
2238
+ */
2239
+ function findMatchingDetailSteps(steps, parentClassName, lookupStepDefinition, filter) {
2240
+ const matches = [];
2241
+ const walk = (list) => {
2242
+ for (const step of list) {
2243
+ if (!step.enabled) continue;
2244
+ const definition = lookupStepDefinition(step.addonId);
2245
+ if (definition !== null && definition.inputClasses !== null && definition.inputClasses.includes(parentClassName)) {
2246
+ if (!filter || filter.includes(step.addonId)) matches.push(step);
2247
+ continue;
2248
+ }
2249
+ if (step.children) walk(step.children);
2250
+ }
2251
+ };
2252
+ walk(steps);
2253
+ return matches;
2254
+ }
2255
+ /**
2256
+ * Prune a matched step's descendant tree to the caller's requested `steps`
2257
+ * set: keep a child ONLY when its `addonId` is in `requested`, recursing into
2258
+ * every kept child. Pure — returns a new step, never mutates the input.
2259
+ *
2260
+ * Applied ONLY on the DETAIL plane (see {@link runDetailSubtree}); the
2261
+ * per-frame executor path is untouched. This is what makes the dispatcher's
2262
+ * `steps` array authoritative: `['face-detection']` runs the detector geometry
2263
+ * ALONE (the `face-embedding` child is dropped — no ArcFace inference), while
2264
+ * `['face-detection','face-embedding']` keeps the child and reproduces the
2265
+ * full detect→embed chain exactly.
2266
+ */
2267
+ function pruneChildStepsToRequested(step, requested) {
2268
+ if (!step.children || step.children.length === 0) return step;
2269
+ const children = step.children.filter((child) => requested.includes(child.addonId)).map((child) => pruneChildStepsToRequested(child, requested));
2270
+ return {
2271
+ ...step,
2272
+ children
2273
+ };
2274
+ }
2275
+ /** Map a detection's CROP-pixel bbox back to FRAME-space pixels. */
2276
+ function mapBboxToFrameSpace(bbox, crop) {
2277
+ const scaleX = crop.frameSpace.w / crop.width;
2278
+ const scaleY = crop.frameSpace.h / crop.height;
2279
+ return {
2280
+ x: crop.frameSpace.x + bbox.x * scaleX,
2281
+ y: crop.frameSpace.y + bbox.y * scaleY,
2282
+ w: bbox.width * scaleX,
2283
+ h: bbox.height * scaleY
2284
+ };
2285
+ }
2286
+ /** Base64-encode a normalized float32 embedding vector. */
2287
+ function encodeEmbeddingBase64(embedding) {
2288
+ return Buffer.from(Float32Array.from(embedding).buffer).toString("base64");
2289
+ }
2290
+ /** First user-visible label — skips the opaque `embedding:*` marker (see `result-assembler.ts`). */
2291
+ function pickLabel(detection) {
2292
+ return detection.labels.find((l) => !l.label.startsWith("embedding:"))?.label;
2293
+ }
2294
+ function toDetailResult(detection, stepId, crop) {
2295
+ const label = pickLabel(detection);
2296
+ return {
2297
+ stepId,
2298
+ className: detection.macroClass,
2299
+ score: detection.score,
2300
+ bbox: mapBboxToFrameSpace(detection.bbox, crop),
2301
+ ...detection.embedding !== void 0 ? { embedding: encodeEmbeddingBase64(detection.embedding) } : {},
2302
+ ...label !== void 0 ? { label } : {},
2303
+ ...detection.faceAlignedCrop !== void 0 ? { alignedCropJpeg: detection.faceAlignedCrop } : {}
2304
+ };
2305
+ }
2306
+ async function resolveCrop(deps, input) {
2307
+ if (input.frameHandle) {
2308
+ const normalized = normalizeRect(padAndClampFrameBbox(input.parent.bbox, input.frameHandle.width, input.frameHandle.height), input.frameHandle.width, input.frameHandle.height);
2309
+ const native = await deps.getNativeCrop(input.deviceId, input.frameHandle, normalized);
2310
+ if (native) return native;
2311
+ }
2312
+ if (input.cropJpeg) {
2313
+ const jpeg = Buffer.from(input.cropJpeg, "base64");
2314
+ const sharp = (await import("sharp")).default;
2315
+ const meta = await sharp(jpeg).metadata();
2316
+ const width = meta.width ?? 0;
2317
+ const height = meta.height ?? 0;
2318
+ if (width <= 0 || height <= 0) return null;
2319
+ return {
2320
+ jpeg,
2321
+ width,
2322
+ height,
2323
+ frameSpace: input.parent.bbox
2324
+ };
2325
+ }
2326
+ return null;
2327
+ }
2328
+ /**
2329
+ * Run the DETAIL subtree for a single tracked detection: resolve a crop
2330
+ * (native lease → `cropJpeg` fallback), run each matched top-level child
2331
+ * step's chain against it, and map every result bbox back to frame space.
2332
+ * Returns `null` when the camera isn't attached, no configured child
2333
+ * matches `input.parent.className`, or no crop source resolves.
2334
+ *
2335
+ * STRICT `steps` semantics: when the caller names an explicit `input.steps`
2336
+ * set, each matched root's descendant chain is pruned to exactly those ids
2337
+ * (see {@link pruneChildStepsToRequested}) — `resolveChildSteps`/`filter`
2338
+ * only selects TOP-LEVEL roots, so without this a matched `face-detection`
2339
+ * would always drag its `face-embedding` child along regardless of what was
2340
+ * asked for. When `input.steps` is omitted, the full configured chain runs
2341
+ * (today's behavior). This is the knob the dispatcher uses to run a periodic
2342
+ * geometry-only face refresh (`['face-detection']`) without re-running ArcFace.
2343
+ */
2344
+ async function runDetailSubtree(deps, input) {
2345
+ const matched = deps.resolveChildSteps(input.deviceId, input.parent.className, input.steps);
2346
+ if (!matched || matched.length === 0) return null;
2347
+ const requested = input.steps;
2348
+ const steps = requested === void 0 ? matched : matched.map((step) => pruneChildStepsToRequested(step, requested));
2349
+ const crop = await resolveCrop(deps, input);
2350
+ if (!crop) return null;
2351
+ const details = [];
2352
+ for (const step of steps) {
2353
+ const result = await deps.runPipeline([step], crop.jpeg, input.deviceId);
2354
+ if (!result) continue;
2355
+ for (const detection of result.detections) details.push(toDetailResult(detection, step.addonId, crop));
2356
+ }
2357
+ return { details };
2358
+ }
2359
+ //#endregion
1966
2360
  //#region src/pipeline-runner/index.ts
1967
2361
  var DEFAULT_CONFIG = {
1968
2362
  maxQueueDepth: 30,
@@ -1987,6 +2381,31 @@ var DEFAULT_CONFIG = {
1987
2381
  */
1988
2382
  var DETECTION_KEEPWARM_MS = 15e3;
1989
2383
  /**
2384
+ * Occupancy-burst safety timeout (both the dial path and the analyzer-tap
2385
+ * path — Task 10). Bounds worst-case burst latency when frames stop
2386
+ * arriving mid-burst — a dial that never gets a keyframe, or a tap whose
2387
+ * motion subscription closes before N frames arrive.
2388
+ */
2389
+ var OCCUPANCY_BURST_SAFETY_TIMEOUT_MS = 8e3;
2390
+ /**
2391
+ * Freshness window for `lastMotionFrameAt` (Task 10 hardening). A non-null
2392
+ * `attachment.motionUnsubscribe` only proves a subscription handle exists —
2393
+ * NOT that frames are actually flowing (guarded-session-decode can return
2394
+ * its teardown synchronously, and a failed `acquireSessionDecodeRestreamFrom`
2395
+ * acquire never retries, leaving the handle permanently non-null with zero
2396
+ * frames arriving). `runOccupancyBurst` additionally requires a motion frame
2397
+ * to have been teed within this window before it will route to the tap path.
2398
+ */
2399
+ var MOTION_FRESHNESS_WINDOW_MS = 5e3;
2400
+ /** Delay before re-dialing a detection decode whose worker died unexpectedly. */
2401
+ var DETECTION_REDIAL_DELAY_MS = 2e3;
2402
+ /**
2403
+ * Two unexpected decode deaths within this window → stop re-dialing and detach
2404
+ * (the orchestrator re-dispatches on the next motion signal). Bounds the loop
2405
+ * when the camera/source itself is broken.
2406
+ */
2407
+ var DETECTION_REDIAL_GIVEUP_WINDOW_MS = 6e4;
2408
+ /**
1990
2409
  * Pure decision helper: returns `true` when a dynamic frame-diff analyzer
1991
2410
  * subscription should be opened for an onboard-motion camera.
1992
2411
  *
@@ -2003,6 +2422,62 @@ function shouldStartOnboardAnalyzer(config) {
2003
2422
  if (config.motionSources.includes("analyzer")) return false;
2004
2423
  return true;
2005
2424
  }
2425
+ /** Recursively collects the `addonId` of every ENABLED step in the tree. */
2426
+ function collectEnabledStepIds(steps, acc) {
2427
+ if (!steps) return;
2428
+ for (const step of steps) {
2429
+ if (!step.enabled) continue;
2430
+ acc.add(step.addonId);
2431
+ collectEnabledStepIds(step.children, acc);
2432
+ }
2433
+ }
2434
+ /**
2435
+ * Looks up a catalog step definition, tolerating an unknown id. A stale
2436
+ * config entry (removed/custom addon) must degrade to "not announced"
2437
+ * rather than fail the attach — `getStepDefinition` throws on a miss.
2438
+ */
2439
+ function tryGetStepDefinition(stepId) {
2440
+ try {
2441
+ return require_step_definitions.getStepDefinition(stepId);
2442
+ } catch {
2443
+ return null;
2444
+ }
2445
+ }
2446
+ /**
2447
+ * Precomputes the `detailSteps` announce (two-plane design) for a camera's
2448
+ * configured step tree: every ENABLED step whose catalog definition is a
2449
+ * detail/child step (`inputClasses !== null` — a root step like
2450
+ * `object-detection` runs on every frame on the `'frame'` plane and has
2451
+ * nothing to announce) is surfaced as `{ stepId, inputClasses, cadence }`
2452
+ * so a track-level consumer knows what it can dispatch via
2453
+ * `pipelineRunner.runDetailSubtree` and on what cadence, without a
2454
+ * separate round-trip to the executor's schema/config caps.
2455
+ *
2456
+ * Unknown step ids (stale config referencing a removed/custom addon) are
2457
+ * skipped rather than thrown — the catalog is the single source of truth
2458
+ * for cadence and a stale entry must not fail the attach.
2459
+ *
2460
+ * Extracted as a pure function so the unit suite can exercise the
2461
+ * derivation without instantiating the full addon.
2462
+ */
2463
+ function deriveDetailSteps(steps) {
2464
+ const stepIds = /* @__PURE__ */ new Set();
2465
+ collectEnabledStepIds(steps, stepIds);
2466
+ const announces = [];
2467
+ for (const stepId of stepIds) {
2468
+ const def = tryGetStepDefinition(stepId);
2469
+ if (!def || def.inputClasses === null) continue;
2470
+ announces.push({
2471
+ stepId: def.id,
2472
+ inputClasses: def.inputClasses,
2473
+ cadence: def.cadence ?? {
2474
+ trigger: "once",
2475
+ maxPerTrack: 3
2476
+ }
2477
+ });
2478
+ }
2479
+ return announces;
2480
+ }
2006
2481
  function toFrameInput(frame) {
2007
2482
  return {
2008
2483
  data: frame.data,
@@ -2264,6 +2739,13 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
2264
2739
  /** Per-camera single-flight: a camera never runs two occupancy bursts at once. */
2265
2740
  occupancyBurstInFlight = /* @__PURE__ */ new Set();
2266
2741
  /**
2742
+ * Devices whose in-flight occupancy burst has seen at least one inference
2743
+ * result with detections>0 (set from the `onResult` handler above, cleared
2744
+ * per-burst in `runOccupancyBurst`). Read at burst cleanup and reported to
2745
+ * `PipelineRunner.reportOccupancyBurstOutcome` — Task 9's arm/disarm signal.
2746
+ */
2747
+ occupancyBurstFound = /* @__PURE__ */ new Set();
2748
+ /**
2267
2749
  * GLOBAL occupancy admission queue. Each occupancy burst SUBSCRIBES a detection
2268
2750
  * stream (rgb) which, during the `watching` phase, has no existing consumer and
2269
2751
  * therefore makes the broker DIAL a fresh decoder. Without a global cap, N
@@ -2280,6 +2762,23 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
2280
2762
  occupancyActiveCount = 0;
2281
2763
  /** Max concurrent occupancy bursts (decoder dials) across all cameras. */
2282
2764
  occupancyMaxConcurrent = 3;
2765
+ /**
2766
+ * TAP-based occupancy bursts in flight, keyed by deviceId (Task 10). See
2767
+ * {@link OccupancyTapState}. A device has an entry here XOR is dialing via
2768
+ * `startFrameHandlePoller` in `runOccupancyBurst` — never both; the choice
2769
+ * is made once at burst start based on `attachment.motionUnsubscribe`.
2770
+ */
2771
+ occupancyTaps = /* @__PURE__ */ new Map();
2772
+ /**
2773
+ * Timestamp (`Date.now()`) of the last motion frame teed for each device
2774
+ * — set in `runMotionAnalysis` at the same call site as
2775
+ * `teeOccupancyTapFrame` (Task 10 hardening). This is the TRUTHFUL "is
2776
+ * the analyzer subscription actually delivering frames" signal;
2777
+ * `attachment.motionUnsubscribe !== null` alone only proves a
2778
+ * subscription handle was assigned, not that it's live. See
2779
+ * {@link MOTION_FRESHNESS_WINDOW_MS}.
2780
+ */
2781
+ lastMotionFrameAt = /* @__PURE__ */ new Map();
2283
2782
  constructor() {
2284
2783
  super({ ...DEFAULT_CONFIG });
2285
2784
  }
@@ -2316,6 +2815,9 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
2316
2815
  });
2317
2816
  this.runner.onResult(async (deviceId, frame, result, _streamType, handle) => {
2318
2817
  this.emitInferenceResult(deviceId, frame, result, handle);
2818
+ const detectionCount = result.detections?.length ?? 0;
2819
+ this.runner?.noteDetectionResult(deviceId, detectionCount);
2820
+ if (detectionCount > 0 && this.occupancyBurstInFlight.has(deviceId)) this.occupancyBurstFound.add(deviceId);
2319
2821
  });
2320
2822
  this.runner.start();
2321
2823
  this.ctx.logger.info("Pipeline runner started", {
@@ -2381,6 +2883,7 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
2381
2883
  attachment.detectionUnsubscribe?.();
2382
2884
  }
2383
2885
  this.attached.clear();
2886
+ this.lastMotionFrameAt.clear();
2384
2887
  }
2385
2888
  async cacheBenchFrame(input) {
2386
2889
  const sharp = (await import("sharp")).default;
@@ -2726,7 +3229,8 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
2726
3229
  config,
2727
3230
  motionUnsubscribe: null,
2728
3231
  detectionUnsubscribe: null,
2729
- detectionKeepWarmTimer: null
3232
+ detectionKeepWarmTimer: null,
3233
+ detailSteps: deriveDetailSteps(config.steps)
2730
3234
  };
2731
3235
  this.attached.set(config.deviceId, attachment);
2732
3236
  runner.registerCamera(config.deviceId, {
@@ -2779,6 +3283,124 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
2779
3283
  height: result.height
2780
3284
  } : null;
2781
3285
  }
3286
+ /**
3287
+ * Two-plane design: run the DETAIL subtree (crop children) for a single
3288
+ * tracked detection. Delegates the algorithm to the deps-injected pure
3289
+ * function in {@link runDetailSubtreeImpl} — this method only wires the
3290
+ * real deps: the attach config's step tree (+ the catalog, for the
3291
+ * `inputClasses` match), the SAME internal native-crop lease path
3292
+ * `getNativeCrop` uses above (no `ctx.api` round-trip — we're already in
3293
+ * the frame's owning process), and the node-local `pipelineExecutor`.
3294
+ */
3295
+ async runDetailSubtree(input) {
3296
+ return runDetailSubtree({
3297
+ resolveChildSteps: (deviceId, parentClassName, filter) => this.resolveDetailChildSteps(deviceId, parentClassName, filter),
3298
+ getNativeCrop: (deviceId, handle, paddedBbox) => this.getNativeCropForDetail(deviceId, handle, paddedBbox),
3299
+ runPipeline: (steps, imageJpeg, deviceId) => this.runDetailPipeline(steps, imageJpeg, deviceId)
3300
+ }, input);
3301
+ }
3302
+ /**
3303
+ * Real `resolveChildSteps` dep: `null` when the camera isn't attached
3304
+ * (matches the cap doc — "camera not attached" is a distinct case from
3305
+ * "attached but no matching child steps", which returns an empty array
3306
+ * and short-circuits inside {@link runDetailSubtreeImpl} the same way).
3307
+ */
3308
+ resolveDetailChildSteps(deviceId, parentClassName, filter) {
3309
+ const attachment = this.attached.get(deviceId);
3310
+ if (!attachment) return null;
3311
+ const steps = attachment.config.steps;
3312
+ if (!steps) return [];
3313
+ return findMatchingDetailSteps(steps, parentClassName, tryGetStepDefinition, filter);
3314
+ }
3315
+ /**
3316
+ * Real `getNativeCrop` dep: reuses the node-local {@link nativeCropRegistry}
3317
+ * lease (same path the `getNativeCrop` cap method above uses), encodes the
3318
+ * raw RGB crop to JPEG, and denormalizes `paddedBbox` back to frame-space
3319
+ * pixels for the `frameSpace` the caller maps result bboxes against.
3320
+ */
3321
+ async getNativeCropForDetail(_deviceId, handle, paddedBbox) {
3322
+ const native = await this.getNativeCrop({
3323
+ handle,
3324
+ bbox: paddedBbox
3325
+ });
3326
+ if (!native) return null;
3327
+ const sharp = (await import("sharp")).default;
3328
+ return {
3329
+ jpeg: await sharp(Buffer.from(native.bytes), { raw: {
3330
+ width: native.width,
3331
+ height: native.height,
3332
+ channels: 3
3333
+ } }).jpeg({ quality: 90 }).toBuffer(),
3334
+ width: native.width,
3335
+ height: native.height,
3336
+ frameSpace: denormalizeRect(paddedBbox, handle.width, handle.height)
3337
+ };
3338
+ }
3339
+ /** Real `runPipeline` dep: node-local `pipelineExecutor.runPipeline` on the `'full'` plane. */
3340
+ async runDetailPipeline(steps, imageJpeg, deviceId) {
3341
+ const api = this.ctxIfReady?.api;
3342
+ if (!api) return null;
3343
+ try {
3344
+ return await api.pipelineExecutor.runPipeline.mutate({
3345
+ steps: [...steps],
3346
+ image: new Uint8Array(imageJpeg),
3347
+ deviceId,
3348
+ plane: "full"
3349
+ });
3350
+ } catch (err) {
3351
+ this.ctxIfReady?.logger.warn("runDetailSubtree: runPipeline failed", {
3352
+ tags: { deviceId },
3353
+ meta: { error: require_dist.errMsg(err) }
3354
+ });
3355
+ return null;
3356
+ }
3357
+ }
3358
+ /** Last unexpected detection-decode end per device — bounds the re-dial. */
3359
+ lastDecodeUnexpectedEndAt = /* @__PURE__ */ new Map();
3360
+ /**
3361
+ * The detection decode pump ended WITHOUT a teardown (worker death /
3362
+ * generator error) while the camera may still be attached. Previously this
3363
+ * zombied the session — phase stayed `active`, frames stopped, and nothing
3364
+ * re-dialed (the pre-fix Channel-closed runner crash used to mask it by
3365
+ * respawning the whole runner). Recovery, bounded:
3366
+ * - first death: clean up the dead subscription and re-dial once (2s);
3367
+ * - second death within 60s: give up re-dialing and DETACH — the
3368
+ * orchestrator re-dispatches cleanly on the next motion/occupancy signal
3369
+ * (the same recovery path validated manually on device 592).
3370
+ */
3371
+ handleDetectionDecodeEnded(deviceId) {
3372
+ const attachment = this.attached.get(deviceId);
3373
+ if (!attachment) return;
3374
+ const log = this.ctx.logger.withTags({ deviceId });
3375
+ attachment.detectionUnsubscribe?.();
3376
+ attachment.detectionUnsubscribe = null;
3377
+ if (attachment.detectionKeepWarmTimer !== null) {
3378
+ clearTimeout(attachment.detectionKeepWarmTimer);
3379
+ attachment.detectionKeepWarmTimer = null;
3380
+ log.warn("detection decode worker died while kept warm — next open re-forks cold");
3381
+ return;
3382
+ }
3383
+ const now = Date.now();
3384
+ const last = this.lastDecodeUnexpectedEndAt.get(deviceId) ?? 0;
3385
+ this.lastDecodeUnexpectedEndAt.set(deviceId, now);
3386
+ if (now - last < DETECTION_REDIAL_GIVEUP_WINDOW_MS) {
3387
+ log.warn("detection decode died twice within the re-dial window — detaching for orchestrator re-dispatch");
3388
+ this.detachInternal(deviceId);
3389
+ return;
3390
+ }
3391
+ log.warn("detection decode ended unexpectedly while attached — re-dialing once");
3392
+ setTimeout(() => {
3393
+ const still = this.attached.get(deviceId);
3394
+ if (!still || still.detectionUnsubscribe !== null) return;
3395
+ this.subscribeDetectionFrames(still.config).then((unsub) => {
3396
+ if (!this.attached.has(deviceId)) {
3397
+ unsub?.();
3398
+ return;
3399
+ }
3400
+ still.detectionUnsubscribe = unsub;
3401
+ });
3402
+ }, DETECTION_REDIAL_DELAY_MS).unref?.();
3403
+ }
2782
3404
  detachInternal(deviceId) {
2783
3405
  const attachment = this.attached.get(deviceId);
2784
3406
  if (!attachment) return;
@@ -2789,6 +3411,8 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
2789
3411
  }
2790
3412
  attachment.motionUnsubscribe?.();
2791
3413
  attachment.detectionUnsubscribe?.();
3414
+ this.lastMotionFrameAt.delete(deviceId);
3415
+ this.lastDecodeUnexpectedEndAt.delete(deviceId);
2792
3416
  const remotePlane = this.remotePlanes.get(deviceId);
2793
3417
  if (remotePlane) {
2794
3418
  this.remotePlanes.delete(deviceId);
@@ -3098,6 +3722,7 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
3098
3722
  format: "rgb"
3099
3723
  },
3100
3724
  acquireSpawnSlot: this.buildDecodeSpawnAcquire(log, "detect"),
3725
+ onEnded: () => this.handleDetectionDecodeEnded(config.deviceId),
3101
3726
  onDecodedFrame: (frame, nativeCrop) => {
3102
3727
  const handle = retentionSink.write(frame) ?? void 0;
3103
3728
  if (handle && nativeCrop) this.nativeCropRegistry.register(handle, nativeCrop);
@@ -3321,7 +3946,8 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
3321
3946
  return await api.pipelineExecutor.runPipeline.mutate({
3322
3947
  steps: [...steps],
3323
3948
  ...useHandle ? { frameHandle: handle } : { frame },
3324
- deviceId
3949
+ deviceId,
3950
+ plane: "frame"
3325
3951
  });
3326
3952
  } catch (err) {
3327
3953
  const msg = require_dist.errMsg(err);
@@ -3333,6 +3959,8 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
3333
3959
  const ctx = this.ctx;
3334
3960
  const runner = this.runner;
3335
3961
  if (!ctx || !runner) return;
3962
+ this.lastMotionFrameAt.set(deviceId, Date.now());
3963
+ this.teeOccupancyTapFrame(deviceId, frame, handle);
3336
3964
  const log = this.ctx.logger.withTags({ deviceId });
3337
3965
  const motionStart = Date.now();
3338
3966
  try {
@@ -3447,11 +4075,98 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
3447
4075
  });
3448
4076
  }
3449
4077
  }
4078
+ /**
4079
+ * Shared burst-completion path (Task 9 report + Task 10 dial/tap
4080
+ * consolidation): clears the in-flight flag, reads the `found` flag the
4081
+ * `onResult` handler set while the burst was live, reports it to the
4082
+ * runner's arm/disarm state machine, and logs. Both the dial path
4083
+ * (`runOccupancyBurst`'s `cleanup`) and the tap path
4084
+ * (`finishOccupancyTap`) fold into this single implementation so the
4085
+ * found/report semantics can't drift between the two admission
4086
+ * mechanisms.
4087
+ */
4088
+ finishOccupancyBurst(deviceId, log, collected) {
4089
+ this.occupancyBurstInFlight.delete(deviceId);
4090
+ const found = this.occupancyBurstFound.has(deviceId);
4091
+ this.occupancyBurstFound.delete(deviceId);
4092
+ this.runner?.reportOccupancyBurstOutcome(deviceId, found);
4093
+ log.debug("occupancy re-check: burst complete", { meta: {
4094
+ collected,
4095
+ found
4096
+ } });
4097
+ }
4098
+ /**
4099
+ * Task 10: arm a TAP-based occupancy burst for a device whose analyzer
4100
+ * motion subscription is already flowing (`attachment.motionUnsubscribe
4101
+ * !== null` — see `runOccupancyBurst`). Frames feed in via
4102
+ * `teeOccupancyTapFrame`, called from `runMotionAnalysis` on every
4103
+ * arriving substream frame; NO new main-stream poller/decode session is
4104
+ * started. A safety timer (mirrors the dial path's) completes the burst
4105
+ * even if the motion subscription stops producing frames mid-burst.
4106
+ *
4107
+ * Returns a promise that resolves once the tap settles (budget drained or
4108
+ * safety timeout) — `runOccupancyBurst` awaits it so the global
4109
+ * `occupancyActiveCount` slot stays held for the tap's full duration, the
4110
+ * same as the dial path.
4111
+ */
4112
+ /**
4113
+ * Returns a promise that resolves with the tap's final `collected` count
4114
+ * once it settles (budget drained or safety timeout). The caller
4115
+ * (`runOccupancyBurst`) decides what to do with that count — report the
4116
+ * outcome via `finishOccupancyBurst` when frames were actually collected,
4117
+ * or fall back to a dial burst when the tap collected zero (Task 10
4118
+ * hardening #2). Resolving with the count rather than reporting from
4119
+ * inside the tap itself is what makes that fallback possible without
4120
+ * double-reporting.
4121
+ */
4122
+ startOccupancyTap(deviceId, frames, log) {
4123
+ this.occupancyBurstInFlight.add(deviceId);
4124
+ this.occupancyBurstFound.delete(deviceId);
4125
+ log.debug("occupancy re-check: tapping live analyzer motion frames (no dial)", { meta: { frames } });
4126
+ return new Promise((resolve) => {
4127
+ const safetyTimer = setTimeout(() => {
4128
+ const collected = this.occupancyTaps.get(deviceId)?.collected ?? 0;
4129
+ log.debug("occupancy re-check: tap safety timeout reached", { meta: { collected } });
4130
+ this.finishOccupancyTap(deviceId);
4131
+ }, OCCUPANCY_BURST_SAFETY_TIMEOUT_MS);
4132
+ this.occupancyTaps.set(deviceId, {
4133
+ remaining: frames,
4134
+ collected: 0,
4135
+ safetyTimer,
4136
+ resolve
4137
+ });
4138
+ });
4139
+ }
4140
+ /** Idempotent: a no-op once the tap has already settled (timer fired, or the budget already drained). */
4141
+ finishOccupancyTap(deviceId) {
4142
+ const tap = this.occupancyTaps.get(deviceId);
4143
+ if (!tap) return;
4144
+ clearTimeout(tap.safetyTimer);
4145
+ this.occupancyTaps.delete(deviceId);
4146
+ tap.resolve(tap.collected);
4147
+ }
4148
+ /**
4149
+ * Tee an arriving analyzer motion frame into occupancy-burst detection
4150
+ * admission (Task 10). Called from `runMotionAnalysis` on EVERY
4151
+ * substream frame; a no-op unless `startOccupancyTap` armed a budget for
4152
+ * this device. The frame always feeds `enqueueOccupancyFrame` — the
4153
+ * handle may be `undefined` (e.g. the Epic-C per-session motion decode
4154
+ * worker never produces a shm handle) and `runInference` already falls
4155
+ * back to the pixel path in that case, so every arriving frame counts
4156
+ * toward the budget regardless of whether it carried a zero-copy handle.
4157
+ */
4158
+ teeOccupancyTapFrame(deviceId, frame, handle) {
4159
+ const tap = this.occupancyTaps.get(deviceId);
4160
+ if (!tap) return;
4161
+ this.runner?.enqueueOccupancyFrame(deviceId, frame, handle);
4162
+ tap.collected++;
4163
+ tap.remaining--;
4164
+ if (tap.remaining <= 0) this.finishOccupancyTap(deviceId);
4165
+ }
3450
4166
  async runOccupancyBurst(deviceId, frames) {
3451
4167
  if (this.occupancyBurstInFlight.has(deviceId)) return;
3452
4168
  const attachment = this.attached.get(deviceId);
3453
4169
  if (!attachment) return;
3454
- const config = attachment.config;
3455
4170
  const runner = this.runner;
3456
4171
  if (!runner) return;
3457
4172
  const log = this.ctx.logger.withTags({ deviceId });
@@ -3460,8 +4175,40 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
3460
4175
  log.debug("runOccupancyBurst: ctx.api not available");
3461
4176
  return;
3462
4177
  }
3463
- this.occupancyBurstInFlight.add(deviceId);
3464
- log.debug("occupancy re-check: starting burst", { meta: { frames } });
4178
+ const lastFrameAt = this.lastMotionFrameAt.get(deviceId);
4179
+ const motionFresh = lastFrameAt !== void 0 && Date.now() - lastFrameAt < MOTION_FRESHNESS_WINDOW_MS;
4180
+ if (attachment.motionUnsubscribe !== null && motionFresh) {
4181
+ const collected = await this.startOccupancyTap(deviceId, frames, log);
4182
+ if (collected > 0) {
4183
+ this.finishOccupancyBurst(deviceId, log, collected);
4184
+ return;
4185
+ }
4186
+ log.debug("occupancy re-check: tap collected 0 frames, falling back to dial", { meta: { frames } });
4187
+ await this.runOccupancyDial(deviceId, frames, attachment, runner, api, log, true);
4188
+ return;
4189
+ }
4190
+ await this.runOccupancyDial(deviceId, frames, attachment, runner, api, log, false);
4191
+ }
4192
+ /**
4193
+ * Dial-path occupancy burst: subscribes a fresh main-stream decode session
4194
+ * via `startFrameHandlePoller` and collects `frames` handles (or times out
4195
+ * at {@link OCCUPANCY_BURST_SAFETY_TIMEOUT_MS}). Called either directly
4196
+ * from `runOccupancyBurst` (no live/fresh motion tap available,
4197
+ * `alreadyInFlight: false`) or as the Task 10 hardening #2 fallback after
4198
+ * a zero-collected tap (`alreadyInFlight: true` — the tap already claimed
4199
+ * the `occupancyBurstInFlight`/`occupancyBurstFound` slot, so this must
4200
+ * NOT re-add it).
4201
+ */
4202
+ async runOccupancyDial(deviceId, frames, attachment, runner, api, log, alreadyInFlight) {
4203
+ const config = attachment.config;
4204
+ if (!alreadyInFlight) {
4205
+ this.occupancyBurstInFlight.add(deviceId);
4206
+ this.occupancyBurstFound.delete(deviceId);
4207
+ }
4208
+ log.debug("occupancy re-check: starting burst", { meta: {
4209
+ frames,
4210
+ fallback: alreadyInFlight
4211
+ } });
3465
4212
  let count = 0;
3466
4213
  let unsubscribe = null;
3467
4214
  let unsubbed = false;
@@ -3476,13 +4223,12 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
3476
4223
  if (settled) return;
3477
4224
  settled = true;
3478
4225
  doUnsub();
3479
- this.occupancyBurstInFlight.delete(deviceId);
3480
- log.debug("occupancy re-check: burst complete", { meta: { collected: count } });
4226
+ this.finishOccupancyBurst(deviceId, log, count);
3481
4227
  };
3482
4228
  const safetyTimer = setTimeout(() => {
3483
4229
  log.debug("occupancy re-check: safety timeout reached", { meta: { collected: count } });
3484
4230
  cleanup();
3485
- }, 8e3);
4231
+ }, OCCUPANCY_BURST_SAFETY_TIMEOUT_MS);
3486
4232
  try {
3487
4233
  unsubscribe = await startFrameHandlePoller({
3488
4234
  api,
@@ -3506,6 +4252,7 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
3506
4252
  } catch (err) {
3507
4253
  clearTimeout(safetyTimer);
3508
4254
  this.occupancyBurstInFlight.delete(deviceId);
4255
+ this.occupancyBurstFound.delete(deviceId);
3509
4256
  log.debug("occupancy re-check: poller start failed", { meta: { error: require_dist.errMsg(err) } });
3510
4257
  return;
3511
4258
  }
@@ -3517,12 +4264,14 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
3517
4264
  emitInferenceResult(deviceId, frame, result, handle) {
3518
4265
  if (!this.ctx?.eventBus) return;
3519
4266
  const capturedAt = frame.capturedAt;
4267
+ const detailSteps = this.attached.get(deviceId)?.detailSteps;
3520
4268
  const payload = {
3521
4269
  deviceId,
3522
4270
  frame: result,
3523
4271
  nodeId: this.nodeId,
3524
4272
  frameHandle: handle,
3525
- ...typeof capturedAt === "number" && capturedAt > 0 ? { capturedAt } : {}
4273
+ ...typeof capturedAt === "number" && capturedAt > 0 ? { capturedAt } : {},
4274
+ ...detailSteps && detailSteps.length > 0 ? { detailSteps } : {}
3526
4275
  };
3527
4276
  this.ctx.eventBus.emit(require_dist.createEvent(require_dist.EventCategory.PipelineInferenceResult, {
3528
4277
  type: "device",
@@ -3687,6 +4436,7 @@ exports.Semaphore = Semaphore;
3687
4436
  exports.acquireSessionDecodeRestreamFrom = acquireSessionDecodeRestreamFrom;
3688
4437
  exports.customActions = pipelineRunnerBenchActions;
3689
4438
  exports.default = PipelineRunnerAddon;
4439
+ exports.deriveDetailSteps = deriveDetailSteps;
3690
4440
  exports.resolveMotionFrameSource = resolveMotionFrameSource;
3691
4441
  exports.resolveSessionDecodeHostname = resolveSessionDecodeHostname;
3692
4442
  exports.resolveSessionDecodeOwnerNodeId = resolveSessionDecodeOwnerNodeId;