@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
@@ -1,7 +1,8 @@
1
- import { $ as boolean, D as nodePin, H as createEvent, I as errMsg, K as makeSourceBrokerId, L as BaseAddon, Q as array, V as EventCategory, Z as _enum, _ as customAction, it as object, k as pipelineRunnerCapability, ot as string, rt as number, tt as lazy, y as defineCustomActions } from "../dist-BalxNwOt.mjs";
2
- import { n as isRemoteRestream, r as profileForStreamId, t as RemoteSourcePlane } from "../remote-source-plane-BCJW5CvF.mjs";
1
+ import { $ as boolean, D as nodePin, H as createEvent, I as errMsg, K as makeSourceBrokerId, L as BaseAddon, Q as array, V as EventCategory, Z as _enum, _ as customAction, it as object, k as pipelineRunnerCapability, ot as string, rt as number, tt as lazy, y as defineCustomActions } from "../dist-RWGGPwVx.mjs";
2
+ import { n as isRemoteRestream, r as profileForStreamId, t as RemoteSourcePlane } from "../remote-source-plane-DU0aRSPv.mjs";
3
3
  import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
4
- import { t as isWorkerReply } from "../worker-protocol-pk7qdYXt.mjs";
4
+ import { t as isWorkerReply } from "../worker-protocol-CyVJTZEO.mjs";
5
+ import { a as getStepDefinition } from "../step-definitions-CP9kVSml.mjs";
5
6
  import { DecoderFrameRingSink, FrameRingReaderCache, unlinkSegment } from "@camstack/shm-ring";
6
7
  import { fork } from "node:child_process";
7
8
  import { fileURLToPath } from "node:url";
@@ -423,6 +424,36 @@ function startPolling(options, source, subscriptionId, resolvedMaxFps, lifecycle
423
424
  };
424
425
  }
425
426
  //#endregion
427
+ //#region src/pipeline-runner/frame-governor.ts
428
+ var FrameGovernor = class {
429
+ config;
430
+ intervals = /* @__PURE__ */ new Map();
431
+ throttled = 0;
432
+ constructor(config) {
433
+ this.config = config;
434
+ }
435
+ update(samples) {
436
+ this.intervals.clear();
437
+ this.throttled = 0;
438
+ if (samples.size === 0) return;
439
+ const shareMsPerSec = this.config.targetLoadPercent / 100 * this.config.maxConcurrentInferences * 1e3 / samples.size;
440
+ for (const [deviceId, s] of samples) {
441
+ const ceilingFps = s.configuredFps > 0 ? s.configuredFps : s.decodeFps;
442
+ const affordableFps = s.avgInferenceMs > 0 ? shareMsPerSec / s.avgInferenceMs : ceilingFps;
443
+ const targetFps = Math.min(ceilingFps, Math.max(this.config.minThrottledFps, affordableFps));
444
+ if (targetFps < ceilingFps) this.throttled++;
445
+ this.intervals.set(deviceId, targetFps > 0 ? 1e3 / targetFps : 0);
446
+ }
447
+ }
448
+ /** Minimum ms between admitted frames; 0 = no gate. */
449
+ minIntervalMs(deviceId) {
450
+ return this.intervals.get(deviceId) ?? 0;
451
+ }
452
+ throttledCount() {
453
+ return this.throttled;
454
+ }
455
+ };
456
+ //#endregion
426
457
  //#region src/pipeline-runner/frame-queue.ts
427
458
  /**
428
459
  * Latest-frame-only buffer. Keeps only the most recent item, dropping all
@@ -604,6 +635,24 @@ var PipelineTimingSampler = class {
604
635
  if (!this.detSamples.has(deviceId)) this.detSamples.set(deviceId, []);
605
636
  this.detSamples.get(deviceId).push(s);
606
637
  }
638
+ /**
639
+ * Moving average inference time (ms) per camera over the current
640
+ * accumulation window — the same `inf` samples the `pipeline stats` log
641
+ * line aggregates (see {@link report}'s `inf` computation). Consumed by
642
+ * the {@link FrameGovernor} recompute tick as `CameraLoadSample.avgInferenceMs`.
643
+ * The window resets on every {@link report} call (every REPORT_INTERVAL_MS),
644
+ * so a camera with zero completed inferences since the last report is
645
+ * simply absent from the returned map — callers supply their own default.
646
+ */
647
+ avgInferenceMsByCamera() {
648
+ const result = /* @__PURE__ */ new Map();
649
+ for (const [deviceId, samples] of this.detSamples) {
650
+ if (samples.length === 0) continue;
651
+ const total = samples.reduce((sum, s) => sum + s.inference, 0);
652
+ result.set(deviceId, total / samples.length);
653
+ }
654
+ return result;
655
+ }
607
656
  addMotionSample(deviceId, ms, frameAge = -1) {
608
657
  if (!this.motSamples.has(deviceId)) this.motSamples.set(deviceId, []);
609
658
  this.motSamples.get(deviceId).push({
@@ -781,6 +830,8 @@ function ownMotionEntry(entry) {
781
830
  */
782
831
  var HANDLE_PASSTHROUGH_MAX_QUEUE_DEPTH = 1;
783
832
  var DEFAULT_MOTION_COOLDOWN_MS = 3e4;
833
+ var GOVERNOR_RECOMPUTE_INTERVAL_MS = 2e3;
834
+ var DEFAULT_DECODE_FPS = 25;
784
835
  function toFrameInput$1(frame) {
785
836
  return {
786
837
  data: frame.data,
@@ -816,10 +867,20 @@ var PipelineRunner = class {
816
867
  detectionStreamHandler = null;
817
868
  logger;
818
869
  timingSampler = new PipelineTimingSampler();
870
+ governor;
871
+ lastAdmittedAt = /* @__PURE__ */ new Map();
872
+ arrivalCounts = /* @__PURE__ */ new Map();
873
+ lastGovernorRecomputeAt = Date.now();
874
+ governorIntervalHandle = null;
819
875
  constructor(config) {
820
876
  this.config = config;
821
877
  this.logger = config.logger;
822
878
  this.semaphore = new Semaphore(config.maxConcurrentInferences);
879
+ this.governor = new FrameGovernor({
880
+ targetLoadPercent: config.targetLoadPercent,
881
+ minThrottledFps: config.minThrottledFps,
882
+ maxConcurrentInferences: config.maxConcurrentInferences
883
+ });
823
884
  }
824
885
  /**
825
886
  * Hot-reload the four tuning fields without tearing down the runner.
@@ -830,8 +891,15 @@ var PipelineRunner = class {
830
891
  * (the FrameQueue implementation is latest-only and ignores maxSize
831
892
  * anyway — see `frame-queue.ts` — so the field is effectively a
832
893
  * metadata hint for observability).
833
- * - `targetLoadPercent` / `minThrottledFps`: stored for future
834
- * throttling logic (not yet consumed in the current runner body).
894
+ * - `targetLoadPercent` / `minThrottledFps`: stored on `this.config` (read
895
+ * by `getLimits()`), but NOT re-fed into the live `FrameGovernor` — the
896
+ * governor is built once, at construction, from the config snapshot
897
+ * passed to the constructor. A hot patch to either knob changes what
898
+ * `getLimits()` reports but does not change live admission pacing until
899
+ * the runner is reconstructed. (Rebuilding the governor live is a
900
+ * follow-up, not in scope here — `maxConcurrentInferences` has the same
901
+ * gap: the semaphore resizes live but the governor's budget calc keeps
902
+ * the construction-time value.)
835
903
  *
836
904
  * Only keys present in the patch are overwritten; unspecified keys
837
905
  * retain their current value. Any illegal combination (e.g.
@@ -877,13 +945,16 @@ var PipelineRunner = class {
877
945
  lastArmedSource: null,
878
946
  lastArmedRegions: void 0,
879
947
  occupancyTimer: null,
880
- motionInFlight: false
948
+ motionInFlight: false,
949
+ lastResultDetections: 0,
950
+ occupancyArmed: false,
951
+ emptyBurstStreak: 0
881
952
  };
882
953
  this.cameras.set(deviceId, state);
883
954
  this.timingSampler.setDroppedFramesSource(deviceId, () => detectionQueue.droppedFrames);
884
955
  if (registration.detectionMode === "on-motion") this.defaultRoundRobinKeys.push(deviceId);
885
956
  if (registration.detectionMode === "on-motion" && registration.occupancyRecheckEnabled === true && (registration.occupancyRecheckSec ?? 0) > 0) state.occupancyTimer = setInterval(() => {
886
- if (state.phase === "watching") this.config.onOccupancyRecheck?.(deviceId, registration.occupancyRecheckFrames ?? 4);
957
+ if (state.phase === "watching" && state.occupancyArmed) this.config.onOccupancyRecheck?.(deviceId, registration.occupancyRecheckFrames ?? 4);
887
958
  }, (registration.occupancyRecheckSec ?? 0) * 1e3);
888
959
  if (initialPhase === "active") {
889
960
  this.detectionStreamHandler?.(deviceId, "subscribe");
@@ -912,6 +983,8 @@ var PipelineRunner = class {
912
983
  state.detectionQueue.clear();
913
984
  this.timingSampler.clearDroppedFramesSource(deviceId);
914
985
  this.cameras.delete(deviceId);
986
+ this.lastAdmittedAt.delete(deviceId);
987
+ this.arrivalCounts.delete(deviceId);
915
988
  const idx = this.defaultRoundRobinKeys.indexOf(deviceId);
916
989
  if (idx !== -1) {
917
990
  this.defaultRoundRobinKeys.splice(idx, 1);
@@ -929,13 +1002,29 @@ var PipelineRunner = class {
929
1002
  enqueueDetectionFrame(deviceId, frame, handle) {
930
1003
  const state = this.cameras.get(deviceId);
931
1004
  if (!state) return;
1005
+ this.arrivalCounts.set(deviceId, (this.arrivalCounts.get(deviceId) ?? 0) + 1);
932
1006
  if (state.phase !== "active") return;
1007
+ const interval = this.governor.minIntervalMs(deviceId);
1008
+ if (interval > 0) {
1009
+ const last = this.lastAdmittedAt.get(deviceId) ?? 0;
1010
+ const now = Date.now();
1011
+ if (now - last < interval) return;
1012
+ this.lastAdmittedAt.set(deviceId, now);
1013
+ }
933
1014
  frame._enqueuedAt = Date.now();
934
1015
  state.detectionQueue.enqueue({
935
1016
  frame,
936
1017
  handle
937
1018
  });
938
1019
  }
1020
+ /**
1021
+ * `handle` is optional (mirrors {@link DetectionQueueEntry.handle}) — the
1022
+ * dial path (`runOccupancyBurst`'s `startFrameHandlePoller`) always has a
1023
+ * real shm handle, but Task 10's analyzer-tap path can feed a frame whose
1024
+ * motion subscription never produced one (e.g. the per-session motion
1025
+ * decode worker). `runInference` already falls back to the pixel path
1026
+ * when the handle is absent.
1027
+ */
939
1028
  enqueueOccupancyFrame(deviceId, frame, handle) {
940
1029
  const state = this.cameras.get(deviceId);
941
1030
  if (!state) return;
@@ -988,6 +1077,42 @@ var PipelineRunner = class {
988
1077
  getPhase(deviceId) {
989
1078
  return this.cameras.get(deviceId)?.phase;
990
1079
  }
1080
+ /**
1081
+ * Record the detection count from the runner's most recent inference
1082
+ * result for this camera. The addon wrapping the scheduler calls this
1083
+ * from its `onResult` callback — the runner itself stays event-agnostic
1084
+ * (it never inspects `FrameResult` shapes), it only remembers the raw
1085
+ * count that `transitionToWatching` reads to decide whether the
1086
+ * occupancy recheck timer arms for the upcoming `watching` phase.
1087
+ */
1088
+ noteDetectionResult(deviceId, detectionCount) {
1089
+ const state = this.cameras.get(deviceId);
1090
+ if (!state) return;
1091
+ state.lastResultDetections = detectionCount;
1092
+ }
1093
+ /**
1094
+ * Report the outcome of a completed occupancy recheck burst (see
1095
+ * `RunnerConfig.onOccupancyRecheck`). Called by the addon once it knows
1096
+ * whether any burst frame's inference result had detections.
1097
+ *
1098
+ * - `found: true` — resets the empty-burst streak. The timer stays armed;
1099
+ * a genuine occupant is a separate `reportMotion` event that reopens the
1100
+ * session through the existing motion/dispatch path — this method does
1101
+ * not itself flip the phase.
1102
+ * - `found: false` — increments the streak; after 2 consecutive empty
1103
+ * bursts the timer disarms (`occupancyArmed = false`) until the next
1104
+ * active→watching transition re-evaluates from scratch.
1105
+ */
1106
+ reportOccupancyBurstOutcome(deviceId, found) {
1107
+ const state = this.cameras.get(deviceId);
1108
+ if (!state) return;
1109
+ if (found) {
1110
+ state.emptyBurstStreak = 0;
1111
+ return;
1112
+ }
1113
+ state.emptyBurstStreak++;
1114
+ if (state.emptyBurstStreak >= 2) state.occupancyArmed = false;
1115
+ }
991
1116
  onResult(callback) {
992
1117
  this.resultCallbacks.push(callback);
993
1118
  }
@@ -995,12 +1120,19 @@ var PipelineRunner = class {
995
1120
  if (this.intervalHandle !== null) return;
996
1121
  this.intervalHandle = setInterval(() => this.tick(), 10);
997
1122
  this.timingSampler.start();
1123
+ this.lastGovernorRecomputeAt = Date.now();
1124
+ this.governorIntervalHandle = setInterval(() => this.recomputeGovernor(), GOVERNOR_RECOMPUTE_INTERVAL_MS);
1125
+ this.governorIntervalHandle?.unref?.();
998
1126
  }
999
1127
  stop() {
1000
1128
  if (this.intervalHandle !== null) {
1001
1129
  clearInterval(this.intervalHandle);
1002
1130
  this.intervalHandle = null;
1003
1131
  }
1132
+ if (this.governorIntervalHandle !== null) {
1133
+ clearInterval(this.governorIntervalHandle);
1134
+ this.governorIntervalHandle = null;
1135
+ }
1004
1136
  this.timingSampler.stop();
1005
1137
  for (const state of this.cameras.values()) {
1006
1138
  if (state.motionCooldownTimer !== null) {
@@ -1013,6 +1145,31 @@ var PipelineRunner = class {
1013
1145
  }
1014
1146
  }
1015
1147
  }
1148
+ /**
1149
+ * Recompute the {@link FrameGovernor}'s per-camera admission intervals
1150
+ * from fresh sampler + arrival data. Called on the {@link
1151
+ * GOVERNOR_RECOMPUTE_INTERVAL_MS} tick started by `start()`; also exposed
1152
+ * so tests can prime the governor deterministically without waiting on a
1153
+ * timer. `now` defaults to the wall clock but is accepted explicitly so a
1154
+ * caller can pass a fixed instant for reproducible elapsed-time math.
1155
+ */
1156
+ recomputeGovernor(now = Date.now()) {
1157
+ const elapsedSec = Math.max((now - this.lastGovernorRecomputeAt) / 1e3, .001);
1158
+ const avgInferenceMsByCamera = this.timingSampler.avgInferenceMsByCamera();
1159
+ const samples = /* @__PURE__ */ new Map();
1160
+ for (const [deviceId, state] of this.cameras) {
1161
+ if (state.phase !== "active") continue;
1162
+ const measuredDecodeFps = (this.arrivalCounts.get(deviceId) ?? 0) / elapsedSec;
1163
+ samples.set(deviceId, {
1164
+ avgInferenceMs: avgInferenceMsByCamera.get(deviceId) ?? 0,
1165
+ configuredFps: state.registration.fps,
1166
+ decodeFps: measuredDecodeFps > 0 ? measuredDecodeFps : DEFAULT_DECODE_FPS
1167
+ });
1168
+ }
1169
+ this.governor.update(samples);
1170
+ this.arrivalCounts.clear();
1171
+ this.lastGovernorRecomputeAt = now;
1172
+ }
1016
1173
  getMetrics() {
1017
1174
  let totalQueueDepth = 0;
1018
1175
  let totalInferenceTime = 0;
@@ -1026,7 +1183,7 @@ var PipelineRunner = class {
1026
1183
  }
1027
1184
  return {
1028
1185
  activeCameras: this.cameras.size,
1029
- throttledCameras: 0,
1186
+ throttledCameras: this.governor.throttledCount(),
1030
1187
  avgInferenceTimeMs: totalInferenceCount > 0 ? totalInferenceTime / totalInferenceCount : 0,
1031
1188
  queueDepth: totalQueueDepth
1032
1189
  };
@@ -1064,6 +1221,7 @@ var PipelineRunner = class {
1064
1221
  }
1065
1222
  transitionToActive(deviceId, state, source, regions, cooldownMs) {
1066
1223
  state.phase = "active";
1224
+ state.lastResultDetections = 0;
1067
1225
  this.logger?.info("motion gate opened — phase=active", {
1068
1226
  tags: { deviceId },
1069
1227
  meta: {
@@ -1082,9 +1240,14 @@ var PipelineRunner = class {
1082
1240
  transitionToWatching(deviceId, state, cooldownMs) {
1083
1241
  state.phase = "watching";
1084
1242
  state.detectionQueue.clear();
1243
+ state.occupancyArmed = state.lastResultDetections > 0;
1244
+ state.emptyBurstStreak = 0;
1085
1245
  this.logger?.info("motion gate closed — phase=watching", {
1086
1246
  tags: { deviceId },
1087
- meta: { lastSource: state.lastArmedSource }
1247
+ meta: {
1248
+ lastSource: state.lastArmedSource,
1249
+ occupancyArmed: state.occupancyArmed
1250
+ }
1088
1251
  });
1089
1252
  this.detectionStreamHandler?.(deviceId, "unsubscribe");
1090
1253
  const source = state.lastArmedSource ?? "analyzer";
@@ -1221,9 +1384,11 @@ var KILL_SIGNALS = {
1221
1384
  };
1222
1385
  /** Adapt a Node {@link ChildProcess} to the coordinator's {@link ForkedChildLike}. */
1223
1386
  function wrapChild(child) {
1387
+ child.on("error", () => void 0);
1224
1388
  return {
1225
1389
  send: (msg) => {
1226
- child.send(msg);
1390
+ if (!child.connected) return;
1391
+ child.send(msg, () => void 0);
1227
1392
  },
1228
1393
  on: (ev, cb) => {
1229
1394
  if (ev === "exit") child.on("exit", () => cb(void 0));
@@ -1451,6 +1616,9 @@ var SessionDecodeSession = class {
1451
1616
  frameTimeoutMs;
1452
1617
  pendingPull = null;
1453
1618
  pendingToBuffers = /* @__PURE__ */ new Map();
1619
+ /** Outstanding `nativeCrop` requests, keyed by the monotonic `requestId`. */
1620
+ pendingNativeCrops = /* @__PURE__ */ new Map();
1621
+ nextNativeCropId = 1;
1454
1622
  exited = false;
1455
1623
  sigkillTimer = null;
1456
1624
  pullTimeoutTimer = null;
@@ -1515,6 +1683,42 @@ var SessionDecodeSession = class {
1515
1683
  }
1516
1684
  });
1517
1685
  }
1686
+ /**
1687
+ * Send `nativeCrop` for `frameId` and resolve with the crop result, or `null`
1688
+ * on any miss (frame aged out, worker error, or worker already exited). Never
1689
+ * rejects — native crops are strictly best-effort with a free fallback.
1690
+ */
1691
+ requestNativeCrop(frameId, bbox, maxWidth) {
1692
+ if (this.exited) return Promise.resolve(null);
1693
+ const requestId = this.nextNativeCropId++;
1694
+ return new Promise((resolve) => {
1695
+ this.pendingNativeCrops.set(requestId, { resolve });
1696
+ try {
1697
+ this.child.send({
1698
+ kind: "nativeCrop",
1699
+ requestId,
1700
+ frameId,
1701
+ bbox,
1702
+ ...maxWidth !== void 0 ? { maxWidth } : {}
1703
+ });
1704
+ } catch {
1705
+ this.pendingNativeCrops.delete(requestId);
1706
+ resolve(null);
1707
+ }
1708
+ });
1709
+ }
1710
+ /**
1711
+ * Prompt-release the worker's RAM native-frame lease for `frameId`. Fire and
1712
+ * forget (no reply): a lease also self-expires on its TTL, so a dropped
1713
+ * release only defers reclaim. No-op once the worker has exited.
1714
+ */
1715
+ releaseLease(frameId) {
1716
+ if (this.exited) return;
1717
+ this.trySend({
1718
+ kind: "releaseNativeLease",
1719
+ frameId
1720
+ });
1721
+ }
1518
1722
  /** Teardown: `stop`, `kill()`, arm the SIGKILL fallback unless already exited. */
1519
1723
  teardown() {
1520
1724
  this.clearPullTimeout();
@@ -1547,6 +1751,16 @@ var SessionDecodeSession = class {
1547
1751
  case "buffer":
1548
1752
  this.settleToBuffer(raw.frameId, raw.bytes);
1549
1753
  return;
1754
+ case "nativeCropResult":
1755
+ this.settleNativeCrop(raw.requestId, {
1756
+ bytes: raw.bytes,
1757
+ width: raw.width,
1758
+ height: raw.height
1759
+ });
1760
+ return;
1761
+ case "nativeCropMiss":
1762
+ this.settleNativeCrop(raw.requestId, null);
1763
+ return;
1550
1764
  case "error":
1551
1765
  this.settleError(raw.message, raw.frameId);
1552
1766
  return;
@@ -1566,6 +1780,15 @@ var SessionDecodeSession = class {
1566
1780
  }
1567
1781
  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"));
1568
1782
  this.pendingToBuffers.clear();
1783
+ for (const waiter of this.pendingNativeCrops.values()) waiter.resolve(null);
1784
+ this.pendingNativeCrops.clear();
1785
+ }
1786
+ /** Settle the `nativeCrop` waiter for `requestId` (dropping unknown/stale ids). */
1787
+ settleNativeCrop(requestId, result) {
1788
+ const waiter = this.pendingNativeCrops.get(requestId);
1789
+ if (!waiter) return;
1790
+ this.pendingNativeCrops.delete(requestId);
1791
+ waiter.resolve(result);
1569
1792
  }
1570
1793
  settlePendingPull(outcome) {
1571
1794
  const waiter = this.pendingPull;
@@ -1697,10 +1920,13 @@ function buildFrameImage(session, reply) {
1697
1920
  width: reply.width,
1698
1921
  height: reply.height,
1699
1922
  format: reply.format,
1923
+ frameId: reply.frameId,
1700
1924
  toBuffer: async (opts) => {
1701
1925
  if (closed) throw new Error(`session-decode-coordinator: toBuffer() called on a closed frame (frameId=${reply.frameId})`);
1702
1926
  return session.requestBuffer(reply.frameId, opts);
1703
1927
  },
1928
+ nativeCrop: (bbox, maxWidth) => session.requestNativeCrop(reply.frameId, bbox, maxWidth),
1929
+ releaseNativeLease: () => session.releaseLease(reply.frameId),
1704
1930
  close: () => {
1705
1931
  closed = true;
1706
1932
  }
@@ -1841,7 +2067,10 @@ function startSessionDecodePump(deps) {
1841
2067
  }
1842
2068
  try {
1843
2069
  const decoded = await toDecodedFrame(frame, format);
1844
- deps.onDecodedFrame(decoded);
2070
+ const image = frame.image;
2071
+ const cropFn = image.nativeCrop;
2072
+ const nativeCrop = cropFn ? Object.assign((bbox, maxWidth) => cropFn(bbox, maxWidth), { release: () => image.releaseNativeLease?.() }) : void 0;
2073
+ deps.onDecodedFrame(decoded, nativeCrop);
1845
2074
  } catch (err) {
1846
2075
  deps.logger.debug("session-decode pump: skipped a frame (decode/consume error)", {
1847
2076
  tags: { deviceId: deps.source.deviceId },
@@ -1874,6 +2103,203 @@ function startSessionDecodePump(deps) {
1874
2103
  iterator.return().catch(() => {});
1875
2104
  };
1876
2105
  }
2106
+ /** Invoke a requester's best-effort `release()`, swallowing any transport error. */
2107
+ function safeRelease(request) {
2108
+ try {
2109
+ request.release();
2110
+ } catch {}
2111
+ }
2112
+ var NativeCropRegistry = class {
2113
+ capacity;
2114
+ entries = /* @__PURE__ */ new Map();
2115
+ constructor(capacity = 64) {
2116
+ this.capacity = capacity;
2117
+ }
2118
+ /** Number of registered handles (tests / metrics). */
2119
+ get size() {
2120
+ return this.entries.size;
2121
+ }
2122
+ /** A retention frame is uniquely identified by its segment + slot + committed seq. */
2123
+ key(handle) {
2124
+ return `${handle.shmId}#${handle.slot}#${handle.seq}`;
2125
+ }
2126
+ /**
2127
+ * Bind `request` to `handle`; evicts the oldest entry past the cap, releasing
2128
+ * its worker-side RAM lease so a lease never outlives its registry entry.
2129
+ */
2130
+ register(handle, request) {
2131
+ const k = this.key(handle);
2132
+ this.entries.delete(k);
2133
+ this.entries.set(k, request);
2134
+ while (this.entries.size > this.capacity) {
2135
+ const oldest = this.entries.keys().next().value;
2136
+ if (oldest === void 0) break;
2137
+ this.releaseEntry(oldest);
2138
+ }
2139
+ }
2140
+ /** The requester for `handle`, or `null` if never registered / already evicted. */
2141
+ get(handle) {
2142
+ return this.entries.get(this.key(handle)) ?? null;
2143
+ }
2144
+ /**
2145
+ * Release the worker-side RAM native lease for `handle` and drop its entry —
2146
+ * the caller is done cutting native crops from that frame. Best-effort +
2147
+ * idempotent (an unknown handle is a no-op; the lease also self-expires).
2148
+ */
2149
+ release(handle) {
2150
+ this.releaseEntry(this.key(handle));
2151
+ }
2152
+ /** Drop every entry, releasing each frame's worker-side lease (node/addon shutdown). */
2153
+ clear() {
2154
+ for (const request of this.entries.values()) safeRelease(request);
2155
+ this.entries.clear();
2156
+ }
2157
+ /** Release + drop one entry by its map key. */
2158
+ releaseEntry(key) {
2159
+ const request = this.entries.get(key);
2160
+ if (!request) return;
2161
+ this.entries.delete(key);
2162
+ safeRelease(request);
2163
+ }
2164
+ };
2165
+ //#endregion
2166
+ //#region src/pipeline-runner/detail-subtree.ts
2167
+ /** Relative padding applied to the parent bbox before cutting the crop — more context for the child model. */
2168
+ var DETAIL_CROP_PADDING_RATIO = .15;
2169
+ /**
2170
+ * Pad `bbox` by {@link DETAIL_CROP_PADDING_RATIO} of its own size, then clamp
2171
+ * to `[0, frameWidth] x [0, frameHeight]`. Pure — always returns a new rect.
2172
+ */
2173
+ function padAndClampFrameBbox(bbox, frameWidth, frameHeight) {
2174
+ const padX = DETAIL_CROP_PADDING_RATIO * bbox.w;
2175
+ const padY = DETAIL_CROP_PADDING_RATIO * bbox.h;
2176
+ const x1 = Math.max(0, bbox.x - padX);
2177
+ const y1 = Math.max(0, bbox.y - padY);
2178
+ const x2 = Math.min(frameWidth, bbox.x + bbox.w + padX);
2179
+ const y2 = Math.min(frameHeight, bbox.y + bbox.h + padY);
2180
+ return {
2181
+ x: x1,
2182
+ y: y1,
2183
+ w: Math.max(0, x2 - x1),
2184
+ h: Math.max(0, y2 - y1)
2185
+ };
2186
+ }
2187
+ /** Normalize a frame-space pixel rect to `[0,1]` by the given frame dims. */
2188
+ function normalizeRect(rect, frameWidth, frameHeight) {
2189
+ return {
2190
+ x: rect.x / frameWidth,
2191
+ y: rect.y / frameHeight,
2192
+ w: rect.w / frameWidth,
2193
+ h: rect.h / frameHeight
2194
+ };
2195
+ }
2196
+ /** Inverse of {@link normalizeRect} — denormalize a `[0,1]` rect back to frame-space pixels. */
2197
+ function denormalizeRect(rect, frameWidth, frameHeight) {
2198
+ return {
2199
+ x: rect.x * frameWidth,
2200
+ y: rect.y * frameHeight,
2201
+ w: rect.w * frameWidth,
2202
+ h: rect.h * frameHeight
2203
+ };
2204
+ }
2205
+ /**
2206
+ * Walk `steps` (the attach config's tree) collecting every ENABLED step
2207
+ * whose catalog definition's `inputClasses` includes `parentClassName` —
2208
+ * these become the subtree's roots. A matched step's own nested `children`
2209
+ * ride along UNCHANGED (transitively included: `face-detection` pulls
2210
+ * `face-embedding`, `plate-detection` pulls `plate-ocr`) — recursion stops
2211
+ * at a match rather than also searching inside it for further matches.
2212
+ * `filter`, when given, keeps only matches whose `addonId` is in the list.
2213
+ */
2214
+ function findMatchingDetailSteps(steps, parentClassName, lookupStepDefinition, filter) {
2215
+ const matches = [];
2216
+ const walk = (list) => {
2217
+ for (const step of list) {
2218
+ if (!step.enabled) continue;
2219
+ const definition = lookupStepDefinition(step.addonId);
2220
+ if (definition !== null && definition.inputClasses !== null && definition.inputClasses.includes(parentClassName)) {
2221
+ if (!filter || filter.includes(step.addonId)) matches.push(step);
2222
+ continue;
2223
+ }
2224
+ if (step.children) walk(step.children);
2225
+ }
2226
+ };
2227
+ walk(steps);
2228
+ return matches;
2229
+ }
2230
+ /** Map a detection's CROP-pixel bbox back to FRAME-space pixels. */
2231
+ function mapBboxToFrameSpace(bbox, crop) {
2232
+ const scaleX = crop.frameSpace.w / crop.width;
2233
+ const scaleY = crop.frameSpace.h / crop.height;
2234
+ return {
2235
+ x: crop.frameSpace.x + bbox.x * scaleX,
2236
+ y: crop.frameSpace.y + bbox.y * scaleY,
2237
+ w: bbox.width * scaleX,
2238
+ h: bbox.height * scaleY
2239
+ };
2240
+ }
2241
+ /** Base64-encode a normalized float32 embedding vector. */
2242
+ function encodeEmbeddingBase64(embedding) {
2243
+ return Buffer.from(Float32Array.from(embedding).buffer).toString("base64");
2244
+ }
2245
+ /** First user-visible label — skips the opaque `embedding:*` marker (see `result-assembler.ts`). */
2246
+ function pickLabel(detection) {
2247
+ return detection.labels.find((l) => !l.label.startsWith("embedding:"))?.label;
2248
+ }
2249
+ function toDetailResult(detection, stepId, crop) {
2250
+ const label = pickLabel(detection);
2251
+ return {
2252
+ stepId,
2253
+ className: detection.macroClass,
2254
+ score: detection.score,
2255
+ bbox: mapBboxToFrameSpace(detection.bbox, crop),
2256
+ ...detection.embedding !== void 0 ? { embedding: encodeEmbeddingBase64(detection.embedding) } : {},
2257
+ ...label !== void 0 ? { label } : {},
2258
+ ...detection.faceAlignedCrop !== void 0 ? { alignedCropJpeg: detection.faceAlignedCrop } : {}
2259
+ };
2260
+ }
2261
+ async function resolveCrop(deps, input) {
2262
+ if (input.frameHandle) {
2263
+ const normalized = normalizeRect(padAndClampFrameBbox(input.parent.bbox, input.frameHandle.width, input.frameHandle.height), input.frameHandle.width, input.frameHandle.height);
2264
+ const native = await deps.getNativeCrop(input.deviceId, input.frameHandle, normalized);
2265
+ if (native) return native;
2266
+ }
2267
+ if (input.cropJpeg) {
2268
+ const jpeg = Buffer.from(input.cropJpeg, "base64");
2269
+ const sharp = (await import("sharp")).default;
2270
+ const meta = await sharp(jpeg).metadata();
2271
+ const width = meta.width ?? 0;
2272
+ const height = meta.height ?? 0;
2273
+ if (width <= 0 || height <= 0) return null;
2274
+ return {
2275
+ jpeg,
2276
+ width,
2277
+ height,
2278
+ frameSpace: input.parent.bbox
2279
+ };
2280
+ }
2281
+ return null;
2282
+ }
2283
+ /**
2284
+ * Run the DETAIL subtree for a single tracked detection: resolve a crop
2285
+ * (native lease → `cropJpeg` fallback), run each matched top-level child
2286
+ * step's chain against it, and map every result bbox back to frame space.
2287
+ * Returns `null` when the camera isn't attached, no configured child
2288
+ * matches `input.parent.className`, or no crop source resolves.
2289
+ */
2290
+ async function runDetailSubtree(deps, input) {
2291
+ const steps = deps.resolveChildSteps(input.deviceId, input.parent.className, input.steps);
2292
+ if (!steps || steps.length === 0) return null;
2293
+ const crop = await resolveCrop(deps, input);
2294
+ if (!crop) return null;
2295
+ const details = [];
2296
+ for (const step of steps) {
2297
+ const result = await deps.runPipeline([step], crop.jpeg, input.deviceId);
2298
+ if (!result) continue;
2299
+ for (const detection of result.detections) details.push(toDetailResult(detection, step.addonId, crop));
2300
+ }
2301
+ return { details };
2302
+ }
1877
2303
  //#endregion
1878
2304
  //#region src/pipeline-runner/index.ts
1879
2305
  var DEFAULT_CONFIG = {
@@ -1899,6 +2325,23 @@ var DEFAULT_CONFIG = {
1899
2325
  */
1900
2326
  var DETECTION_KEEPWARM_MS = 15e3;
1901
2327
  /**
2328
+ * Occupancy-burst safety timeout (both the dial path and the analyzer-tap
2329
+ * path — Task 10). Bounds worst-case burst latency when frames stop
2330
+ * arriving mid-burst — a dial that never gets a keyframe, or a tap whose
2331
+ * motion subscription closes before N frames arrive.
2332
+ */
2333
+ var OCCUPANCY_BURST_SAFETY_TIMEOUT_MS = 8e3;
2334
+ /**
2335
+ * Freshness window for `lastMotionFrameAt` (Task 10 hardening). A non-null
2336
+ * `attachment.motionUnsubscribe` only proves a subscription handle exists —
2337
+ * NOT that frames are actually flowing (guarded-session-decode can return
2338
+ * its teardown synchronously, and a failed `acquireSessionDecodeRestreamFrom`
2339
+ * acquire never retries, leaving the handle permanently non-null with zero
2340
+ * frames arriving). `runOccupancyBurst` additionally requires a motion frame
2341
+ * to have been teed within this window before it will route to the tap path.
2342
+ */
2343
+ var MOTION_FRESHNESS_WINDOW_MS = 5e3;
2344
+ /**
1902
2345
  * Pure decision helper: returns `true` when a dynamic frame-diff analyzer
1903
2346
  * subscription should be opened for an onboard-motion camera.
1904
2347
  *
@@ -1915,6 +2358,62 @@ function shouldStartOnboardAnalyzer(config) {
1915
2358
  if (config.motionSources.includes("analyzer")) return false;
1916
2359
  return true;
1917
2360
  }
2361
+ /** Recursively collects the `addonId` of every ENABLED step in the tree. */
2362
+ function collectEnabledStepIds(steps, acc) {
2363
+ if (!steps) return;
2364
+ for (const step of steps) {
2365
+ if (!step.enabled) continue;
2366
+ acc.add(step.addonId);
2367
+ collectEnabledStepIds(step.children, acc);
2368
+ }
2369
+ }
2370
+ /**
2371
+ * Looks up a catalog step definition, tolerating an unknown id. A stale
2372
+ * config entry (removed/custom addon) must degrade to "not announced"
2373
+ * rather than fail the attach — `getStepDefinition` throws on a miss.
2374
+ */
2375
+ function tryGetStepDefinition(stepId) {
2376
+ try {
2377
+ return getStepDefinition(stepId);
2378
+ } catch {
2379
+ return null;
2380
+ }
2381
+ }
2382
+ /**
2383
+ * Precomputes the `detailSteps` announce (two-plane design) for a camera's
2384
+ * configured step tree: every ENABLED step whose catalog definition is a
2385
+ * detail/child step (`inputClasses !== null` — a root step like
2386
+ * `object-detection` runs on every frame on the `'frame'` plane and has
2387
+ * nothing to announce) is surfaced as `{ stepId, inputClasses, cadence }`
2388
+ * so a track-level consumer knows what it can dispatch via
2389
+ * `pipelineRunner.runDetailSubtree` and on what cadence, without a
2390
+ * separate round-trip to the executor's schema/config caps.
2391
+ *
2392
+ * Unknown step ids (stale config referencing a removed/custom addon) are
2393
+ * skipped rather than thrown — the catalog is the single source of truth
2394
+ * for cadence and a stale entry must not fail the attach.
2395
+ *
2396
+ * Extracted as a pure function so the unit suite can exercise the
2397
+ * derivation without instantiating the full addon.
2398
+ */
2399
+ function deriveDetailSteps(steps) {
2400
+ const stepIds = /* @__PURE__ */ new Set();
2401
+ collectEnabledStepIds(steps, stepIds);
2402
+ const announces = [];
2403
+ for (const stepId of stepIds) {
2404
+ const def = tryGetStepDefinition(stepId);
2405
+ if (!def || def.inputClasses === null) continue;
2406
+ announces.push({
2407
+ stepId: def.id,
2408
+ inputClasses: def.inputClasses,
2409
+ cadence: def.cadence ?? {
2410
+ trigger: "once",
2411
+ maxPerTrack: 3
2412
+ }
2413
+ });
2414
+ }
2415
+ return announces;
2416
+ }
1918
2417
  function toFrameInput(frame) {
1919
2418
  return {
1920
2419
  data: frame.data,
@@ -2098,6 +2597,13 @@ var PipelineRunnerAddon = class extends BaseAddon {
2098
2597
  * `resize()`d live in `onConfigChanged`. Null until `onInitialize`.
2099
2598
  */
2100
2599
  spawnGate = null;
2600
+ /**
2601
+ * Node-level map from a retention-ring `FrameHandle` to the decode worker
2602
+ * still holding that frame's NATIVE surface — backs the `getNativeCrop` cap
2603
+ * so post-analysis can cut native-res crops (best-effort; a miss falls back
2604
+ * to the detection-frame crop). See {@link NativeCropRegistry}.
2605
+ */
2606
+ nativeCropRegistry = new NativeCropRegistry();
2101
2607
  attached = /* @__PURE__ */ new Map();
2102
2608
  nodeId = "unknown";
2103
2609
  metricsSnapshotTimer = null;
@@ -2169,6 +2675,13 @@ var PipelineRunnerAddon = class extends BaseAddon {
2169
2675
  /** Per-camera single-flight: a camera never runs two occupancy bursts at once. */
2170
2676
  occupancyBurstInFlight = /* @__PURE__ */ new Set();
2171
2677
  /**
2678
+ * Devices whose in-flight occupancy burst has seen at least one inference
2679
+ * result with detections>0 (set from the `onResult` handler above, cleared
2680
+ * per-burst in `runOccupancyBurst`). Read at burst cleanup and reported to
2681
+ * `PipelineRunner.reportOccupancyBurstOutcome` — Task 9's arm/disarm signal.
2682
+ */
2683
+ occupancyBurstFound = /* @__PURE__ */ new Set();
2684
+ /**
2172
2685
  * GLOBAL occupancy admission queue. Each occupancy burst SUBSCRIBES a detection
2173
2686
  * stream (rgb) which, during the `watching` phase, has no existing consumer and
2174
2687
  * therefore makes the broker DIAL a fresh decoder. Without a global cap, N
@@ -2185,6 +2698,23 @@ var PipelineRunnerAddon = class extends BaseAddon {
2185
2698
  occupancyActiveCount = 0;
2186
2699
  /** Max concurrent occupancy bursts (decoder dials) across all cameras. */
2187
2700
  occupancyMaxConcurrent = 3;
2701
+ /**
2702
+ * TAP-based occupancy bursts in flight, keyed by deviceId (Task 10). See
2703
+ * {@link OccupancyTapState}. A device has an entry here XOR is dialing via
2704
+ * `startFrameHandlePoller` in `runOccupancyBurst` — never both; the choice
2705
+ * is made once at burst start based on `attachment.motionUnsubscribe`.
2706
+ */
2707
+ occupancyTaps = /* @__PURE__ */ new Map();
2708
+ /**
2709
+ * Timestamp (`Date.now()`) of the last motion frame teed for each device
2710
+ * — set in `runMotionAnalysis` at the same call site as
2711
+ * `teeOccupancyTapFrame` (Task 10 hardening). This is the TRUTHFUL "is
2712
+ * the analyzer subscription actually delivering frames" signal;
2713
+ * `attachment.motionUnsubscribe !== null` alone only proves a
2714
+ * subscription handle was assigned, not that it's live. See
2715
+ * {@link MOTION_FRESHNESS_WINDOW_MS}.
2716
+ */
2717
+ lastMotionFrameAt = /* @__PURE__ */ new Map();
2188
2718
  constructor() {
2189
2719
  super({ ...DEFAULT_CONFIG });
2190
2720
  }
@@ -2221,6 +2751,9 @@ var PipelineRunnerAddon = class extends BaseAddon {
2221
2751
  });
2222
2752
  this.runner.onResult(async (deviceId, frame, result, _streamType, handle) => {
2223
2753
  this.emitInferenceResult(deviceId, frame, result, handle);
2754
+ const detectionCount = result.detections?.length ?? 0;
2755
+ this.runner?.noteDetectionResult(deviceId, detectionCount);
2756
+ if (detectionCount > 0 && this.occupancyBurstInFlight.has(deviceId)) this.occupancyBurstFound.add(deviceId);
2224
2757
  });
2225
2758
  this.runner.start();
2226
2759
  this.ctx.logger.info("Pipeline runner started", {
@@ -2286,6 +2819,7 @@ var PipelineRunnerAddon = class extends BaseAddon {
2286
2819
  attachment.detectionUnsubscribe?.();
2287
2820
  }
2288
2821
  this.attached.clear();
2822
+ this.lastMotionFrameAt.clear();
2289
2823
  }
2290
2824
  async cacheBenchFrame(input) {
2291
2825
  const sharp = (await import("sharp")).default;
@@ -2631,7 +3165,8 @@ var PipelineRunnerAddon = class extends BaseAddon {
2631
3165
  config,
2632
3166
  motionUnsubscribe: null,
2633
3167
  detectionUnsubscribe: null,
2634
- detectionKeepWarmTimer: null
3168
+ detectionKeepWarmTimer: null,
3169
+ detailSteps: deriveDetailSteps(config.steps)
2635
3170
  };
2636
3171
  this.attached.set(config.deviceId, attachment);
2637
3172
  runner.registerCamera(config.deviceId, {
@@ -2667,6 +3202,95 @@ var PipelineRunnerAddon = class extends BaseAddon {
2667
3202
  this.runner?.reportMotion(input.deviceId, input.detected, input.source, input.regions);
2668
3203
  return { success: true };
2669
3204
  }
3205
+ /**
3206
+ * Best-effort native-resolution crop for a retention-ring frame (native-crop
3207
+ * feature). Looks the handle up in {@link nativeCropRegistry}; a miss (never
3208
+ * registered, or the worker's tiny native ring already evicted the frame)
3209
+ * returns `null` so the caller falls back to a detection-frame crop. The
3210
+ * worker downloads ONLY the ROI — a full native frame never travels the wire.
3211
+ */
3212
+ async getNativeCrop(input) {
3213
+ const requester = this.nativeCropRegistry.get(input.handle);
3214
+ if (!requester) return null;
3215
+ const result = await requester(input.bbox, input.maxWidth);
3216
+ return result ? {
3217
+ bytes: result.bytes,
3218
+ width: result.width,
3219
+ height: result.height
3220
+ } : null;
3221
+ }
3222
+ /**
3223
+ * Two-plane design: run the DETAIL subtree (crop children) for a single
3224
+ * tracked detection. Delegates the algorithm to the deps-injected pure
3225
+ * function in {@link runDetailSubtreeImpl} — this method only wires the
3226
+ * real deps: the attach config's step tree (+ the catalog, for the
3227
+ * `inputClasses` match), the SAME internal native-crop lease path
3228
+ * `getNativeCrop` uses above (no `ctx.api` round-trip — we're already in
3229
+ * the frame's owning process), and the node-local `pipelineExecutor`.
3230
+ */
3231
+ async runDetailSubtree(input) {
3232
+ return runDetailSubtree({
3233
+ resolveChildSteps: (deviceId, parentClassName, filter) => this.resolveDetailChildSteps(deviceId, parentClassName, filter),
3234
+ getNativeCrop: (deviceId, handle, paddedBbox) => this.getNativeCropForDetail(deviceId, handle, paddedBbox),
3235
+ runPipeline: (steps, imageJpeg, deviceId) => this.runDetailPipeline(steps, imageJpeg, deviceId)
3236
+ }, input);
3237
+ }
3238
+ /**
3239
+ * Real `resolveChildSteps` dep: `null` when the camera isn't attached
3240
+ * (matches the cap doc — "camera not attached" is a distinct case from
3241
+ * "attached but no matching child steps", which returns an empty array
3242
+ * and short-circuits inside {@link runDetailSubtreeImpl} the same way).
3243
+ */
3244
+ resolveDetailChildSteps(deviceId, parentClassName, filter) {
3245
+ const attachment = this.attached.get(deviceId);
3246
+ if (!attachment) return null;
3247
+ const steps = attachment.config.steps;
3248
+ if (!steps) return [];
3249
+ return findMatchingDetailSteps(steps, parentClassName, tryGetStepDefinition, filter);
3250
+ }
3251
+ /**
3252
+ * Real `getNativeCrop` dep: reuses the node-local {@link nativeCropRegistry}
3253
+ * lease (same path the `getNativeCrop` cap method above uses), encodes the
3254
+ * raw RGB crop to JPEG, and denormalizes `paddedBbox` back to frame-space
3255
+ * pixels for the `frameSpace` the caller maps result bboxes against.
3256
+ */
3257
+ async getNativeCropForDetail(_deviceId, handle, paddedBbox) {
3258
+ const native = await this.getNativeCrop({
3259
+ handle,
3260
+ bbox: paddedBbox
3261
+ });
3262
+ if (!native) return null;
3263
+ const sharp = (await import("sharp")).default;
3264
+ return {
3265
+ jpeg: await sharp(Buffer.from(native.bytes), { raw: {
3266
+ width: native.width,
3267
+ height: native.height,
3268
+ channels: 3
3269
+ } }).jpeg({ quality: 90 }).toBuffer(),
3270
+ width: native.width,
3271
+ height: native.height,
3272
+ frameSpace: denormalizeRect(paddedBbox, handle.width, handle.height)
3273
+ };
3274
+ }
3275
+ /** Real `runPipeline` dep: node-local `pipelineExecutor.runPipeline` on the `'full'` plane. */
3276
+ async runDetailPipeline(steps, imageJpeg, deviceId) {
3277
+ const api = this.ctxIfReady?.api;
3278
+ if (!api) return null;
3279
+ try {
3280
+ return await api.pipelineExecutor.runPipeline.mutate({
3281
+ steps: [...steps],
3282
+ image: new Uint8Array(imageJpeg),
3283
+ deviceId,
3284
+ plane: "full"
3285
+ });
3286
+ } catch (err) {
3287
+ this.ctxIfReady?.logger.warn("runDetailSubtree: runPipeline failed", {
3288
+ tags: { deviceId },
3289
+ meta: { error: errMsg(err) }
3290
+ });
3291
+ return null;
3292
+ }
3293
+ }
2670
3294
  detachInternal(deviceId) {
2671
3295
  const attachment = this.attached.get(deviceId);
2672
3296
  if (!attachment) return;
@@ -2677,6 +3301,7 @@ var PipelineRunnerAddon = class extends BaseAddon {
2677
3301
  }
2678
3302
  attachment.motionUnsubscribe?.();
2679
3303
  attachment.detectionUnsubscribe?.();
3304
+ this.lastMotionFrameAt.delete(deviceId);
2680
3305
  const remotePlane = this.remotePlanes.get(deviceId);
2681
3306
  if (remotePlane) {
2682
3307
  this.remotePlanes.delete(deviceId);
@@ -2986,8 +3611,9 @@ var PipelineRunnerAddon = class extends BaseAddon {
2986
3611
  format: "rgb"
2987
3612
  },
2988
3613
  acquireSpawnSlot: this.buildDecodeSpawnAcquire(log, "detect"),
2989
- onDecodedFrame: (frame) => {
3614
+ onDecodedFrame: (frame, nativeCrop) => {
2990
3615
  const handle = retentionSink.write(frame) ?? void 0;
3616
+ if (handle && nativeCrop) this.nativeCropRegistry.register(handle, nativeCrop);
2991
3617
  this.enqueueSharedDetectionFrame(config.deviceId, frame, handle);
2992
3618
  }
2993
3619
  });
@@ -3208,7 +3834,8 @@ var PipelineRunnerAddon = class extends BaseAddon {
3208
3834
  return await api.pipelineExecutor.runPipeline.mutate({
3209
3835
  steps: [...steps],
3210
3836
  ...useHandle ? { frameHandle: handle } : { frame },
3211
- deviceId
3837
+ deviceId,
3838
+ plane: "frame"
3212
3839
  });
3213
3840
  } catch (err) {
3214
3841
  const msg = errMsg(err);
@@ -3220,6 +3847,8 @@ var PipelineRunnerAddon = class extends BaseAddon {
3220
3847
  const ctx = this.ctx;
3221
3848
  const runner = this.runner;
3222
3849
  if (!ctx || !runner) return;
3850
+ this.lastMotionFrameAt.set(deviceId, Date.now());
3851
+ this.teeOccupancyTapFrame(deviceId, frame, handle);
3223
3852
  const log = this.ctx.logger.withTags({ deviceId });
3224
3853
  const motionStart = Date.now();
3225
3854
  try {
@@ -3334,11 +3963,98 @@ var PipelineRunnerAddon = class extends BaseAddon {
3334
3963
  });
3335
3964
  }
3336
3965
  }
3966
+ /**
3967
+ * Shared burst-completion path (Task 9 report + Task 10 dial/tap
3968
+ * consolidation): clears the in-flight flag, reads the `found` flag the
3969
+ * `onResult` handler set while the burst was live, reports it to the
3970
+ * runner's arm/disarm state machine, and logs. Both the dial path
3971
+ * (`runOccupancyBurst`'s `cleanup`) and the tap path
3972
+ * (`finishOccupancyTap`) fold into this single implementation so the
3973
+ * found/report semantics can't drift between the two admission
3974
+ * mechanisms.
3975
+ */
3976
+ finishOccupancyBurst(deviceId, log, collected) {
3977
+ this.occupancyBurstInFlight.delete(deviceId);
3978
+ const found = this.occupancyBurstFound.has(deviceId);
3979
+ this.occupancyBurstFound.delete(deviceId);
3980
+ this.runner?.reportOccupancyBurstOutcome(deviceId, found);
3981
+ log.debug("occupancy re-check: burst complete", { meta: {
3982
+ collected,
3983
+ found
3984
+ } });
3985
+ }
3986
+ /**
3987
+ * Task 10: arm a TAP-based occupancy burst for a device whose analyzer
3988
+ * motion subscription is already flowing (`attachment.motionUnsubscribe
3989
+ * !== null` — see `runOccupancyBurst`). Frames feed in via
3990
+ * `teeOccupancyTapFrame`, called from `runMotionAnalysis` on every
3991
+ * arriving substream frame; NO new main-stream poller/decode session is
3992
+ * started. A safety timer (mirrors the dial path's) completes the burst
3993
+ * even if the motion subscription stops producing frames mid-burst.
3994
+ *
3995
+ * Returns a promise that resolves once the tap settles (budget drained or
3996
+ * safety timeout) — `runOccupancyBurst` awaits it so the global
3997
+ * `occupancyActiveCount` slot stays held for the tap's full duration, the
3998
+ * same as the dial path.
3999
+ */
4000
+ /**
4001
+ * Returns a promise that resolves with the tap's final `collected` count
4002
+ * once it settles (budget drained or safety timeout). The caller
4003
+ * (`runOccupancyBurst`) decides what to do with that count — report the
4004
+ * outcome via `finishOccupancyBurst` when frames were actually collected,
4005
+ * or fall back to a dial burst when the tap collected zero (Task 10
4006
+ * hardening #2). Resolving with the count rather than reporting from
4007
+ * inside the tap itself is what makes that fallback possible without
4008
+ * double-reporting.
4009
+ */
4010
+ startOccupancyTap(deviceId, frames, log) {
4011
+ this.occupancyBurstInFlight.add(deviceId);
4012
+ this.occupancyBurstFound.delete(deviceId);
4013
+ log.debug("occupancy re-check: tapping live analyzer motion frames (no dial)", { meta: { frames } });
4014
+ return new Promise((resolve) => {
4015
+ const safetyTimer = setTimeout(() => {
4016
+ const collected = this.occupancyTaps.get(deviceId)?.collected ?? 0;
4017
+ log.debug("occupancy re-check: tap safety timeout reached", { meta: { collected } });
4018
+ this.finishOccupancyTap(deviceId);
4019
+ }, OCCUPANCY_BURST_SAFETY_TIMEOUT_MS);
4020
+ this.occupancyTaps.set(deviceId, {
4021
+ remaining: frames,
4022
+ collected: 0,
4023
+ safetyTimer,
4024
+ resolve
4025
+ });
4026
+ });
4027
+ }
4028
+ /** Idempotent: a no-op once the tap has already settled (timer fired, or the budget already drained). */
4029
+ finishOccupancyTap(deviceId) {
4030
+ const tap = this.occupancyTaps.get(deviceId);
4031
+ if (!tap) return;
4032
+ clearTimeout(tap.safetyTimer);
4033
+ this.occupancyTaps.delete(deviceId);
4034
+ tap.resolve(tap.collected);
4035
+ }
4036
+ /**
4037
+ * Tee an arriving analyzer motion frame into occupancy-burst detection
4038
+ * admission (Task 10). Called from `runMotionAnalysis` on EVERY
4039
+ * substream frame; a no-op unless `startOccupancyTap` armed a budget for
4040
+ * this device. The frame always feeds `enqueueOccupancyFrame` — the
4041
+ * handle may be `undefined` (e.g. the Epic-C per-session motion decode
4042
+ * worker never produces a shm handle) and `runInference` already falls
4043
+ * back to the pixel path in that case, so every arriving frame counts
4044
+ * toward the budget regardless of whether it carried a zero-copy handle.
4045
+ */
4046
+ teeOccupancyTapFrame(deviceId, frame, handle) {
4047
+ const tap = this.occupancyTaps.get(deviceId);
4048
+ if (!tap) return;
4049
+ this.runner?.enqueueOccupancyFrame(deviceId, frame, handle);
4050
+ tap.collected++;
4051
+ tap.remaining--;
4052
+ if (tap.remaining <= 0) this.finishOccupancyTap(deviceId);
4053
+ }
3337
4054
  async runOccupancyBurst(deviceId, frames) {
3338
4055
  if (this.occupancyBurstInFlight.has(deviceId)) return;
3339
4056
  const attachment = this.attached.get(deviceId);
3340
4057
  if (!attachment) return;
3341
- const config = attachment.config;
3342
4058
  const runner = this.runner;
3343
4059
  if (!runner) return;
3344
4060
  const log = this.ctx.logger.withTags({ deviceId });
@@ -3347,8 +4063,40 @@ var PipelineRunnerAddon = class extends BaseAddon {
3347
4063
  log.debug("runOccupancyBurst: ctx.api not available");
3348
4064
  return;
3349
4065
  }
3350
- this.occupancyBurstInFlight.add(deviceId);
3351
- log.debug("occupancy re-check: starting burst", { meta: { frames } });
4066
+ const lastFrameAt = this.lastMotionFrameAt.get(deviceId);
4067
+ const motionFresh = lastFrameAt !== void 0 && Date.now() - lastFrameAt < MOTION_FRESHNESS_WINDOW_MS;
4068
+ if (attachment.motionUnsubscribe !== null && motionFresh) {
4069
+ const collected = await this.startOccupancyTap(deviceId, frames, log);
4070
+ if (collected > 0) {
4071
+ this.finishOccupancyBurst(deviceId, log, collected);
4072
+ return;
4073
+ }
4074
+ log.debug("occupancy re-check: tap collected 0 frames, falling back to dial", { meta: { frames } });
4075
+ await this.runOccupancyDial(deviceId, frames, attachment, runner, api, log, true);
4076
+ return;
4077
+ }
4078
+ await this.runOccupancyDial(deviceId, frames, attachment, runner, api, log, false);
4079
+ }
4080
+ /**
4081
+ * Dial-path occupancy burst: subscribes a fresh main-stream decode session
4082
+ * via `startFrameHandlePoller` and collects `frames` handles (or times out
4083
+ * at {@link OCCUPANCY_BURST_SAFETY_TIMEOUT_MS}). Called either directly
4084
+ * from `runOccupancyBurst` (no live/fresh motion tap available,
4085
+ * `alreadyInFlight: false`) or as the Task 10 hardening #2 fallback after
4086
+ * a zero-collected tap (`alreadyInFlight: true` — the tap already claimed
4087
+ * the `occupancyBurstInFlight`/`occupancyBurstFound` slot, so this must
4088
+ * NOT re-add it).
4089
+ */
4090
+ async runOccupancyDial(deviceId, frames, attachment, runner, api, log, alreadyInFlight) {
4091
+ const config = attachment.config;
4092
+ if (!alreadyInFlight) {
4093
+ this.occupancyBurstInFlight.add(deviceId);
4094
+ this.occupancyBurstFound.delete(deviceId);
4095
+ }
4096
+ log.debug("occupancy re-check: starting burst", { meta: {
4097
+ frames,
4098
+ fallback: alreadyInFlight
4099
+ } });
3352
4100
  let count = 0;
3353
4101
  let unsubscribe = null;
3354
4102
  let unsubbed = false;
@@ -3363,13 +4111,12 @@ var PipelineRunnerAddon = class extends BaseAddon {
3363
4111
  if (settled) return;
3364
4112
  settled = true;
3365
4113
  doUnsub();
3366
- this.occupancyBurstInFlight.delete(deviceId);
3367
- log.debug("occupancy re-check: burst complete", { meta: { collected: count } });
4114
+ this.finishOccupancyBurst(deviceId, log, count);
3368
4115
  };
3369
4116
  const safetyTimer = setTimeout(() => {
3370
4117
  log.debug("occupancy re-check: safety timeout reached", { meta: { collected: count } });
3371
4118
  cleanup();
3372
- }, 8e3);
4119
+ }, OCCUPANCY_BURST_SAFETY_TIMEOUT_MS);
3373
4120
  try {
3374
4121
  unsubscribe = await startFrameHandlePoller({
3375
4122
  api,
@@ -3393,6 +4140,7 @@ var PipelineRunnerAddon = class extends BaseAddon {
3393
4140
  } catch (err) {
3394
4141
  clearTimeout(safetyTimer);
3395
4142
  this.occupancyBurstInFlight.delete(deviceId);
4143
+ this.occupancyBurstFound.delete(deviceId);
3396
4144
  log.debug("occupancy re-check: poller start failed", { meta: { error: errMsg(err) } });
3397
4145
  return;
3398
4146
  }
@@ -3404,12 +4152,14 @@ var PipelineRunnerAddon = class extends BaseAddon {
3404
4152
  emitInferenceResult(deviceId, frame, result, handle) {
3405
4153
  if (!this.ctx?.eventBus) return;
3406
4154
  const capturedAt = frame.capturedAt;
4155
+ const detailSteps = this.attached.get(deviceId)?.detailSteps;
3407
4156
  const payload = {
3408
4157
  deviceId,
3409
4158
  frame: result,
3410
4159
  nodeId: this.nodeId,
3411
4160
  frameHandle: handle,
3412
- ...typeof capturedAt === "number" && capturedAt > 0 ? { capturedAt } : {}
4161
+ ...typeof capturedAt === "number" && capturedAt > 0 ? { capturedAt } : {},
4162
+ ...detailSteps && detailSteps.length > 0 ? { detailSteps } : {}
3413
4163
  };
3414
4164
  this.ctx.eventBus.emit(createEvent(EventCategory.PipelineInferenceResult, {
3415
4165
  type: "device",
@@ -3566,4 +4316,4 @@ var PipelineRunnerAddon = class extends BaseAddon {
3566
4316
  }
3567
4317
  };
3568
4318
  //#endregion
3569
- export { DEFAULT_CONFIG, FrameQueue, PipelineRunner, PipelineTimingSampler, Semaphore, acquireSessionDecodeRestreamFrom, pipelineRunnerBenchActions as customActions, PipelineRunnerAddon as default, resolveMotionFrameSource, resolveSessionDecodeHostname, resolveSessionDecodeOwnerNodeId, rgbFrameToGrayFrame, shouldStartOnboardAnalyzer };
4319
+ export { DEFAULT_CONFIG, FrameQueue, PipelineRunner, PipelineTimingSampler, Semaphore, acquireSessionDecodeRestreamFrom, pipelineRunnerBenchActions as customActions, PipelineRunnerAddon as default, deriveDetailSteps, resolveMotionFrameSource, resolveSessionDecodeHostname, resolveSessionDecodeOwnerNodeId, rgbFrameToGrayFrame, shouldStartOnboardAnalyzer };