@camstack/addon-pipeline 1.1.53 → 1.1.55

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 (35) 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 +289 -1469
  4. package/dist/detection-pipeline/index.mjs +269 -1449
  5. package/dist/{dist-DWb62H5U.js → dist-DI57FC8K.js} +167 -8
  6. package/dist/{dist-BalxNwOt.mjs → dist-RWGGPwVx.mjs} +167 -8
  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 +772 -21
  10. package/dist/pipeline-runner/index.mjs +772 -22
  11. package/dist/recorder/index.js +1 -1
  12. package/dist/recorder/index.mjs +1 -1
  13. package/dist/{remote-source-plane-CZpzIVro.js → remote-source-plane-CHgvzzA6.js} +1 -1
  14. package/dist/{remote-source-plane-BCJW5CvF.mjs → remote-source-plane-DU0aRSPv.mjs} +1 -1
  15. package/dist/session-decode/decode-worker-child.js +538 -35
  16. package/dist/session-decode/decode-worker-child.mjs +538 -35
  17. package/dist/step-definitions-CNBFKjZe.js +1514 -0
  18. package/dist/step-definitions-CP9kVSml.mjs +1479 -0
  19. package/dist/stream-broker/_stub.js +2 -2
  20. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DIN3CcRP.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-mDCPzmT3.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-B3gTdHEh.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-C9fwKMfg.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-Dn_pxzP-.mjs} +1 -1
  23. package/dist/stream-broker/{hostInit-CGUEq--X.mjs → hostInit-GM_CI22k.mjs} +3 -3
  24. package/dist/stream-broker/index.js +2 -2
  25. package/dist/stream-broker/index.mjs +2 -2
  26. package/dist/stream-broker/remoteEntry.js +1 -1
  27. package/dist/{worker-protocol-pk7qdYXt.mjs → worker-protocol-CyVJTZEO.mjs} +7 -0
  28. package/dist/{worker-protocol-BCfO8gUF.js → worker-protocol-PP4jKHHJ.js} +7 -0
  29. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-CqOe8eYa.js → MaskShapeCanvas-DI4BY7W2-BChW0ntM.js} +1 -1
  30. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-CZyLeUnd.js → MotionZonesSettings-NcxxQN8r-DDzqEbSe.js} +1 -1
  31. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-Cn0hGZnu.js → PrivacyMaskSettings-APgPLF7p-B879GXaf.js} +1 -1
  32. package/embed-dist/assets/{index-DRl4XYjA.js → index-CNjQ5rAE.js} +10 -10
  33. package/embed-dist/index.html +1 -1
  34. package/package.json +1 -1
  35. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-o4tu_xuc.mjs +0 -26
@@ -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-DWb62H5U.js");
6
- const require_remote_source_plane = require("../remote-source-plane-CZpzIVro.js");
5
+ const require_dist = require("../dist-DI57FC8K.js");
6
+ const require_remote_source_plane = require("../remote-source-plane-CHgvzzA6.js");
7
7
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
8
- const require_worker_protocol = require("../worker-protocol-BCfO8gUF.js");
8
+ const require_worker_protocol = require("../worker-protocol-PP4jKHHJ.js");
9
+ const require_step_definitions = require("../step-definitions-CNBFKjZe.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,9 +1388,11 @@ 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
1398
  if (ev === "exit") child.on("exit", () => cb(void 0));
@@ -1455,6 +1620,9 @@ var SessionDecodeSession = class {
1455
1620
  frameTimeoutMs;
1456
1621
  pendingPull = null;
1457
1622
  pendingToBuffers = /* @__PURE__ */ new Map();
1623
+ /** Outstanding `nativeCrop` requests, keyed by the monotonic `requestId`. */
1624
+ pendingNativeCrops = /* @__PURE__ */ new Map();
1625
+ nextNativeCropId = 1;
1458
1626
  exited = false;
1459
1627
  sigkillTimer = null;
1460
1628
  pullTimeoutTimer = null;
@@ -1519,6 +1687,42 @@ var SessionDecodeSession = class {
1519
1687
  }
1520
1688
  });
1521
1689
  }
1690
+ /**
1691
+ * Send `nativeCrop` for `frameId` and resolve with the crop result, or `null`
1692
+ * on any miss (frame aged out, worker error, or worker already exited). Never
1693
+ * rejects — native crops are strictly best-effort with a free fallback.
1694
+ */
1695
+ requestNativeCrop(frameId, bbox, maxWidth) {
1696
+ if (this.exited) return Promise.resolve(null);
1697
+ const requestId = this.nextNativeCropId++;
1698
+ return new Promise((resolve) => {
1699
+ this.pendingNativeCrops.set(requestId, { resolve });
1700
+ try {
1701
+ this.child.send({
1702
+ kind: "nativeCrop",
1703
+ requestId,
1704
+ frameId,
1705
+ bbox,
1706
+ ...maxWidth !== void 0 ? { maxWidth } : {}
1707
+ });
1708
+ } catch {
1709
+ this.pendingNativeCrops.delete(requestId);
1710
+ resolve(null);
1711
+ }
1712
+ });
1713
+ }
1714
+ /**
1715
+ * Prompt-release the worker's RAM native-frame lease for `frameId`. Fire and
1716
+ * forget (no reply): a lease also self-expires on its TTL, so a dropped
1717
+ * release only defers reclaim. No-op once the worker has exited.
1718
+ */
1719
+ releaseLease(frameId) {
1720
+ if (this.exited) return;
1721
+ this.trySend({
1722
+ kind: "releaseNativeLease",
1723
+ frameId
1724
+ });
1725
+ }
1522
1726
  /** Teardown: `stop`, `kill()`, arm the SIGKILL fallback unless already exited. */
1523
1727
  teardown() {
1524
1728
  this.clearPullTimeout();
@@ -1551,6 +1755,16 @@ var SessionDecodeSession = class {
1551
1755
  case "buffer":
1552
1756
  this.settleToBuffer(raw.frameId, raw.bytes);
1553
1757
  return;
1758
+ case "nativeCropResult":
1759
+ this.settleNativeCrop(raw.requestId, {
1760
+ bytes: raw.bytes,
1761
+ width: raw.width,
1762
+ height: raw.height
1763
+ });
1764
+ return;
1765
+ case "nativeCropMiss":
1766
+ this.settleNativeCrop(raw.requestId, null);
1767
+ return;
1554
1768
  case "error":
1555
1769
  this.settleError(raw.message, raw.frameId);
1556
1770
  return;
@@ -1570,6 +1784,15 @@ var SessionDecodeSession = class {
1570
1784
  }
1571
1785
  for (const waiters of this.pendingToBuffers.values()) for (const waiter of waiters) waiter.reject(/* @__PURE__ */ new Error("session-decode-coordinator: worker exited before toBuffer reply"));
1572
1786
  this.pendingToBuffers.clear();
1787
+ for (const waiter of this.pendingNativeCrops.values()) waiter.resolve(null);
1788
+ this.pendingNativeCrops.clear();
1789
+ }
1790
+ /** Settle the `nativeCrop` waiter for `requestId` (dropping unknown/stale ids). */
1791
+ settleNativeCrop(requestId, result) {
1792
+ const waiter = this.pendingNativeCrops.get(requestId);
1793
+ if (!waiter) return;
1794
+ this.pendingNativeCrops.delete(requestId);
1795
+ waiter.resolve(result);
1573
1796
  }
1574
1797
  settlePendingPull(outcome) {
1575
1798
  const waiter = this.pendingPull;
@@ -1701,10 +1924,13 @@ function buildFrameImage(session, reply) {
1701
1924
  width: reply.width,
1702
1925
  height: reply.height,
1703
1926
  format: reply.format,
1927
+ frameId: reply.frameId,
1704
1928
  toBuffer: async (opts) => {
1705
1929
  if (closed) throw new Error(`session-decode-coordinator: toBuffer() called on a closed frame (frameId=${reply.frameId})`);
1706
1930
  return session.requestBuffer(reply.frameId, opts);
1707
1931
  },
1932
+ nativeCrop: (bbox, maxWidth) => session.requestNativeCrop(reply.frameId, bbox, maxWidth),
1933
+ releaseNativeLease: () => session.releaseLease(reply.frameId),
1708
1934
  close: () => {
1709
1935
  closed = true;
1710
1936
  }
@@ -1845,7 +2071,10 @@ function startSessionDecodePump(deps) {
1845
2071
  }
1846
2072
  try {
1847
2073
  const decoded = await toDecodedFrame(frame, format);
1848
- deps.onDecodedFrame(decoded);
2074
+ const image = frame.image;
2075
+ const cropFn = image.nativeCrop;
2076
+ const nativeCrop = cropFn ? Object.assign((bbox, maxWidth) => cropFn(bbox, maxWidth), { release: () => image.releaseNativeLease?.() }) : void 0;
2077
+ deps.onDecodedFrame(decoded, nativeCrop);
1849
2078
  } catch (err) {
1850
2079
  deps.logger.debug("session-decode pump: skipped a frame (decode/consume error)", {
1851
2080
  tags: { deviceId: deps.source.deviceId },
@@ -1878,6 +2107,203 @@ function startSessionDecodePump(deps) {
1878
2107
  iterator.return().catch(() => {});
1879
2108
  };
1880
2109
  }
2110
+ /** Invoke a requester's best-effort `release()`, swallowing any transport error. */
2111
+ function safeRelease(request) {
2112
+ try {
2113
+ request.release();
2114
+ } catch {}
2115
+ }
2116
+ var NativeCropRegistry = class {
2117
+ capacity;
2118
+ entries = /* @__PURE__ */ new Map();
2119
+ constructor(capacity = 64) {
2120
+ this.capacity = capacity;
2121
+ }
2122
+ /** Number of registered handles (tests / metrics). */
2123
+ get size() {
2124
+ return this.entries.size;
2125
+ }
2126
+ /** A retention frame is uniquely identified by its segment + slot + committed seq. */
2127
+ key(handle) {
2128
+ return `${handle.shmId}#${handle.slot}#${handle.seq}`;
2129
+ }
2130
+ /**
2131
+ * Bind `request` to `handle`; evicts the oldest entry past the cap, releasing
2132
+ * its worker-side RAM lease so a lease never outlives its registry entry.
2133
+ */
2134
+ register(handle, request) {
2135
+ const k = this.key(handle);
2136
+ this.entries.delete(k);
2137
+ this.entries.set(k, request);
2138
+ while (this.entries.size > this.capacity) {
2139
+ const oldest = this.entries.keys().next().value;
2140
+ if (oldest === void 0) break;
2141
+ this.releaseEntry(oldest);
2142
+ }
2143
+ }
2144
+ /** The requester for `handle`, or `null` if never registered / already evicted. */
2145
+ get(handle) {
2146
+ return this.entries.get(this.key(handle)) ?? null;
2147
+ }
2148
+ /**
2149
+ * Release the worker-side RAM native lease for `handle` and drop its entry —
2150
+ * the caller is done cutting native crops from that frame. Best-effort +
2151
+ * idempotent (an unknown handle is a no-op; the lease also self-expires).
2152
+ */
2153
+ release(handle) {
2154
+ this.releaseEntry(this.key(handle));
2155
+ }
2156
+ /** Drop every entry, releasing each frame's worker-side lease (node/addon shutdown). */
2157
+ clear() {
2158
+ for (const request of this.entries.values()) safeRelease(request);
2159
+ this.entries.clear();
2160
+ }
2161
+ /** Release + drop one entry by its map key. */
2162
+ releaseEntry(key) {
2163
+ const request = this.entries.get(key);
2164
+ if (!request) return;
2165
+ this.entries.delete(key);
2166
+ safeRelease(request);
2167
+ }
2168
+ };
2169
+ //#endregion
2170
+ //#region src/pipeline-runner/detail-subtree.ts
2171
+ /** Relative padding applied to the parent bbox before cutting the crop — more context for the child model. */
2172
+ var DETAIL_CROP_PADDING_RATIO = .15;
2173
+ /**
2174
+ * Pad `bbox` by {@link DETAIL_CROP_PADDING_RATIO} of its own size, then clamp
2175
+ * to `[0, frameWidth] x [0, frameHeight]`. Pure — always returns a new rect.
2176
+ */
2177
+ function padAndClampFrameBbox(bbox, frameWidth, frameHeight) {
2178
+ const padX = DETAIL_CROP_PADDING_RATIO * bbox.w;
2179
+ const padY = DETAIL_CROP_PADDING_RATIO * bbox.h;
2180
+ const x1 = Math.max(0, bbox.x - padX);
2181
+ const y1 = Math.max(0, bbox.y - padY);
2182
+ const x2 = Math.min(frameWidth, bbox.x + bbox.w + padX);
2183
+ const y2 = Math.min(frameHeight, bbox.y + bbox.h + padY);
2184
+ return {
2185
+ x: x1,
2186
+ y: y1,
2187
+ w: Math.max(0, x2 - x1),
2188
+ h: Math.max(0, y2 - y1)
2189
+ };
2190
+ }
2191
+ /** Normalize a frame-space pixel rect to `[0,1]` by the given frame dims. */
2192
+ function normalizeRect(rect, frameWidth, frameHeight) {
2193
+ return {
2194
+ x: rect.x / frameWidth,
2195
+ y: rect.y / frameHeight,
2196
+ w: rect.w / frameWidth,
2197
+ h: rect.h / frameHeight
2198
+ };
2199
+ }
2200
+ /** Inverse of {@link normalizeRect} — denormalize a `[0,1]` rect back to frame-space pixels. */
2201
+ function denormalizeRect(rect, frameWidth, frameHeight) {
2202
+ return {
2203
+ x: rect.x * frameWidth,
2204
+ y: rect.y * frameHeight,
2205
+ w: rect.w * frameWidth,
2206
+ h: rect.h * frameHeight
2207
+ };
2208
+ }
2209
+ /**
2210
+ * Walk `steps` (the attach config's tree) collecting every ENABLED step
2211
+ * whose catalog definition's `inputClasses` includes `parentClassName` —
2212
+ * these become the subtree's roots. A matched step's own nested `children`
2213
+ * ride along UNCHANGED (transitively included: `face-detection` pulls
2214
+ * `face-embedding`, `plate-detection` pulls `plate-ocr`) — recursion stops
2215
+ * at a match rather than also searching inside it for further matches.
2216
+ * `filter`, when given, keeps only matches whose `addonId` is in the list.
2217
+ */
2218
+ function findMatchingDetailSteps(steps, parentClassName, lookupStepDefinition, filter) {
2219
+ const matches = [];
2220
+ const walk = (list) => {
2221
+ for (const step of list) {
2222
+ if (!step.enabled) continue;
2223
+ const definition = lookupStepDefinition(step.addonId);
2224
+ if (definition !== null && definition.inputClasses !== null && definition.inputClasses.includes(parentClassName)) {
2225
+ if (!filter || filter.includes(step.addonId)) matches.push(step);
2226
+ continue;
2227
+ }
2228
+ if (step.children) walk(step.children);
2229
+ }
2230
+ };
2231
+ walk(steps);
2232
+ return matches;
2233
+ }
2234
+ /** Map a detection's CROP-pixel bbox back to FRAME-space pixels. */
2235
+ function mapBboxToFrameSpace(bbox, crop) {
2236
+ const scaleX = crop.frameSpace.w / crop.width;
2237
+ const scaleY = crop.frameSpace.h / crop.height;
2238
+ return {
2239
+ x: crop.frameSpace.x + bbox.x * scaleX,
2240
+ y: crop.frameSpace.y + bbox.y * scaleY,
2241
+ w: bbox.width * scaleX,
2242
+ h: bbox.height * scaleY
2243
+ };
2244
+ }
2245
+ /** Base64-encode a normalized float32 embedding vector. */
2246
+ function encodeEmbeddingBase64(embedding) {
2247
+ return Buffer.from(Float32Array.from(embedding).buffer).toString("base64");
2248
+ }
2249
+ /** First user-visible label — skips the opaque `embedding:*` marker (see `result-assembler.ts`). */
2250
+ function pickLabel(detection) {
2251
+ return detection.labels.find((l) => !l.label.startsWith("embedding:"))?.label;
2252
+ }
2253
+ function toDetailResult(detection, stepId, crop) {
2254
+ const label = pickLabel(detection);
2255
+ return {
2256
+ stepId,
2257
+ className: detection.macroClass,
2258
+ score: detection.score,
2259
+ bbox: mapBboxToFrameSpace(detection.bbox, crop),
2260
+ ...detection.embedding !== void 0 ? { embedding: encodeEmbeddingBase64(detection.embedding) } : {},
2261
+ ...label !== void 0 ? { label } : {},
2262
+ ...detection.faceAlignedCrop !== void 0 ? { alignedCropJpeg: detection.faceAlignedCrop } : {}
2263
+ };
2264
+ }
2265
+ async function resolveCrop(deps, input) {
2266
+ if (input.frameHandle) {
2267
+ const normalized = normalizeRect(padAndClampFrameBbox(input.parent.bbox, input.frameHandle.width, input.frameHandle.height), input.frameHandle.width, input.frameHandle.height);
2268
+ const native = await deps.getNativeCrop(input.deviceId, input.frameHandle, normalized);
2269
+ if (native) return native;
2270
+ }
2271
+ if (input.cropJpeg) {
2272
+ const jpeg = Buffer.from(input.cropJpeg, "base64");
2273
+ const sharp = (await import("sharp")).default;
2274
+ const meta = await sharp(jpeg).metadata();
2275
+ const width = meta.width ?? 0;
2276
+ const height = meta.height ?? 0;
2277
+ if (width <= 0 || height <= 0) return null;
2278
+ return {
2279
+ jpeg,
2280
+ width,
2281
+ height,
2282
+ frameSpace: input.parent.bbox
2283
+ };
2284
+ }
2285
+ return null;
2286
+ }
2287
+ /**
2288
+ * Run the DETAIL subtree for a single tracked detection: resolve a crop
2289
+ * (native lease → `cropJpeg` fallback), run each matched top-level child
2290
+ * step's chain against it, and map every result bbox back to frame space.
2291
+ * Returns `null` when the camera isn't attached, no configured child
2292
+ * matches `input.parent.className`, or no crop source resolves.
2293
+ */
2294
+ async function runDetailSubtree(deps, input) {
2295
+ const steps = deps.resolveChildSteps(input.deviceId, input.parent.className, input.steps);
2296
+ if (!steps || steps.length === 0) return null;
2297
+ const crop = await resolveCrop(deps, input);
2298
+ if (!crop) return null;
2299
+ const details = [];
2300
+ for (const step of steps) {
2301
+ const result = await deps.runPipeline([step], crop.jpeg, input.deviceId);
2302
+ if (!result) continue;
2303
+ for (const detection of result.detections) details.push(toDetailResult(detection, step.addonId, crop));
2304
+ }
2305
+ return { details };
2306
+ }
1881
2307
  //#endregion
1882
2308
  //#region src/pipeline-runner/index.ts
1883
2309
  var DEFAULT_CONFIG = {
@@ -1903,6 +2329,23 @@ var DEFAULT_CONFIG = {
1903
2329
  */
1904
2330
  var DETECTION_KEEPWARM_MS = 15e3;
1905
2331
  /**
2332
+ * Occupancy-burst safety timeout (both the dial path and the analyzer-tap
2333
+ * path — Task 10). Bounds worst-case burst latency when frames stop
2334
+ * arriving mid-burst — a dial that never gets a keyframe, or a tap whose
2335
+ * motion subscription closes before N frames arrive.
2336
+ */
2337
+ var OCCUPANCY_BURST_SAFETY_TIMEOUT_MS = 8e3;
2338
+ /**
2339
+ * Freshness window for `lastMotionFrameAt` (Task 10 hardening). A non-null
2340
+ * `attachment.motionUnsubscribe` only proves a subscription handle exists —
2341
+ * NOT that frames are actually flowing (guarded-session-decode can return
2342
+ * its teardown synchronously, and a failed `acquireSessionDecodeRestreamFrom`
2343
+ * acquire never retries, leaving the handle permanently non-null with zero
2344
+ * frames arriving). `runOccupancyBurst` additionally requires a motion frame
2345
+ * to have been teed within this window before it will route to the tap path.
2346
+ */
2347
+ var MOTION_FRESHNESS_WINDOW_MS = 5e3;
2348
+ /**
1906
2349
  * Pure decision helper: returns `true` when a dynamic frame-diff analyzer
1907
2350
  * subscription should be opened for an onboard-motion camera.
1908
2351
  *
@@ -1919,6 +2362,62 @@ function shouldStartOnboardAnalyzer(config) {
1919
2362
  if (config.motionSources.includes("analyzer")) return false;
1920
2363
  return true;
1921
2364
  }
2365
+ /** Recursively collects the `addonId` of every ENABLED step in the tree. */
2366
+ function collectEnabledStepIds(steps, acc) {
2367
+ if (!steps) return;
2368
+ for (const step of steps) {
2369
+ if (!step.enabled) continue;
2370
+ acc.add(step.addonId);
2371
+ collectEnabledStepIds(step.children, acc);
2372
+ }
2373
+ }
2374
+ /**
2375
+ * Looks up a catalog step definition, tolerating an unknown id. A stale
2376
+ * config entry (removed/custom addon) must degrade to "not announced"
2377
+ * rather than fail the attach — `getStepDefinition` throws on a miss.
2378
+ */
2379
+ function tryGetStepDefinition(stepId) {
2380
+ try {
2381
+ return require_step_definitions.getStepDefinition(stepId);
2382
+ } catch {
2383
+ return null;
2384
+ }
2385
+ }
2386
+ /**
2387
+ * Precomputes the `detailSteps` announce (two-plane design) for a camera's
2388
+ * configured step tree: every ENABLED step whose catalog definition is a
2389
+ * detail/child step (`inputClasses !== null` — a root step like
2390
+ * `object-detection` runs on every frame on the `'frame'` plane and has
2391
+ * nothing to announce) is surfaced as `{ stepId, inputClasses, cadence }`
2392
+ * so a track-level consumer knows what it can dispatch via
2393
+ * `pipelineRunner.runDetailSubtree` and on what cadence, without a
2394
+ * separate round-trip to the executor's schema/config caps.
2395
+ *
2396
+ * Unknown step ids (stale config referencing a removed/custom addon) are
2397
+ * skipped rather than thrown — the catalog is the single source of truth
2398
+ * for cadence and a stale entry must not fail the attach.
2399
+ *
2400
+ * Extracted as a pure function so the unit suite can exercise the
2401
+ * derivation without instantiating the full addon.
2402
+ */
2403
+ function deriveDetailSteps(steps) {
2404
+ const stepIds = /* @__PURE__ */ new Set();
2405
+ collectEnabledStepIds(steps, stepIds);
2406
+ const announces = [];
2407
+ for (const stepId of stepIds) {
2408
+ const def = tryGetStepDefinition(stepId);
2409
+ if (!def || def.inputClasses === null) continue;
2410
+ announces.push({
2411
+ stepId: def.id,
2412
+ inputClasses: def.inputClasses,
2413
+ cadence: def.cadence ?? {
2414
+ trigger: "once",
2415
+ maxPerTrack: 3
2416
+ }
2417
+ });
2418
+ }
2419
+ return announces;
2420
+ }
1922
2421
  function toFrameInput(frame) {
1923
2422
  return {
1924
2423
  data: frame.data,
@@ -2102,6 +2601,13 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
2102
2601
  * `resize()`d live in `onConfigChanged`. Null until `onInitialize`.
2103
2602
  */
2104
2603
  spawnGate = null;
2604
+ /**
2605
+ * Node-level map from a retention-ring `FrameHandle` to the decode worker
2606
+ * still holding that frame's NATIVE surface — backs the `getNativeCrop` cap
2607
+ * so post-analysis can cut native-res crops (best-effort; a miss falls back
2608
+ * to the detection-frame crop). See {@link NativeCropRegistry}.
2609
+ */
2610
+ nativeCropRegistry = new NativeCropRegistry();
2105
2611
  attached = /* @__PURE__ */ new Map();
2106
2612
  nodeId = "unknown";
2107
2613
  metricsSnapshotTimer = null;
@@ -2173,6 +2679,13 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
2173
2679
  /** Per-camera single-flight: a camera never runs two occupancy bursts at once. */
2174
2680
  occupancyBurstInFlight = /* @__PURE__ */ new Set();
2175
2681
  /**
2682
+ * Devices whose in-flight occupancy burst has seen at least one inference
2683
+ * result with detections>0 (set from the `onResult` handler above, cleared
2684
+ * per-burst in `runOccupancyBurst`). Read at burst cleanup and reported to
2685
+ * `PipelineRunner.reportOccupancyBurstOutcome` — Task 9's arm/disarm signal.
2686
+ */
2687
+ occupancyBurstFound = /* @__PURE__ */ new Set();
2688
+ /**
2176
2689
  * GLOBAL occupancy admission queue. Each occupancy burst SUBSCRIBES a detection
2177
2690
  * stream (rgb) which, during the `watching` phase, has no existing consumer and
2178
2691
  * therefore makes the broker DIAL a fresh decoder. Without a global cap, N
@@ -2189,6 +2702,23 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
2189
2702
  occupancyActiveCount = 0;
2190
2703
  /** Max concurrent occupancy bursts (decoder dials) across all cameras. */
2191
2704
  occupancyMaxConcurrent = 3;
2705
+ /**
2706
+ * TAP-based occupancy bursts in flight, keyed by deviceId (Task 10). See
2707
+ * {@link OccupancyTapState}. A device has an entry here XOR is dialing via
2708
+ * `startFrameHandlePoller` in `runOccupancyBurst` — never both; the choice
2709
+ * is made once at burst start based on `attachment.motionUnsubscribe`.
2710
+ */
2711
+ occupancyTaps = /* @__PURE__ */ new Map();
2712
+ /**
2713
+ * Timestamp (`Date.now()`) of the last motion frame teed for each device
2714
+ * — set in `runMotionAnalysis` at the same call site as
2715
+ * `teeOccupancyTapFrame` (Task 10 hardening). This is the TRUTHFUL "is
2716
+ * the analyzer subscription actually delivering frames" signal;
2717
+ * `attachment.motionUnsubscribe !== null` alone only proves a
2718
+ * subscription handle was assigned, not that it's live. See
2719
+ * {@link MOTION_FRESHNESS_WINDOW_MS}.
2720
+ */
2721
+ lastMotionFrameAt = /* @__PURE__ */ new Map();
2192
2722
  constructor() {
2193
2723
  super({ ...DEFAULT_CONFIG });
2194
2724
  }
@@ -2225,6 +2755,9 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
2225
2755
  });
2226
2756
  this.runner.onResult(async (deviceId, frame, result, _streamType, handle) => {
2227
2757
  this.emitInferenceResult(deviceId, frame, result, handle);
2758
+ const detectionCount = result.detections?.length ?? 0;
2759
+ this.runner?.noteDetectionResult(deviceId, detectionCount);
2760
+ if (detectionCount > 0 && this.occupancyBurstInFlight.has(deviceId)) this.occupancyBurstFound.add(deviceId);
2228
2761
  });
2229
2762
  this.runner.start();
2230
2763
  this.ctx.logger.info("Pipeline runner started", {
@@ -2290,6 +2823,7 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
2290
2823
  attachment.detectionUnsubscribe?.();
2291
2824
  }
2292
2825
  this.attached.clear();
2826
+ this.lastMotionFrameAt.clear();
2293
2827
  }
2294
2828
  async cacheBenchFrame(input) {
2295
2829
  const sharp = (await import("sharp")).default;
@@ -2635,7 +3169,8 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
2635
3169
  config,
2636
3170
  motionUnsubscribe: null,
2637
3171
  detectionUnsubscribe: null,
2638
- detectionKeepWarmTimer: null
3172
+ detectionKeepWarmTimer: null,
3173
+ detailSteps: deriveDetailSteps(config.steps)
2639
3174
  };
2640
3175
  this.attached.set(config.deviceId, attachment);
2641
3176
  runner.registerCamera(config.deviceId, {
@@ -2671,6 +3206,95 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
2671
3206
  this.runner?.reportMotion(input.deviceId, input.detected, input.source, input.regions);
2672
3207
  return { success: true };
2673
3208
  }
3209
+ /**
3210
+ * Best-effort native-resolution crop for a retention-ring frame (native-crop
3211
+ * feature). Looks the handle up in {@link nativeCropRegistry}; a miss (never
3212
+ * registered, or the worker's tiny native ring already evicted the frame)
3213
+ * returns `null` so the caller falls back to a detection-frame crop. The
3214
+ * worker downloads ONLY the ROI — a full native frame never travels the wire.
3215
+ */
3216
+ async getNativeCrop(input) {
3217
+ const requester = this.nativeCropRegistry.get(input.handle);
3218
+ if (!requester) return null;
3219
+ const result = await requester(input.bbox, input.maxWidth);
3220
+ return result ? {
3221
+ bytes: result.bytes,
3222
+ width: result.width,
3223
+ height: result.height
3224
+ } : null;
3225
+ }
3226
+ /**
3227
+ * Two-plane design: run the DETAIL subtree (crop children) for a single
3228
+ * tracked detection. Delegates the algorithm to the deps-injected pure
3229
+ * function in {@link runDetailSubtreeImpl} — this method only wires the
3230
+ * real deps: the attach config's step tree (+ the catalog, for the
3231
+ * `inputClasses` match), the SAME internal native-crop lease path
3232
+ * `getNativeCrop` uses above (no `ctx.api` round-trip — we're already in
3233
+ * the frame's owning process), and the node-local `pipelineExecutor`.
3234
+ */
3235
+ async runDetailSubtree(input) {
3236
+ return runDetailSubtree({
3237
+ resolveChildSteps: (deviceId, parentClassName, filter) => this.resolveDetailChildSteps(deviceId, parentClassName, filter),
3238
+ getNativeCrop: (deviceId, handle, paddedBbox) => this.getNativeCropForDetail(deviceId, handle, paddedBbox),
3239
+ runPipeline: (steps, imageJpeg, deviceId) => this.runDetailPipeline(steps, imageJpeg, deviceId)
3240
+ }, input);
3241
+ }
3242
+ /**
3243
+ * Real `resolveChildSteps` dep: `null` when the camera isn't attached
3244
+ * (matches the cap doc — "camera not attached" is a distinct case from
3245
+ * "attached but no matching child steps", which returns an empty array
3246
+ * and short-circuits inside {@link runDetailSubtreeImpl} the same way).
3247
+ */
3248
+ resolveDetailChildSteps(deviceId, parentClassName, filter) {
3249
+ const attachment = this.attached.get(deviceId);
3250
+ if (!attachment) return null;
3251
+ const steps = attachment.config.steps;
3252
+ if (!steps) return [];
3253
+ return findMatchingDetailSteps(steps, parentClassName, tryGetStepDefinition, filter);
3254
+ }
3255
+ /**
3256
+ * Real `getNativeCrop` dep: reuses the node-local {@link nativeCropRegistry}
3257
+ * lease (same path the `getNativeCrop` cap method above uses), encodes the
3258
+ * raw RGB crop to JPEG, and denormalizes `paddedBbox` back to frame-space
3259
+ * pixels for the `frameSpace` the caller maps result bboxes against.
3260
+ */
3261
+ async getNativeCropForDetail(_deviceId, handle, paddedBbox) {
3262
+ const native = await this.getNativeCrop({
3263
+ handle,
3264
+ bbox: paddedBbox
3265
+ });
3266
+ if (!native) return null;
3267
+ const sharp = (await import("sharp")).default;
3268
+ return {
3269
+ jpeg: await sharp(Buffer.from(native.bytes), { raw: {
3270
+ width: native.width,
3271
+ height: native.height,
3272
+ channels: 3
3273
+ } }).jpeg({ quality: 90 }).toBuffer(),
3274
+ width: native.width,
3275
+ height: native.height,
3276
+ frameSpace: denormalizeRect(paddedBbox, handle.width, handle.height)
3277
+ };
3278
+ }
3279
+ /** Real `runPipeline` dep: node-local `pipelineExecutor.runPipeline` on the `'full'` plane. */
3280
+ async runDetailPipeline(steps, imageJpeg, deviceId) {
3281
+ const api = this.ctxIfReady?.api;
3282
+ if (!api) return null;
3283
+ try {
3284
+ return await api.pipelineExecutor.runPipeline.mutate({
3285
+ steps: [...steps],
3286
+ image: new Uint8Array(imageJpeg),
3287
+ deviceId,
3288
+ plane: "full"
3289
+ });
3290
+ } catch (err) {
3291
+ this.ctxIfReady?.logger.warn("runDetailSubtree: runPipeline failed", {
3292
+ tags: { deviceId },
3293
+ meta: { error: require_dist.errMsg(err) }
3294
+ });
3295
+ return null;
3296
+ }
3297
+ }
2674
3298
  detachInternal(deviceId) {
2675
3299
  const attachment = this.attached.get(deviceId);
2676
3300
  if (!attachment) return;
@@ -2681,6 +3305,7 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
2681
3305
  }
2682
3306
  attachment.motionUnsubscribe?.();
2683
3307
  attachment.detectionUnsubscribe?.();
3308
+ this.lastMotionFrameAt.delete(deviceId);
2684
3309
  const remotePlane = this.remotePlanes.get(deviceId);
2685
3310
  if (remotePlane) {
2686
3311
  this.remotePlanes.delete(deviceId);
@@ -2990,8 +3615,9 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
2990
3615
  format: "rgb"
2991
3616
  },
2992
3617
  acquireSpawnSlot: this.buildDecodeSpawnAcquire(log, "detect"),
2993
- onDecodedFrame: (frame) => {
3618
+ onDecodedFrame: (frame, nativeCrop) => {
2994
3619
  const handle = retentionSink.write(frame) ?? void 0;
3620
+ if (handle && nativeCrop) this.nativeCropRegistry.register(handle, nativeCrop);
2995
3621
  this.enqueueSharedDetectionFrame(config.deviceId, frame, handle);
2996
3622
  }
2997
3623
  });
@@ -3212,7 +3838,8 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
3212
3838
  return await api.pipelineExecutor.runPipeline.mutate({
3213
3839
  steps: [...steps],
3214
3840
  ...useHandle ? { frameHandle: handle } : { frame },
3215
- deviceId
3841
+ deviceId,
3842
+ plane: "frame"
3216
3843
  });
3217
3844
  } catch (err) {
3218
3845
  const msg = require_dist.errMsg(err);
@@ -3224,6 +3851,8 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
3224
3851
  const ctx = this.ctx;
3225
3852
  const runner = this.runner;
3226
3853
  if (!ctx || !runner) return;
3854
+ this.lastMotionFrameAt.set(deviceId, Date.now());
3855
+ this.teeOccupancyTapFrame(deviceId, frame, handle);
3227
3856
  const log = this.ctx.logger.withTags({ deviceId });
3228
3857
  const motionStart = Date.now();
3229
3858
  try {
@@ -3338,11 +3967,98 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
3338
3967
  });
3339
3968
  }
3340
3969
  }
3970
+ /**
3971
+ * Shared burst-completion path (Task 9 report + Task 10 dial/tap
3972
+ * consolidation): clears the in-flight flag, reads the `found` flag the
3973
+ * `onResult` handler set while the burst was live, reports it to the
3974
+ * runner's arm/disarm state machine, and logs. Both the dial path
3975
+ * (`runOccupancyBurst`'s `cleanup`) and the tap path
3976
+ * (`finishOccupancyTap`) fold into this single implementation so the
3977
+ * found/report semantics can't drift between the two admission
3978
+ * mechanisms.
3979
+ */
3980
+ finishOccupancyBurst(deviceId, log, collected) {
3981
+ this.occupancyBurstInFlight.delete(deviceId);
3982
+ const found = this.occupancyBurstFound.has(deviceId);
3983
+ this.occupancyBurstFound.delete(deviceId);
3984
+ this.runner?.reportOccupancyBurstOutcome(deviceId, found);
3985
+ log.debug("occupancy re-check: burst complete", { meta: {
3986
+ collected,
3987
+ found
3988
+ } });
3989
+ }
3990
+ /**
3991
+ * Task 10: arm a TAP-based occupancy burst for a device whose analyzer
3992
+ * motion subscription is already flowing (`attachment.motionUnsubscribe
3993
+ * !== null` — see `runOccupancyBurst`). Frames feed in via
3994
+ * `teeOccupancyTapFrame`, called from `runMotionAnalysis` on every
3995
+ * arriving substream frame; NO new main-stream poller/decode session is
3996
+ * started. A safety timer (mirrors the dial path's) completes the burst
3997
+ * even if the motion subscription stops producing frames mid-burst.
3998
+ *
3999
+ * Returns a promise that resolves once the tap settles (budget drained or
4000
+ * safety timeout) — `runOccupancyBurst` awaits it so the global
4001
+ * `occupancyActiveCount` slot stays held for the tap's full duration, the
4002
+ * same as the dial path.
4003
+ */
4004
+ /**
4005
+ * Returns a promise that resolves with the tap's final `collected` count
4006
+ * once it settles (budget drained or safety timeout). The caller
4007
+ * (`runOccupancyBurst`) decides what to do with that count — report the
4008
+ * outcome via `finishOccupancyBurst` when frames were actually collected,
4009
+ * or fall back to a dial burst when the tap collected zero (Task 10
4010
+ * hardening #2). Resolving with the count rather than reporting from
4011
+ * inside the tap itself is what makes that fallback possible without
4012
+ * double-reporting.
4013
+ */
4014
+ startOccupancyTap(deviceId, frames, log) {
4015
+ this.occupancyBurstInFlight.add(deviceId);
4016
+ this.occupancyBurstFound.delete(deviceId);
4017
+ log.debug("occupancy re-check: tapping live analyzer motion frames (no dial)", { meta: { frames } });
4018
+ return new Promise((resolve) => {
4019
+ const safetyTimer = setTimeout(() => {
4020
+ const collected = this.occupancyTaps.get(deviceId)?.collected ?? 0;
4021
+ log.debug("occupancy re-check: tap safety timeout reached", { meta: { collected } });
4022
+ this.finishOccupancyTap(deviceId);
4023
+ }, OCCUPANCY_BURST_SAFETY_TIMEOUT_MS);
4024
+ this.occupancyTaps.set(deviceId, {
4025
+ remaining: frames,
4026
+ collected: 0,
4027
+ safetyTimer,
4028
+ resolve
4029
+ });
4030
+ });
4031
+ }
4032
+ /** Idempotent: a no-op once the tap has already settled (timer fired, or the budget already drained). */
4033
+ finishOccupancyTap(deviceId) {
4034
+ const tap = this.occupancyTaps.get(deviceId);
4035
+ if (!tap) return;
4036
+ clearTimeout(tap.safetyTimer);
4037
+ this.occupancyTaps.delete(deviceId);
4038
+ tap.resolve(tap.collected);
4039
+ }
4040
+ /**
4041
+ * Tee an arriving analyzer motion frame into occupancy-burst detection
4042
+ * admission (Task 10). Called from `runMotionAnalysis` on EVERY
4043
+ * substream frame; a no-op unless `startOccupancyTap` armed a budget for
4044
+ * this device. The frame always feeds `enqueueOccupancyFrame` — the
4045
+ * handle may be `undefined` (e.g. the Epic-C per-session motion decode
4046
+ * worker never produces a shm handle) and `runInference` already falls
4047
+ * back to the pixel path in that case, so every arriving frame counts
4048
+ * toward the budget regardless of whether it carried a zero-copy handle.
4049
+ */
4050
+ teeOccupancyTapFrame(deviceId, frame, handle) {
4051
+ const tap = this.occupancyTaps.get(deviceId);
4052
+ if (!tap) return;
4053
+ this.runner?.enqueueOccupancyFrame(deviceId, frame, handle);
4054
+ tap.collected++;
4055
+ tap.remaining--;
4056
+ if (tap.remaining <= 0) this.finishOccupancyTap(deviceId);
4057
+ }
3341
4058
  async runOccupancyBurst(deviceId, frames) {
3342
4059
  if (this.occupancyBurstInFlight.has(deviceId)) return;
3343
4060
  const attachment = this.attached.get(deviceId);
3344
4061
  if (!attachment) return;
3345
- const config = attachment.config;
3346
4062
  const runner = this.runner;
3347
4063
  if (!runner) return;
3348
4064
  const log = this.ctx.logger.withTags({ deviceId });
@@ -3351,8 +4067,40 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
3351
4067
  log.debug("runOccupancyBurst: ctx.api not available");
3352
4068
  return;
3353
4069
  }
3354
- this.occupancyBurstInFlight.add(deviceId);
3355
- log.debug("occupancy re-check: starting burst", { meta: { frames } });
4070
+ const lastFrameAt = this.lastMotionFrameAt.get(deviceId);
4071
+ const motionFresh = lastFrameAt !== void 0 && Date.now() - lastFrameAt < MOTION_FRESHNESS_WINDOW_MS;
4072
+ if (attachment.motionUnsubscribe !== null && motionFresh) {
4073
+ const collected = await this.startOccupancyTap(deviceId, frames, log);
4074
+ if (collected > 0) {
4075
+ this.finishOccupancyBurst(deviceId, log, collected);
4076
+ return;
4077
+ }
4078
+ log.debug("occupancy re-check: tap collected 0 frames, falling back to dial", { meta: { frames } });
4079
+ await this.runOccupancyDial(deviceId, frames, attachment, runner, api, log, true);
4080
+ return;
4081
+ }
4082
+ await this.runOccupancyDial(deviceId, frames, attachment, runner, api, log, false);
4083
+ }
4084
+ /**
4085
+ * Dial-path occupancy burst: subscribes a fresh main-stream decode session
4086
+ * via `startFrameHandlePoller` and collects `frames` handles (or times out
4087
+ * at {@link OCCUPANCY_BURST_SAFETY_TIMEOUT_MS}). Called either directly
4088
+ * from `runOccupancyBurst` (no live/fresh motion tap available,
4089
+ * `alreadyInFlight: false`) or as the Task 10 hardening #2 fallback after
4090
+ * a zero-collected tap (`alreadyInFlight: true` — the tap already claimed
4091
+ * the `occupancyBurstInFlight`/`occupancyBurstFound` slot, so this must
4092
+ * NOT re-add it).
4093
+ */
4094
+ async runOccupancyDial(deviceId, frames, attachment, runner, api, log, alreadyInFlight) {
4095
+ const config = attachment.config;
4096
+ if (!alreadyInFlight) {
4097
+ this.occupancyBurstInFlight.add(deviceId);
4098
+ this.occupancyBurstFound.delete(deviceId);
4099
+ }
4100
+ log.debug("occupancy re-check: starting burst", { meta: {
4101
+ frames,
4102
+ fallback: alreadyInFlight
4103
+ } });
3356
4104
  let count = 0;
3357
4105
  let unsubscribe = null;
3358
4106
  let unsubbed = false;
@@ -3367,13 +4115,12 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
3367
4115
  if (settled) return;
3368
4116
  settled = true;
3369
4117
  doUnsub();
3370
- this.occupancyBurstInFlight.delete(deviceId);
3371
- log.debug("occupancy re-check: burst complete", { meta: { collected: count } });
4118
+ this.finishOccupancyBurst(deviceId, log, count);
3372
4119
  };
3373
4120
  const safetyTimer = setTimeout(() => {
3374
4121
  log.debug("occupancy re-check: safety timeout reached", { meta: { collected: count } });
3375
4122
  cleanup();
3376
- }, 8e3);
4123
+ }, OCCUPANCY_BURST_SAFETY_TIMEOUT_MS);
3377
4124
  try {
3378
4125
  unsubscribe = await startFrameHandlePoller({
3379
4126
  api,
@@ -3397,6 +4144,7 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
3397
4144
  } catch (err) {
3398
4145
  clearTimeout(safetyTimer);
3399
4146
  this.occupancyBurstInFlight.delete(deviceId);
4147
+ this.occupancyBurstFound.delete(deviceId);
3400
4148
  log.debug("occupancy re-check: poller start failed", { meta: { error: require_dist.errMsg(err) } });
3401
4149
  return;
3402
4150
  }
@@ -3408,12 +4156,14 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
3408
4156
  emitInferenceResult(deviceId, frame, result, handle) {
3409
4157
  if (!this.ctx?.eventBus) return;
3410
4158
  const capturedAt = frame.capturedAt;
4159
+ const detailSteps = this.attached.get(deviceId)?.detailSteps;
3411
4160
  const payload = {
3412
4161
  deviceId,
3413
4162
  frame: result,
3414
4163
  nodeId: this.nodeId,
3415
4164
  frameHandle: handle,
3416
- ...typeof capturedAt === "number" && capturedAt > 0 ? { capturedAt } : {}
4165
+ ...typeof capturedAt === "number" && capturedAt > 0 ? { capturedAt } : {},
4166
+ ...detailSteps && detailSteps.length > 0 ? { detailSteps } : {}
3417
4167
  };
3418
4168
  this.ctx.eventBus.emit(require_dist.createEvent(require_dist.EventCategory.PipelineInferenceResult, {
3419
4169
  type: "device",
@@ -3578,6 +4328,7 @@ exports.Semaphore = Semaphore;
3578
4328
  exports.acquireSessionDecodeRestreamFrom = acquireSessionDecodeRestreamFrom;
3579
4329
  exports.customActions = pipelineRunnerBenchActions;
3580
4330
  exports.default = PipelineRunnerAddon;
4331
+ exports.deriveDetailSteps = deriveDetailSteps;
3581
4332
  exports.resolveMotionFrameSource = resolveMotionFrameSource;
3582
4333
  exports.resolveSessionDecodeHostname = resolveSessionDecodeHostname;
3583
4334
  exports.resolveSessionDecodeOwnerNodeId = resolveSessionDecodeOwnerNodeId;