@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
@@ -1,4 +1,4 @@
1
- import { e as errMsg, o as object, s as string, _ as _enum, l as lazy, a as array, b as boolean, n as number, d as defineCustomActions, c as customAction, B as BaseAddon, E as EventCategory, p as pipelineRunnerCapability, f as createEvent } from "../index-asZs8U_s.mjs";
1
+ import { e as errMsg, o as object, s as string, _ as _enum, l as lazy, a as array, b as boolean, n as number, d as defineCustomActions, c as customAction, B as BaseAddon, E as EventCategory, p as pipelineRunnerCapability, m as makeSourceBrokerId, f as createEvent } from "../index-5aYef068.mjs";
2
2
  import { FrameRingReaderCache } from "@camstack/shm-ring";
3
3
  class FrameQueue {
4
4
  /**
@@ -113,9 +113,9 @@ class PipelineTimingSampler {
113
113
  if (!this.detSamples.has(deviceId)) this.detSamples.set(deviceId, []);
114
114
  this.detSamples.get(deviceId).push(s);
115
115
  }
116
- addMotionSample(deviceId, ms) {
116
+ addMotionSample(deviceId, ms, frameAge = -1) {
117
117
  if (!this.motSamples.has(deviceId)) this.motSamples.set(deviceId, []);
118
- this.motSamples.get(deviceId).push(ms);
118
+ this.motSamples.get(deviceId).push({ ms, frameAge });
119
119
  }
120
120
  addAudioSample(deviceId, s) {
121
121
  if (!this.audioSamples.has(deviceId)) this.audioSamples.set(deviceId, []);
@@ -140,6 +140,7 @@ class PipelineTimingSampler {
140
140
  if (det.length === 0) continue;
141
141
  const e2e = det.map((s) => s.endToEnd);
142
142
  const inf = det.map((s) => s.inference);
143
+ const frameAge = det.map((s) => s.frameAge).filter((v) => v >= 0);
143
144
  const totalDet = det.reduce((s, d) => s + d.detections, 0);
144
145
  this.log.info(
145
146
  "pipeline stats",
@@ -148,7 +149,20 @@ class PipelineTimingSampler {
148
149
  meta: {
149
150
  frames: det.length,
150
151
  intervalSec: REPORT_INTERVAL_MS / 1e3,
152
+ // enqueue → emit, in ms. Stage breakdown (avg) exposes WHERE the
153
+ // latency sits: queue backlog vs semaphore contention vs inference
154
+ // vs result-emit. inference is usually the floor (model chain).
151
155
  e2e: { avg: avg(e2e), p95: p95(e2e), max: max(e2e) },
156
+ // Frame age (capture→inference-pick): if large, the analyzed frame is
157
+ // already stale (decoder/ring behind), which delays the overlay
158
+ // regardless of how fast the result is delivered.
159
+ frameAge: { avg: avg(frameAge), p95: p95(frameAge), max: max(frameAge) },
160
+ stagesMs: {
161
+ queueWait: avg(det.map((s) => s.queueWait)),
162
+ semaphoreWait: avg(det.map((s) => s.semaphoreWait)),
163
+ inference: avg(inf),
164
+ resultToEmit: avg(det.map((s) => s.resultToEmit))
165
+ },
152
166
  inference: { avg: avg(inf), p95: p95(inf) },
153
167
  detections: totalDet,
154
168
  dropped,
@@ -161,6 +175,8 @@ class PipelineTimingSampler {
161
175
  this.detSamples.clear();
162
176
  for (const [deviceId, mot] of this.motSamples) {
163
177
  if (mot.length === 0) continue;
178
+ const ms = mot.map((s) => s.ms);
179
+ const frameAge = mot.map((s) => s.frameAge).filter((v) => v >= 0);
164
180
  this.log.info(
165
181
  "motion stats",
166
182
  {
@@ -168,10 +184,12 @@ class PipelineTimingSampler {
168
184
  meta: {
169
185
  frames: mot.length,
170
186
  intervalSec: REPORT_INTERVAL_MS / 1e3,
171
- avg: avg(mot),
172
- p95: p95(mot),
173
- max: max(mot)
174
- // motionAddon: rt.motionAddon ?? null,
187
+ avg: avg(ms),
188
+ p95: p95(ms),
189
+ max: max(ms),
190
+ // Frame age at motion analysis (capture→analysis). Large = stale
191
+ // input frame (decoder/ring behind) → motion box lags real movement.
192
+ frameAge: { avg: avg(frameAge), p95: p95(frameAge), max: max(frameAge) }
175
193
  }
176
194
  }
177
195
  );
@@ -517,8 +535,7 @@ class PipelineRunner {
517
535
  async processWithSemaphore(deviceId, entry, frameInput, state, streamType) {
518
536
  const pickedAt = Date.now();
519
537
  const { frame, handle } = entry;
520
- const captureTs = frame.timestamp;
521
- const enqueuedAt = frame._enqueuedAt ?? captureTs;
538
+ const enqueuedAt = frame._enqueuedAt ?? pickedAt;
522
539
  const release = await this.semaphore.acquire();
523
540
  const semAcquiredAt = Date.now();
524
541
  try {
@@ -533,13 +550,19 @@ class PipelineRunner {
533
550
  if (result) {
534
551
  await this.notifyCallbacks(deviceId, frame, result, streamType, handle);
535
552
  const emittedAt = Date.now();
553
+ const capturedAt = frame.capturedAt;
536
554
  this.timingSampler.addSample(deviceId, {
537
- captureToEnqueue: enqueuedAt - captureTs,
538
555
  queueWait: pickedAt - enqueuedAt,
539
556
  semaphoreWait: semAcquiredAt - pickedAt,
540
557
  inference: inferenceMs,
541
558
  resultToEmit: emittedAt - inferDoneAt,
542
- endToEnd: emittedAt - captureTs,
559
+ // capturedAt is the shm-ring commit wall-clock; pickedAt − capturedAt
560
+ // is how stale the frame was when inference started. <0/absent → unknown.
561
+ frameAge: typeof capturedAt === "number" && capturedAt > 0 ? pickedAt - capturedAt : -1,
562
+ // Wall-clock pipeline latency: enqueue → result emitted. (Capture →
563
+ // enqueue, i.e. RTSP decode + shm-ring write, is not measured here —
564
+ // the shm frame carries only a PTS, no wall-clock capture stamp.)
565
+ endToEnd: emittedAt - enqueuedAt,
543
566
  detections: result.detections?.length ?? 0
544
567
  });
545
568
  }
@@ -583,36 +606,87 @@ class PipelineRunner {
583
606
  const PULL_MAX_COUNT = 4;
584
607
  const MIN_POLL_INTERVAL_MS = 20;
585
608
  const FALLBACK_POLL_INTERVAL_MS = 200;
609
+ const INITIAL_SUBSCRIBE_RETRY_BACKOFF_MS = 250;
610
+ const MAX_SUBSCRIBE_RETRY_BACKOFF_MS = 5e3;
586
611
  async function startFrameHandlePoller(options) {
587
- const { api, brokerId, format, maxFps, tag, onFrame, logger } = options;
588
- let result;
589
- try {
590
- result = await api.streamBroker.subscribeFrames.mutate({
591
- brokerId,
592
- format,
593
- maxFps,
594
- tag
595
- });
596
- } catch (err) {
597
- logger.warn("frame-handle poller: subscribeFrames failed", {
598
- meta: { brokerId, format, tag, error: errMsg(err) }
599
- });
600
- return null;
612
+ const lifecycle = {
613
+ stopped: false,
614
+ retryTimer: void 0,
615
+ activeTeardown: null
616
+ };
617
+ const teardown = () => {
618
+ if (lifecycle.stopped) return;
619
+ lifecycle.stopped = true;
620
+ if (lifecycle.retryTimer) {
621
+ clearTimeout(lifecycle.retryTimer);
622
+ lifecycle.retryTimer = void 0;
623
+ }
624
+ lifecycle.activeTeardown?.();
625
+ };
626
+ void subscribeWithRetry(options, lifecycle);
627
+ return teardown;
628
+ }
629
+ async function subscribeWithRetry(options, lifecycle) {
630
+ const { api, brokerId, format, maxFps, tag, logger } = options;
631
+ let backoffMs = INITIAL_SUBSCRIBE_RETRY_BACKOFF_MS;
632
+ let attempt = 0;
633
+ while (!lifecycle.stopped) {
634
+ attempt += 1;
635
+ try {
636
+ const result = await api.streamBroker.subscribeFrames.mutate({
637
+ brokerId,
638
+ format,
639
+ maxFps,
640
+ tag
641
+ });
642
+ if (lifecycle.stopped) {
643
+ await api.streamBroker.unsubscribeFrames.mutate({ subscriptionId: result.subscriptionId }).catch((err) => {
644
+ logger.warn("frame-handle poller: late unsubscribe failed", {
645
+ meta: { brokerId, subscriptionId: result.subscriptionId, error: errMsg(err) }
646
+ });
647
+ });
648
+ return;
649
+ }
650
+ lifecycle.activeTeardown = startPolling(options, result.subscriptionId, result.maxFps, lifecycle);
651
+ return;
652
+ } catch (err) {
653
+ if (lifecycle.stopped) return;
654
+ if (attempt === 1) {
655
+ logger.warn("frame-handle poller: subscribeFrames failed, retrying", {
656
+ meta: { brokerId, format, tag, error: errMsg(err), nextRetryInMs: backoffMs }
657
+ });
658
+ } else {
659
+ logger.debug("frame-handle poller: subscribeFrames still failing", {
660
+ meta: { brokerId, format, tag, attempt, error: errMsg(err), nextRetryInMs: backoffMs }
661
+ });
662
+ }
663
+ await sleep(backoffMs, lifecycle);
664
+ backoffMs = Math.min(MAX_SUBSCRIBE_RETRY_BACKOFF_MS, backoffMs * 2);
665
+ }
601
666
  }
602
- const { subscriptionId } = result;
667
+ }
668
+ function sleep(ms, lifecycle) {
669
+ return new Promise((resolve) => {
670
+ lifecycle.retryTimer = setTimeout(() => {
671
+ lifecycle.retryTimer = void 0;
672
+ resolve();
673
+ }, ms);
674
+ });
675
+ }
676
+ function startPolling(options, subscriptionId, resolvedMaxFps, lifecycle) {
677
+ const { api, brokerId, onFrame, logger } = options;
603
678
  const readers = new FrameRingReaderCache(logger);
604
- const pollIntervalMs = result.maxFps > 0 ? Math.max(MIN_POLL_INTERVAL_MS, Math.round(1e3 / result.maxFps)) : FALLBACK_POLL_INTERVAL_MS;
605
- let stopped = false;
679
+ const pollIntervalMs = resolvedMaxFps > 0 ? Math.max(MIN_POLL_INTERVAL_MS, Math.round(1e3 / resolvedMaxFps)) : FALLBACK_POLL_INTERVAL_MS;
606
680
  let timer;
607
681
  const tick = async () => {
608
- if (stopped) return;
682
+ if (lifecycle.stopped) return;
609
683
  try {
610
684
  const handles = await api.streamBroker.pullFrameHandles.query({
611
685
  subscriptionId,
612
686
  maxCount: PULL_MAX_COUNT
613
687
  });
614
688
  for (const handle of handles) {
615
- if (stopped) break;
689
+ if (lifecycle.stopped) break;
616
690
  const frame = readers.read(handle);
617
691
  if (frame) onFrame(frame, handle);
618
692
  }
@@ -621,14 +695,12 @@ async function startFrameHandlePoller(options) {
621
695
  meta: { brokerId, subscriptionId, error: errMsg(err) }
622
696
  });
623
697
  }
624
- if (!stopped) {
698
+ if (!lifecycle.stopped) {
625
699
  timer = setTimeout(() => void tick(), pollIntervalMs);
626
700
  }
627
701
  };
628
702
  void tick();
629
703
  return () => {
630
- if (stopped) return;
631
- stopped = true;
632
704
  if (timer) {
633
705
  clearTimeout(timer);
634
706
  timer = void 0;
@@ -730,6 +802,11 @@ const DEFAULT_CONFIG = {
730
802
  targetLoadPercent: 80,
731
803
  minThrottledFps: 1
732
804
  };
805
+ function shouldStartOnboardAnalyzer(config) {
806
+ if (!config.onboardMotionDrivesAnalyzer) return false;
807
+ if (config.motionSources.includes("analyzer")) return false;
808
+ return true;
809
+ }
733
810
  function toFrameInput(frame) {
734
811
  return {
735
812
  data: frame.data,
@@ -739,14 +816,12 @@ function toFrameInput(frame) {
739
816
  timestamp: frame.timestamp
740
817
  };
741
818
  }
742
- const STEP_LOG_INTERVAL_MS = 3e4;
743
819
  const METRICS_SNAPSHOT_INTERVAL_MS = 1e3;
744
820
  const METRICS_HEARTBEAT_MS = 3e4;
745
821
  class PipelineRunnerAddon extends BaseAddon {
746
822
  runner = null;
747
823
  attached = /* @__PURE__ */ new Map();
748
824
  nodeId = "unknown";
749
- stepLogTimer = null;
750
825
  metricsSnapshotTimer = null;
751
826
  unsubMotionEvents = null;
752
827
  /** Last analyzer-detected state per device — gates the
@@ -760,6 +835,20 @@ class PipelineRunnerAddon extends BaseAddon {
760
835
  * detach.
761
836
  */
762
837
  lastMotionAt = /* @__PURE__ */ new Map();
838
+ /**
839
+ * Dynamic analyzer subscriptions opened on `MotionOnMotionChanged
840
+ * source:'onboard'` when `onboardMotionDrivesAnalyzer === true`. Each
841
+ * entry is the unsubscribe handle returned by `subscribeMotionFrames`.
842
+ * Cleared on teardown timer fire, detach, and shutdown.
843
+ */
844
+ onboardAnalyzerSubs = /* @__PURE__ */ new Map();
845
+ /**
846
+ * Teardown timers that close the dynamic analyzer subscription after
847
+ * `motionCooldownMs` without a new motion event. Re-armed on every
848
+ * `MotionOnMotionChanged source:'onboard'` call so the sub stays open
849
+ * while motion persists.
850
+ */
851
+ onboardAnalyzerTeardownTimers = /* @__PURE__ */ new Map();
763
852
  /**
764
853
  * Snapshot-equality cache for metrics-snapshot defer. The runner
765
854
  * fires per-camera metrics every `METRICS_SNAPSHOT_INTERVAL_MS`;
@@ -824,6 +913,9 @@ class PipelineRunnerAddon extends BaseAddon {
824
913
  const attachment = this.attached.get(deviceId);
825
914
  if (!attachment) return;
826
915
  const source = data.source;
916
+ if (source === "onboard") {
917
+ void this.handleOnboardMotionAnalyzer(deviceId, data.detected);
918
+ }
827
919
  if (!attachment.config.motionSources.includes(source)) return;
828
920
  this.runner?.reportMotion(
829
921
  deviceId,
@@ -834,7 +926,6 @@ class PipelineRunnerAddon extends BaseAddon {
834
926
  }
835
927
  );
836
928
  }
837
- this.stepLogTimer = setInterval(() => this.logAttachedSteps(), STEP_LOG_INTERVAL_MS);
838
929
  this.metricsSnapshotTimer = setInterval(
839
930
  () => this.emitMetricsSnapshot(),
840
931
  METRICS_SNAPSHOT_INTERVAL_MS
@@ -854,10 +945,6 @@ class PipelineRunnerAddon extends BaseAddon {
854
945
  clearInterval(this.metricsSnapshotTimer);
855
946
  this.metricsSnapshotTimer = null;
856
947
  }
857
- if (this.stepLogTimer) {
858
- clearInterval(this.stepLogTimer);
859
- this.stepLogTimer = null;
860
- }
861
948
  if (this.benchFrameSweeper) {
862
949
  clearInterval(this.benchFrameSweeper);
863
950
  this.benchFrameSweeper = null;
@@ -868,6 +955,9 @@ class PipelineRunnerAddon extends BaseAddon {
868
955
  this.unsubMotionEvents = null;
869
956
  }
870
957
  this.lastAnalyzerDetected.clear();
958
+ for (const deviceId of [...this.onboardAnalyzerTeardownTimers.keys(), ...this.onboardAnalyzerSubs.keys()]) {
959
+ this.clearOnboardAnalyzer(deviceId);
960
+ }
871
961
  if (this.runner) {
872
962
  this.runner.stop();
873
963
  this.runner = null;
@@ -1254,54 +1344,10 @@ class PipelineRunnerAddon extends BaseAddon {
1254
1344
  this.runner?.reportMotion(input.deviceId, input.detected, input.source, input.regions);
1255
1345
  return { success: true };
1256
1346
  }
1257
- /**
1258
- * Periodic per-camera step roster dump. Once every
1259
- * STEP_LOG_INTERVAL_MS (30s) emits one log line per attached camera
1260
- * with the configured detection step tree + audio classifier branch
1261
- * so an operator looking at the agent log can quickly see what each
1262
- * camera is currently running without crossing tRPC. Skips when no
1263
- * cameras are attached so quiet dev runs stay silent.
1264
- */
1265
- logAttachedSteps() {
1266
- if (this.attached.size === 0) return;
1267
- for (const [deviceId, attachment] of this.attached) {
1268
- const cfg = attachment.config;
1269
- const detectionSteps = cfg.steps && cfg.steps.length > 0 ? this.flattenSteps(cfg.steps).filter((s) => s.enabled) : [];
1270
- const detectionLabel = detectionSteps.length > 0 ? detectionSteps.map((s) => `${s.addonId}/${s.modelId}`).join(" → ") : "<none>";
1271
- const audioLabel = cfg.audio && cfg.audio.enabled ? `${cfg.audio.engine.runtime}/${cfg.audio.engine.backend}/${cfg.audio.modelId}` : "<off>";
1272
- const engineLabel = cfg.engine ? `${cfg.engine.runtime}/${cfg.engine.backend}${cfg.engine.device ? `/${cfg.engine.device}` : ""}` : "<unset>";
1273
- this.ctx.logger.info("Camera pipeline roster", {
1274
- tags: { deviceId },
1275
- meta: {
1276
- phase: "roster",
1277
- intervalSec: STEP_LOG_INTERVAL_MS / 1e3,
1278
- pipelineEnabled: cfg.pipelineEnabled,
1279
- motionSources: cfg.motionSources,
1280
- motionFps: cfg.motionFps,
1281
- detectionFps: cfg.detectionFps,
1282
- engine: engineLabel,
1283
- videoSteps: detectionLabel,
1284
- videoStepCount: detectionSteps.length,
1285
- audio: audioLabel
1286
- }
1287
- });
1288
- }
1289
- }
1290
- /** Recursively flatten the step tree → ordered list of every node. */
1291
- flattenSteps(steps) {
1292
- const out = [];
1293
- const walk = (s) => {
1294
- out.push(s);
1295
- if (s.children) {
1296
- for (const c of s.children) walk(c);
1297
- }
1298
- };
1299
- for (const s of steps) walk(s);
1300
- return out;
1301
- }
1302
1347
  detachInternal(deviceId) {
1303
1348
  const attachment = this.attached.get(deviceId);
1304
1349
  if (!attachment) return;
1350
+ this.clearOnboardAnalyzer(deviceId);
1305
1351
  attachment.motionUnsubscribe?.();
1306
1352
  attachment.detectionUnsubscribe?.();
1307
1353
  this.attached.delete(deviceId);
@@ -1310,6 +1356,70 @@ class PipelineRunnerAddon extends BaseAddon {
1310
1356
  this.runner?.unregisterCamera(deviceId);
1311
1357
  this.ctx?.logger.info("detachCamera", { tags: { deviceId } });
1312
1358
  }
1359
+ /**
1360
+ * Synchronously cancel the teardown timer and call the unsubscribe
1361
+ * handle for the dynamic onboard analyzer, if one is open. Safe to
1362
+ * call when no subscription exists.
1363
+ */
1364
+ clearOnboardAnalyzer(deviceId) {
1365
+ const timer = this.onboardAnalyzerTeardownTimers.get(deviceId);
1366
+ if (timer !== void 0) {
1367
+ clearTimeout(timer);
1368
+ this.onboardAnalyzerTeardownTimers.delete(deviceId);
1369
+ }
1370
+ const unsub = this.onboardAnalyzerSubs.get(deviceId);
1371
+ if (unsub !== void 0) {
1372
+ try {
1373
+ unsub();
1374
+ } catch {
1375
+ }
1376
+ this.onboardAnalyzerSubs.delete(deviceId);
1377
+ }
1378
+ }
1379
+ /**
1380
+ * Dynamic analyzer gate for onboard-motion cameras.
1381
+ *
1382
+ * Called from the `MotionOnMotionChanged` subscriber whenever
1383
+ * `source === 'onboard'`. Opens a `subscribeMotionFrames` subscription
1384
+ * the first time motion is detected (idempotent — a second `detected:true`
1385
+ * while the sub is already open is a no-op). Always re-arms the teardown
1386
+ * timer so the subscription stays open as long as motion events keep
1387
+ * arriving and tears down `motionCooldownMs` after the last event.
1388
+ *
1389
+ * No-op when:
1390
+ * - The camera is not currently attached.
1391
+ * - `shouldStartOnboardAnalyzer(config)` returns false (flag off or
1392
+ * `motionSources` already includes `'analyzer'`).
1393
+ */
1394
+ async handleOnboardMotionAnalyzer(deviceId, detected) {
1395
+ const attachment = this.attached.get(deviceId);
1396
+ if (!attachment) return;
1397
+ const config = attachment.config;
1398
+ if (!shouldStartOnboardAnalyzer(config)) return;
1399
+ const log = this.ctx.logger.withTags({ deviceId });
1400
+ if (detected && !this.onboardAnalyzerSubs.has(deviceId)) {
1401
+ const unsub = await this.subscribeMotionFrames(config);
1402
+ if (unsub) {
1403
+ this.onboardAnalyzerSubs.set(deviceId, unsub);
1404
+ log.debug("onboard-analyzer: opened motion-frame subscription");
1405
+ }
1406
+ }
1407
+ const existing = this.onboardAnalyzerTeardownTimers.get(deviceId);
1408
+ if (existing !== void 0) clearTimeout(existing);
1409
+ const cooldownMs = config.motionCooldownMs;
1410
+ const timer = setTimeout(() => {
1411
+ this.onboardAnalyzerTeardownTimers.delete(deviceId);
1412
+ const unsub = this.onboardAnalyzerSubs.get(deviceId);
1413
+ if (!unsub) return;
1414
+ try {
1415
+ unsub();
1416
+ } catch {
1417
+ }
1418
+ this.onboardAnalyzerSubs.delete(deviceId);
1419
+ log.debug("onboard-analyzer: closed after motion cooldown", { meta: { cooldownMs } });
1420
+ }, cooldownMs);
1421
+ this.onboardAnalyzerTeardownTimers.set(deviceId, timer);
1422
+ }
1313
1423
  async getLocalLoad() {
1314
1424
  const metrics = this.runner?.getMetrics() ?? { avgInferenceTimeMs: 0, queueDepth: 0 };
1315
1425
  const allCameraMetrics = this.runner?.getAllCameraMetrics() ?? [];
@@ -1358,7 +1468,7 @@ class PipelineRunnerAddon extends BaseAddon {
1358
1468
  }
1359
1469
  return startFrameHandlePoller({
1360
1470
  api,
1361
- brokerId: `${config.deviceId}/${config.motionStreamId}`,
1471
+ brokerId: makeSourceBrokerId(config.deviceId, config.motionStreamId),
1362
1472
  format: "gray",
1363
1473
  maxFps: config.motionFps,
1364
1474
  tag: "motion",
@@ -1444,7 +1554,7 @@ class PipelineRunnerAddon extends BaseAddon {
1444
1554
  }
1445
1555
  return startFrameHandlePoller({
1446
1556
  api,
1447
- brokerId: `${config.deviceId}/${config.detectionStreamId}`,
1557
+ brokerId: makeSourceBrokerId(config.deviceId, config.detectionStreamId),
1448
1558
  format: "rgb",
1449
1559
  maxFps: config.detectionFps,
1450
1560
  tag: "detection",
@@ -1566,20 +1676,24 @@ class PipelineRunnerAddon extends BaseAddon {
1566
1676
  zonesPayload
1567
1677
  ));
1568
1678
  }
1569
- runner.timingSampler.addMotionSample(deviceId, Date.now() - motionStart);
1679
+ const capturedAt = frame.capturedAt;
1680
+ const motionFrameAge = typeof capturedAt === "number" && capturedAt > 0 ? motionStart - capturedAt : -1;
1681
+ runner.timingSampler.addMotionSample(deviceId, Date.now() - motionStart, motionFrameAge);
1570
1682
  } catch (error) {
1571
1683
  const msg = errMsg(error);
1572
1684
  log.error("runMotionAnalysis failed", { meta: { error: msg } });
1573
1685
  }
1574
1686
  }
1575
- emitInferenceResult(deviceId, _frame, result, handle) {
1687
+ emitInferenceResult(deviceId, frame, result, handle) {
1576
1688
  const ctx = this.ctx;
1577
1689
  if (!ctx?.eventBus) return;
1690
+ const capturedAt = frame.capturedAt;
1578
1691
  const payload = {
1579
1692
  deviceId,
1580
1693
  frame: result,
1581
1694
  nodeId: this.nodeId,
1582
- frameHandle: handle
1695
+ frameHandle: handle,
1696
+ ...typeof capturedAt === "number" && capturedAt > 0 ? { capturedAt } : {}
1583
1697
  };
1584
1698
  this.ctx.eventBus.emit(createEvent(
1585
1699
  EventCategory.PipelineInferenceResult,
@@ -1720,6 +1834,7 @@ export {
1720
1834
  PipelineTimingSampler,
1721
1835
  Semaphore,
1722
1836
  pipelineRunnerBenchActions as customActions,
1723
- PipelineRunnerAddon as default
1837
+ PipelineRunnerAddon as default,
1838
+ shouldStartOnboardAnalyzer
1724
1839
  };
1725
1840
  //# sourceMappingURL=index.mjs.map