@camstack/addon-pipeline 1.2.51 → 1.2.53

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 (36) hide show
  1. package/dist/{addon-utils-DveoBR6G.js → addon-utils-CQs-AjTJ.js} +177 -177
  2. package/dist/{addon-utils-QuxtOdK1.mjs → addon-utils-CZ2xo67g.mjs} +177 -177
  3. package/dist/audio-analyzer/index.js +2 -2
  4. package/dist/audio-analyzer/index.mjs +2 -2
  5. package/dist/detection-pipeline/index.js +3 -3
  6. package/dist/detection-pipeline/index.mjs +3 -3
  7. package/dist/{dist-Dkvk5ykl.mjs → dist-B55xTTsS.mjs} +16337 -15811
  8. package/dist/{dist-BsBEe_rX.js → dist-D8TX_P_A.js} +16337 -15811
  9. package/dist/{event-loop-stall-monitor-DqJXi0_B.js → event-loop-stall-monitor-Bs6d-u_F.js} +1 -1
  10. package/dist/{event-loop-stall-monitor-BvtjRcal.mjs → event-loop-stall-monitor-Cq_KOlfb.mjs} +1 -1
  11. package/dist/motion-wasm/index.js +1 -1
  12. package/dist/motion-wasm/index.mjs +1 -1
  13. package/dist/pipeline-runner/index.js +160 -40
  14. package/dist/pipeline-runner/index.mjs +160 -40
  15. package/dist/recorder/index.js +2 -2
  16. package/dist/recorder/index.mjs +2 -2
  17. package/dist/session-decode/decode-worker-child.js +6 -2
  18. package/dist/session-decode/decode-worker-child.mjs +6 -2
  19. package/dist/stream-broker/_stub.js +2 -2
  20. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CUZ0nS_r.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-C-9q6y1s.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-CAmiVOsA.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-CaGsMvou.mjs +26 -0
  23. package/dist/stream-broker/{hostInit-B8O23a6l.mjs → hostInit-DiXYwnXH.mjs} +3 -3
  24. package/dist/stream-broker/index.js +337 -312
  25. package/dist/stream-broker/index.mjs +337 -312
  26. package/dist/stream-broker/remoteEntry.js +1 -1
  27. package/dist/{worker-protocol-B0c3i2km.mjs → worker-protocol-Bol291_n.mjs} +1 -1
  28. package/dist/{worker-protocol-CWhkLkHx.js → worker-protocol-C2uctCrd.js} +1 -1
  29. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-kAtChdz2.js → MaskShapeCanvas-DI4BY7W2-CXHeTkIT.js} +1 -1
  30. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-DEwkvjzt.js → MotionZonesSettings-NcxxQN8r-De5VKf09.js} +1 -1
  31. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-Brv9VL0h.js → PrivacyMaskSettings-APgPLF7p-Z7dsjOG5.js} +1 -1
  32. package/embed-dist/assets/{index-Dv7fSuDA.js → index-NJB31mpg.js} +12 -12
  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-Mv3UeZUE.mjs +0 -26
  36. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-Czrsozsg.mjs +0 -26
@@ -1,4 +1,4 @@
1
- const require_dist = require("./dist-BsBEe_rX.js");
1
+ const require_dist = require("./dist-D8TX_P_A.js");
2
2
  let node_perf_hooks = require("node:perf_hooks");
3
3
  //#region src/detection-pipeline/registry/model-catalogs.ts
4
4
  var HF_REPO = "camstack/camstack-models";
@@ -1,4 +1,4 @@
1
- import { R as hfModelUrl, a as COCO_TO_MACRO, i as COCO_80_LABELS, r as AUDIO_MACRO_LABELS } from "./dist-Dkvk5ykl.mjs";
1
+ import { R as hfModelUrl, a as COCO_TO_MACRO, i as COCO_80_LABELS, r as AUDIO_MACRO_LABELS } from "./dist-B55xTTsS.mjs";
2
2
  import { PerformanceObserver } from "node:perf_hooks";
3
3
  //#region src/detection-pipeline/registry/model-catalogs.ts
4
4
  var HF_REPO = "camstack/camstack-models";
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-BsBEe_rX.js");
5
+ const require_dist = require("../dist-D8TX_P_A.js");
6
6
  const require_lazy_sharp = require("../lazy-sharp-CcYuXtsa.js");
7
7
  let node_fs = require("node:fs");
8
8
  let node_path = require("node:path");
@@ -1,4 +1,4 @@
1
- import { L as evaluateZoneRules, V as motionDetectionCapability, dt as hydrateSchema, lt as DeviceType, ot as BaseAddon } from "../dist-Dkvk5ykl.mjs";
1
+ import { L as evaluateZoneRules, V as motionDetectionCapability, dt as hydrateSchema, lt as DeviceType, ot as BaseAddon } from "../dist-B55xTTsS.mjs";
2
2
  import { t as getSharp } from "../lazy-sharp-B-mb8uWx.mjs";
3
3
  import { readFileSync } from "node:fs";
4
4
  import { join } from "node:path";
@@ -3,9 +3,9 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../chunk-emK7D4bc.js");
6
- const require_dist = require("../dist-BsBEe_rX.js");
7
- const require_event_loop_stall_monitor = require("../event-loop-stall-monitor-DqJXi0_B.js");
8
- const require_worker_protocol = require("../worker-protocol-CWhkLkHx.js");
6
+ const require_dist = require("../dist-D8TX_P_A.js");
7
+ const require_event_loop_stall_monitor = require("../event-loop-stall-monitor-Bs6d-u_F.js");
8
+ const require_worker_protocol = require("../worker-protocol-C2uctCrd.js");
9
9
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
10
10
  const require_lazy_sharp = require("../lazy-sharp-CcYuXtsa.js");
11
11
  const require_remote_restream = require("../remote-restream-CO36Sr30.js");
@@ -543,11 +543,35 @@ function cropRgb(frame, bbox, maxWidth) {
543
543
  /** How long to wait after `kill()` before escalating to `kill('SIGKILL')`. */
544
544
  var SIGKILL_FALLBACK_MS = 1e3;
545
545
  /**
546
- * Default timeout for the `pull` round-trip preceding the FIRST delivered
547
- * frame. Task 0 live-measured cold-start (restream dial + hwaccel context +
548
- * keyframe wait) at 5-8s, so this must tolerate a slow-but-healthy start.
546
+ * How often the cold-start wait REPORTS that it is still waiting. Task 0
547
+ * live-measured cold-start (restream dial + hwaccel context + keyframe wait) at
548
+ * 5-8s, so this must tolerate a slow-but-healthy start.
549
+ *
550
+ * It is no longer the point at which the session is abandoned — see
551
+ * {@link DEFAULT_FIRST_FRAME_MAX_WAIT_MS}.
549
552
  */
550
553
  var DEFAULT_FIRST_FRAME_TIMEOUT_MS = 2e4;
554
+ /**
555
+ * Hard ceiling on the TOTAL wait for the FIRST frame of a dial.
556
+ *
557
+ * The first decodable frame is not a fixed cost: it is one IDR interval plus
558
+ * every GOP the decoder has to discard. Measured on device 618 (2560x1440
559
+ * h264, 8.0 s IDR interval, `Invalid NAL unit` / `corrupt decoded frame` in its
560
+ * bitstream), dial → first decoded frame over the broker's own muted restream
561
+ * was 1.8 / 6.5 / 7.0 / 7.2 / 9.2 / 10.0 / 10.8 / 12.9 / 14.9 / 20.0 / 30.3 /
562
+ * >40 s across 12 trials — against a healthy 587's 2.0 s ± 0.02.
563
+ *
564
+ * Abandoning at 20 s was therefore not a timeout, it was a coin flip, and the
565
+ * recovery made it worse: ending the session RE-DIALS, which throws the
566
+ * accumulated wait away and re-pays the RTSP setup + probe from zero, and two
567
+ * ends inside 60 s DETACH the camera. That loop cost 618 25.9% of its assigned
568
+ * decode airtime in one day (325 of 1257 ten-second windows delivered nothing).
569
+ *
570
+ * The wait is bounded instead of deadlined: the worker keeps the outstanding
571
+ * `pull` latched, so re-arming costs nothing but the wait, and the ceiling is
572
+ * what stops a genuinely dead input from holding a spawn permit forever.
573
+ */
574
+ var DEFAULT_FIRST_FRAME_MAX_WAIT_MS = 45e3;
551
575
  /** Default timeout for every `pull` round-trip AFTER the first frame arrives. */
552
576
  var DEFAULT_FRAME_TIMEOUT_MS = 1e4;
553
577
  /** Narrow an 'exit' payload to {@link WorkerExitInfo} (fakes may emit undefined). */
@@ -567,8 +591,10 @@ function toError(err) {
567
591
  var SessionDecodeSession = class {
568
592
  child;
569
593
  logger;
594
+ deviceId;
570
595
  firstFrameTimeoutMs;
571
596
  frameTimeoutMs;
597
+ firstFrameMaxWaitMs;
572
598
  pendingPull = null;
573
599
  pendingToBuffers = /* @__PURE__ */ new Map();
574
600
  /** Outstanding `nativeCrop` requests, keyed by the monotonic `requestId`. */
@@ -580,11 +606,15 @@ var SessionDecodeSession = class {
580
606
  sigkillTimer = null;
581
607
  pullTimeoutTimer = null;
582
608
  firstFrameReceived = false;
583
- constructor(child, logger, firstFrameTimeoutMs, frameTimeoutMs) {
609
+ /** `Date.now()` of the FIRST `pull` — the origin of the cold-start ceiling. */
610
+ firstPullAt = 0;
611
+ constructor(child, logger, deviceId, firstFrameTimeoutMs, frameTimeoutMs, firstFrameMaxWaitMs) {
584
612
  this.child = child;
585
613
  this.logger = logger;
614
+ this.deviceId = deviceId;
586
615
  this.firstFrameTimeoutMs = firstFrameTimeoutMs;
587
616
  this.frameTimeoutMs = frameTimeoutMs;
617
+ this.firstFrameMaxWaitMs = firstFrameMaxWaitMs;
588
618
  this.child.on("message", (m) => this.handleMessage(m));
589
619
  this.child.on("exit", (m) => this.handleExit(toWorkerExitInfo(m)));
590
620
  }
@@ -604,6 +634,7 @@ var SessionDecodeSession = class {
604
634
  */
605
635
  pullNext() {
606
636
  if (this.exited) return Promise.resolve({ kind: "ended" });
637
+ if (this.firstPullAt === 0) this.firstPullAt = Date.now();
607
638
  return new Promise((resolve, reject) => {
608
639
  this.pendingPull = {
609
640
  resolve,
@@ -785,25 +816,63 @@ var SessionDecodeSession = class {
785
816
  }
786
817
  /**
787
818
  * Fires when a `pull` round-trip outlives its liveness timeout — the child
788
- * stopped producing frames WITHOUT exiting (a hung decode). Settles the
789
- * outstanding pull as `ended` (not a throw) so the consumer's async
790
- * generator resumes cleanly, runs its `finally`, and tears the child down
791
- * via {@link teardown} which sends `stop`, `kill()`s, and arms the 1s
792
- * SIGKILL fallback.
819
+ * has not produced a frame and has not exited.
820
+ *
821
+ * BEFORE the first frame this is not yet a failure. The worker latches the
822
+ * outstanding `pull` (`decode-worker-child.handlePull` sets `pendingPull` and
823
+ * delivers on the next decoded frame), so re-arming the timer costs nothing
824
+ * but the wait and the session keeps the dial, the RTSP setup and the probe
825
+ * it has already paid for. Only the {@link firstFrameMaxWaitMs} ceiling ends
826
+ * it — see that constant for the measurement that set it.
827
+ *
828
+ * AFTER the first frame the deadline is unchanged: a steady stream that stops
829
+ * is a hung decode, and ending settles the outstanding pull as `ended` (not a
830
+ * throw) so the consumer's async generator resumes cleanly, runs its
831
+ * `finally`, and tears the child down via {@link teardown}.
832
+ *
833
+ * Every line here carries `tags: { deviceId }` and a `reason`, because
834
+ * "detaching" a camera is the loudest dropped work in this path and an
835
+ * operator has to be able to answer "why did 618 stop" from the logs alone.
793
836
  */
794
837
  handlePullTimeout(timeoutMs) {
795
838
  this.pullTimeoutTimer = null;
796
839
  const waiter = this.pendingPull;
797
840
  if (!waiter) return;
841
+ const waitedMs = this.firstPullAt === 0 ? timeoutMs : Date.now() - this.firstPullAt;
842
+ if (!this.firstFrameReceived && waitedMs < this.firstFrameMaxWaitMs) {
843
+ this.logger.warn("session-decode-coordinator: still waiting for its first decodable frame — holding the dial", {
844
+ tags: { deviceId: this.deviceId },
845
+ meta: {
846
+ waitedMs,
847
+ reportEveryMs: timeoutMs,
848
+ maxWaitMs: this.firstFrameMaxWaitMs,
849
+ hint: "long IDR interval or a discarded GOP looks exactly like this"
850
+ }
851
+ });
852
+ this.armPullTimeout();
853
+ return;
854
+ }
798
855
  this.pendingPull = null;
799
- this.logger.warn(`session-decode-coordinator: decode worker produced no frame within ${timeoutMs}ms — ending session`, { meta: {
800
- timeoutMs,
801
- firstFrameReceived: this.firstFrameReceived
802
- } });
856
+ const reason = this.firstFrameReceived ? "stalled-after-first-frame" : "no-first-frame";
857
+ this.logger.warn(this.firstFrameReceived ? "session-decode-coordinator: gave up waiting for the next frame — ending session" : "session-decode-coordinator: gave up waiting for the first decodable frame — ending session", {
858
+ tags: { deviceId: this.deviceId },
859
+ meta: {
860
+ reason,
861
+ waitedMs,
862
+ timeoutMs,
863
+ ...this.firstFrameReceived ? {} : { maxWaitMs: this.firstFrameMaxWaitMs }
864
+ }
865
+ });
803
866
  waiter.resolve({ kind: "ended" });
804
867
  }
868
+ /**
869
+ * Arm the next round-trip deadline. Before the first frame the timer is
870
+ * clamped to what is LEFT of the ceiling, so the give-up lands exactly on
871
+ * {@link firstFrameMaxWaitMs} instead of on the next report boundary past it
872
+ * — otherwise a 45 s ceiling reported every 20 s would really give up at 60 s.
873
+ */
805
874
  armPullTimeout() {
806
- const timeoutMs = this.firstFrameReceived ? this.frameTimeoutMs : this.firstFrameTimeoutMs;
875
+ const timeoutMs = this.firstFrameReceived ? this.frameTimeoutMs : Math.max(1, Math.min(this.firstFrameTimeoutMs, this.firstFrameMaxWaitMs - (this.firstPullAt === 0 ? 0 : Date.now() - this.firstPullAt)));
807
876
  this.pullTimeoutTimer = setTimeout(() => this.handlePullTimeout(timeoutMs), timeoutMs);
808
877
  this.pullTimeoutTimer.unref?.();
809
878
  }
@@ -925,8 +994,9 @@ function buildFrameImage(session, reply) {
925
994
  function createSessionDecodeCoordinator(deps) {
926
995
  const firstFrameTimeoutMs = deps.firstFrameTimeoutMs ?? DEFAULT_FIRST_FRAME_TIMEOUT_MS;
927
996
  const frameTimeoutMs = deps.frameTimeoutMs ?? DEFAULT_FRAME_TIMEOUT_MS;
997
+ const firstFrameMaxWaitMs = Math.max(deps.firstFrameMaxWaitMs ?? DEFAULT_FIRST_FRAME_MAX_WAIT_MS, firstFrameTimeoutMs);
928
998
  return { async *generateVideoFrames(source, opts) {
929
- const session = new SessionDecodeSession(deps.fork(source), deps.logger, firstFrameTimeoutMs, frameTimeoutMs);
999
+ const session = new SessionDecodeSession(deps.fork(source), deps.logger, source.deviceId, firstFrameTimeoutMs, frameTimeoutMs, firstFrameMaxWaitMs);
930
1000
  session.start(source, opts);
931
1001
  try {
932
1002
  while (true) {
@@ -1036,6 +1106,7 @@ function startSessionDecodePump(deps) {
1036
1106
  break;
1037
1107
  }
1038
1108
  try {
1109
+ if (deps.shouldMaterializeFrame && !deps.shouldMaterializeFrame()) continue;
1039
1110
  const decoded = await toDecodedFrame(frame, format);
1040
1111
  const image = frame.image;
1041
1112
  const cropFn = image.nativeCrop;
@@ -3067,15 +3138,8 @@ var PipelineRunner = class {
3067
3138
  * (the FrameQueue implementation is latest-only and ignores maxSize
3068
3139
  * anyway — see `frame-queue.ts` — so the field is effectively a
3069
3140
  * metadata hint for observability).
3070
- * - `targetLoadPercent` / `minThrottledFps`: stored on `this.config` (read
3071
- * by `getLimits()`), but NOT re-fed into the live `FrameGovernor` — the
3072
- * governor is built once, at construction, from the config snapshot
3073
- * passed to the constructor. A hot patch to either knob changes what
3074
- * `getLimits()` reports but does not change live admission pacing until
3075
- * the runner is reconstructed. (Rebuilding the governor live is a
3076
- * follow-up, not in scope here — `maxConcurrentInferences` has the same
3077
- * gap: the semaphore resizes live but the governor's budget calc keeps
3078
- * the construction-time value.)
3141
+ * - `targetLoadPercent` / `minThrottledFps` and inference concurrency:
3142
+ * rebuild the live governor and immediately recompute admission intervals.
3079
3143
  *
3080
3144
  * Only keys present in the patch are overwritten; unspecified keys
3081
3145
  * retain their current value. Any illegal combination (e.g.
@@ -3091,6 +3155,12 @@ var PipelineRunner = class {
3091
3155
  };
3092
3156
  if (next.maxConcurrentInferences !== this.config.maxConcurrentInferences) this.semaphore.resize(next.maxConcurrentInferences);
3093
3157
  this.config = next;
3158
+ this.governor = new FrameGovernor({
3159
+ targetLoadPercent: next.targetLoadPercent,
3160
+ minThrottledFps: next.minThrottledFps,
3161
+ maxConcurrentInferences: next.maxConcurrentInferences
3162
+ });
3163
+ this.recomputeGovernor();
3094
3164
  }
3095
3165
  /** Read the current tuning fields for diagnostics / tests. */
3096
3166
  getLimits() {
@@ -3184,17 +3254,36 @@ var PipelineRunner = class {
3184
3254
  * saving; do not turn it back into `void` for tidiness.
3185
3255
  */
3186
3256
  enqueueDetectionFrame(deviceId, frame, handle) {
3257
+ if (!this.reserveDetectionFrame(deviceId)) return false;
3258
+ return this.enqueueReservedDetectionFrame(deviceId, frame, handle);
3259
+ }
3260
+ /**
3261
+ * Reserve adaptive admission before a session-decode frame is materialized.
3262
+ * This is intentionally pixel-free: the pump can skip `toBuffer()` entirely
3263
+ * when the frame is outside the governor interval, avoiding RGB conversion,
3264
+ * IPC and retention work. Raw arrivals are counted here, before the gate, so
3265
+ * recomputation never feeds the throttled output rate back in as source FPS.
3266
+ */
3267
+ reserveDetectionFrame(deviceId, now = Date.now()) {
3187
3268
  const state = this.cameras.get(deviceId);
3188
3269
  if (!state) return false;
3189
3270
  this.arrivalCounts.set(deviceId, (this.arrivalCounts.get(deviceId) ?? 0) + 1);
3190
3271
  if (state.phase !== "active") return false;
3191
3272
  const interval = this.governor.minIntervalMs(deviceId);
3192
3273
  if (interval > 0) {
3193
- const last = this.lastAdmittedAt.get(deviceId) ?? 0;
3194
- const now = Date.now();
3195
- if (now - last < interval) return false;
3274
+ if (now - (this.lastAdmittedAt.get(deviceId) ?? 0) < interval) return false;
3196
3275
  this.lastAdmittedAt.set(deviceId, now);
3197
3276
  }
3277
+ return true;
3278
+ }
3279
+ /**
3280
+ * Queue a frame whose admission was already reserved synchronously through
3281
+ * {@link reserveDetectionFrame}. Kept separate so the ordinary public path
3282
+ * cannot accidentally count one source frame twice.
3283
+ */
3284
+ enqueueReservedDetectionFrame(deviceId, frame, handle) {
3285
+ const state = this.cameras.get(deviceId);
3286
+ if (!state || state.phase !== "active") return false;
3198
3287
  frame._enqueuedAt = Date.now();
3199
3288
  state.detectionQueue.enqueue({
3200
3289
  frame,
@@ -5580,21 +5669,44 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
5580
5669
  * no second dial, no second decode, no GPU). Mirrors the shm shared-motion
5581
5670
  * seam (`subscribeMotionFrames` `shared` branch).
5582
5671
  */
5583
- enqueueSharedDetectionFrame(deviceId, frame, handle) {
5672
+ enqueueSharedDetectionFrame(deviceId, frame, handle, reservation) {
5584
5673
  const runner = this.runner;
5585
5674
  if (!runner) return false;
5586
- const admitted = runner.enqueueDetectionFrame(deviceId, frame, handle);
5587
- const sink = this.sharedMotionSinks.get(deviceId);
5588
- if (sink) {
5589
- const now = this.sharedSinkNow();
5590
- if (now - sink.lastMotionEnqueueTs >= sink.minIntervalMs) {
5591
- sink.lastMotionEnqueueTs = now;
5592
- runner.enqueueMotionFrame(deviceId, rgbFrameToGrayFrame(frame), void 0);
5675
+ const admitted = reservation ? reservation.detection && runner.enqueueReservedDetectionFrame(deviceId, frame, handle) : runner.enqueueDetectionFrame(deviceId, frame, handle);
5676
+ if (reservation?.motion) runner.enqueueMotionFrame(deviceId, rgbFrameToGrayFrame(frame), void 0);
5677
+ else if (!reservation) {
5678
+ const sink = this.sharedMotionSinks.get(deviceId);
5679
+ if (sink) {
5680
+ const now = this.sharedSinkNow();
5681
+ if (now - sink.lastMotionEnqueueTs >= sink.minIntervalMs) {
5682
+ sink.lastMotionEnqueueTs = now;
5683
+ runner.enqueueMotionFrame(deviceId, rgbFrameToGrayFrame(frame), void 0);
5684
+ }
5593
5685
  }
5594
5686
  }
5595
5687
  return admitted;
5596
5688
  }
5597
5689
  /**
5690
+ * Reserve all consumers of one lazy RGB frame before materializing pixels.
5691
+ * Detection follows the adaptive governor; shared motion keeps its independent
5692
+ * cadence so throttling inference cannot starve the analyzer.
5693
+ */
5694
+ reserveSharedDetectionFrame(deviceId) {
5695
+ const detection = this.runner?.reserveDetectionFrame(deviceId) ?? false;
5696
+ const sink = this.sharedMotionSinks.get(deviceId);
5697
+ if (!sink) return {
5698
+ detection,
5699
+ motion: false
5700
+ };
5701
+ const now = this.sharedSinkNow();
5702
+ const motion = now - sink.lastMotionEnqueueTs >= sink.minIntervalMs;
5703
+ if (motion) sink.lastMotionEnqueueTs = now;
5704
+ return {
5705
+ detection,
5706
+ motion
5707
+ };
5708
+ }
5709
+ /**
5598
5710
  * Epic C P1 detection path for a flagged camera: decode `rgb` in a
5599
5711
  * per-session forked worker and feed the runner's detection queue INLINE (no
5600
5712
  * `FrameHandle`), exactly like the shared shm path. Teardown stops the pump
@@ -5647,6 +5759,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
5647
5759
  streamId: config.detectionStreamId,
5648
5760
  codec: source.codec
5649
5761
  } });
5762
+ let reservation = null;
5650
5763
  return startSessionDecodePump({
5651
5764
  fork: this.forkDecodeWorkerWithLease,
5652
5765
  logger: log,
@@ -5655,12 +5768,19 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
5655
5768
  fps: config.detectionFps,
5656
5769
  format: "rgb"
5657
5770
  },
5771
+ shouldMaterializeFrame: () => {
5772
+ reservation = this.reserveSharedDetectionFrame(config.deviceId);
5773
+ return reservation.detection || reservation.motion;
5774
+ },
5658
5775
  acquireSpawnSlot: this.buildDecodeSpawnAcquire(log, "detect"),
5659
5776
  onEnded: () => this.handleDetectionDecodeEnded(config.deviceId),
5660
5777
  onDecodedFrame: (frame, nativeCrop) => {
5661
- const handle = retainedFrames?.write(config.deviceId, frame) ?? void 0;
5778
+ const current = reservation;
5779
+ reservation = null;
5780
+ if (!current) return;
5781
+ const handle = current.detection ? retainedFrames?.write(config.deviceId, frame) ?? void 0 : void 0;
5662
5782
  if (handle && nativeCrop) this.nativeCropRegistry.register(handle, nativeCrop, config.deviceId);
5663
- if (this.enqueueSharedDetectionFrame(config.deviceId, frame, handle)) nativeCrop?.markInferred();
5783
+ if (this.enqueueSharedDetectionFrame(config.deviceId, frame, handle, current)) nativeCrop?.markInferred();
5664
5784
  }
5665
5785
  });
5666
5786
  }