@camstack/addon-pipeline 0.1.18 → 0.1.20

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 (67) hide show
  1. package/dist/audio-analyzer/index.js +12 -4
  2. package/dist/audio-analyzer/index.js.map +1 -1
  3. package/dist/audio-analyzer/index.mjs +12 -4
  4. package/dist/audio-analyzer/index.mjs.map +1 -1
  5. package/dist/audio-codec-nodeav/index.js +1 -1
  6. package/dist/audio-codec-nodeav/index.mjs +1 -1
  7. package/dist/decoder-nodeav/index.js +2 -2
  8. package/dist/decoder-nodeav/index.mjs +2 -2
  9. package/dist/detection-pipeline/index.js +47 -44
  10. package/dist/detection-pipeline/index.js.map +1 -1
  11. package/dist/detection-pipeline/index.mjs +47 -44
  12. package/dist/detection-pipeline/index.mjs.map +1 -1
  13. package/dist/{index-asZs8U_s.mjs → index-5aYef068.mjs} +4020 -820
  14. package/dist/index-5aYef068.mjs.map +1 -0
  15. package/dist/{index-DLHaHm6u.js → index-B36NMAdu.js} +3996 -796
  16. package/dist/index-B36NMAdu.js.map +1 -0
  17. package/dist/{index-D_cl0Qqb.js → index-CMcx_k6Y.js} +48 -48
  18. package/dist/{index-D_cl0Qqb.js.map → index-CMcx_k6Y.js.map} +1 -1
  19. package/dist/{index-UbcdLS7a.mjs → index-CYb7cFrv.mjs} +46 -46
  20. package/dist/{index-UbcdLS7a.mjs.map → index-CYb7cFrv.mjs.map} +1 -1
  21. package/dist/motion-wasm/index.js +1 -1
  22. package/dist/motion-wasm/index.mjs +1 -1
  23. package/dist/pipeline-runner/index.js +205 -90
  24. package/dist/pipeline-runner/index.js.map +1 -1
  25. package/dist/pipeline-runner/index.mjs +206 -91
  26. package/dist/pipeline-runner/index.mjs.map +1 -1
  27. package/dist/recorder/index.js +2209 -0
  28. package/dist/recorder/index.js.map +1 -0
  29. package/dist/recorder/index.mjs +2209 -0
  30. package/dist/recorder/index.mjs.map +1 -0
  31. package/dist/stream-broker/@mf-types/compiled-types/stream-broker/widgets/FfmpegParamsField.d.ts +41 -0
  32. package/dist/stream-broker/@mf-types/compiled-types/stream-broker/widgets/GeometryBuilder.d.ts +54 -0
  33. package/dist/stream-broker/@mf-types/compiled-types/stream-broker/widgets/format-ua.d.ts +13 -0
  34. package/dist/stream-broker/@mf-types/compiled-types/stream-broker/widgets/index.d.ts +2 -0
  35. package/dist/stream-broker/@mf-types.zip +0 -0
  36. package/dist/stream-broker/{__mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_sdk__loadShare__.mjs-h5aXOPSA.mjs → __mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_sdk__loadShare__.mjs-lantnv8e.mjs} +1 -1
  37. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.mjs-DJ3UNg7O.mjs +30 -0
  38. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.mjs-CYXy_bhS.mjs +21 -0
  39. package/dist/stream-broker/{__mfe_internal__addon_stream_broker_widgets__loadShare__react__loadShare__.mjs-DAssX3h0.mjs → __mfe_internal__addon_stream_broker_widgets__loadShare__react__loadShare__.mjs-CaDEYBIU.mjs} +9 -7
  40. package/dist/stream-broker/{__mfe_internal__addon_stream_broker_widgets__loadShare__react__loadShare__.mjs_commonjs-proxy-DFoJJhpt.mjs → __mfe_internal__addon_stream_broker_widgets__loadShare__react__loadShare__.mjs_commonjs-proxy-D6EROtlA.mjs} +1 -1
  41. package/dist/stream-broker/{__mfe_internal__addon_stream_broker_widgets__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__.mjs-gBEZsQrp.mjs → __mfe_internal__addon_stream_broker_widgets__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__.mjs-x6pP3Ghk.mjs} +2 -2
  42. package/dist/stream-broker/{__mfe_internal__addon_stream_broker_widgets__loadShare__react_mf_2_dom__loadShare__.mjs_commonjs-proxy-x7XMEeuJ.mjs → __mfe_internal__addon_stream_broker_widgets__loadShare__react_mf_2_dom__loadShare__.mjs_commonjs-proxy-CcnN6sbA.mjs} +1 -1
  43. package/dist/stream-broker/_stub.js +963 -333
  44. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-3TxRVJ5L.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CL9DR49k.mjs} +6 -6
  45. package/dist/stream-broker/{client-CZXrddDR.mjs → client-BvTmMOQu.mjs} +2 -2
  46. package/dist/stream-broker/{hostInit-De6APW25.mjs → hostInit-ChmiMPS0.mjs} +12 -12
  47. package/dist/stream-broker/{index-cYW01SNH.mjs → index-BxsFuFmE.mjs} +24 -24
  48. package/dist/stream-broker/{index-KtR7Pp0O.mjs → index-C-248uOU.mjs} +2 -2
  49. package/dist/stream-broker/{index-C0BzaWmB.mjs → index-C05B6jqp.mjs} +1 -1
  50. package/dist/stream-broker/index-DOJoSShD.mjs +67784 -0
  51. package/dist/stream-broker/index-DtOI1aTU.mjs +18504 -0
  52. package/dist/stream-broker/{index-BvV3RVTZ.mjs → index-oMq6ilgR.mjs} +254 -268
  53. package/dist/stream-broker/{index-CZNxa0ad.mjs → index-vIWZQBIL.mjs} +1 -1
  54. package/dist/stream-broker/index.js +4666 -756
  55. package/dist/stream-broker/index.js.map +1 -1
  56. package/dist/stream-broker/index.mjs +4668 -756
  57. package/dist/stream-broker/index.mjs.map +1 -1
  58. package/dist/stream-broker/{jsx-runtime-B_evVsXl.mjs → jsx-runtime-BRT_HL0A.mjs} +1 -1
  59. package/dist/stream-broker/remoteEntry.js +1 -1
  60. package/dist/stream-broker/{schemas-ChN4Ih0h.mjs → schemas-B7L0qZtq.mjs} +530 -515
  61. package/package.json +51 -3
  62. package/dist/index-DLHaHm6u.js.map +0 -1
  63. package/dist/index-asZs8U_s.mjs.map +0 -1
  64. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.mjs-d8PmLbO2.mjs +0 -19
  65. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.mjs-B4l8Nb2y.mjs +0 -20
  66. package/dist/stream-broker/index-CUXiTSWS.mjs +0 -13883
  67. package/dist/stream-broker/index-Kb4xa8FX.mjs +0 -36403
@@ -22,7 +22,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
22
22
  mod
23
23
  ));
24
24
  Object.defineProperties(exports, { __esModule: { value: true }, [Symbol.toStringTag]: { value: "Module" } });
25
- const index = require("../index-DLHaHm6u.js");
25
+ const index = require("../index-B36NMAdu.js");
26
26
  const fs = require("node:fs");
27
27
  const path = require("node:path");
28
28
  const DEFAULT_CONFIG$1 = {
@@ -1,4 +1,4 @@
1
- import { i as evaluateZoneRules, B as BaseAddon, t as motionDetectionCapability, m as hydrateSchema, D as DeviceType } from "../index-asZs8U_s.mjs";
1
+ import { j as evaluateZoneRules, B as BaseAddon, v as motionDetectionCapability, r as hydrateSchema, D as DeviceType } from "../index-5aYef068.mjs";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  const DEFAULT_CONFIG$1 = {
@@ -22,7 +22,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
22
22
  mod
23
23
  ));
24
24
  Object.defineProperties(exports, { __esModule: { value: true }, [Symbol.toStringTag]: { value: "Module" } });
25
- const index = require("../index-DLHaHm6u.js");
25
+ const index = require("../index-B36NMAdu.js");
26
26
  const shmRing = require("@camstack/shm-ring");
27
27
  class FrameQueue {
28
28
  /**
@@ -137,9 +137,9 @@ class PipelineTimingSampler {
137
137
  if (!this.detSamples.has(deviceId)) this.detSamples.set(deviceId, []);
138
138
  this.detSamples.get(deviceId).push(s);
139
139
  }
140
- addMotionSample(deviceId, ms) {
140
+ addMotionSample(deviceId, ms, frameAge = -1) {
141
141
  if (!this.motSamples.has(deviceId)) this.motSamples.set(deviceId, []);
142
- this.motSamples.get(deviceId).push(ms);
142
+ this.motSamples.get(deviceId).push({ ms, frameAge });
143
143
  }
144
144
  addAudioSample(deviceId, s) {
145
145
  if (!this.audioSamples.has(deviceId)) this.audioSamples.set(deviceId, []);
@@ -164,6 +164,7 @@ class PipelineTimingSampler {
164
164
  if (det.length === 0) continue;
165
165
  const e2e = det.map((s) => s.endToEnd);
166
166
  const inf = det.map((s) => s.inference);
167
+ const frameAge = det.map((s) => s.frameAge).filter((v) => v >= 0);
167
168
  const totalDet = det.reduce((s, d) => s + d.detections, 0);
168
169
  this.log.info(
169
170
  "pipeline stats",
@@ -172,7 +173,20 @@ class PipelineTimingSampler {
172
173
  meta: {
173
174
  frames: det.length,
174
175
  intervalSec: REPORT_INTERVAL_MS / 1e3,
176
+ // enqueue → emit, in ms. Stage breakdown (avg) exposes WHERE the
177
+ // latency sits: queue backlog vs semaphore contention vs inference
178
+ // vs result-emit. inference is usually the floor (model chain).
175
179
  e2e: { avg: avg(e2e), p95: p95(e2e), max: max(e2e) },
180
+ // Frame age (capture→inference-pick): if large, the analyzed frame is
181
+ // already stale (decoder/ring behind), which delays the overlay
182
+ // regardless of how fast the result is delivered.
183
+ frameAge: { avg: avg(frameAge), p95: p95(frameAge), max: max(frameAge) },
184
+ stagesMs: {
185
+ queueWait: avg(det.map((s) => s.queueWait)),
186
+ semaphoreWait: avg(det.map((s) => s.semaphoreWait)),
187
+ inference: avg(inf),
188
+ resultToEmit: avg(det.map((s) => s.resultToEmit))
189
+ },
176
190
  inference: { avg: avg(inf), p95: p95(inf) },
177
191
  detections: totalDet,
178
192
  dropped,
@@ -185,6 +199,8 @@ class PipelineTimingSampler {
185
199
  this.detSamples.clear();
186
200
  for (const [deviceId, mot] of this.motSamples) {
187
201
  if (mot.length === 0) continue;
202
+ const ms = mot.map((s) => s.ms);
203
+ const frameAge = mot.map((s) => s.frameAge).filter((v) => v >= 0);
188
204
  this.log.info(
189
205
  "motion stats",
190
206
  {
@@ -192,10 +208,12 @@ class PipelineTimingSampler {
192
208
  meta: {
193
209
  frames: mot.length,
194
210
  intervalSec: REPORT_INTERVAL_MS / 1e3,
195
- avg: avg(mot),
196
- p95: p95(mot),
197
- max: max(mot)
198
- // motionAddon: rt.motionAddon ?? null,
211
+ avg: avg(ms),
212
+ p95: p95(ms),
213
+ max: max(ms),
214
+ // Frame age at motion analysis (capture→analysis). Large = stale
215
+ // input frame (decoder/ring behind) → motion box lags real movement.
216
+ frameAge: { avg: avg(frameAge), p95: p95(frameAge), max: max(frameAge) }
199
217
  }
200
218
  }
201
219
  );
@@ -541,8 +559,7 @@ class PipelineRunner {
541
559
  async processWithSemaphore(deviceId, entry, frameInput, state, streamType) {
542
560
  const pickedAt = Date.now();
543
561
  const { frame, handle } = entry;
544
- const captureTs = frame.timestamp;
545
- const enqueuedAt = frame._enqueuedAt ?? captureTs;
562
+ const enqueuedAt = frame._enqueuedAt ?? pickedAt;
546
563
  const release = await this.semaphore.acquire();
547
564
  const semAcquiredAt = Date.now();
548
565
  try {
@@ -557,13 +574,19 @@ class PipelineRunner {
557
574
  if (result) {
558
575
  await this.notifyCallbacks(deviceId, frame, result, streamType, handle);
559
576
  const emittedAt = Date.now();
577
+ const capturedAt = frame.capturedAt;
560
578
  this.timingSampler.addSample(deviceId, {
561
- captureToEnqueue: enqueuedAt - captureTs,
562
579
  queueWait: pickedAt - enqueuedAt,
563
580
  semaphoreWait: semAcquiredAt - pickedAt,
564
581
  inference: inferenceMs,
565
582
  resultToEmit: emittedAt - inferDoneAt,
566
- endToEnd: emittedAt - captureTs,
583
+ // capturedAt is the shm-ring commit wall-clock; pickedAt − capturedAt
584
+ // is how stale the frame was when inference started. <0/absent → unknown.
585
+ frameAge: typeof capturedAt === "number" && capturedAt > 0 ? pickedAt - capturedAt : -1,
586
+ // Wall-clock pipeline latency: enqueue → result emitted. (Capture →
587
+ // enqueue, i.e. RTSP decode + shm-ring write, is not measured here —
588
+ // the shm frame carries only a PTS, no wall-clock capture stamp.)
589
+ endToEnd: emittedAt - enqueuedAt,
567
590
  detections: result.detections?.length ?? 0
568
591
  });
569
592
  }
@@ -607,36 +630,87 @@ class PipelineRunner {
607
630
  const PULL_MAX_COUNT = 4;
608
631
  const MIN_POLL_INTERVAL_MS = 20;
609
632
  const FALLBACK_POLL_INTERVAL_MS = 200;
633
+ const INITIAL_SUBSCRIBE_RETRY_BACKOFF_MS = 250;
634
+ const MAX_SUBSCRIBE_RETRY_BACKOFF_MS = 5e3;
610
635
  async function startFrameHandlePoller(options) {
611
- const { api, brokerId, format, maxFps, tag, onFrame, logger } = options;
612
- let result;
613
- try {
614
- result = await api.streamBroker.subscribeFrames.mutate({
615
- brokerId,
616
- format,
617
- maxFps,
618
- tag
619
- });
620
- } catch (err) {
621
- logger.warn("frame-handle poller: subscribeFrames failed", {
622
- meta: { brokerId, format, tag, error: index.errMsg(err) }
623
- });
624
- return null;
636
+ const lifecycle = {
637
+ stopped: false,
638
+ retryTimer: void 0,
639
+ activeTeardown: null
640
+ };
641
+ const teardown = () => {
642
+ if (lifecycle.stopped) return;
643
+ lifecycle.stopped = true;
644
+ if (lifecycle.retryTimer) {
645
+ clearTimeout(lifecycle.retryTimer);
646
+ lifecycle.retryTimer = void 0;
647
+ }
648
+ lifecycle.activeTeardown?.();
649
+ };
650
+ void subscribeWithRetry(options, lifecycle);
651
+ return teardown;
652
+ }
653
+ async function subscribeWithRetry(options, lifecycle) {
654
+ const { api, brokerId, format, maxFps, tag, logger } = options;
655
+ let backoffMs = INITIAL_SUBSCRIBE_RETRY_BACKOFF_MS;
656
+ let attempt = 0;
657
+ while (!lifecycle.stopped) {
658
+ attempt += 1;
659
+ try {
660
+ const result = await api.streamBroker.subscribeFrames.mutate({
661
+ brokerId,
662
+ format,
663
+ maxFps,
664
+ tag
665
+ });
666
+ if (lifecycle.stopped) {
667
+ await api.streamBroker.unsubscribeFrames.mutate({ subscriptionId: result.subscriptionId }).catch((err) => {
668
+ logger.warn("frame-handle poller: late unsubscribe failed", {
669
+ meta: { brokerId, subscriptionId: result.subscriptionId, error: index.errMsg(err) }
670
+ });
671
+ });
672
+ return;
673
+ }
674
+ lifecycle.activeTeardown = startPolling(options, result.subscriptionId, result.maxFps, lifecycle);
675
+ return;
676
+ } catch (err) {
677
+ if (lifecycle.stopped) return;
678
+ if (attempt === 1) {
679
+ logger.warn("frame-handle poller: subscribeFrames failed, retrying", {
680
+ meta: { brokerId, format, tag, error: index.errMsg(err), nextRetryInMs: backoffMs }
681
+ });
682
+ } else {
683
+ logger.debug("frame-handle poller: subscribeFrames still failing", {
684
+ meta: { brokerId, format, tag, attempt, error: index.errMsg(err), nextRetryInMs: backoffMs }
685
+ });
686
+ }
687
+ await sleep(backoffMs, lifecycle);
688
+ backoffMs = Math.min(MAX_SUBSCRIBE_RETRY_BACKOFF_MS, backoffMs * 2);
689
+ }
625
690
  }
626
- const { subscriptionId } = result;
691
+ }
692
+ function sleep(ms, lifecycle) {
693
+ return new Promise((resolve) => {
694
+ lifecycle.retryTimer = setTimeout(() => {
695
+ lifecycle.retryTimer = void 0;
696
+ resolve();
697
+ }, ms);
698
+ });
699
+ }
700
+ function startPolling(options, subscriptionId, resolvedMaxFps, lifecycle) {
701
+ const { api, brokerId, onFrame, logger } = options;
627
702
  const readers = new shmRing.FrameRingReaderCache(logger);
628
- const pollIntervalMs = result.maxFps > 0 ? Math.max(MIN_POLL_INTERVAL_MS, Math.round(1e3 / result.maxFps)) : FALLBACK_POLL_INTERVAL_MS;
629
- let stopped = false;
703
+ const pollIntervalMs = resolvedMaxFps > 0 ? Math.max(MIN_POLL_INTERVAL_MS, Math.round(1e3 / resolvedMaxFps)) : FALLBACK_POLL_INTERVAL_MS;
630
704
  let timer;
631
705
  const tick = async () => {
632
- if (stopped) return;
706
+ if (lifecycle.stopped) return;
633
707
  try {
634
708
  const handles = await api.streamBroker.pullFrameHandles.query({
635
709
  subscriptionId,
636
710
  maxCount: PULL_MAX_COUNT
637
711
  });
638
712
  for (const handle of handles) {
639
- if (stopped) break;
713
+ if (lifecycle.stopped) break;
640
714
  const frame = readers.read(handle);
641
715
  if (frame) onFrame(frame, handle);
642
716
  }
@@ -645,14 +719,12 @@ async function startFrameHandlePoller(options) {
645
719
  meta: { brokerId, subscriptionId, error: index.errMsg(err) }
646
720
  });
647
721
  }
648
- if (!stopped) {
722
+ if (!lifecycle.stopped) {
649
723
  timer = setTimeout(() => void tick(), pollIntervalMs);
650
724
  }
651
725
  };
652
726
  void tick();
653
727
  return () => {
654
- if (stopped) return;
655
- stopped = true;
656
728
  if (timer) {
657
729
  clearTimeout(timer);
658
730
  timer = void 0;
@@ -754,6 +826,11 @@ const DEFAULT_CONFIG = {
754
826
  targetLoadPercent: 80,
755
827
  minThrottledFps: 1
756
828
  };
829
+ function shouldStartOnboardAnalyzer(config) {
830
+ if (!config.onboardMotionDrivesAnalyzer) return false;
831
+ if (config.motionSources.includes("analyzer")) return false;
832
+ return true;
833
+ }
757
834
  function toFrameInput(frame) {
758
835
  return {
759
836
  data: frame.data,
@@ -763,14 +840,12 @@ function toFrameInput(frame) {
763
840
  timestamp: frame.timestamp
764
841
  };
765
842
  }
766
- const STEP_LOG_INTERVAL_MS = 3e4;
767
843
  const METRICS_SNAPSHOT_INTERVAL_MS = 1e3;
768
844
  const METRICS_HEARTBEAT_MS = 3e4;
769
845
  class PipelineRunnerAddon extends index.BaseAddon {
770
846
  runner = null;
771
847
  attached = /* @__PURE__ */ new Map();
772
848
  nodeId = "unknown";
773
- stepLogTimer = null;
774
849
  metricsSnapshotTimer = null;
775
850
  unsubMotionEvents = null;
776
851
  /** Last analyzer-detected state per device — gates the
@@ -784,6 +859,20 @@ class PipelineRunnerAddon extends index.BaseAddon {
784
859
  * detach.
785
860
  */
786
861
  lastMotionAt = /* @__PURE__ */ new Map();
862
+ /**
863
+ * Dynamic analyzer subscriptions opened on `MotionOnMotionChanged
864
+ * source:'onboard'` when `onboardMotionDrivesAnalyzer === true`. Each
865
+ * entry is the unsubscribe handle returned by `subscribeMotionFrames`.
866
+ * Cleared on teardown timer fire, detach, and shutdown.
867
+ */
868
+ onboardAnalyzerSubs = /* @__PURE__ */ new Map();
869
+ /**
870
+ * Teardown timers that close the dynamic analyzer subscription after
871
+ * `motionCooldownMs` without a new motion event. Re-armed on every
872
+ * `MotionOnMotionChanged source:'onboard'` call so the sub stays open
873
+ * while motion persists.
874
+ */
875
+ onboardAnalyzerTeardownTimers = /* @__PURE__ */ new Map();
787
876
  /**
788
877
  * Snapshot-equality cache for metrics-snapshot defer. The runner
789
878
  * fires per-camera metrics every `METRICS_SNAPSHOT_INTERVAL_MS`;
@@ -848,6 +937,9 @@ class PipelineRunnerAddon extends index.BaseAddon {
848
937
  const attachment = this.attached.get(deviceId);
849
938
  if (!attachment) return;
850
939
  const source = data.source;
940
+ if (source === "onboard") {
941
+ void this.handleOnboardMotionAnalyzer(deviceId, data.detected);
942
+ }
851
943
  if (!attachment.config.motionSources.includes(source)) return;
852
944
  this.runner?.reportMotion(
853
945
  deviceId,
@@ -858,7 +950,6 @@ class PipelineRunnerAddon extends index.BaseAddon {
858
950
  }
859
951
  );
860
952
  }
861
- this.stepLogTimer = setInterval(() => this.logAttachedSteps(), STEP_LOG_INTERVAL_MS);
862
953
  this.metricsSnapshotTimer = setInterval(
863
954
  () => this.emitMetricsSnapshot(),
864
955
  METRICS_SNAPSHOT_INTERVAL_MS
@@ -878,10 +969,6 @@ class PipelineRunnerAddon extends index.BaseAddon {
878
969
  clearInterval(this.metricsSnapshotTimer);
879
970
  this.metricsSnapshotTimer = null;
880
971
  }
881
- if (this.stepLogTimer) {
882
- clearInterval(this.stepLogTimer);
883
- this.stepLogTimer = null;
884
- }
885
972
  if (this.benchFrameSweeper) {
886
973
  clearInterval(this.benchFrameSweeper);
887
974
  this.benchFrameSweeper = null;
@@ -892,6 +979,9 @@ class PipelineRunnerAddon extends index.BaseAddon {
892
979
  this.unsubMotionEvents = null;
893
980
  }
894
981
  this.lastAnalyzerDetected.clear();
982
+ for (const deviceId of [...this.onboardAnalyzerTeardownTimers.keys(), ...this.onboardAnalyzerSubs.keys()]) {
983
+ this.clearOnboardAnalyzer(deviceId);
984
+ }
895
985
  if (this.runner) {
896
986
  this.runner.stop();
897
987
  this.runner = null;
@@ -1278,54 +1368,10 @@ class PipelineRunnerAddon extends index.BaseAddon {
1278
1368
  this.runner?.reportMotion(input.deviceId, input.detected, input.source, input.regions);
1279
1369
  return { success: true };
1280
1370
  }
1281
- /**
1282
- * Periodic per-camera step roster dump. Once every
1283
- * STEP_LOG_INTERVAL_MS (30s) emits one log line per attached camera
1284
- * with the configured detection step tree + audio classifier branch
1285
- * so an operator looking at the agent log can quickly see what each
1286
- * camera is currently running without crossing tRPC. Skips when no
1287
- * cameras are attached so quiet dev runs stay silent.
1288
- */
1289
- logAttachedSteps() {
1290
- if (this.attached.size === 0) return;
1291
- for (const [deviceId, attachment] of this.attached) {
1292
- const cfg = attachment.config;
1293
- const detectionSteps = cfg.steps && cfg.steps.length > 0 ? this.flattenSteps(cfg.steps).filter((s) => s.enabled) : [];
1294
- const detectionLabel = detectionSteps.length > 0 ? detectionSteps.map((s) => `${s.addonId}/${s.modelId}`).join(" → ") : "<none>";
1295
- const audioLabel = cfg.audio && cfg.audio.enabled ? `${cfg.audio.engine.runtime}/${cfg.audio.engine.backend}/${cfg.audio.modelId}` : "<off>";
1296
- const engineLabel = cfg.engine ? `${cfg.engine.runtime}/${cfg.engine.backend}${cfg.engine.device ? `/${cfg.engine.device}` : ""}` : "<unset>";
1297
- this.ctx.logger.info("Camera pipeline roster", {
1298
- tags: { deviceId },
1299
- meta: {
1300
- phase: "roster",
1301
- intervalSec: STEP_LOG_INTERVAL_MS / 1e3,
1302
- pipelineEnabled: cfg.pipelineEnabled,
1303
- motionSources: cfg.motionSources,
1304
- motionFps: cfg.motionFps,
1305
- detectionFps: cfg.detectionFps,
1306
- engine: engineLabel,
1307
- videoSteps: detectionLabel,
1308
- videoStepCount: detectionSteps.length,
1309
- audio: audioLabel
1310
- }
1311
- });
1312
- }
1313
- }
1314
- /** Recursively flatten the step tree → ordered list of every node. */
1315
- flattenSteps(steps) {
1316
- const out = [];
1317
- const walk = (s) => {
1318
- out.push(s);
1319
- if (s.children) {
1320
- for (const c of s.children) walk(c);
1321
- }
1322
- };
1323
- for (const s of steps) walk(s);
1324
- return out;
1325
- }
1326
1371
  detachInternal(deviceId) {
1327
1372
  const attachment = this.attached.get(deviceId);
1328
1373
  if (!attachment) return;
1374
+ this.clearOnboardAnalyzer(deviceId);
1329
1375
  attachment.motionUnsubscribe?.();
1330
1376
  attachment.detectionUnsubscribe?.();
1331
1377
  this.attached.delete(deviceId);
@@ -1334,6 +1380,70 @@ class PipelineRunnerAddon extends index.BaseAddon {
1334
1380
  this.runner?.unregisterCamera(deviceId);
1335
1381
  this.ctx?.logger.info("detachCamera", { tags: { deviceId } });
1336
1382
  }
1383
+ /**
1384
+ * Synchronously cancel the teardown timer and call the unsubscribe
1385
+ * handle for the dynamic onboard analyzer, if one is open. Safe to
1386
+ * call when no subscription exists.
1387
+ */
1388
+ clearOnboardAnalyzer(deviceId) {
1389
+ const timer = this.onboardAnalyzerTeardownTimers.get(deviceId);
1390
+ if (timer !== void 0) {
1391
+ clearTimeout(timer);
1392
+ this.onboardAnalyzerTeardownTimers.delete(deviceId);
1393
+ }
1394
+ const unsub = this.onboardAnalyzerSubs.get(deviceId);
1395
+ if (unsub !== void 0) {
1396
+ try {
1397
+ unsub();
1398
+ } catch {
1399
+ }
1400
+ this.onboardAnalyzerSubs.delete(deviceId);
1401
+ }
1402
+ }
1403
+ /**
1404
+ * Dynamic analyzer gate for onboard-motion cameras.
1405
+ *
1406
+ * Called from the `MotionOnMotionChanged` subscriber whenever
1407
+ * `source === 'onboard'`. Opens a `subscribeMotionFrames` subscription
1408
+ * the first time motion is detected (idempotent — a second `detected:true`
1409
+ * while the sub is already open is a no-op). Always re-arms the teardown
1410
+ * timer so the subscription stays open as long as motion events keep
1411
+ * arriving and tears down `motionCooldownMs` after the last event.
1412
+ *
1413
+ * No-op when:
1414
+ * - The camera is not currently attached.
1415
+ * - `shouldStartOnboardAnalyzer(config)` returns false (flag off or
1416
+ * `motionSources` already includes `'analyzer'`).
1417
+ */
1418
+ async handleOnboardMotionAnalyzer(deviceId, detected) {
1419
+ const attachment = this.attached.get(deviceId);
1420
+ if (!attachment) return;
1421
+ const config = attachment.config;
1422
+ if (!shouldStartOnboardAnalyzer(config)) return;
1423
+ const log = this.ctx.logger.withTags({ deviceId });
1424
+ if (detected && !this.onboardAnalyzerSubs.has(deviceId)) {
1425
+ const unsub = await this.subscribeMotionFrames(config);
1426
+ if (unsub) {
1427
+ this.onboardAnalyzerSubs.set(deviceId, unsub);
1428
+ log.debug("onboard-analyzer: opened motion-frame subscription");
1429
+ }
1430
+ }
1431
+ const existing = this.onboardAnalyzerTeardownTimers.get(deviceId);
1432
+ if (existing !== void 0) clearTimeout(existing);
1433
+ const cooldownMs = config.motionCooldownMs;
1434
+ const timer = setTimeout(() => {
1435
+ this.onboardAnalyzerTeardownTimers.delete(deviceId);
1436
+ const unsub = this.onboardAnalyzerSubs.get(deviceId);
1437
+ if (!unsub) return;
1438
+ try {
1439
+ unsub();
1440
+ } catch {
1441
+ }
1442
+ this.onboardAnalyzerSubs.delete(deviceId);
1443
+ log.debug("onboard-analyzer: closed after motion cooldown", { meta: { cooldownMs } });
1444
+ }, cooldownMs);
1445
+ this.onboardAnalyzerTeardownTimers.set(deviceId, timer);
1446
+ }
1337
1447
  async getLocalLoad() {
1338
1448
  const metrics = this.runner?.getMetrics() ?? { avgInferenceTimeMs: 0, queueDepth: 0 };
1339
1449
  const allCameraMetrics = this.runner?.getAllCameraMetrics() ?? [];
@@ -1382,7 +1492,7 @@ class PipelineRunnerAddon extends index.BaseAddon {
1382
1492
  }
1383
1493
  return startFrameHandlePoller({
1384
1494
  api,
1385
- brokerId: `${config.deviceId}/${config.motionStreamId}`,
1495
+ brokerId: index.makeSourceBrokerId(config.deviceId, config.motionStreamId),
1386
1496
  format: "gray",
1387
1497
  maxFps: config.motionFps,
1388
1498
  tag: "motion",
@@ -1468,7 +1578,7 @@ class PipelineRunnerAddon extends index.BaseAddon {
1468
1578
  }
1469
1579
  return startFrameHandlePoller({
1470
1580
  api,
1471
- brokerId: `${config.deviceId}/${config.detectionStreamId}`,
1581
+ brokerId: index.makeSourceBrokerId(config.deviceId, config.detectionStreamId),
1472
1582
  format: "rgb",
1473
1583
  maxFps: config.detectionFps,
1474
1584
  tag: "detection",
@@ -1590,20 +1700,24 @@ class PipelineRunnerAddon extends index.BaseAddon {
1590
1700
  zonesPayload
1591
1701
  ));
1592
1702
  }
1593
- runner.timingSampler.addMotionSample(deviceId, Date.now() - motionStart);
1703
+ const capturedAt = frame.capturedAt;
1704
+ const motionFrameAge = typeof capturedAt === "number" && capturedAt > 0 ? motionStart - capturedAt : -1;
1705
+ runner.timingSampler.addMotionSample(deviceId, Date.now() - motionStart, motionFrameAge);
1594
1706
  } catch (error) {
1595
1707
  const msg = index.errMsg(error);
1596
1708
  log.error("runMotionAnalysis failed", { meta: { error: msg } });
1597
1709
  }
1598
1710
  }
1599
- emitInferenceResult(deviceId, _frame, result, handle) {
1711
+ emitInferenceResult(deviceId, frame, result, handle) {
1600
1712
  const ctx = this.ctx;
1601
1713
  if (!ctx?.eventBus) return;
1714
+ const capturedAt = frame.capturedAt;
1602
1715
  const payload = {
1603
1716
  deviceId,
1604
1717
  frame: result,
1605
1718
  nodeId: this.nodeId,
1606
- frameHandle: handle
1719
+ frameHandle: handle,
1720
+ ...typeof capturedAt === "number" && capturedAt > 0 ? { capturedAt } : {}
1607
1721
  };
1608
1722
  this.ctx.eventBus.emit(index.createEvent(
1609
1723
  index.EventCategory.PipelineInferenceResult,
@@ -1744,4 +1858,5 @@ exports.PipelineTimingSampler = PipelineTimingSampler;
1744
1858
  exports.Semaphore = Semaphore;
1745
1859
  exports.customActions = pipelineRunnerBenchActions;
1746
1860
  exports.default = PipelineRunnerAddon;
1861
+ exports.shouldStartOnboardAnalyzer = shouldStartOnboardAnalyzer;
1747
1862
  //# sourceMappingURL=index.js.map