@camstack/addon-pipeline 0.1.19 → 0.2.0

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 (96) hide show
  1. package/dist/audio-analyzer/index.js +736 -716
  2. package/dist/audio-analyzer/index.mjs +726 -676
  3. package/dist/audio-codec-nodeav/index.js +304 -461
  4. package/dist/audio-codec-nodeav/index.mjs +300 -462
  5. package/dist/chunk-BdkLduGY.mjs +5 -0
  6. package/dist/chunk-D6vf50IK.js +28 -0
  7. package/dist/codec-runtime-BOk-13PN.js +202 -0
  8. package/dist/codec-runtime-BsqlEjPi.mjs +197 -0
  9. package/dist/constants-B_b0a-6h.mjs +3119 -0
  10. package/dist/{index-D_cl0Qqb.js → constants-D65v6yp6.js} +3107 -2935
  11. package/dist/decoder-nodeav/index.js +1374 -1444
  12. package/dist/decoder-nodeav/index.mjs +1369 -1425
  13. package/dist/detection-pipeline/index.js +6462 -5613
  14. package/dist/detection-pipeline/index.mjs +6451 -5574
  15. package/dist/dist-7ewQjTle.js +22454 -0
  16. package/dist/dist-C5jnNl0n.mjs +22089 -0
  17. package/dist/motion-wasm/index.js +469 -467
  18. package/dist/motion-wasm/index.mjs +464 -446
  19. package/dist/pipeline-runner/index.js +2035 -1836
  20. package/dist/pipeline-runner/index.mjs +2031 -1820
  21. package/dist/recorder/index.js +2097 -0
  22. package/dist/recorder/index.mjs +2095 -0
  23. package/dist/stream-broker/_stub.js +1818 -734
  24. package/dist/stream-broker/_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-D4-DHanK.mjs +156 -0
  25. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Tf-HACFd.mjs +26 -0
  26. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-C9WX5HNw.mjs +26 -0
  27. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_tanstack_mf_1_react_mf_2_query__loadShare__.js-BO7TIbJV.mjs +26 -0
  28. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare__react__loadShare__.js-C9j-2lBe.mjs +26 -0
  29. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__.js-XO0-Pyu6.mjs +26 -0
  30. package/dist/stream-broker/dist-CYZr2fwk.mjs +2726 -0
  31. package/dist/stream-broker/hostInit-Di6vceAU.mjs +129 -0
  32. package/dist/stream-broker/index.js +17837 -12904
  33. package/dist/stream-broker/index.mjs +17826 -12896
  34. package/dist/stream-broker/remoteEntry.js +134 -2973
  35. package/dist/stream-broker/remoteEntry.ssr.js +33 -0
  36. package/dist/stream-broker/virtualExposes-dYNvIwoR.mjs +27 -0
  37. package/dist/stream-broker/virtual_mf-exposes-ssr___mfe_internal__addon_stream_broker_widgets__remoteEntry_js-Cmqfp4i_.mjs +10 -0
  38. package/embed-dist/assets/index-B8VlSD0-.js +150 -0
  39. package/embed-dist/assets/index-ZhDdp1Nd.css +2 -0
  40. package/embed-dist/index.html +13 -0
  41. package/package.json +75 -9
  42. package/wasm/assembly/index.ts +41 -16
  43. package/dist/audio-analyzer/index.js.map +0 -1
  44. package/dist/audio-analyzer/index.mjs.map +0 -1
  45. package/dist/audio-codec-nodeav/index.js.map +0 -1
  46. package/dist/audio-codec-nodeav/index.mjs.map +0 -1
  47. package/dist/decoder-nodeav/index.js.map +0 -1
  48. package/dist/decoder-nodeav/index.mjs.map +0 -1
  49. package/dist/detection-pipeline/index.js.map +0 -1
  50. package/dist/detection-pipeline/index.mjs.map +0 -1
  51. package/dist/index-BbPPvoCx.js +0 -14682
  52. package/dist/index-BbPPvoCx.js.map +0 -1
  53. package/dist/index-Bmlkm0Fd.mjs +0 -14683
  54. package/dist/index-Bmlkm0Fd.mjs.map +0 -1
  55. package/dist/index-D_cl0Qqb.js.map +0 -1
  56. package/dist/index-UbcdLS7a.mjs +0 -5790
  57. package/dist/index-UbcdLS7a.mjs.map +0 -1
  58. package/dist/motion-wasm/index.js.map +0 -1
  59. package/dist/motion-wasm/index.mjs.map +0 -1
  60. package/dist/pipeline-runner/index.js.map +0 -1
  61. package/dist/pipeline-runner/index.mjs.map +0 -1
  62. package/dist/stream-broker/@mf-types/compiled-types/stream-broker/widgets/StreamBrokerPanel.d.ts +0 -21
  63. package/dist/stream-broker/@mf-types/compiled-types/stream-broker/widgets/index.d.ts +0 -13
  64. package/dist/stream-broker/@mf-types/widgets.d.ts +0 -2
  65. package/dist/stream-broker/@mf-types.d.ts +0 -3
  66. package/dist/stream-broker/@mf-types.zip +0 -0
  67. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_sdk__loadShare__.mjs-h5aXOPSA.mjs +0 -12
  68. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.mjs-NjF4kxzW.mjs +0 -19
  69. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.mjs-BAv_5ISf.mjs +0 -20
  70. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_tanstack_mf_1_react_mf_2_query__loadShare__.mjs-U1EUeEPs.mjs +0 -104
  71. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_trpc_mf_1_client__loadShare__.mjs-DeouEaSs.mjs +0 -85
  72. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_trpc_mf_1_react_mf_2_query__loadShare__.mjs-DHUwjbb9.mjs +0 -62
  73. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare__react__loadShare__.mjs-BsB2G7oY.mjs +0 -88
  74. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare__react__loadShare__.mjs_commonjs-proxy-xrRiPUpA.mjs +0 -29
  75. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__.mjs-gBEZsQrp.mjs +0 -36
  76. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare__react_mf_2_dom__loadShare__.mjs-DYEKzzY-.mjs +0 -45
  77. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare__react_mf_2_dom__loadShare__.mjs_commonjs-proxy-C0E2yCzO.mjs +0 -6
  78. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare__react_mf_2_dom_mf_1_client__loadShare__.mjs-DICOtMTl.mjs +0 -34
  79. package/dist/stream-broker/_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CupRlwqG.mjs +0 -156
  80. package/dist/stream-broker/client-NPZqorv9.mjs +0 -9836
  81. package/dist/stream-broker/getErrorShape-BPSzUA7W-TlK8ipWe.mjs +0 -211
  82. package/dist/stream-broker/hostInit-Bh4w7o5_.mjs +0 -168
  83. package/dist/stream-broker/index-2Qp8vT3w.mjs +0 -185
  84. package/dist/stream-broker/index-BBcZvb5t.mjs +0 -435
  85. package/dist/stream-broker/index-CIJue-4t.mjs +0 -37880
  86. package/dist/stream-broker/index-CWkKuNLr.mjs +0 -232
  87. package/dist/stream-broker/index-Cc6QBqMk.mjs +0 -1655
  88. package/dist/stream-broker/index-D_1p2K9B.mjs +0 -2603
  89. package/dist/stream-broker/index-Dy2V7VOm.mjs +0 -14379
  90. package/dist/stream-broker/index-mX3Kgiv1.mjs +0 -725
  91. package/dist/stream-broker/index-xncRG7-x.mjs +0 -2713
  92. package/dist/stream-broker/index.js.map +0 -1
  93. package/dist/stream-broker/index.mjs.map +0 -1
  94. package/dist/stream-broker/jsx-runtime-lb0mH5st.mjs +0 -55
  95. package/dist/stream-broker/schemas-ClCuS4qa.mjs +0 -3594
  96. package/dist/stream-broker/virtualExposes-pCd777Rp.mjs +0 -42
@@ -1,1843 +1,2054 @@
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-Bmlkm0Fd.mjs";
1
+ import { $ as boolean, D as customAction, E as createEvent, I as makeSourceBrokerId, Q as array, W as pipelineRunnerCapability, Z as _enum, i as BaseAddon, it as object, j as errMsg, k as defineCustomActions, ot as string, p as EventCategory, rt as number, tt as lazy } from "../dist-C5jnNl0n.mjs";
2
2
  import { FrameRingReaderCache } from "@camstack/shm-ring";
3
- class FrameQueue {
4
- /**
5
- * `clone` runs on every {@link enqueue} to detach a queue-owned copy from
6
- * the (possibly borrowed) source required by the D9 Task 7b ownership
7
- * contract above. Motion call sites pass {@link ownFrame}; the detection
8
- * path passes a clone that copies its inner frame's pixel buffer.
9
- */
10
- constructor(maxSize, clone) {
11
- this.maxSize = maxSize;
12
- this.clone = clone;
13
- }
14
- latest = null;
15
- _droppedFrames = 0;
16
- clone;
17
- enqueue(item) {
18
- if (this.latest !== null) {
19
- this._droppedFrames++;
20
- }
21
- this.latest = this.clone(item);
22
- }
23
- dequeue() {
24
- const item = this.latest ?? void 0;
25
- this.latest = null;
26
- return item;
27
- }
28
- get size() {
29
- return this.latest !== null ? 1 : 0;
30
- }
31
- get droppedFrames() {
32
- return this._droppedFrames;
33
- }
34
- clear() {
35
- this.latest = null;
36
- }
37
- }
3
+ //#region src/pipeline-runner/frame-queue.ts
4
+ /**
5
+ * Latest-frame-only buffer. Keeps only the most recent item, dropping all
6
+ * older items immediately. This ensures inference always runs on the
7
+ * freshest available frame, never accumulating a backlog regardless of
8
+ * inference latency.
9
+ *
10
+ * ## Buffer ownership (Phase 5 / D9 Task 7b)
11
+ *
12
+ * Frames arrive from the shm frame plane via a `FrameRingReader`, whose
13
+ * `FrameRead.pixels` is **borrowed** — a view onto the reader's reusable
14
+ * scratch buffer, valid only until that reader's next read (see
15
+ * `@camstack/shm-ring`'s `FrameRead` contract). Both queue consumers retain
16
+ * the frame PAST the next read:
17
+ *
18
+ * - the **motion** queue is drained by the scheduler's `tick`, not by the
19
+ * poller that read the frame — and the poller drains a burst of handles
20
+ * per poll, so even handle #2's read would clobber handle #1's pixels;
21
+ * - the **detection** queue is drained asynchronously after a semaphore wait
22
+ * and a 10s–100s ms inference.
23
+ *
24
+ * In both cases holding the borrowed buffer is silent corruption. So
25
+ * `enqueue` runs the queue's injected `clone` function which copies the
26
+ * pixels into queue-owned storage at the retention boundary. The queue is
27
+ * latest-only (capacity 1), so this is exactly one allocation per *queued*
28
+ * frame — bounded by the scheduler/inference cadence, NOT per *read* frame.
29
+ * The high-frequency per-read allocation that failed the D9 perf gate is
30
+ * eliminated; this bounded copy-on-retain is the correct cost.
31
+ *
32
+ * The generic parameter `T` lets the detection path queue a
33
+ * `{ frame, handle }` entry (Task 7 — propagating the `FrameHandle`
34
+ * through to the inference-result event), while the motion path keeps
35
+ * queueing bare `DecodedFrame`s. Each instance provides its own
36
+ * `clone` so the inner pixel buffer is detached at the same retention
37
+ * boundary regardless of the wrapping entry shape.
38
+ */
39
+ var FrameQueue = class {
40
+ maxSize;
41
+ latest = null;
42
+ _droppedFrames = 0;
43
+ clone;
44
+ /**
45
+ * `clone` runs on every {@link enqueue} to detach a queue-owned copy from
46
+ * the (possibly borrowed) source — required by the D9 Task 7b ownership
47
+ * contract above. Motion call sites pass {@link ownFrame}; the detection
48
+ * path passes a clone that copies its inner frame's pixel buffer.
49
+ */
50
+ constructor(maxSize, clone) {
51
+ this.maxSize = maxSize;
52
+ this.clone = clone;
53
+ }
54
+ enqueue(item) {
55
+ if (this.latest !== null) this._droppedFrames++;
56
+ this.latest = this.clone(item);
57
+ }
58
+ dequeue() {
59
+ const item = this.latest ?? void 0;
60
+ this.latest = null;
61
+ return item;
62
+ }
63
+ get size() {
64
+ return this.latest !== null ? 1 : 0;
65
+ }
66
+ get droppedFrames() {
67
+ return this._droppedFrames;
68
+ }
69
+ clear() {
70
+ this.latest = null;
71
+ }
72
+ };
73
+ /**
74
+ * Return a copy of `frame` whose `data` is queue-owned — a detached
75
+ * `Buffer.from` of the (possibly borrowed) source pixels. Every other field is
76
+ * a plain value and is carried through unchanged; any extra non-`DecodedFrame`
77
+ * property the runner pins on a frame (e.g. `_enqueuedAt`) is preserved by the
78
+ * spread so downstream timing accounting is unaffected.
79
+ */
38
80
  function ownFrame(frame) {
39
- return { ...frame, data: Buffer.from(frame.data) };
40
- }
41
- class Semaphore {
42
- _concurrency;
43
- _available;
44
- waiters = [];
45
- constructor(concurrency) {
46
- this._concurrency = concurrency;
47
- this._available = concurrency;
48
- }
49
- get concurrency() {
50
- return this._concurrency;
51
- }
52
- get available() {
53
- return this._available;
54
- }
55
- /**
56
- * Change the concurrency limit at runtime. Growing wakes as many
57
- * pending waiters as possible without exceeding the new headroom;
58
- * shrinking simply caps `_available` to `max(0, _available + delta)`.
59
- * In-flight permits are never revoked — the excess will drain
60
- * naturally as existing callers release.
61
- */
62
- resize(newConcurrency) {
63
- if (newConcurrency < 1) throw new Error("Semaphore: concurrency must be >= 1");
64
- const delta = newConcurrency - this._concurrency;
65
- this._concurrency = newConcurrency;
66
- this._available = Math.max(0, this._available + delta);
67
- while (this._available > 0 && this.waiters.length > 0) {
68
- const next = this.waiters.shift();
69
- if (next) next();
70
- }
71
- }
72
- async acquire() {
73
- if (this._available > 0) {
74
- this._available--;
75
- return () => this.release();
76
- }
77
- return new Promise((resolve) => {
78
- this.waiters.push(() => {
79
- this._available--;
80
- resolve(() => this.release());
81
- });
82
- });
83
- }
84
- release() {
85
- this._available++;
86
- const next = this.waiters.shift();
87
- if (next) next();
88
- }
89
- }
90
- const REPORT_INTERVAL_MS = 1e4;
91
- class PipelineTimingSampler {
92
- detSamples = /* @__PURE__ */ new Map();
93
- motSamples = /* @__PURE__ */ new Map();
94
- audioSamples = /* @__PURE__ */ new Map();
95
- droppedFrames = 0;
96
- reportTimer = null;
97
- log = null;
98
- runtimeInfo = {};
99
- setLogger(logger) {
100
- this.log = logger;
101
- }
102
- start() {
103
- if (this.reportTimer) return;
104
- this.reportTimer = setInterval(() => this.report(), REPORT_INTERVAL_MS);
105
- }
106
- stop() {
107
- if (this.reportTimer) {
108
- clearInterval(this.reportTimer);
109
- this.reportTimer = null;
110
- }
111
- }
112
- addSample(deviceId, s) {
113
- if (!this.detSamples.has(deviceId)) this.detSamples.set(deviceId, []);
114
- this.detSamples.get(deviceId).push(s);
115
- }
116
- addMotionSample(deviceId, ms, frameAge = -1) {
117
- if (!this.motSamples.has(deviceId)) this.motSamples.set(deviceId, []);
118
- this.motSamples.get(deviceId).push({ ms, frameAge });
119
- }
120
- addAudioSample(deviceId, s) {
121
- if (!this.audioSamples.has(deviceId)) this.audioSamples.set(deviceId, []);
122
- this.audioSamples.get(deviceId).push(s);
123
- }
124
- addDrop() {
125
- this.droppedFrames++;
126
- }
127
- report() {
128
- if (!this.log) return;
129
- const dropped = this.droppedFrames;
130
- this.droppedFrames = 0;
131
- const avg = (arr) => arr.length > 0 ? Math.round(arr.reduce((a, b) => a + b, 0) / arr.length) : 0;
132
- const max = (arr) => arr.length > 0 ? Math.round(Math.max(...arr)) : 0;
133
- const p95 = (arr) => {
134
- if (arr.length === 0) return 0;
135
- const sorted = [...arr].sort((a, b) => a - b);
136
- return Math.round(sorted[Math.floor(sorted.length * 0.95)] ?? sorted[sorted.length - 1]);
137
- };
138
- const rt = this.runtimeInfo;
139
- for (const [deviceId, det] of this.detSamples) {
140
- if (det.length === 0) continue;
141
- const e2e = det.map((s) => s.endToEnd);
142
- const inf = det.map((s) => s.inference);
143
- const frameAge = det.map((s) => s.frameAge).filter((v) => v >= 0);
144
- const totalDet = det.reduce((s, d) => s + d.detections, 0);
145
- this.log.info(
146
- "pipeline stats",
147
- {
148
- tags: { deviceId },
149
- meta: {
150
- frames: det.length,
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).
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
- },
166
- inference: { avg: avg(inf), p95: p95(inf) },
167
- detections: totalDet,
168
- dropped,
169
- pipelineRuntime: rt.pipelineRuntime ?? null,
170
- pipelineModels: rt.pipelineModels ?? null
171
- }
172
- }
173
- );
174
- }
175
- this.detSamples.clear();
176
- for (const [deviceId, mot] of this.motSamples) {
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);
180
- this.log.info(
181
- "motion stats",
182
- {
183
- tags: { deviceId },
184
- meta: {
185
- frames: mot.length,
186
- intervalSec: REPORT_INTERVAL_MS / 1e3,
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) }
193
- }
194
- }
195
- );
196
- }
197
- this.motSamples.clear();
198
- for (const [deviceId, aud] of this.audioSamples) {
199
- if (aud.length === 0) continue;
200
- const classifyTimes = aud.filter((a) => a.classifyMs > 0).map((a) => a.classifyMs);
201
- const classified = aud.filter((a) => a.topLabel !== null);
202
- const topLabels = /* @__PURE__ */ new Map();
203
- for (const a of classified) {
204
- if (a.topLabel) topLabels.set(a.topLabel, (topLabels.get(a.topLabel) ?? 0) + 1);
205
- }
206
- const topSummary = [...topLabels.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([l, c]) => `${l}×${c}`).join(", ");
207
- const avgDbfs = avg(aud.map((a) => Math.round(a.dbfs)));
208
- this.log.info(
209
- "audio stats",
210
- {
211
- tags: { deviceId },
212
- meta: {
213
- chunks: aud.length,
214
- intervalSec: REPORT_INTERVAL_MS / 1e3,
215
- classified: classified.length,
216
- classifyAvgMs: classifyTimes.length > 0 ? avg(classifyTimes) : 0,
217
- avgDbfs,
218
- topLabels: topSummary,
219
- audioEngine: rt.audioEngine ?? null
220
- }
221
- }
222
- );
223
- }
224
- this.audioSamples.clear();
225
- }
81
+ return {
82
+ ...frame,
83
+ data: Buffer.from(frame.data)
84
+ };
226
85
  }
86
+ //#endregion
87
+ //#region src/pipeline-runner/semaphore.ts
88
+ /**
89
+ * Counting semaphore with FIFO waiter queue. Used by the runner to bound
90
+ * concurrent inference invocations across all attached cameras.
91
+ *
92
+ * The concurrency limit is **mutable** via `resize()` so the
93
+ * pipeline-runner addon can hot-reload `maxConcurrentInferences` without
94
+ * tearing down and restarting the runner. In-flight permits are
95
+ * preserved: shrinking the limit just lowers the headroom until existing
96
+ * releases catch up; growing it wakes pending waiters immediately.
97
+ */
98
+ var Semaphore = class {
99
+ _concurrency;
100
+ _available;
101
+ waiters = [];
102
+ constructor(concurrency) {
103
+ this._concurrency = concurrency;
104
+ this._available = concurrency;
105
+ }
106
+ get concurrency() {
107
+ return this._concurrency;
108
+ }
109
+ get available() {
110
+ return this._available;
111
+ }
112
+ /**
113
+ * Change the concurrency limit at runtime. Growing wakes as many
114
+ * pending waiters as possible without exceeding the new headroom;
115
+ * shrinking simply caps `_available` to `max(0, _available + delta)`.
116
+ * In-flight permits are never revoked — the excess will drain
117
+ * naturally as existing callers release.
118
+ */
119
+ resize(newConcurrency) {
120
+ if (newConcurrency < 1) throw new Error("Semaphore: concurrency must be >= 1");
121
+ const delta = newConcurrency - this._concurrency;
122
+ this._concurrency = newConcurrency;
123
+ this._available = Math.max(0, this._available + delta);
124
+ while (this._available > 0 && this.waiters.length > 0) {
125
+ const next = this.waiters.shift();
126
+ if (next) next();
127
+ }
128
+ }
129
+ async acquire() {
130
+ if (this._available > 0) {
131
+ this._available--;
132
+ return () => this.release();
133
+ }
134
+ return new Promise((resolve) => {
135
+ this.waiters.push(() => {
136
+ this._available--;
137
+ resolve(() => this.release());
138
+ });
139
+ });
140
+ }
141
+ release() {
142
+ this._available++;
143
+ const next = this.waiters.shift();
144
+ if (next) next();
145
+ }
146
+ };
147
+ //#endregion
148
+ //#region src/pipeline-runner/timing-sampler.ts
149
+ var REPORT_INTERVAL_MS = 1e4;
150
+ /**
151
+ * Periodic timing-stats sampler. Accumulates per-camera samples for the
152
+ * detection pipeline (capture → enqueue → semaphore → inference → emit),
153
+ * motion analysis, and audio classification, and logs aggregated stats
154
+ * every REPORT_INTERVAL_MS.
155
+ */
156
+ var PipelineTimingSampler = class {
157
+ detSamples = /* @__PURE__ */ new Map();
158
+ motSamples = /* @__PURE__ */ new Map();
159
+ audioSamples = /* @__PURE__ */ new Map();
160
+ droppedFrames = 0;
161
+ reportTimer = null;
162
+ log = null;
163
+ runtimeInfo = {};
164
+ setLogger(logger) {
165
+ this.log = logger;
166
+ }
167
+ start() {
168
+ if (this.reportTimer) return;
169
+ this.reportTimer = setInterval(() => this.report(), REPORT_INTERVAL_MS);
170
+ }
171
+ stop() {
172
+ if (this.reportTimer) {
173
+ clearInterval(this.reportTimer);
174
+ this.reportTimer = null;
175
+ }
176
+ }
177
+ addSample(deviceId, s) {
178
+ if (!this.detSamples.has(deviceId)) this.detSamples.set(deviceId, []);
179
+ this.detSamples.get(deviceId).push(s);
180
+ }
181
+ addMotionSample(deviceId, ms, frameAge = -1) {
182
+ if (!this.motSamples.has(deviceId)) this.motSamples.set(deviceId, []);
183
+ this.motSamples.get(deviceId).push({
184
+ ms,
185
+ frameAge
186
+ });
187
+ }
188
+ addAudioSample(deviceId, s) {
189
+ if (!this.audioSamples.has(deviceId)) this.audioSamples.set(deviceId, []);
190
+ this.audioSamples.get(deviceId).push(s);
191
+ }
192
+ addDrop() {
193
+ this.droppedFrames++;
194
+ }
195
+ report() {
196
+ if (!this.log) return;
197
+ const dropped = this.droppedFrames;
198
+ this.droppedFrames = 0;
199
+ const avg = (arr) => arr.length > 0 ? Math.round(arr.reduce((a, b) => a + b, 0) / arr.length) : 0;
200
+ const max = (arr) => arr.length > 0 ? Math.round(Math.max(...arr)) : 0;
201
+ const p95 = (arr) => {
202
+ if (arr.length === 0) return 0;
203
+ const sorted = [...arr].toSorted((a, b) => a - b);
204
+ return Math.round(sorted[Math.floor(sorted.length * .95)] ?? sorted[sorted.length - 1]);
205
+ };
206
+ const rt = this.runtimeInfo;
207
+ for (const [deviceId, det] of this.detSamples) {
208
+ if (det.length === 0) continue;
209
+ const e2e = det.map((s) => s.endToEnd);
210
+ const inf = det.map((s) => s.inference);
211
+ const frameAge = det.map((s) => s.frameAge).filter((v) => v >= 0);
212
+ const totalDet = det.reduce((s, d) => s + d.detections, 0);
213
+ this.log.info("pipeline stats", {
214
+ tags: { deviceId },
215
+ meta: {
216
+ frames: det.length,
217
+ intervalSec: REPORT_INTERVAL_MS / 1e3,
218
+ e2e: {
219
+ avg: avg(e2e),
220
+ p95: p95(e2e),
221
+ max: max(e2e)
222
+ },
223
+ frameAge: {
224
+ avg: avg(frameAge),
225
+ p95: p95(frameAge),
226
+ max: max(frameAge)
227
+ },
228
+ stagesMs: {
229
+ queueWait: avg(det.map((s) => s.queueWait)),
230
+ semaphoreWait: avg(det.map((s) => s.semaphoreWait)),
231
+ inference: avg(inf),
232
+ resultToEmit: avg(det.map((s) => s.resultToEmit))
233
+ },
234
+ inference: {
235
+ avg: avg(inf),
236
+ p95: p95(inf)
237
+ },
238
+ detections: totalDet,
239
+ dropped,
240
+ pipelineRuntime: rt.pipelineRuntime ?? null,
241
+ pipelineModels: rt.pipelineModels ?? null
242
+ }
243
+ });
244
+ }
245
+ this.detSamples.clear();
246
+ for (const [deviceId, mot] of this.motSamples) {
247
+ if (mot.length === 0) continue;
248
+ const ms = mot.map((s) => s.ms);
249
+ const frameAge = mot.map((s) => s.frameAge).filter((v) => v >= 0);
250
+ this.log.info("motion stats", {
251
+ tags: { deviceId },
252
+ meta: {
253
+ frames: mot.length,
254
+ intervalSec: REPORT_INTERVAL_MS / 1e3,
255
+ avg: avg(ms),
256
+ p95: p95(ms),
257
+ max: max(ms),
258
+ frameAge: {
259
+ avg: avg(frameAge),
260
+ p95: p95(frameAge),
261
+ max: max(frameAge)
262
+ }
263
+ }
264
+ });
265
+ }
266
+ this.motSamples.clear();
267
+ for (const [deviceId, aud] of this.audioSamples) {
268
+ if (aud.length === 0) continue;
269
+ const classifyTimes = aud.filter((a) => a.classifyMs > 0).map((a) => a.classifyMs);
270
+ const classified = aud.filter((a) => a.topLabel !== null);
271
+ const topLabels = /* @__PURE__ */ new Map();
272
+ for (const a of classified) if (a.topLabel) topLabels.set(a.topLabel, (topLabels.get(a.topLabel) ?? 0) + 1);
273
+ const topSummary = [...topLabels.entries()].toSorted((a, b) => b[1] - a[1]).slice(0, 3).map(([l, c]) => `${l}×${c}`).join(", ");
274
+ const avgDbfs = avg(aud.map((a) => Math.round(a.dbfs)));
275
+ this.log.info("audio stats", {
276
+ tags: { deviceId },
277
+ meta: {
278
+ chunks: aud.length,
279
+ intervalSec: REPORT_INTERVAL_MS / 1e3,
280
+ classified: classified.length,
281
+ classifyAvgMs: classifyTimes.length > 0 ? avg(classifyTimes) : 0,
282
+ avgDbfs,
283
+ topLabels: topSummary,
284
+ audioEngine: rt.audioEngine ?? null
285
+ }
286
+ });
287
+ }
288
+ this.audioSamples.clear();
289
+ }
290
+ };
291
+ //#endregion
292
+ //#region src/pipeline-runner/runner.ts
293
+ /**
294
+ * Clone a {@link DetectionQueueEntry} for the queue's copy-on-retain
295
+ * boundary: the inner frame's pixel buffer is detached via
296
+ * {@link ownFrame}, the `FrameHandle` is carried through unchanged (it's
297
+ * a plain serialisable record with no borrowed buffers).
298
+ */
227
299
  function ownDetectionEntry(entry) {
228
- return { frame: ownFrame(entry.frame), handle: entry.handle };
300
+ return {
301
+ frame: ownFrame(entry.frame),
302
+ handle: entry.handle
303
+ };
229
304
  }
230
- const DEFAULT_MOTION_COOLDOWN_MS = 3e4;
305
+ var DEFAULT_MOTION_COOLDOWN_MS = 3e4;
231
306
  function toFrameInput$1(frame) {
232
- return {
233
- data: frame.data,
234
- width: frame.width,
235
- height: frame.height,
236
- format: frame.format,
237
- timestamp: frame.timestamp
238
- };
239
- }
240
- class PipelineRunner {
241
- // Config is mutable (not `readonly`) because `updateLimits()` hot-reloads
242
- // the four tuning fields when the pipeline-runner addon's
243
- // `updateAddonSettings` is invoked via the new three-level settings API.
244
- // The callbacks (`processFrame`, `analyzeMotion`) are invariants captured
245
- // at construction and never changed.
246
- config;
247
- cameras = /* @__PURE__ */ new Map();
248
- semaphore;
249
- resultCallbacks = [];
250
- defaultRoundRobinKeys = [];
251
- defaultRoundRobinIndex = 0;
252
- intervalHandle = null;
253
- detectionStreamHandler = null;
254
- logger;
255
- timingSampler = new PipelineTimingSampler();
256
- constructor(config) {
257
- this.config = config;
258
- this.logger = config.logger;
259
- this.semaphore = new Semaphore(config.maxConcurrentInferences);
260
- }
261
- /**
262
- * Hot-reload the four tuning fields without tearing down the runner.
263
- * - `maxConcurrentInferences`: resized on the live semaphore; in-flight
264
- * permits are preserved, new capacity is available immediately.
265
- * - `maxQueueDepth`: new `FrameQueue`s created from this point on use
266
- * the updated ceiling. Existing per-camera queues are not resized
267
- * (the FrameQueue implementation is latest-only and ignores maxSize
268
- * anyway — see `frame-queue.ts` — so the field is effectively a
269
- * metadata hint for observability).
270
- * - `targetLoadPercent` / `minThrottledFps`: stored for future
271
- * throttling logic (not yet consumed in the current runner body).
272
- *
273
- * Only keys present in the patch are overwritten; unspecified keys
274
- * retain their current value. Any illegal combination (e.g.
275
- * concurrency < 1) throws and leaves the runner unchanged.
276
- */
277
- updateLimits(patch) {
278
- const next = {
279
- ...this.config,
280
- maxQueueDepth: patch.maxQueueDepth ?? this.config.maxQueueDepth,
281
- maxConcurrentInferences: patch.maxConcurrentInferences ?? this.config.maxConcurrentInferences,
282
- targetLoadPercent: patch.targetLoadPercent ?? this.config.targetLoadPercent,
283
- minThrottledFps: patch.minThrottledFps ?? this.config.minThrottledFps
284
- };
285
- if (next.maxConcurrentInferences !== this.config.maxConcurrentInferences) {
286
- this.semaphore.resize(next.maxConcurrentInferences);
287
- }
288
- this.config = next;
289
- }
290
- /** Read the current tuning fields for diagnostics / tests. */
291
- getLimits() {
292
- return {
293
- maxQueueDepth: this.config.maxQueueDepth,
294
- maxConcurrentInferences: this.config.maxConcurrentInferences,
295
- targetLoadPercent: this.config.targetLoadPercent,
296
- minThrottledFps: this.config.minThrottledFps
297
- };
298
- }
299
- /** Set a handler called when the runner needs to subscribe/unsubscribe the detection stream. */
300
- onDetectionStreamChange(handler) {
301
- this.detectionStreamHandler = handler;
302
- }
303
- registerCamera(deviceId, registration) {
304
- const motionQueue = new FrameQueue(this.config.maxQueueDepth, ownFrame);
305
- const detectionQueue = new FrameQueue(
306
- this.config.maxQueueDepth,
307
- ownDetectionEntry
308
- );
309
- const initialPhase = registration.detectionMode === "disabled" ? "idle" : registration.detectionMode === "always-on" ? "active" : "watching";
310
- const state = {
311
- registration,
312
- motionQueue,
313
- detectionQueue,
314
- inferenceTimes: [],
315
- processedCount: 0,
316
- startTime: Date.now(),
317
- phase: initialPhase,
318
- motionCooldownTimer: null,
319
- lastArmedSource: null,
320
- lastArmedRegions: void 0
321
- };
322
- this.cameras.set(deviceId, state);
323
- if (registration.detectionMode === "on-motion") {
324
- this.defaultRoundRobinKeys.push(deviceId);
325
- }
326
- if (initialPhase === "active") {
327
- this.detectionStreamHandler?.(deviceId, "subscribe");
328
- const cooldownMs = registration.motionCooldownMs ?? DEFAULT_MOTION_COOLDOWN_MS;
329
- this.config.onPhaseChanged?.(deviceId, "active", {
330
- source: "analyzer",
331
- regions: void 0,
332
- timestamp: Date.now(),
333
- cooldownMs
334
- });
335
- }
336
- }
337
- unregisterCamera(deviceId) {
338
- const state = this.cameras.get(deviceId);
339
- if (!state) return;
340
- if (state.motionCooldownTimer !== null) {
341
- clearTimeout(state.motionCooldownTimer);
342
- state.motionCooldownTimer = null;
343
- }
344
- if (state.phase === "active") {
345
- this.detectionStreamHandler?.(deviceId, "unsubscribe");
346
- }
347
- state.motionQueue.clear();
348
- state.detectionQueue.clear();
349
- this.cameras.delete(deviceId);
350
- const idx = this.defaultRoundRobinKeys.indexOf(deviceId);
351
- if (idx !== -1) {
352
- this.defaultRoundRobinKeys.splice(idx, 1);
353
- if (this.defaultRoundRobinIndex >= this.defaultRoundRobinKeys.length) {
354
- this.defaultRoundRobinIndex = 0;
355
- }
356
- }
357
- }
358
- enqueueMotionFrame(deviceId, frame) {
359
- const state = this.cameras.get(deviceId);
360
- if (!state) return;
361
- state.motionQueue.enqueue(frame);
362
- }
363
- enqueueDetectionFrame(deviceId, frame, handle) {
364
- const state = this.cameras.get(deviceId);
365
- if (!state) return;
366
- if (state.phase !== "active") return;
367
- frame._enqueuedAt = Date.now();
368
- state.detectionQueue.enqueue({ frame, handle });
369
- }
370
- /**
371
- * Report a motion event for a camera. Drives the unified phase
372
- * machine for both motion sources (analyzer + onboard):
373
- *
374
- * - Every `detected: true` (any source) clears + rearms the
375
- * cooldown timer and transitions watching → active. The same
376
- * timer applies regardless of which source(s) are configured;
377
- * concurrent sources just keep refreshing the same window.
378
- * - `detected: false` is a no-op. Onboard sources never send an
379
- * explicit clear, and the analyzer's "false" pulses would
380
- * otherwise fight the cooldown when motion paused briefly
381
- * during a scene. The timer is the single closure path.
382
- * - Timer expiry transitions active → watching.
383
- *
384
- * Always-on cameras silently ignore reportMotion calls — they're
385
- * already in `active` and have no cooldown.
386
- *
387
- * `source` and `regions` propagate into the phase-transition event
388
- * so the wrapping addon can attach them to the cap-state slice +
389
- * bus event.
390
- */
391
- reportMotion(deviceId, detected, source = "analyzer", regions = void 0) {
392
- const state = this.cameras.get(deviceId);
393
- if (!state) return;
394
- if (state.registration.detectionMode !== "on-motion") return;
395
- if (!detected) return;
396
- state.lastArmedSource = source;
397
- state.lastArmedRegions = regions;
398
- const cooldownMs = state.registration.motionCooldownMs ?? DEFAULT_MOTION_COOLDOWN_MS;
399
- if (state.motionCooldownTimer !== null) {
400
- clearTimeout(state.motionCooldownTimer);
401
- state.motionCooldownTimer = null;
402
- }
403
- if (state.phase === "watching") {
404
- this.transitionToActive(deviceId, state, source, regions, cooldownMs);
405
- }
406
- state.motionCooldownTimer = setTimeout(() => {
407
- state.motionCooldownTimer = null;
408
- this.transitionToWatching(deviceId, state, cooldownMs);
409
- }, cooldownMs);
410
- }
411
- getPhase(deviceId) {
412
- return this.cameras.get(deviceId)?.phase;
413
- }
414
- onResult(callback) {
415
- this.resultCallbacks.push(callback);
416
- }
417
- start() {
418
- if (this.intervalHandle !== null) return;
419
- this.intervalHandle = setInterval(() => this.tick(), 10);
420
- this.timingSampler.start();
421
- }
422
- stop() {
423
- if (this.intervalHandle !== null) {
424
- clearInterval(this.intervalHandle);
425
- this.intervalHandle = null;
426
- }
427
- this.timingSampler.stop();
428
- for (const state of this.cameras.values()) {
429
- if (state.motionCooldownTimer !== null) {
430
- clearTimeout(state.motionCooldownTimer);
431
- state.motionCooldownTimer = null;
432
- }
433
- }
434
- }
435
- getMetrics() {
436
- let totalQueueDepth = 0;
437
- let totalInferenceTime = 0;
438
- let totalInferenceCount = 0;
439
- for (const state of this.cameras.values()) {
440
- totalQueueDepth += state.motionQueue.size + state.detectionQueue.size;
441
- for (const t of state.inferenceTimes) {
442
- totalInferenceTime += t;
443
- totalInferenceCount++;
444
- }
445
- }
446
- return {
447
- activeCameras: this.cameras.size,
448
- throttledCameras: 0,
449
- avgInferenceTimeMs: totalInferenceCount > 0 ? totalInferenceTime / totalInferenceCount : 0,
450
- queueDepth: totalQueueDepth
451
- };
452
- }
453
- getCameraMetrics(deviceId) {
454
- const state = this.cameras.get(deviceId);
455
- if (!state) return void 0;
456
- const elapsedMs = Date.now() - state.startTime;
457
- const elapsedSec = elapsedMs / 1e3;
458
- const actualFps = elapsedSec > 0 ? state.processedCount / elapsedSec : 0;
459
- const times = state.inferenceTimes;
460
- const avgInference = times.length > 0 ? times.reduce((a, b) => a + b, 0) / times.length : 0;
461
- return {
462
- detectionMode: state.registration.detectionMode,
463
- configuredFps: state.registration.fps,
464
- actualFps,
465
- queueDepth: state.motionQueue.size + state.detectionQueue.size,
466
- avgInferenceTimeMs: avgInference,
467
- droppedFrames: state.motionQueue.droppedFrames + state.detectionQueue.droppedFrames,
468
- phase: state.phase
469
- };
470
- }
471
- getAllCameraMetrics() {
472
- const results = [];
473
- for (const [deviceId] of this.cameras) {
474
- const metrics = this.getCameraMetrics(deviceId);
475
- if (metrics) {
476
- results.push({ deviceId, ...metrics });
477
- }
478
- }
479
- return results;
480
- }
481
- getAttachedCameras() {
482
- return [...this.cameras.keys()];
483
- }
484
- transitionToActive(deviceId, state, source, regions, cooldownMs) {
485
- state.phase = "active";
486
- this.logger?.info("motion gate opened — phase=active", {
487
- tags: { deviceId },
488
- meta: { detectionMode: state.registration.detectionMode, source }
489
- });
490
- this.detectionStreamHandler?.(deviceId, "subscribe");
491
- this.config.onPhaseChanged?.(deviceId, "active", {
492
- source,
493
- regions,
494
- timestamp: Date.now(),
495
- cooldownMs
496
- });
497
- }
498
- transitionToWatching(deviceId, state, cooldownMs) {
499
- state.phase = "watching";
500
- state.detectionQueue.clear();
501
- this.logger?.info("motion gate closed — phase=watching", {
502
- tags: { deviceId },
503
- meta: { lastSource: state.lastArmedSource }
504
- });
505
- this.detectionStreamHandler?.(deviceId, "unsubscribe");
506
- const source = state.lastArmedSource ?? "analyzer";
507
- this.config.onPhaseChanged?.(deviceId, "watching", {
508
- source,
509
- regions: void 0,
510
- timestamp: Date.now(),
511
- cooldownMs
512
- });
513
- state.lastArmedSource = null;
514
- state.lastArmedRegions = void 0;
515
- }
516
- tick() {
517
- this.drainMotionQueues();
518
- if (this.semaphore.available <= 0) return;
519
- const picked = this.pickNextDetectionFrame();
520
- if (!picked) return;
521
- const { deviceId, entry, state } = picked;
522
- const frameInput = toFrameInput$1(entry.frame);
523
- void this.processWithSemaphore(deviceId, entry, frameInput, state, "detection");
524
- }
525
- drainMotionQueues() {
526
- for (const [deviceId, state] of this.cameras) {
527
- while (state.motionQueue.size > 0) {
528
- const frame = state.motionQueue.dequeue();
529
- if (frame) {
530
- void this.config.analyzeMotion(deviceId, frame);
531
- }
532
- }
533
- }
534
- }
535
- async processWithSemaphore(deviceId, entry, frameInput, state, streamType) {
536
- const pickedAt = Date.now();
537
- const { frame, handle } = entry;
538
- const enqueuedAt = frame._enqueuedAt ?? pickedAt;
539
- const release = await this.semaphore.acquire();
540
- const semAcquiredAt = Date.now();
541
- try {
542
- const result = await this.config.processFrame(deviceId, frameInput);
543
- const inferDoneAt = Date.now();
544
- const inferenceMs = inferDoneAt - semAcquiredAt;
545
- state.inferenceTimes.push(inferenceMs);
546
- if (state.inferenceTimes.length > 100) {
547
- state.inferenceTimes.shift();
548
- }
549
- state.processedCount++;
550
- if (result) {
551
- await this.notifyCallbacks(deviceId, frame, result, streamType, handle);
552
- const emittedAt = Date.now();
553
- const capturedAt = frame.capturedAt;
554
- this.timingSampler.addSample(deviceId, {
555
- queueWait: pickedAt - enqueuedAt,
556
- semaphoreWait: semAcquiredAt - pickedAt,
557
- inference: inferenceMs,
558
- resultToEmit: emittedAt - inferDoneAt,
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,
566
- detections: result.detections?.length ?? 0
567
- });
568
- }
569
- } finally {
570
- release();
571
- }
572
- }
573
- async notifyCallbacks(deviceId, frame, result, streamType, handle) {
574
- for (const callback of this.resultCallbacks) {
575
- try {
576
- await callback(deviceId, frame, result, streamType, handle);
577
- } catch {
578
- }
579
- }
580
- }
581
- pickNextDetectionFrame() {
582
- for (const [deviceId, state] of this.cameras) {
583
- if (state.registration.detectionMode === "always-on" && state.detectionQueue.size > 0) {
584
- const entry = state.detectionQueue.dequeue();
585
- return { deviceId, entry, state };
586
- }
587
- }
588
- if (this.defaultRoundRobinKeys.length === 0) return null;
589
- const startIndex = this.defaultRoundRobinIndex;
590
- for (let i = 0; i < this.defaultRoundRobinKeys.length; i++) {
591
- const idx = (startIndex + i) % this.defaultRoundRobinKeys.length;
592
- const deviceId = this.defaultRoundRobinKeys[idx];
593
- if (!deviceId) continue;
594
- const state = this.cameras.get(deviceId);
595
- if (!state) continue;
596
- if (state.phase === "active" && state.detectionQueue.size > 0) {
597
- this.defaultRoundRobinIndex = (idx + 1) % this.defaultRoundRobinKeys.length;
598
- const entry = state.detectionQueue.dequeue();
599
- if (!entry) continue;
600
- return { deviceId, entry, state };
601
- }
602
- }
603
- return null;
604
- }
307
+ return {
308
+ data: frame.data,
309
+ width: frame.width,
310
+ height: frame.height,
311
+ format: frame.format,
312
+ timestamp: frame.timestamp
313
+ };
605
314
  }
606
- const PULL_MAX_COUNT = 4;
607
- const MIN_POLL_INTERVAL_MS = 20;
608
- const FALLBACK_POLL_INTERVAL_MS = 200;
315
+ /**
316
+ * Pipeline runner scheduler — the per-node detection scheduler.
317
+ *
318
+ * Owns per-camera frame queues, the inference semaphore, the round-robin
319
+ * scheduler, and the motion → detection phase machine. Inference and
320
+ * motion analysis are delegated to caller-provided callbacks injected at
321
+ * construction time so the scheduler stays decoupled from the surrounding
322
+ * addon ecosystem (the addon class wrapping this scheduler injects
323
+ * callbacks that resolve to the local pipeline-executor / motion-detection
324
+ * caps when running in production).
325
+ *
326
+ * This class is intentionally framework-agnostic and side-effect-free
327
+ * outside the callbacks — it can be unit tested with mock callbacks
328
+ * without spinning up Moleculer or the addon framework.
329
+ */
330
+ var PipelineRunner = class {
331
+ config;
332
+ cameras = /* @__PURE__ */ new Map();
333
+ semaphore;
334
+ resultCallbacks = [];
335
+ defaultRoundRobinKeys = [];
336
+ defaultRoundRobinIndex = 0;
337
+ intervalHandle = null;
338
+ detectionStreamHandler = null;
339
+ logger;
340
+ timingSampler = new PipelineTimingSampler();
341
+ constructor(config) {
342
+ this.config = config;
343
+ this.logger = config.logger;
344
+ this.semaphore = new Semaphore(config.maxConcurrentInferences);
345
+ }
346
+ /**
347
+ * Hot-reload the four tuning fields without tearing down the runner.
348
+ * - `maxConcurrentInferences`: resized on the live semaphore; in-flight
349
+ * permits are preserved, new capacity is available immediately.
350
+ * - `maxQueueDepth`: new `FrameQueue`s created from this point on use
351
+ * the updated ceiling. Existing per-camera queues are not resized
352
+ * (the FrameQueue implementation is latest-only and ignores maxSize
353
+ * anyway — see `frame-queue.ts` — so the field is effectively a
354
+ * metadata hint for observability).
355
+ * - `targetLoadPercent` / `minThrottledFps`: stored for future
356
+ * throttling logic (not yet consumed in the current runner body).
357
+ *
358
+ * Only keys present in the patch are overwritten; unspecified keys
359
+ * retain their current value. Any illegal combination (e.g.
360
+ * concurrency < 1) throws and leaves the runner unchanged.
361
+ */
362
+ updateLimits(patch) {
363
+ const next = {
364
+ ...this.config,
365
+ maxQueueDepth: patch.maxQueueDepth ?? this.config.maxQueueDepth,
366
+ maxConcurrentInferences: patch.maxConcurrentInferences ?? this.config.maxConcurrentInferences,
367
+ targetLoadPercent: patch.targetLoadPercent ?? this.config.targetLoadPercent,
368
+ minThrottledFps: patch.minThrottledFps ?? this.config.minThrottledFps
369
+ };
370
+ if (next.maxConcurrentInferences !== this.config.maxConcurrentInferences) this.semaphore.resize(next.maxConcurrentInferences);
371
+ this.config = next;
372
+ }
373
+ /** Read the current tuning fields for diagnostics / tests. */
374
+ getLimits() {
375
+ return {
376
+ maxQueueDepth: this.config.maxQueueDepth,
377
+ maxConcurrentInferences: this.config.maxConcurrentInferences,
378
+ targetLoadPercent: this.config.targetLoadPercent,
379
+ minThrottledFps: this.config.minThrottledFps
380
+ };
381
+ }
382
+ /** Set a handler called when the runner needs to subscribe/unsubscribe the detection stream. */
383
+ onDetectionStreamChange(handler) {
384
+ this.detectionStreamHandler = handler;
385
+ }
386
+ registerCamera(deviceId, registration) {
387
+ const motionQueue = new FrameQueue(this.config.maxQueueDepth, ownFrame);
388
+ const detectionQueue = new FrameQueue(this.config.maxQueueDepth, ownDetectionEntry);
389
+ const initialPhase = registration.detectionMode === "disabled" ? "idle" : registration.detectionMode === "always-on" ? "active" : "watching";
390
+ const state = {
391
+ registration,
392
+ motionQueue,
393
+ detectionQueue,
394
+ inferenceTimes: [],
395
+ processedCount: 0,
396
+ startTime: Date.now(),
397
+ phase: initialPhase,
398
+ motionCooldownTimer: null,
399
+ lastArmedSource: null,
400
+ lastArmedRegions: void 0,
401
+ occupancyTimer: null
402
+ };
403
+ this.cameras.set(deviceId, state);
404
+ if (registration.detectionMode === "on-motion") this.defaultRoundRobinKeys.push(deviceId);
405
+ if (registration.detectionMode === "on-motion" && (registration.occupancyRecheckSec ?? 0) > 0) state.occupancyTimer = setInterval(() => {
406
+ if (state.phase === "watching") this.config.onOccupancyRecheck?.(deviceId, registration.occupancyRecheckFrames ?? 4);
407
+ }, (registration.occupancyRecheckSec ?? 0) * 1e3);
408
+ if (initialPhase === "active") {
409
+ this.detectionStreamHandler?.(deviceId, "subscribe");
410
+ const cooldownMs = registration.motionCooldownMs ?? DEFAULT_MOTION_COOLDOWN_MS;
411
+ this.config.onPhaseChanged?.(deviceId, "active", {
412
+ source: "analyzer",
413
+ regions: void 0,
414
+ timestamp: Date.now(),
415
+ cooldownMs
416
+ });
417
+ }
418
+ }
419
+ unregisterCamera(deviceId) {
420
+ const state = this.cameras.get(deviceId);
421
+ if (!state) return;
422
+ if (state.motionCooldownTimer !== null) {
423
+ clearTimeout(state.motionCooldownTimer);
424
+ state.motionCooldownTimer = null;
425
+ }
426
+ if (state.occupancyTimer !== null) {
427
+ clearInterval(state.occupancyTimer);
428
+ state.occupancyTimer = null;
429
+ }
430
+ if (state.phase === "active") this.detectionStreamHandler?.(deviceId, "unsubscribe");
431
+ state.motionQueue.clear();
432
+ state.detectionQueue.clear();
433
+ this.cameras.delete(deviceId);
434
+ const idx = this.defaultRoundRobinKeys.indexOf(deviceId);
435
+ if (idx !== -1) {
436
+ this.defaultRoundRobinKeys.splice(idx, 1);
437
+ if (this.defaultRoundRobinIndex >= this.defaultRoundRobinKeys.length) this.defaultRoundRobinIndex = 0;
438
+ }
439
+ }
440
+ enqueueMotionFrame(deviceId, frame) {
441
+ const state = this.cameras.get(deviceId);
442
+ if (!state) return;
443
+ state.motionQueue.enqueue(frame);
444
+ }
445
+ enqueueDetectionFrame(deviceId, frame, handle) {
446
+ const state = this.cameras.get(deviceId);
447
+ if (!state) return;
448
+ if (state.phase !== "active") return;
449
+ frame._enqueuedAt = Date.now();
450
+ state.detectionQueue.enqueue({
451
+ frame,
452
+ handle
453
+ });
454
+ }
455
+ enqueueOccupancyFrame(deviceId, frame, handle) {
456
+ const state = this.cameras.get(deviceId);
457
+ if (!state) return;
458
+ if (state.phase !== "watching") return;
459
+ frame._enqueuedAt = Date.now();
460
+ state.detectionQueue.enqueue({
461
+ frame,
462
+ handle
463
+ });
464
+ }
465
+ /**
466
+ * Report a motion event for a camera. Drives the unified phase
467
+ * machine for both motion sources (analyzer + onboard):
468
+ *
469
+ * - Every `detected: true` (any source) clears + rearms the
470
+ * cooldown timer and transitions watching → active. The same
471
+ * timer applies regardless of which source(s) are configured;
472
+ * concurrent sources just keep refreshing the same window.
473
+ * - `detected: false` is a no-op. Onboard sources never send an
474
+ * explicit clear, and the analyzer's "false" pulses would
475
+ * otherwise fight the cooldown when motion paused briefly
476
+ * during a scene. The timer is the single closure path.
477
+ * - Timer expiry transitions active → watching.
478
+ *
479
+ * Always-on cameras silently ignore reportMotion calls — they're
480
+ * already in `active` and have no cooldown.
481
+ *
482
+ * `source` and `regions` propagate into the phase-transition event
483
+ * so the wrapping addon can attach them to the cap-state slice +
484
+ * bus event.
485
+ */
486
+ reportMotion(deviceId, detected, source = "analyzer", regions = void 0) {
487
+ const state = this.cameras.get(deviceId);
488
+ if (!state) return;
489
+ if (state.registration.detectionMode !== "on-motion") return;
490
+ if (!detected) return;
491
+ state.lastArmedSource = source;
492
+ state.lastArmedRegions = regions;
493
+ const cooldownMs = state.registration.motionCooldownMs ?? DEFAULT_MOTION_COOLDOWN_MS;
494
+ if (state.motionCooldownTimer !== null) {
495
+ clearTimeout(state.motionCooldownTimer);
496
+ state.motionCooldownTimer = null;
497
+ }
498
+ if (state.phase === "watching") this.transitionToActive(deviceId, state, source, regions, cooldownMs);
499
+ state.motionCooldownTimer = setTimeout(() => {
500
+ state.motionCooldownTimer = null;
501
+ this.transitionToWatching(deviceId, state, cooldownMs);
502
+ }, cooldownMs);
503
+ }
504
+ getPhase(deviceId) {
505
+ return this.cameras.get(deviceId)?.phase;
506
+ }
507
+ onResult(callback) {
508
+ this.resultCallbacks.push(callback);
509
+ }
510
+ start() {
511
+ if (this.intervalHandle !== null) return;
512
+ this.intervalHandle = setInterval(() => this.tick(), 10);
513
+ this.timingSampler.start();
514
+ }
515
+ stop() {
516
+ if (this.intervalHandle !== null) {
517
+ clearInterval(this.intervalHandle);
518
+ this.intervalHandle = null;
519
+ }
520
+ this.timingSampler.stop();
521
+ for (const state of this.cameras.values()) {
522
+ if (state.motionCooldownTimer !== null) {
523
+ clearTimeout(state.motionCooldownTimer);
524
+ state.motionCooldownTimer = null;
525
+ }
526
+ if (state.occupancyTimer !== null) {
527
+ clearInterval(state.occupancyTimer);
528
+ state.occupancyTimer = null;
529
+ }
530
+ }
531
+ }
532
+ getMetrics() {
533
+ let totalQueueDepth = 0;
534
+ let totalInferenceTime = 0;
535
+ let totalInferenceCount = 0;
536
+ for (const state of this.cameras.values()) {
537
+ totalQueueDepth += state.motionQueue.size + state.detectionQueue.size;
538
+ for (const t of state.inferenceTimes) {
539
+ totalInferenceTime += t;
540
+ totalInferenceCount++;
541
+ }
542
+ }
543
+ return {
544
+ activeCameras: this.cameras.size,
545
+ throttledCameras: 0,
546
+ avgInferenceTimeMs: totalInferenceCount > 0 ? totalInferenceTime / totalInferenceCount : 0,
547
+ queueDepth: totalQueueDepth
548
+ };
549
+ }
550
+ getCameraMetrics(deviceId) {
551
+ const state = this.cameras.get(deviceId);
552
+ if (!state) return void 0;
553
+ const elapsedSec = (Date.now() - state.startTime) / 1e3;
554
+ const actualFps = elapsedSec > 0 ? state.processedCount / elapsedSec : 0;
555
+ const times = state.inferenceTimes;
556
+ const avgInference = times.length > 0 ? times.reduce((a, b) => a + b, 0) / times.length : 0;
557
+ return {
558
+ detectionMode: state.registration.detectionMode,
559
+ configuredFps: state.registration.fps,
560
+ actualFps,
561
+ queueDepth: state.motionQueue.size + state.detectionQueue.size,
562
+ avgInferenceTimeMs: avgInference,
563
+ droppedFrames: state.motionQueue.droppedFrames + state.detectionQueue.droppedFrames,
564
+ phase: state.phase
565
+ };
566
+ }
567
+ getAllCameraMetrics() {
568
+ const results = [];
569
+ for (const [deviceId] of this.cameras) {
570
+ const metrics = this.getCameraMetrics(deviceId);
571
+ if (metrics) results.push({
572
+ deviceId,
573
+ ...metrics
574
+ });
575
+ }
576
+ return results;
577
+ }
578
+ getAttachedCameras() {
579
+ return [...this.cameras.keys()];
580
+ }
581
+ transitionToActive(deviceId, state, source, regions, cooldownMs) {
582
+ state.phase = "active";
583
+ this.logger?.info("motion gate opened — phase=active", {
584
+ tags: { deviceId },
585
+ meta: {
586
+ detectionMode: state.registration.detectionMode,
587
+ source
588
+ }
589
+ });
590
+ this.detectionStreamHandler?.(deviceId, "subscribe");
591
+ this.config.onPhaseChanged?.(deviceId, "active", {
592
+ source,
593
+ regions,
594
+ timestamp: Date.now(),
595
+ cooldownMs
596
+ });
597
+ }
598
+ transitionToWatching(deviceId, state, cooldownMs) {
599
+ state.phase = "watching";
600
+ state.detectionQueue.clear();
601
+ this.logger?.info("motion gate closed — phase=watching", {
602
+ tags: { deviceId },
603
+ meta: { lastSource: state.lastArmedSource }
604
+ });
605
+ this.detectionStreamHandler?.(deviceId, "unsubscribe");
606
+ const source = state.lastArmedSource ?? "analyzer";
607
+ this.config.onPhaseChanged?.(deviceId, "watching", {
608
+ source,
609
+ regions: void 0,
610
+ timestamp: Date.now(),
611
+ cooldownMs
612
+ });
613
+ state.lastArmedSource = null;
614
+ state.lastArmedRegions = void 0;
615
+ }
616
+ tick() {
617
+ this.drainMotionQueues();
618
+ if (this.semaphore.available <= 0) return;
619
+ const picked = this.pickNextDetectionFrame();
620
+ if (!picked) return;
621
+ const { deviceId, entry, state } = picked;
622
+ const frameInput = toFrameInput$1(entry.frame);
623
+ this.processWithSemaphore(deviceId, entry, frameInput, state, "detection");
624
+ }
625
+ drainMotionQueues() {
626
+ for (const [deviceId, state] of this.cameras) while (state.motionQueue.size > 0) {
627
+ const frame = state.motionQueue.dequeue();
628
+ if (frame) this.config.analyzeMotion(deviceId, frame);
629
+ }
630
+ }
631
+ async processWithSemaphore(deviceId, entry, frameInput, state, streamType) {
632
+ const pickedAt = Date.now();
633
+ const { frame, handle } = entry;
634
+ const enqueuedAt = frame._enqueuedAt ?? pickedAt;
635
+ const release = await this.semaphore.acquire();
636
+ const semAcquiredAt = Date.now();
637
+ try {
638
+ const result = await this.config.processFrame(deviceId, frameInput);
639
+ const inferDoneAt = Date.now();
640
+ const inferenceMs = inferDoneAt - semAcquiredAt;
641
+ state.inferenceTimes.push(inferenceMs);
642
+ if (state.inferenceTimes.length > 100) state.inferenceTimes.shift();
643
+ state.processedCount++;
644
+ if (result) {
645
+ await this.notifyCallbacks(deviceId, frame, result, streamType, handle);
646
+ const emittedAt = Date.now();
647
+ const capturedAt = frame.capturedAt;
648
+ this.timingSampler.addSample(deviceId, {
649
+ queueWait: pickedAt - enqueuedAt,
650
+ semaphoreWait: semAcquiredAt - pickedAt,
651
+ inference: inferenceMs,
652
+ resultToEmit: emittedAt - inferDoneAt,
653
+ frameAge: typeof capturedAt === "number" && capturedAt > 0 ? pickedAt - capturedAt : -1,
654
+ endToEnd: emittedAt - enqueuedAt,
655
+ detections: result.detections?.length ?? 0
656
+ });
657
+ }
658
+ } finally {
659
+ release();
660
+ }
661
+ }
662
+ async notifyCallbacks(deviceId, frame, result, streamType, handle) {
663
+ for (const callback of this.resultCallbacks) try {
664
+ await callback(deviceId, frame, result, streamType, handle);
665
+ } catch {}
666
+ }
667
+ pickNextDetectionFrame() {
668
+ for (const [deviceId, state] of this.cameras) if (state.registration.detectionMode === "always-on" && state.detectionQueue.size > 0) return {
669
+ deviceId,
670
+ entry: state.detectionQueue.dequeue(),
671
+ state
672
+ };
673
+ if (this.defaultRoundRobinKeys.length === 0) return null;
674
+ const startIndex = this.defaultRoundRobinIndex;
675
+ for (let i = 0; i < this.defaultRoundRobinKeys.length; i++) {
676
+ const idx = (startIndex + i) % this.defaultRoundRobinKeys.length;
677
+ const deviceId = this.defaultRoundRobinKeys[idx];
678
+ if (!deviceId) continue;
679
+ const state = this.cameras.get(deviceId);
680
+ if (!state) continue;
681
+ if (state.phase === "active" && state.detectionQueue.size > 0) {
682
+ this.defaultRoundRobinIndex = (idx + 1) % this.defaultRoundRobinKeys.length;
683
+ const entry = state.detectionQueue.dequeue();
684
+ if (!entry) continue;
685
+ return {
686
+ deviceId,
687
+ entry,
688
+ state
689
+ };
690
+ }
691
+ }
692
+ return null;
693
+ }
694
+ };
695
+ //#endregion
696
+ //#region src/pipeline-runner/frame-handle-poller.ts
697
+ /** How many handles to drain per poll — a small burst absorbs jitter. */
698
+ var PULL_MAX_COUNT = 4;
699
+ /** Floor on the poll period so a 0/absurd `maxFps` can't busy-spin. */
700
+ var MIN_POLL_INTERVAL_MS = 20;
701
+ /** Poll period when the broker reports a non-positive cadence hint. */
702
+ var FALLBACK_POLL_INTERVAL_MS = 200;
703
+ /** First subscribe-retry delay, doubled on every subsequent failure. */
704
+ var INITIAL_SUBSCRIBE_RETRY_BACKOFF_MS = 250;
705
+ /**
706
+ * Subscribe-retry backoff ceiling. 5 s is fast enough to recover within a
707
+ * single roster refresh of the consuming UI and slow enough that a
708
+ * misconfigured camStream costs ~12 op/min, not 50/s.
709
+ */
710
+ var MAX_SUBSCRIBE_RETRY_BACKOFF_MS = 5e3;
711
+ /**
712
+ * Subscribe to a broker's shm frame stream and start the poll loop. Always
713
+ * resolves to a teardown closure — when the broker is not yet registered the
714
+ * closure cancels the ongoing retry loop; when polling is active it stops the
715
+ * loop, releases the broker subscription, and closes every shm segment the
716
+ * reader cache opened.
717
+ */
609
718
  async function startFrameHandlePoller(options) {
610
- const { api, brokerId, format, maxFps, tag, onFrame, logger } = options;
611
- let result;
612
- try {
613
- result = await api.streamBroker.subscribeFrames.mutate({
614
- brokerId,
615
- format,
616
- maxFps,
617
- tag
618
- });
619
- } catch (err) {
620
- logger.warn("frame-handle poller: subscribeFrames failed", {
621
- meta: { brokerId, format, tag, error: errMsg(err) }
622
- });
623
- return null;
624
- }
625
- const { subscriptionId } = result;
626
- const readers = new FrameRingReaderCache(logger);
627
- const pollIntervalMs = result.maxFps > 0 ? Math.max(MIN_POLL_INTERVAL_MS, Math.round(1e3 / result.maxFps)) : FALLBACK_POLL_INTERVAL_MS;
628
- let stopped = false;
629
- let timer;
630
- const tick = async () => {
631
- if (stopped) return;
632
- try {
633
- const handles = await api.streamBroker.pullFrameHandles.query({
634
- subscriptionId,
635
- maxCount: PULL_MAX_COUNT
636
- });
637
- for (const handle of handles) {
638
- if (stopped) break;
639
- const frame = readers.read(handle);
640
- if (frame) onFrame(frame, handle);
641
- }
642
- } catch (err) {
643
- logger.warn("frame-handle poller: pullFrameHandles failed", {
644
- meta: { brokerId, subscriptionId, error: errMsg(err) }
645
- });
646
- }
647
- if (!stopped) {
648
- timer = setTimeout(() => void tick(), pollIntervalMs);
649
- }
650
- };
651
- void tick();
652
- return () => {
653
- if (stopped) return;
654
- stopped = true;
655
- if (timer) {
656
- clearTimeout(timer);
657
- timer = void 0;
658
- }
659
- readers.close();
660
- api.streamBroker.unsubscribeFrames.mutate({ subscriptionId }).catch((err) => {
661
- logger.warn("frame-handle poller: unsubscribeFrames failed", {
662
- meta: { brokerId, subscriptionId, error: errMsg(err) }
663
- });
664
- });
665
- };
719
+ const lifecycle = {
720
+ stopped: false,
721
+ retryTimer: void 0,
722
+ activeTeardown: null
723
+ };
724
+ const teardown = () => {
725
+ if (lifecycle.stopped) return;
726
+ lifecycle.stopped = true;
727
+ if (lifecycle.retryTimer) {
728
+ clearTimeout(lifecycle.retryTimer);
729
+ lifecycle.retryTimer = void 0;
730
+ }
731
+ lifecycle.activeTeardown?.();
732
+ };
733
+ subscribeWithRetry(options, lifecycle);
734
+ return teardown;
735
+ }
736
+ /**
737
+ * Run the subscribe → poll handshake with exponential backoff on subscribe
738
+ * failures. Resolves once the subscription is acquired (and the poll loop has
739
+ * been started) or once `lifecycle.stopped` flips, whichever comes first.
740
+ */
741
+ async function subscribeWithRetry(options, lifecycle) {
742
+ const { api, brokerId, format, maxFps, tag, logger } = options;
743
+ let backoffMs = INITIAL_SUBSCRIBE_RETRY_BACKOFF_MS;
744
+ let attempt = 0;
745
+ while (!lifecycle.stopped) {
746
+ attempt += 1;
747
+ try {
748
+ const result = await api.streamBroker.subscribeFrames.mutate({
749
+ brokerId,
750
+ format,
751
+ maxFps,
752
+ tag
753
+ });
754
+ if (lifecycle.stopped) {
755
+ await api.streamBroker.unsubscribeFrames.mutate({ subscriptionId: result.subscriptionId }).catch((err) => {
756
+ logger.warn("frame-handle poller: late unsubscribe failed", { meta: {
757
+ brokerId,
758
+ subscriptionId: result.subscriptionId,
759
+ error: errMsg(err)
760
+ } });
761
+ });
762
+ return;
763
+ }
764
+ lifecycle.activeTeardown = startPolling(options, result.subscriptionId, result.maxFps, lifecycle);
765
+ return;
766
+ } catch (err) {
767
+ if (lifecycle.stopped) return;
768
+ if (attempt === 1) logger.warn("frame-handle poller: subscribeFrames failed, retrying", { meta: {
769
+ brokerId,
770
+ format,
771
+ tag,
772
+ error: errMsg(err),
773
+ nextRetryInMs: backoffMs
774
+ } });
775
+ else logger.debug("frame-handle poller: subscribeFrames still failing", { meta: {
776
+ brokerId,
777
+ format,
778
+ tag,
779
+ attempt,
780
+ error: errMsg(err),
781
+ nextRetryInMs: backoffMs
782
+ } });
783
+ await sleep(backoffMs, lifecycle);
784
+ backoffMs = Math.min(MAX_SUBSCRIBE_RETRY_BACKOFF_MS, backoffMs * 2);
785
+ }
786
+ }
787
+ }
788
+ /**
789
+ * Sleep `ms` milliseconds, but resolve early when `lifecycle.stopped` flips so
790
+ * a teardown during a retry pause doesn't have to wait out the full backoff.
791
+ */
792
+ function sleep(ms, lifecycle) {
793
+ return new Promise((resolve) => {
794
+ lifecycle.retryTimer = setTimeout(() => {
795
+ lifecycle.retryTimer = void 0;
796
+ resolve();
797
+ }, ms);
798
+ });
799
+ }
800
+ /**
801
+ * Run the steady-state poll loop for a successfully-acquired subscription.
802
+ * Returns the teardown closure that stops the loop and releases the
803
+ * subscription.
804
+ */
805
+ function startPolling(options, subscriptionId, resolvedMaxFps, lifecycle) {
806
+ const { api, brokerId, onFrame, logger } = options;
807
+ const readers = new FrameRingReaderCache(logger);
808
+ const pollIntervalMs = resolvedMaxFps > 0 ? Math.max(MIN_POLL_INTERVAL_MS, Math.round(1e3 / resolvedMaxFps)) : FALLBACK_POLL_INTERVAL_MS;
809
+ let timer;
810
+ const tick = async () => {
811
+ if (lifecycle.stopped) return;
812
+ try {
813
+ const handles = await api.streamBroker.pullFrameHandles.query({
814
+ subscriptionId,
815
+ maxCount: PULL_MAX_COUNT
816
+ });
817
+ for (const handle of handles) {
818
+ if (lifecycle.stopped) break;
819
+ const frame = readers.read(handle);
820
+ if (frame) onFrame(frame, handle);
821
+ }
822
+ } catch (err) {
823
+ logger.warn("frame-handle poller: pullFrameHandles failed", { meta: {
824
+ brokerId,
825
+ subscriptionId,
826
+ error: errMsg(err)
827
+ } });
828
+ }
829
+ if (!lifecycle.stopped) timer = setTimeout(() => void tick(), pollIntervalMs);
830
+ };
831
+ tick();
832
+ return () => {
833
+ if (timer) {
834
+ clearTimeout(timer);
835
+ timer = void 0;
836
+ }
837
+ readers.close();
838
+ api.streamBroker.unsubscribeFrames.mutate({ subscriptionId }).catch((err) => {
839
+ logger.warn("frame-handle poller: unsubscribeFrames failed", { meta: {
840
+ brokerId,
841
+ subscriptionId,
842
+ error: errMsg(err)
843
+ } });
844
+ });
845
+ };
666
846
  }
667
- const BenchEngineChoiceSchema = object({
668
- runtime: _enum(["node", "python"]),
669
- backend: string(),
670
- format: _enum(["onnx", "coreml", "openvino", "tflite", "pt"]),
671
- device: string().optional()
847
+ //#endregion
848
+ //#region src/pipeline-runner/bench-actions.ts
849
+ /**
850
+ * Synthetic-bench custom actions for pipeline-runner.
851
+ *
852
+ * Lets the admin UI drive a production-realistic detection benchmark:
853
+ * the runner — already co-located with detection-pipeline in the
854
+ * `pipeline` group — invokes `api.pipelineExecutor.runPipeline` directly
855
+ * (in-process via localProviderLink, no Moleculer hop), N workers
856
+ * concurrent × M iterations. Returns aggregate stats.
857
+ *
858
+ * Why this exists: the older bench path (UI → benchmark addon → pipeline
859
+ * addon) crosses TWO Moleculer process boundaries that the production
860
+ * camera path does NOT have (broker / runner / detection-pipeline are
861
+ * all in the same group). Running the loop from inside the runner gives
862
+ * fps numbers that match what real cameras achieve.
863
+ */
864
+ var BenchEngineChoiceSchema = object({
865
+ runtime: _enum(["node", "python"]),
866
+ backend: string(),
867
+ format: _enum([
868
+ "onnx",
869
+ "coreml",
870
+ "openvino",
871
+ "tflite",
872
+ "pt"
873
+ ]),
874
+ device: string().optional()
672
875
  });
673
- const BenchStepSchema = lazy(() => object({
674
- addonId: string(),
675
- modelId: string(),
676
- enabled: boolean(),
677
- children: array(BenchStepSchema).optional()
876
+ var BenchStepSchema = lazy(() => object({
877
+ addonId: string(),
878
+ modelId: string(),
879
+ enabled: boolean(),
880
+ children: array(BenchStepSchema).optional()
678
881
  }));
679
- const CacheBenchFrameInputSchema = object({
680
- imageBase64: string(),
681
- ttlSeconds: number().int().positive().optional()
882
+ var CacheBenchFrameInputSchema = object({
883
+ imageBase64: string(),
884
+ ttlSeconds: number().int().positive().optional()
682
885
  });
683
- const CacheBenchFrameResultSchema = object({
684
- frameId: string(),
685
- width: number(),
686
- height: number(),
687
- expiresAt: number()
886
+ var CacheBenchFrameResultSchema = object({
887
+ frameId: string(),
888
+ width: number(),
889
+ height: number(),
890
+ expiresAt: number()
688
891
  });
689
- const ReleaseBenchFrameInputSchema = object({
690
- frameId: string()
892
+ var ReleaseBenchFrameInputSchema = object({ frameId: string() });
893
+ var ReleaseBenchFrameResultSchema = object({ released: boolean() });
894
+ var RunSyntheticBenchInputSchema = object({
895
+ frameId: string(),
896
+ steps: array(BenchStepSchema).min(1),
897
+ parallel: number().int().min(1).max(32),
898
+ iterations: number().int().min(1).max(1e4),
899
+ warmup: number().int().min(0).max(100).optional(),
900
+ sessionId: string().optional(),
901
+ simulatePipeline: boolean().optional(),
902
+ engine: BenchEngineChoiceSchema.optional()
691
903
  });
692
- const ReleaseBenchFrameResultSchema = object({
693
- released: boolean()
904
+ var TimingSplitSchema = object({
905
+ mean: number(),
906
+ p50: number(),
907
+ p95: number(),
908
+ p99: number()
694
909
  });
695
- const RunSyntheticBenchInputSchema = object({
696
- frameId: string(),
697
- steps: array(BenchStepSchema).min(1),
698
- parallel: number().int().min(1).max(32),
699
- iterations: number().int().min(1).max(1e4),
700
- warmup: number().int().min(0).max(100).optional(),
701
- sessionId: string().optional(),
702
- simulatePipeline: boolean().optional(),
703
- engine: BenchEngineChoiceSchema.optional()
910
+ var RunSyntheticBenchResultSchema = object({
911
+ runs: number(),
912
+ wallSec: number(),
913
+ fps: number(),
914
+ detectionsPerSec: number(),
915
+ avgDetections: number(),
916
+ callMs: TimingSplitSchema,
917
+ inferMs: number(),
918
+ preprocessMs: number(),
919
+ predictMs: number(),
920
+ batchSizeMean: number(),
921
+ batchSizeMax: number(),
922
+ engine: object({
923
+ runtime: string(),
924
+ backend: string(),
925
+ device: string().optional()
926
+ }).optional(),
927
+ tuning: object({
928
+ batchMode: string(),
929
+ windowMs: number(),
930
+ maxBatchSize: number(),
931
+ concurrency: number()
932
+ }).optional(),
933
+ path: string().optional()
704
934
  });
705
- const TimingSplitSchema = object({
706
- mean: number(),
707
- p50: number(),
708
- p95: number(),
709
- p99: number()
935
+ var pipelineRunnerBenchActions = defineCustomActions({
936
+ cacheBenchFrame: customAction(CacheBenchFrameInputSchema, CacheBenchFrameResultSchema, { kind: "mutation" }),
937
+ releaseBenchFrame: customAction(ReleaseBenchFrameInputSchema, ReleaseBenchFrameResultSchema, { kind: "mutation" }),
938
+ runSyntheticBench: customAction(RunSyntheticBenchInputSchema, RunSyntheticBenchResultSchema, { kind: "mutation" })
710
939
  });
711
- const RunSyntheticBenchResultSchema = object({
712
- runs: number(),
713
- wallSec: number(),
714
- fps: number(),
715
- detectionsPerSec: number(),
716
- avgDetections: number(),
717
- callMs: TimingSplitSchema,
718
- inferMs: number(),
719
- preprocessMs: number(),
720
- predictMs: number(),
721
- batchSizeMean: number(),
722
- batchSizeMax: number(),
723
- engine: object({ runtime: string(), backend: string(), device: string().optional() }).optional(),
724
- tuning: object({ batchMode: string(), windowMs: number(), maxBatchSize: number(), concurrency: number() }).optional(),
725
- path: string().optional()
726
- });
727
- const pipelineRunnerBenchActions = defineCustomActions({
728
- cacheBenchFrame: customAction(
729
- CacheBenchFrameInputSchema,
730
- CacheBenchFrameResultSchema,
731
- { kind: "mutation" }
732
- ),
733
- releaseBenchFrame: customAction(
734
- ReleaseBenchFrameInputSchema,
735
- ReleaseBenchFrameResultSchema,
736
- { kind: "mutation" }
737
- ),
738
- runSyntheticBench: customAction(
739
- RunSyntheticBenchInputSchema,
740
- RunSyntheticBenchResultSchema,
741
- { kind: "mutation" }
742
- )
743
- });
744
- const DEFAULT_CONFIG = {
745
- maxQueueDepth: 30,
746
- // CoreML window accumulator coalesces concurrent calls into a single
747
- // model.predict([list]) — the more in-flight, the larger the batch and
748
- // the higher the per-frame throughput. With concurrency=2 the window
749
- // never fills past batch=2, capping the pool at ~50 fps single-node.
750
- // 16 matches the slider ceiling and lines up with bench numbers
751
- // (parallel=16 hits batch=7-8/8, sustaining ~140 fps full path).
752
- maxConcurrentInferences: 16,
753
- targetLoadPercent: 80,
754
- minThrottledFps: 1
940
+ //#endregion
941
+ //#region src/pipeline-runner/index.ts
942
+ var DEFAULT_CONFIG = {
943
+ maxQueueDepth: 30,
944
+ maxConcurrentInferences: 16,
945
+ targetLoadPercent: 80,
946
+ minThrottledFps: 1
755
947
  };
948
+ /**
949
+ * Pure decision helper: returns `true` when a dynamic frame-diff analyzer
950
+ * subscription should be opened for an onboard-motion camera.
951
+ *
952
+ * Conditions (all must hold):
953
+ * - `onboardMotionDrivesAnalyzer` flag is enabled on the config.
954
+ * - `motionSources` does NOT already include `'analyzer'` — if it does,
955
+ * the analyzer runs continuously and there is nothing to gate.
956
+ *
957
+ * Extracted as a pure function so the unit suite can exercise the decision
958
+ * logic without instantiating the full addon.
959
+ */
756
960
  function shouldStartOnboardAnalyzer(config) {
757
- if (!config.onboardMotionDrivesAnalyzer) return false;
758
- if (config.motionSources.includes("analyzer")) return false;
759
- return true;
961
+ if (!config.onboardMotionDrivesAnalyzer) return false;
962
+ if (config.motionSources.includes("analyzer")) return false;
963
+ return true;
760
964
  }
761
965
  function toFrameInput(frame) {
762
- return {
763
- data: frame.data,
764
- width: frame.width,
765
- height: frame.height,
766
- format: frame.format,
767
- timestamp: frame.timestamp
768
- };
769
- }
770
- const STEP_LOG_INTERVAL_MS = 3e4;
771
- const METRICS_SNAPSHOT_INTERVAL_MS = 1e3;
772
- const METRICS_HEARTBEAT_MS = 3e4;
773
- class PipelineRunnerAddon extends BaseAddon {
774
- runner = null;
775
- attached = /* @__PURE__ */ new Map();
776
- nodeId = "unknown";
777
- stepLogTimer = null;
778
- metricsSnapshotTimer = null;
779
- unsubMotionEvents = null;
780
- /** Last analyzer-detected state per device — gates the
781
- * `MotionOnMotionChanged` emit in `runMotionAnalysis` to transitions
782
- * only (otherwise we'd emit on every analyzer frame). */
783
- lastAnalyzerDetected = /* @__PURE__ */ new Map();
784
- /**
785
- * Last positive motion timestamp per device — preserved across the
786
- * OFF transition so the motion runtime-state slice keeps a stable
787
- * `lastDetectedAt` after the cooldown closes the phase. Cleared on
788
- * detach.
789
- */
790
- lastMotionAt = /* @__PURE__ */ new Map();
791
- /**
792
- * Dynamic analyzer subscriptions opened on `MotionOnMotionChanged
793
- * source:'onboard'` when `onboardMotionDrivesAnalyzer === true`. Each
794
- * entry is the unsubscribe handle returned by `subscribeMotionFrames`.
795
- * Cleared on teardown timer fire, detach, and shutdown.
796
- */
797
- onboardAnalyzerSubs = /* @__PURE__ */ new Map();
798
- /**
799
- * Teardown timers that close the dynamic analyzer subscription after
800
- * `motionCooldownMs` without a new motion event. Re-armed on every
801
- * `MotionOnMotionChanged source:'onboard'` call so the sub stays open
802
- * while motion persists.
803
- */
804
- onboardAnalyzerTeardownTimers = /* @__PURE__ */ new Map();
805
- /**
806
- * Snapshot-equality cache for metrics-snapshot defer. The runner
807
- * fires per-camera metrics every `METRICS_SNAPSHOT_INTERVAL_MS`;
808
- * for an idle camera (no inference, queue empty, fps=0) every tick
809
- * carries an identical payload. We skip the bus emit when the
810
- * payload deep-equals the previous one so the events tab + remote
811
- * subscribers stop seeing 60 metrics-snapshots/min/camera that
812
- * convey nothing. A periodic heartbeat re-emits every
813
- * METRICS_HEARTBEAT_MS so consumers know the runner is still
814
- * alive.
815
- */
816
- lastEmittedCameraMetrics = /* @__PURE__ */ new Map();
817
- lastEmittedRunnerLoad = null;
818
- /**
819
- * In-memory bench-frame cache (decoded JPEG bytes). Populated by the
820
- * `cacheBenchFrame` custom action. Fed into the synthetic-bench loop
821
- * via the `frame: FrameInput` shape that mirrors what stream-broker
822
- * delivers to this very addon during real camera detection.
823
- */
824
- benchFrameCache = /* @__PURE__ */ new Map();
825
- benchFrameSweeper = null;
826
- constructor() {
827
- super({ ...DEFAULT_CONFIG });
828
- }
829
- async onInitialize() {
830
- const raw = this.ctx.kernel.localNodeId ?? this.ctx.id;
831
- this.nodeId = raw.includes("/") ? raw.split("/")[0] : raw;
832
- this.runner = new PipelineRunner({
833
- maxQueueDepth: this.config.maxQueueDepth,
834
- maxConcurrentInferences: this.config.maxConcurrentInferences,
835
- targetLoadPercent: this.config.targetLoadPercent,
836
- minThrottledFps: this.config.minThrottledFps,
837
- processFrame: (deviceId, frame) => this.runInference(deviceId, frame),
838
- analyzeMotion: (deviceId, frame) => this.runMotionAnalysis(deviceId, frame),
839
- onPhaseChanged: (deviceId, phase, meta) => this.handlePhaseChanged(deviceId, phase, meta),
840
- logger: this.ctx.logger
841
- });
842
- this.runner.timingSampler.setLogger(this.ctx.logger.child("timing"));
843
- this.runner.onDetectionStreamChange((deviceId, action) => {
844
- this.handleDetectionStreamChange(deviceId, action);
845
- });
846
- this.runner.onResult(async (deviceId, frame, result, _streamType, handle) => {
847
- this.emitInferenceResult(deviceId, frame, result, handle);
848
- });
849
- this.runner.start();
850
- this.ctx.logger.info(
851
- "Pipeline runner started",
852
- {
853
- tags: { nodeId: this.nodeId },
854
- meta: {
855
- maxConcurrent: this.config.maxConcurrentInferences,
856
- queueDepth: this.config.maxQueueDepth
857
- }
858
- }
859
- );
860
- if (this.ctx.eventBus) {
861
- this.unsubMotionEvents = this.ctx.eventBus.subscribe(
862
- { category: EventCategory.MotionOnMotionChanged },
863
- (event) => {
864
- const data = event.data;
865
- const deviceId = data.deviceId;
866
- const attachment = this.attached.get(deviceId);
867
- if (!attachment) return;
868
- const source = data.source;
869
- if (source === "onboard") {
870
- void this.handleOnboardMotionAnalyzer(deviceId, data.detected);
871
- }
872
- if (!attachment.config.motionSources.includes(source)) return;
873
- this.runner?.reportMotion(
874
- deviceId,
875
- data.detected,
876
- source,
877
- data.regions ? [...data.regions] : void 0
878
- );
879
- }
880
- );
881
- }
882
- this.stepLogTimer = setInterval(() => this.logAttachedSteps(), STEP_LOG_INTERVAL_MS);
883
- this.metricsSnapshotTimer = setInterval(
884
- () => this.emitMetricsSnapshot(),
885
- METRICS_SNAPSHOT_INTERVAL_MS
886
- );
887
- return {
888
- providers: [{ capability: pipelineRunnerCapability, provider: this }],
889
- customActions: pipelineRunnerBenchActions,
890
- actionHandlers: {
891
- cacheBenchFrame: async (input) => this.cacheBenchFrame(input),
892
- releaseBenchFrame: async (input) => this.releaseBenchFrame(input),
893
- runSyntheticBench: async (input) => this.runSyntheticBench(input)
894
- }
895
- };
896
- }
897
- async onShutdown() {
898
- if (this.metricsSnapshotTimer) {
899
- clearInterval(this.metricsSnapshotTimer);
900
- this.metricsSnapshotTimer = null;
901
- }
902
- if (this.stepLogTimer) {
903
- clearInterval(this.stepLogTimer);
904
- this.stepLogTimer = null;
905
- }
906
- if (this.benchFrameSweeper) {
907
- clearInterval(this.benchFrameSweeper);
908
- this.benchFrameSweeper = null;
909
- }
910
- this.benchFrameCache.clear();
911
- if (this.unsubMotionEvents) {
912
- this.unsubMotionEvents();
913
- this.unsubMotionEvents = null;
914
- }
915
- this.lastAnalyzerDetected.clear();
916
- for (const deviceId of [...this.onboardAnalyzerTeardownTimers.keys(), ...this.onboardAnalyzerSubs.keys()]) {
917
- this.clearOnboardAnalyzer(deviceId);
918
- }
919
- if (this.runner) {
920
- this.runner.stop();
921
- this.runner = null;
922
- }
923
- for (const attachment of this.attached.values()) {
924
- attachment.motionUnsubscribe?.();
925
- attachment.detectionUnsubscribe?.();
926
- }
927
- this.attached.clear();
928
- }
929
- // ── Synthetic bench (production-equivalent measurement) ───────────────
930
- async cacheBenchFrame(input) {
931
- const sharp = (await import("sharp")).default;
932
- const jpeg = Buffer.from(input.imageBase64, "base64");
933
- const { data, info } = await sharp(jpeg).raw().toBuffer({ resolveWithObject: true });
934
- if (info.channels !== 3) {
935
- throw new Error(`cacheBenchFrame: expected 3 channels (rgb), got ${info.channels}`);
936
- }
937
- const rgb = new Uint8Array(data);
938
- const ttlMs = Math.max(6e4, (input.ttlSeconds ?? 600) * 1e3);
939
- const frameId = `runner-bench-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
940
- const expiresAt = Date.now() + ttlMs;
941
- this.benchFrameCache.set(frameId, { data: rgb, width: info.width, height: info.height, format: "rgb", expiresAt });
942
- if (!this.benchFrameSweeper) {
943
- this.benchFrameSweeper = setInterval(() => this.sweepBenchFrameCache(), 6e4);
944
- this.benchFrameSweeper.unref?.();
945
- }
946
- this.ctx.logger.info("cached bench frame", {
947
- meta: { frameId, width: info.width, height: info.height, bytes: rgb.length, ttlMs }
948
- });
949
- return { frameId, width: info.width, height: info.height, expiresAt };
950
- }
951
- async releaseBenchFrame(input) {
952
- return { released: this.benchFrameCache.delete(input.frameId) };
953
- }
954
- sweepBenchFrameCache() {
955
- const now = Date.now();
956
- for (const [id, entry] of this.benchFrameCache) {
957
- if (entry.expiresAt < now) this.benchFrameCache.delete(id);
958
- }
959
- }
960
- async runSyntheticBench(input) {
961
- const ctx = this.ctx;
962
- const api = ctx.api;
963
- if (!api) throw new Error("runSyntheticBench: ctx.api unavailable");
964
- ctx.logger.info("runSyntheticBench input", {
965
- meta: { frameId: input.frameId, parallel: input.parallel, iterations: input.iterations }
966
- });
967
- const cached = this.benchFrameCache.get(input.frameId);
968
- if (!cached) {
969
- throw new Error(`runSyntheticBench: frameId ${input.frameId} not cached (call cacheBenchFrame first)`);
970
- }
971
- const stepsToRun = input.steps.map((s) => ({
972
- addonId: s.addonId,
973
- modelId: s.modelId,
974
- enabled: s.enabled,
975
- children: s.children ?? []
976
- }));
977
- const enabledSteps = stepsToRun.filter((s) => s.enabled);
978
- const isSingleStep = enabledSteps.length === 1 && (!enabledSteps[0].children || enabledSteps[0].children.filter((c) => c.enabled).length === 0);
979
- const useFastPath = isSingleStep && !input.simulatePipeline;
980
- const rootStep = enabledSteps[0];
981
- const sharedFrame = {
982
- data: cached.data,
983
- format: cached.format,
984
- width: cached.width,
985
- height: cached.height,
986
- timestamp: Date.now()
987
- };
988
- let poolFrameId = null;
989
- if (useFastPath && rootStep) {
990
- ctx.logger.info("synthetic bench: using Python cache path", {
991
- meta: { step: rootStep.addonId, model: rootStep.modelId }
992
- });
993
- const cacheResult = await api.pipelineExecutor.cacheFrameInPool.mutate({
994
- data: new Uint8Array(cached.data.slice().buffer),
995
- width: cached.width,
996
- height: cached.height,
997
- format: cached.format
998
- });
999
- poolFrameId = cacheResult.frameId;
1000
- await api.pipelineExecutor.runPipeline.mutate({
1001
- steps: stepsToRun,
1002
- frame: sharedFrame,
1003
- ...input.engine ? { engine: input.engine } : {}
1004
- });
1005
- const warmupCount2 = input.warmup ?? 1;
1006
- for (let w = 0; w < warmupCount2; w++) {
1007
- await api.pipelineExecutor.inferCached.mutate({
1008
- stepId: rootStep.addonId,
1009
- frameId: poolFrameId
1010
- });
1011
- }
1012
- const wallTimings2 = [];
1013
- const inferTimings2 = [];
1014
- const preprocessTimings2 = [];
1015
- const predictTimings2 = [];
1016
- const batchSizes2 = [];
1017
- const detCounts2 = [];
1018
- let _n = 0;
1019
- const sessionId2 = input.sessionId ?? `synth-${Date.now().toString(36)}`;
1020
- const totalRuns2 = input.parallel * input.iterations;
1021
- const wallStart2 = performance.now();
1022
- const worker2 = async () => {
1023
- for (let i = 0; i < input.iterations; i++) {
1024
- const t0 = performance.now();
1025
- const result = await api.pipelineExecutor.inferCached.mutate({
1026
- stepId: rootStep.addonId,
1027
- frameId: poolFrameId
1028
- });
1029
- const wallMs = performance.now() - t0;
1030
- const r = result;
1031
- const inferMs = typeof r["inferenceMs"] === "number" ? r["inferenceMs"] : wallMs;
1032
- const preMs = typeof r["preprocessMs"] === "number" ? r["preprocessMs"] : 0;
1033
- const predMs = typeof r["predictMs"] === "number" ? r["predictMs"] : 0;
1034
- const bs = typeof r["batchSize"] === "number" ? r["batchSize"] : 1;
1035
- const dets = Array.isArray(r["detections"]) ? r["detections"].length : 0;
1036
- wallTimings2.push(wallMs);
1037
- inferTimings2.push(inferMs);
1038
- preprocessTimings2.push(preMs);
1039
- predictTimings2.push(predMs);
1040
- batchSizes2.push(bs);
1041
- detCounts2.push(dets);
1042
- const n = ++_n;
1043
- if (n <= 20) {
1044
- ctx.logger.info("bench call trace (cached)", {
1045
- meta: { n, wallMs: Math.round(wallMs), inferMs: Math.round(inferMs), preMs: Math.round(preMs * 10) / 10, predMs: Math.round(predMs * 10) / 10, bs }
1046
- });
1047
- }
1048
- if (n % Math.max(1, input.parallel) === 0) {
1049
- const elapsed = (performance.now() - wallStart2) / 1e3;
1050
- const fps = elapsed > 0 ? n / elapsed : 0;
1051
- const meanCallMs = wallTimings2.reduce((s, v) => s + v, 0) / wallTimings2.length;
1052
- const sorted = [...wallTimings2].sort((a, b) => a - b);
1053
- const p95 = sorted[Math.min(sorted.length - 1, Math.floor(0.95 * sorted.length))] ?? 0;
1054
- const totalDet = detCounts2.reduce((s, v) => s + v, 0);
1055
- const avgDet = detCounts2.length > 0 ? totalDet / detCounts2.length : 0;
1056
- const bsMean = batchSizes2.reduce((s, v) => s + v, 0) / batchSizes2.length;
1057
- const msg = `runs ${n}/${totalRuns2} · ${fps.toFixed(1)} fps · call ${meanCallMs.toFixed(1)}ms · batch ${bsMean.toFixed(1)}`;
1058
- if (ctx.eventBus) {
1059
- ctx.eventBus.emit({
1060
- id: `bench-${n}`,
1061
- timestamp: /* @__PURE__ */ new Date(),
1062
- source: { type: "pipeline", id: "synthetic-bench" },
1063
- category: EventCategory.PipelineProgress,
1064
- data: {
1065
- nodeId: "hub",
1066
- sessionId: sessionId2,
1067
- step: "synthetic-bench",
1068
- message: msg,
1069
- benchProgress: true,
1070
- runs: n,
1071
- totalRuns: totalRuns2,
1072
- fps: Math.round(fps * 100) / 100,
1073
- meanMs: Math.round(meanCallMs * 100) / 100,
1074
- p95Ms: Math.round(p95 * 100) / 100,
1075
- inferMeanMs: Math.round(inferTimings2.reduce((s, v) => s + v, 0) / inferTimings2.length * 100) / 100,
1076
- preprocessMeanMs: Math.round(preprocessTimings2.reduce((s, v) => s + v, 0) / preprocessTimings2.length * 100) / 100,
1077
- predictMeanMs: Math.round(predictTimings2.reduce((s, v) => s + v, 0) / predictTimings2.length * 100) / 100,
1078
- batchSizeMean: Math.round(bsMean * 100) / 100,
1079
- detPerSec: elapsed > 0 ? Math.round(totalDet / elapsed * 100) / 100 : 0,
1080
- avgDetections: Math.round(avgDet * 100) / 100
1081
- }
1082
- });
1083
- } else {
1084
- ctx.logger.warn("emitProgress: NO eventBus");
1085
- }
1086
- }
1087
- }
1088
- };
1089
- await Promise.all(Array.from({ length: input.parallel }, () => worker2()));
1090
- const wallSec2 = (performance.now() - wallStart2) / 1e3;
1091
- await api.pipelineExecutor.uncacheFrame.mutate({ frameId: poolFrameId }).catch(() => {
1092
- });
1093
- return this.buildBenchResult(wallTimings2, inferTimings2, preprocessTimings2, predictTimings2, batchSizes2, detCounts2, wallSec2, "cached");
1094
- }
1095
- ctx.logger.info("synthetic bench: using full runPipeline path", {
1096
- meta: { steps: enabledSteps.length, simulatePipeline: !!input.simulatePipeline }
1097
- });
1098
- let _callCount = 0;
1099
- const callOnce = async () => {
1100
- const t0 = performance.now();
1101
- const result = await api.pipelineExecutor.runPipeline.mutate({
1102
- steps: stepsToRun,
1103
- frame: sharedFrame,
1104
- ...input.engine ? { engine: input.engine } : {}
1105
- });
1106
- const wallMs = performance.now() - t0;
1107
- const n = ++_callCount;
1108
- if (n <= 20) {
1109
- ctx.logger.info("bench call trace", {
1110
- meta: {
1111
- n,
1112
- wallMs: Math.round(wallMs),
1113
- totalInferenceMs: Math.round(result.debug?.totalInferenceMs ?? 0),
1114
- predictMs: Math.round((result.debug?.predictMs ?? 0) * 10) / 10,
1115
- preprocessMs: Math.round((result.debug?.preprocessMs ?? 0) * 10) / 10,
1116
- batchSize: result.debug?.batchSize ?? 1
1117
- }
1118
- });
1119
- }
1120
- return { wallMs, result };
1121
- };
1122
- const warmupCount = input.warmup ?? 1;
1123
- for (let i = 0; i < warmupCount; i++) {
1124
- await callOnce();
1125
- }
1126
- const wallTimings = [];
1127
- const serverWallTimings = [];
1128
- const inferTimings = [];
1129
- const preprocessTimings = [];
1130
- const predictTimings = [];
1131
- const batchSizes = [];
1132
- const detCounts = [];
1133
- const sessionId = input.sessionId ?? `synth-${Date.now().toString(36)}`;
1134
- const totalRuns = input.parallel * input.iterations;
1135
- const wallStart = performance.now();
1136
- const worker = async () => {
1137
- for (let i = 0; i < input.iterations; i++) {
1138
- const { wallMs, result } = await callOnce();
1139
- wallTimings.push(wallMs);
1140
- serverWallTimings.push(result.debug?.wallMs ?? 0);
1141
- inferTimings.push(result.debug?.totalInferenceMs ?? 0);
1142
- preprocessTimings.push(result.debug?.preprocessMs ?? 0);
1143
- predictTimings.push(result.debug?.predictMs ?? 0);
1144
- batchSizes.push(result.debug?.batchSize ?? 1);
1145
- detCounts.push(result.detections?.length ?? 0);
1146
- const n = wallTimings.length;
1147
- if (n % Math.max(1, input.parallel) === 0 && ctx.eventBus) {
1148
- const elapsed = (performance.now() - wallStart) / 1e3;
1149
- const fps = elapsed > 0 ? n / elapsed : 0;
1150
- const meanMs = wallTimings.reduce((s, v) => s + v, 0) / n;
1151
- const sorted = [...wallTimings].sort((a, b) => a - b);
1152
- const p95 = sorted[Math.min(sorted.length - 1, Math.floor(0.95 * sorted.length))] ?? 0;
1153
- const totalDet = detCounts.reduce((s, v) => s + v, 0);
1154
- const bsMean = batchSizes.reduce((s, v) => s + v, 0) / n;
1155
- ctx.eventBus.emit({
1156
- id: `bench-${n}`,
1157
- timestamp: /* @__PURE__ */ new Date(),
1158
- source: { type: "pipeline", id: "synthetic-bench" },
1159
- category: EventCategory.PipelineProgress,
1160
- data: {
1161
- nodeId: "hub",
1162
- sessionId,
1163
- step: "synthetic-bench",
1164
- message: `runs ${n}/${totalRuns} · ${fps.toFixed(1)} fps · call ${meanMs.toFixed(1)}ms · batch ${bsMean.toFixed(1)}`,
1165
- benchProgress: true,
1166
- runs: n,
1167
- totalRuns,
1168
- fps: Math.round(fps * 100) / 100,
1169
- meanMs: Math.round(meanMs * 100) / 100,
1170
- p95Ms: Math.round(p95 * 100) / 100,
1171
- inferMeanMs: Math.round(inferTimings.reduce((s, v) => s + v, 0) / n * 100) / 100,
1172
- preprocessMeanMs: Math.round(preprocessTimings.reduce((s, v) => s + v, 0) / n * 100) / 100,
1173
- predictMeanMs: Math.round(predictTimings.reduce((s, v) => s + v, 0) / n * 100) / 100,
1174
- batchSizeMean: Math.round(bsMean * 100) / 100,
1175
- detPerSec: elapsed > 0 ? Math.round(totalDet / elapsed * 100) / 100 : 0,
1176
- avgDetections: n > 0 ? Math.round(totalDet / n * 100) / 100 : 0
1177
- }
1178
- });
1179
- }
1180
- }
1181
- };
1182
- await Promise.all(Array.from({ length: input.parallel }, () => worker()));
1183
- const wallSec = (performance.now() - wallStart) / 1e3;
1184
- return this.buildBenchResult(wallTimings, inferTimings, preprocessTimings, predictTimings, batchSizes, detCounts, wallSec, "pipeline");
1185
- }
1186
- async buildBenchResult(wallTimings, inferTimings, preprocessTimings, predictTimings, batchSizes, detCounts, wallSec, path) {
1187
- const meanOfArr = (xs) => xs.length > 0 ? xs.reduce((s, v) => s + v, 0) / xs.length : 0;
1188
- this.ctx.logger.info("synthetic bench summary", {
1189
- meta: {
1190
- runs: wallTimings.length,
1191
- wallSec: Math.round(wallSec * 100) / 100,
1192
- fps: Math.round(wallTimings.length / wallSec * 100) / 100,
1193
- callMeanMs: Math.round(meanOfArr(wallTimings)),
1194
- inferMeanMs: Math.round(meanOfArr(inferTimings)),
1195
- preprocessMeanMs: Math.round(meanOfArr(preprocessTimings)),
1196
- predictMeanMs: Math.round(meanOfArr(predictTimings)),
1197
- batchSizeMean: Math.round(meanOfArr(batchSizes) * 100) / 100,
1198
- batchSizeMax: batchSizes.length > 0 ? Math.max(...batchSizes) : 0
1199
- }
1200
- });
1201
- const sorted = [...wallTimings].sort((a, b) => a - b);
1202
- const pick = (q) => sorted.length > 0 ? sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))] : 0;
1203
- const meanOf = (xs) => xs.length > 0 ? xs.reduce((s, v) => s + v, 0) / xs.length : 0;
1204
- const totalRuns = wallTimings.length;
1205
- const totalDet = detCounts.reduce((s, v) => s + v, 0);
1206
- return {
1207
- runs: totalRuns,
1208
- wallSec: Math.round(wallSec * 1e3) / 1e3,
1209
- fps: wallSec > 0 ? Math.round(totalRuns / wallSec * 100) / 100 : 0,
1210
- detectionsPerSec: wallSec > 0 ? Math.round(totalDet / wallSec * 100) / 100 : 0,
1211
- avgDetections: totalRuns > 0 ? Math.round(totalDet / totalRuns * 100) / 100 : 0,
1212
- callMs: {
1213
- mean: Math.round(meanOf(wallTimings) * 100) / 100,
1214
- p50: Math.round(pick(0.5) * 100) / 100,
1215
- p95: Math.round(pick(0.95) * 100) / 100,
1216
- p99: Math.round(pick(0.99) * 100) / 100
1217
- },
1218
- inferMs: Math.round(meanOf(inferTimings) * 100) / 100,
1219
- preprocessMs: Math.round(meanOf(preprocessTimings) * 100) / 100,
1220
- predictMs: Math.round(meanOf(predictTimings) * 100) / 100,
1221
- batchSizeMean: Math.round(meanOf(batchSizes) * 100) / 100,
1222
- batchSizeMax: batchSizes.length > 0 ? Math.max(...batchSizes) : 0,
1223
- path,
1224
- ...await this.getEngineAndTuning()
1225
- };
1226
- }
1227
- async getEngineAndTuning() {
1228
- try {
1229
- const api = this.ctx.api;
1230
- if (!api) return {};
1231
- const [eng, tuning] = await Promise.all([
1232
- api.pipelineExecutor.getSelectedEngine.query(),
1233
- api.pipelineExecutor.getEffectiveTuning.query()
1234
- ]);
1235
- return {
1236
- engine: eng ? { runtime: eng.runtime, backend: eng.backend, device: eng.device } : void 0,
1237
- tuning: tuning ?? void 0
1238
- };
1239
- } catch {
1240
- return {};
1241
- }
1242
- }
1243
- // ── IPipelineRunnerProvider implementation ────────────────────────────
1244
- async attachCamera(config) {
1245
- const runner = this.runner;
1246
- const ctx = this.ctx;
1247
- if (!runner || !ctx) {
1248
- throw new Error("PipelineRunnerAddon: attachCamera called before initialize completed");
1249
- }
1250
- this.ctx.logger.info("attachCamera received config", {
1251
- tags: { deviceId: config.deviceId },
1252
- meta: {
1253
- motionSources: config.motionSources,
1254
- motionSourcesType: Array.isArray(config.motionSources) ? `array(${config.motionSources.length})` : typeof config.motionSources,
1255
- motionStreamId: config.motionStreamId,
1256
- detectionStreamId: config.detectionStreamId,
1257
- keys: Object.keys(config)
1258
- }
1259
- });
1260
- if (this.attached.has(config.deviceId)) {
1261
- this.detachInternal(config.deviceId);
1262
- }
1263
- runner.registerCamera(config.deviceId, {
1264
- detectionMode: config.detectionMode,
1265
- fps: config.detectionFps,
1266
- motionCooldownMs: config.motionCooldownMs
1267
- });
1268
- const attachment = {
1269
- config,
1270
- motionUnsubscribe: null,
1271
- detectionUnsubscribe: null
1272
- };
1273
- this.attached.set(config.deviceId, attachment);
1274
- if (config.motionSources.includes("analyzer")) {
1275
- attachment.motionUnsubscribe = await this.subscribeMotionFrames(config);
1276
- }
1277
- const stepsCount = config.steps?.length ?? 0;
1278
- const dispatch = stepsCount > 0 ? `runPipeline(${stepsCount}step${stepsCount === 1 ? "" : "s"})` : config.steps !== void 0 ? "skip(0steps)" : "runFrame(legacy)";
1279
- const engineLabel = config.engine ? `${config.engine.runtime}+${config.engine.backend}/${config.engine.format}` : "default";
1280
- this.ctx.logger.info(
1281
- "attachCamera",
1282
- {
1283
- tags: { deviceId: config.deviceId },
1284
- meta: {
1285
- detectionMode: config.detectionMode,
1286
- audioMode: config.audioMode,
1287
- motionFps: config.motionFps,
1288
- detectionFps: config.detectionFps,
1289
- motionSources: config.motionSources,
1290
- dispatch,
1291
- engine: engineLabel
1292
- }
1293
- }
1294
- );
1295
- return { success: true };
1296
- }
1297
- async detachCamera(input) {
1298
- this.detachInternal(input.deviceId);
1299
- return { success: true };
1300
- }
1301
- async reportMotion(input) {
1302
- this.runner?.reportMotion(input.deviceId, input.detected, input.source, input.regions);
1303
- return { success: true };
1304
- }
1305
- /**
1306
- * Periodic per-camera step roster dump. Once every
1307
- * STEP_LOG_INTERVAL_MS (30s) emits one log line per attached camera
1308
- * with the configured detection step tree + audio classifier branch
1309
- * so an operator looking at the agent log can quickly see what each
1310
- * camera is currently running without crossing tRPC. Skips when no
1311
- * cameras are attached so quiet dev runs stay silent.
1312
- */
1313
- logAttachedSteps() {
1314
- if (this.attached.size === 0) return;
1315
- for (const [deviceId, attachment] of this.attached) {
1316
- const cfg = attachment.config;
1317
- const detectionSteps = cfg.steps && cfg.steps.length > 0 ? this.flattenSteps(cfg.steps).filter((s) => s.enabled) : [];
1318
- const detectionLabel = detectionSteps.length > 0 ? detectionSteps.map((s) => `${s.addonId}/${s.modelId}`).join(" → ") : "<none>";
1319
- const audioLabel = cfg.audio && cfg.audio.enabled ? `${cfg.audio.engine.runtime}/${cfg.audio.engine.backend}/${cfg.audio.modelId}` : "<off>";
1320
- const engineLabel = cfg.engine ? `${cfg.engine.runtime}/${cfg.engine.backend}${cfg.engine.device ? `/${cfg.engine.device}` : ""}` : "<unset>";
1321
- this.ctx.logger.info("Camera pipeline roster", {
1322
- tags: { deviceId },
1323
- meta: {
1324
- phase: "roster",
1325
- intervalSec: STEP_LOG_INTERVAL_MS / 1e3,
1326
- pipelineEnabled: cfg.pipelineEnabled,
1327
- motionSources: cfg.motionSources,
1328
- motionFps: cfg.motionFps,
1329
- detectionFps: cfg.detectionFps,
1330
- engine: engineLabel,
1331
- videoSteps: detectionLabel,
1332
- videoStepCount: detectionSteps.length,
1333
- audio: audioLabel
1334
- }
1335
- });
1336
- }
1337
- }
1338
- /** Recursively flatten the step tree → ordered list of every node. */
1339
- flattenSteps(steps) {
1340
- const out = [];
1341
- const walk = (s) => {
1342
- out.push(s);
1343
- if (s.children) {
1344
- for (const c of s.children) walk(c);
1345
- }
1346
- };
1347
- for (const s of steps) walk(s);
1348
- return out;
1349
- }
1350
- detachInternal(deviceId) {
1351
- const attachment = this.attached.get(deviceId);
1352
- if (!attachment) return;
1353
- this.clearOnboardAnalyzer(deviceId);
1354
- attachment.motionUnsubscribe?.();
1355
- attachment.detectionUnsubscribe?.();
1356
- this.attached.delete(deviceId);
1357
- this.lastMotionAt.delete(deviceId);
1358
- this.lastEmittedCameraMetrics.delete(deviceId);
1359
- this.runner?.unregisterCamera(deviceId);
1360
- this.ctx?.logger.info("detachCamera", { tags: { deviceId } });
1361
- }
1362
- /**
1363
- * Synchronously cancel the teardown timer and call the unsubscribe
1364
- * handle for the dynamic onboard analyzer, if one is open. Safe to
1365
- * call when no subscription exists.
1366
- */
1367
- clearOnboardAnalyzer(deviceId) {
1368
- const timer = this.onboardAnalyzerTeardownTimers.get(deviceId);
1369
- if (timer !== void 0) {
1370
- clearTimeout(timer);
1371
- this.onboardAnalyzerTeardownTimers.delete(deviceId);
1372
- }
1373
- const unsub = this.onboardAnalyzerSubs.get(deviceId);
1374
- if (unsub !== void 0) {
1375
- try {
1376
- unsub();
1377
- } catch {
1378
- }
1379
- this.onboardAnalyzerSubs.delete(deviceId);
1380
- }
1381
- }
1382
- /**
1383
- * Dynamic analyzer gate for onboard-motion cameras.
1384
- *
1385
- * Called from the `MotionOnMotionChanged` subscriber whenever
1386
- * `source === 'onboard'`. Opens a `subscribeMotionFrames` subscription
1387
- * the first time motion is detected (idempotent — a second `detected:true`
1388
- * while the sub is already open is a no-op). Always re-arms the teardown
1389
- * timer so the subscription stays open as long as motion events keep
1390
- * arriving and tears down `motionCooldownMs` after the last event.
1391
- *
1392
- * No-op when:
1393
- * - The camera is not currently attached.
1394
- * - `shouldStartOnboardAnalyzer(config)` returns false (flag off or
1395
- * `motionSources` already includes `'analyzer'`).
1396
- */
1397
- async handleOnboardMotionAnalyzer(deviceId, detected) {
1398
- const attachment = this.attached.get(deviceId);
1399
- if (!attachment) return;
1400
- const config = attachment.config;
1401
- if (!shouldStartOnboardAnalyzer(config)) return;
1402
- const log = this.ctx.logger.withTags({ deviceId });
1403
- if (detected && !this.onboardAnalyzerSubs.has(deviceId)) {
1404
- const unsub = await this.subscribeMotionFrames(config);
1405
- if (unsub) {
1406
- this.onboardAnalyzerSubs.set(deviceId, unsub);
1407
- log.debug("onboard-analyzer: opened motion-frame subscription");
1408
- }
1409
- }
1410
- const existing = this.onboardAnalyzerTeardownTimers.get(deviceId);
1411
- if (existing !== void 0) clearTimeout(existing);
1412
- const cooldownMs = config.motionCooldownMs;
1413
- const timer = setTimeout(() => {
1414
- this.onboardAnalyzerTeardownTimers.delete(deviceId);
1415
- const unsub = this.onboardAnalyzerSubs.get(deviceId);
1416
- if (!unsub) return;
1417
- try {
1418
- unsub();
1419
- } catch {
1420
- }
1421
- this.onboardAnalyzerSubs.delete(deviceId);
1422
- log.debug("onboard-analyzer: closed after motion cooldown", { meta: { cooldownMs } });
1423
- }, cooldownMs);
1424
- this.onboardAnalyzerTeardownTimers.set(deviceId, timer);
1425
- }
1426
- async getLocalLoad() {
1427
- const metrics = this.runner?.getMetrics() ?? { avgInferenceTimeMs: 0, queueDepth: 0 };
1428
- const allCameraMetrics = this.runner?.getAllCameraMetrics() ?? [];
1429
- let activeCameras = 0;
1430
- let totalActualFps = 0;
1431
- for (const cm of allCameraMetrics) {
1432
- if (cm.phase === "active") activeCameras++;
1433
- totalActualFps += cm.actualFps;
1434
- }
1435
- return {
1436
- nodeId: this.nodeId,
1437
- attachedCameras: this.attached.size,
1438
- activeCameras,
1439
- avgInferenceFps: totalActualFps,
1440
- avgInferenceTimeMs: metrics.avgInferenceTimeMs,
1441
- queueDepthTotal: metrics.queueDepth,
1442
- hardware: {
1443
- hasGpu: false,
1444
- inferenceBackend: void 0
1445
- }
1446
- };
1447
- }
1448
- async getLocalMetrics() {
1449
- const m = this.runner?.getMetrics() ?? { activeCameras: 0, throttledCameras: 0, avgInferenceTimeMs: 0, queueDepth: 0 };
1450
- return { nodeId: this.nodeId, ...m };
1451
- }
1452
- async getCameraMetrics(input) {
1453
- return this.runner?.getCameraMetrics(input.deviceId) ?? null;
1454
- }
1455
- getAllCameraMetrics() {
1456
- return this.runner?.getAllCameraMetrics() ?? [];
1457
- }
1458
- getLocalCameras() {
1459
- return [...this.attached.keys()];
1460
- }
1461
- // ── Internal: broker subscription wiring ─────────────────────────────
1462
- async subscribeMotionFrames(config) {
1463
- const ctx = this.ctx;
1464
- const runner = this.runner;
1465
- if (!ctx || !runner) return null;
1466
- const log = this.ctx.logger.withTags({ deviceId: config.deviceId });
1467
- const api = this.ctx.api;
1468
- if (!api) {
1469
- log.warn("subscribeMotionFrames: this.ctx.api not available");
1470
- return null;
1471
- }
1472
- return startFrameHandlePoller({
1473
- api,
1474
- brokerId: `${config.deviceId}/${config.motionStreamId}`,
1475
- format: "gray",
1476
- maxFps: config.motionFps,
1477
- tag: "motion",
1478
- logger: log,
1479
- // Motion analysis is intra-process and never propagates beyond
1480
- // the runner; the handle is intentionally ignored here.
1481
- onFrame: (frame, _handle) => {
1482
- runner.enqueueMotionFrame(config.deviceId, frame);
1483
- }
1484
- });
1485
- }
1486
- handleDetectionStreamChange(deviceId, action) {
1487
- const attachment = this.attached.get(deviceId);
1488
- if (!attachment) return;
1489
- if (action === "subscribe") {
1490
- void this.subscribeDetectionFrames(attachment.config).then((unsub) => {
1491
- attachment.detectionUnsubscribe = unsub;
1492
- });
1493
- } else {
1494
- attachment.detectionUnsubscribe?.();
1495
- attachment.detectionUnsubscribe = null;
1496
- }
1497
- }
1498
- /**
1499
- * Bridge runner phase transitions to the device's `motion` runtime
1500
- * state + the bus. Single ownership point — every motion source
1501
- * (analyzer, onboard, future variants) funnels through the runner's
1502
- * phase machine and lands here.
1503
- *
1504
- * - Cap-state via the unified `device-state.setCapSlice` API.
1505
- * `autoClearAfterMs = cooldownMs` on ON, `null` on OFF.
1506
- * `lastDetectedAt` is preserved across OFF using `lastMotionAt`.
1507
- * - Bus event `MotionOnMotionChanged` fires alongside for consumers
1508
- * that prefer event-driven over runtime-state polling.
1509
- */
1510
- handlePhaseChanged(deviceId, phase, meta) {
1511
- const detected = phase === "active";
1512
- if (detected) this.lastMotionAt.set(deviceId, meta.timestamp);
1513
- const lastDetectedAt = this.lastMotionAt.get(deviceId) ?? null;
1514
- const slice = {
1515
- detected,
1516
- lastDetectedAt,
1517
- autoClearAfterMs: detected ? meta.cooldownMs : null
1518
- };
1519
- void (async () => {
1520
- const dev = await this.ctx.fetchDevice(deviceId);
1521
- await dev.deviceState.setCapSlice({ capName: "motion", slice });
1522
- })().catch((err) => {
1523
- this.ctx.logger.debug("motion cap-state write failed", {
1524
- tags: { deviceId },
1525
- meta: { error: errMsg(err) }
1526
- });
1527
- });
1528
- if (this.ctx.eventBus) {
1529
- const from = detected ? "watching" : "active";
1530
- const to = detected ? "active" : "watching";
1531
- const reason = detected ? "motion_detected" : "cooldown_expired";
1532
- const payload = {
1533
- deviceId,
1534
- from,
1535
- to,
1536
- reason,
1537
- source: meta.source,
1538
- cooldownMs: meta.cooldownMs,
1539
- timestamp: meta.timestamp
1540
- };
1541
- this.ctx.eventBus.emit(createEvent(
1542
- EventCategory.DetectionPhaseTransition,
1543
- { type: "device", id: deviceId, addonId: this.ctx.id, deviceId, nodeId: this.nodeId },
1544
- payload
1545
- ));
1546
- }
1547
- }
1548
- async subscribeDetectionFrames(config) {
1549
- const ctx = this.ctx;
1550
- const runner = this.runner;
1551
- if (!ctx || !runner) return null;
1552
- const log = this.ctx.logger.withTags({ deviceId: config.deviceId });
1553
- const api = this.ctx.api;
1554
- if (!api) {
1555
- log.warn("subscribeDetectionFrames: this.ctx.api not available");
1556
- return null;
1557
- }
1558
- return startFrameHandlePoller({
1559
- api,
1560
- brokerId: `${config.deviceId}/${config.detectionStreamId}`,
1561
- format: "rgb",
1562
- maxFps: config.detectionFps,
1563
- tag: "detection",
1564
- logger: log,
1565
- // Detection threads the `FrameHandle` through the runner so the
1566
- // emitted `PipelineInferenceResultPayload` can name the shm slot
1567
- // post-analysis (Task 8) reads pixels back from.
1568
- onFrame: (frame, handle) => {
1569
- runner.enqueueDetectionFrame(config.deviceId, frame, handle);
1570
- }
1571
- });
1572
- }
1573
- // ── Internal: inference + motion callbacks ───────────────────────────
1574
- async runInference(deviceId, frame) {
1575
- const ctx = this.ctx;
1576
- if (!ctx) return null;
1577
- const log = this.ctx.logger.withTags({ deviceId });
1578
- const api = this.ctx.api;
1579
- if (!api) {
1580
- log.error("runInference: this.ctx.api not available");
1581
- return null;
1582
- }
1583
- const attachment = this.attached.get(deviceId);
1584
- const camConfig = attachment?.config;
1585
- const steps = camConfig?.steps;
1586
- const engine = camConfig?.engine;
1587
- if (!steps) {
1588
- log.warn("runInference: no steps in attach config — skipping frame (legacy attach?)");
1589
- return null;
1590
- }
1591
- if (steps.length === 0) {
1592
- return null;
1593
- }
1594
- try {
1595
- return await api.pipelineExecutor.runPipeline.mutate({
1596
- // tRPC input is a mutable array; the attach payload holds it
1597
- // as readonly. One spread copy at the cap boundary is cheap
1598
- // (pipeline step trees are tiny) and keeps the type surface
1599
- // clean without casting.
1600
- steps: [...steps],
1601
- frame,
1602
- deviceId,
1603
- ...engine ? { engine } : {}
1604
- });
1605
- } catch (err) {
1606
- const msg = errMsg(err);
1607
- log.error("runInference failed", { meta: { error: msg } });
1608
- return null;
1609
- }
1610
- }
1611
- async runMotionAnalysis(deviceId, frame) {
1612
- const ctx = this.ctx;
1613
- const runner = this.runner;
1614
- if (!ctx || !runner) return;
1615
- const log = this.ctx.logger.withTags({ deviceId });
1616
- const motionStart = Date.now();
1617
- try {
1618
- const api = this.ctx.api;
1619
- if (!api) {
1620
- log.warn("runMotionAnalysis: this.ctx.api not available");
1621
- return;
1622
- }
1623
- const result = await api.motionDetection.analyze.mutate({ deviceId, frame: toFrameInput(frame) });
1624
- if (!result) return;
1625
- const detected = result.regions.length > 0;
1626
- const prevDetected = this.lastAnalyzerDetected.get(deviceId) ?? false;
1627
- if (detected !== prevDetected) {
1628
- this.lastAnalyzerDetected.set(deviceId, detected);
1629
- if (this.ctx.eventBus) {
1630
- this.ctx.eventBus.emit(createEvent(
1631
- EventCategory.MotionOnMotionChanged,
1632
- // EventSource wrapper kept symmetric with the onboard
1633
- // emit (Reolink/ONVIF/etc.) so consumers grouping by
1634
- // addonId / deviceId see consistent provenance. `nodeId`
1635
- // identifies which cluster node ran the analyzer.
1636
- { type: "device", id: deviceId, addonId: this.ctx.id, deviceId, nodeId: this.nodeId },
1637
- {
1638
- deviceId,
1639
- detected,
1640
- timestamp: frame.timestamp,
1641
- source: "analyzer",
1642
- ...detected ? { regions: result.regions } : {}
1643
- }
1644
- ));
1645
- }
1646
- }
1647
- if (this.ctx.eventBus) {
1648
- const motionPayload = {
1649
- detected,
1650
- regionCount: result.regions.length,
1651
- regions: result.regions.map((r) => ({
1652
- bbox: { x: r.bbox.x, y: r.bbox.y, w: r.bbox.w, h: r.bbox.h },
1653
- pixelCount: r.pixelCount,
1654
- intensity: r.intensity
1655
- })),
1656
- frameWidth: frame.width,
1657
- frameHeight: frame.height,
1658
- analysisMs: result.analysisMs
1659
- };
1660
- const analyzerSource = { type: "device", id: deviceId, addonId: this.ctx.id, deviceId, nodeId: this.nodeId };
1661
- this.ctx.eventBus.emit(createEvent(
1662
- EventCategory.MotionAnalysis,
1663
- analyzerSource,
1664
- motionPayload
1665
- ));
1666
- const zonesPayload = {
1667
- deviceId,
1668
- timestamp: frame.timestamp,
1669
- zones: result.rawRegions.map((r) => ({
1670
- bbox: [r.bbox.x, r.bbox.y, r.bbox.x + r.bbox.w, r.bbox.y + r.bbox.h],
1671
- pixelCount: r.pixelCount,
1672
- changeScore: r.intensity / 255
1673
- })),
1674
- frameSize: { width: frame.width, height: frame.height }
1675
- };
1676
- this.ctx.eventBus.emit(createEvent(
1677
- EventCategory.MotionZonesRaw,
1678
- analyzerSource,
1679
- zonesPayload
1680
- ));
1681
- }
1682
- const capturedAt = frame.capturedAt;
1683
- const motionFrameAge = typeof capturedAt === "number" && capturedAt > 0 ? motionStart - capturedAt : -1;
1684
- runner.timingSampler.addMotionSample(deviceId, Date.now() - motionStart, motionFrameAge);
1685
- } catch (error) {
1686
- const msg = errMsg(error);
1687
- log.error("runMotionAnalysis failed", { meta: { error: msg } });
1688
- }
1689
- }
1690
- emitInferenceResult(deviceId, frame, result, handle) {
1691
- const ctx = this.ctx;
1692
- if (!ctx?.eventBus) return;
1693
- const capturedAt = frame.capturedAt;
1694
- const payload = {
1695
- deviceId,
1696
- frame: result,
1697
- nodeId: this.nodeId,
1698
- frameHandle: handle,
1699
- ...typeof capturedAt === "number" && capturedAt > 0 ? { capturedAt } : {}
1700
- };
1701
- this.ctx.eventBus.emit(createEvent(
1702
- EventCategory.PipelineInferenceResult,
1703
- { type: "device", id: deviceId, nodeId: this.nodeId },
1704
- payload
1705
- ));
1706
- }
1707
- /**
1708
- * Emit periodic metric snapshots: one runner-load event for the
1709
- * node + one camera-metrics event per attached camera. Subscribed
1710
- * by admin-ui dashboards (LiveLoadPanel, NodeDetailHeader,
1711
- * CameraStreamPanel) to drive live overlays without polling.
1712
- *
1713
- * Skipped when there are no cameras attached so quiet dev runs
1714
- * don't emit needless bus traffic. The runner-load event is still
1715
- * emitted in that case because the dashboards rely on it to see
1716
- * "agent reachable, idle".
1717
- */
1718
- emitMetricsSnapshot() {
1719
- const ctx = this.ctx;
1720
- const runner = this.runner;
1721
- if (!ctx?.eventBus || !runner) return;
1722
- const timestamp = Date.now();
1723
- void this.getLocalLoad().then((load) => {
1724
- if (!ctx.eventBus) return;
1725
- const json = JSON.stringify(load);
1726
- const prev = this.lastEmittedRunnerLoad;
1727
- const heartbeatDue = !prev || timestamp - prev.emittedAt >= METRICS_HEARTBEAT_MS;
1728
- if (prev && prev.json === json && !heartbeatDue) return;
1729
- this.lastEmittedRunnerLoad = { json, emittedAt: timestamp };
1730
- ctx.eventBus.emit(createEvent(
1731
- EventCategory.PipelineRunnerLoadSnapshot,
1732
- { type: "node", id: this.nodeId, nodeId: this.nodeId },
1733
- { nodeId: this.nodeId, load, timestamp }
1734
- ));
1735
- }).catch(() => {
1736
- });
1737
- if (this.attached.size === 0) return;
1738
- for (const deviceId of this.attached.keys()) {
1739
- const metrics = runner.getCameraMetrics(deviceId);
1740
- if (!metrics) continue;
1741
- const json = JSON.stringify(metrics);
1742
- const prev = this.lastEmittedCameraMetrics.get(deviceId);
1743
- const heartbeatDue = !prev || timestamp - prev.emittedAt >= METRICS_HEARTBEAT_MS;
1744
- if (prev && prev.json === json && !heartbeatDue) continue;
1745
- this.lastEmittedCameraMetrics.set(deviceId, { json, emittedAt: timestamp });
1746
- ctx.eventBus.emit(createEvent(
1747
- EventCategory.PipelineCameraMetricsSnapshot,
1748
- { type: "device", id: deviceId, nodeId: this.nodeId },
1749
- { deviceId, nodeId: this.nodeId, metrics, timestamp }
1750
- ));
1751
- }
1752
- }
1753
- // ── Standard ICamstackAddon — three-level settings API (Phase 3) ─────
1754
- //
1755
- // The runner is a per-node addon with only ADDON-LEVEL settings (no
1756
- // per-device overrides, no cluster-wide tunables). All four tuning
1757
- // fields live in `getAddonSettings()`. When the UI surface moves in
1758
- // Phase 9 these will be rendered under Pipeline -> node -> Settings.
1759
- globalSettingsSchema() {
1760
- return this.schema({
1761
- sections: [
1762
- {
1763
- id: "pipeline-runner-tuning",
1764
- title: "Pipeline Runner",
1765
- tab: "scheduler",
1766
- description: "Per-node detection scheduler tuning. Change only if you understand the pipeline internals.",
1767
- columns: 2,
1768
- fields: [
1769
- {
1770
- type: "slider",
1771
- key: "maxConcurrentInferences",
1772
- label: "Scheduler concurrency",
1773
- description: 'Max parallel inferences the runner scheduler allows across all cameras on this node. Distinct from the detection-pipeline inference-pool worker count (Pipeline tab → "Worker concurrency"), which controls Python-side thread pool sizing inside a single inference job.',
1774
- min: 1,
1775
- max: 16,
1776
- step: 1,
1777
- default: DEFAULT_CONFIG.maxConcurrentInferences,
1778
- showValue: true
1779
- },
1780
- {
1781
- type: "slider",
1782
- key: "maxQueueDepth",
1783
- label: "Max queue depth",
1784
- description: "Maximum frames held per camera before dropping.",
1785
- min: 5,
1786
- max: 100,
1787
- step: 5,
1788
- default: DEFAULT_CONFIG.maxQueueDepth,
1789
- showValue: true
1790
- },
1791
- {
1792
- type: "slider",
1793
- key: "targetLoadPercent",
1794
- label: "Target load",
1795
- description: "Percentage of inference capacity to target before throttling FPS.",
1796
- min: 50,
1797
- max: 100,
1798
- step: 5,
1799
- default: DEFAULT_CONFIG.targetLoadPercent,
1800
- unit: "%",
1801
- showValue: true
1802
- },
1803
- {
1804
- type: "slider",
1805
- key: "minThrottledFps",
1806
- label: "Min throttled FPS",
1807
- description: "Lowest FPS the runner will allow when load-shedding.",
1808
- min: 1,
1809
- max: 10,
1810
- step: 1,
1811
- default: DEFAULT_CONFIG.minThrottledFps,
1812
- showValue: true
1813
- }
1814
- ]
1815
- }
1816
- ]
1817
- });
1818
- }
1819
- async onConfigChanged() {
1820
- this.runner?.updateLimits(this.config);
1821
- this.ctx.logger.info(
1822
- "pipeline-runner tuning updated",
1823
- {
1824
- meta: {
1825
- maxQueueDepth: this.config.maxQueueDepth,
1826
- maxConcurrentInferences: this.config.maxConcurrentInferences,
1827
- targetLoadPercent: this.config.targetLoadPercent,
1828
- minThrottledFps: this.config.minThrottledFps
1829
- }
1830
- }
1831
- );
1832
- }
966
+ return {
967
+ data: frame.data,
968
+ width: frame.width,
969
+ height: frame.height,
970
+ format: frame.format,
971
+ timestamp: frame.timestamp
972
+ };
1833
973
  }
1834
- export {
1835
- FrameQueue,
1836
- PipelineRunner,
1837
- PipelineTimingSampler,
1838
- Semaphore,
1839
- pipelineRunnerBenchActions as customActions,
1840
- PipelineRunnerAddon as default,
1841
- shouldStartOnboardAnalyzer
974
+ /**
975
+ * Interval for periodic metric snapshot emission. ~1 Hz strikes the
976
+ * balance between UI freshness (overlay phase / fps numbers feel live)
977
+ * and bus traffic (one snapshot per attached camera + one per node
978
+ * load is well under 100 events/s even at 50 cameras).
979
+ */
980
+ var METRICS_SNAPSHOT_INTERVAL_MS = 1e3;
981
+ /**
982
+ * Force a metrics-snapshot emit at least every 30s even when the
983
+ * payload hasn't changed — gives the UI's "agent reachable" chip a
984
+ * heartbeat without the per-tick spam an unconditional emit
985
+ * produces. Picked so a 5-minute idle window emits ~10 events
986
+ * instead of ~300.
987
+ */
988
+ var METRICS_HEARTBEAT_MS = 3e4;
989
+ var PipelineRunnerAddon = class extends BaseAddon {
990
+ runner = null;
991
+ attached = /* @__PURE__ */ new Map();
992
+ nodeId = "unknown";
993
+ metricsSnapshotTimer = null;
994
+ unsubMotionEvents = null;
995
+ /** Last analyzer-detected state per device — gates the
996
+ * `MotionOnMotionChanged` emit in `runMotionAnalysis` to transitions
997
+ * only (otherwise we'd emit on every analyzer frame). */
998
+ lastAnalyzerDetected = /* @__PURE__ */ new Map();
999
+ /**
1000
+ * Last positive motion timestamp per device — preserved across the
1001
+ * OFF transition so the motion runtime-state slice keeps a stable
1002
+ * `lastDetectedAt` after the cooldown closes the phase. Cleared on
1003
+ * detach.
1004
+ */
1005
+ lastMotionAt = /* @__PURE__ */ new Map();
1006
+ /**
1007
+ * Dynamic analyzer subscriptions opened on `MotionOnMotionChanged
1008
+ * source:'onboard'` when `onboardMotionDrivesAnalyzer === true`. Each
1009
+ * entry is the unsubscribe handle returned by `subscribeMotionFrames`.
1010
+ * Cleared on teardown timer fire, detach, and shutdown.
1011
+ */
1012
+ onboardAnalyzerSubs = /* @__PURE__ */ new Map();
1013
+ /**
1014
+ * Teardown timers that close the dynamic analyzer subscription after
1015
+ * `motionCooldownMs` without a new motion event. Re-armed on every
1016
+ * `MotionOnMotionChanged source:'onboard'` call so the sub stays open
1017
+ * while motion persists.
1018
+ */
1019
+ onboardAnalyzerTeardownTimers = /* @__PURE__ */ new Map();
1020
+ /**
1021
+ * Snapshot-equality cache for metrics-snapshot defer. The runner
1022
+ * fires per-camera metrics every `METRICS_SNAPSHOT_INTERVAL_MS`;
1023
+ * for an idle camera (no inference, queue empty, fps=0) every tick
1024
+ * carries an identical payload. We skip the bus emit when the
1025
+ * payload deep-equals the previous one so the events tab + remote
1026
+ * subscribers stop seeing 60 metrics-snapshots/min/camera that
1027
+ * convey nothing. A periodic heartbeat re-emits every
1028
+ * METRICS_HEARTBEAT_MS so consumers know the runner is still
1029
+ * alive.
1030
+ */
1031
+ lastEmittedCameraMetrics = /* @__PURE__ */ new Map();
1032
+ lastEmittedRunnerLoad = null;
1033
+ /**
1034
+ * In-memory bench-frame cache (decoded JPEG bytes). Populated by the
1035
+ * `cacheBenchFrame` custom action. Fed into the synthetic-bench loop
1036
+ * via the `frame: FrameInput` shape that mirrors what stream-broker
1037
+ * delivers to this very addon during real camera detection.
1038
+ */
1039
+ benchFrameCache = /* @__PURE__ */ new Map();
1040
+ benchFrameSweeper = null;
1041
+ occupancyBurstInFlight = /* @__PURE__ */ new Set();
1042
+ constructor() {
1043
+ super({ ...DEFAULT_CONFIG });
1044
+ }
1045
+ async onInitialize() {
1046
+ const raw = this.ctx.kernel.localNodeId ?? this.ctx.id;
1047
+ this.nodeId = raw.includes("/") ? raw.split("/")[0] : raw;
1048
+ this.runner = new PipelineRunner({
1049
+ maxQueueDepth: this.config.maxQueueDepth,
1050
+ maxConcurrentInferences: this.config.maxConcurrentInferences,
1051
+ targetLoadPercent: this.config.targetLoadPercent,
1052
+ minThrottledFps: this.config.minThrottledFps,
1053
+ processFrame: (deviceId, frame) => this.runInference(deviceId, frame),
1054
+ analyzeMotion: (deviceId, frame) => this.runMotionAnalysis(deviceId, frame),
1055
+ onPhaseChanged: (deviceId, phase, meta) => this.handlePhaseChanged(deviceId, phase, meta),
1056
+ logger: this.ctx.logger,
1057
+ onOccupancyRecheck: (deviceId, frames) => {
1058
+ this.runOccupancyBurst(deviceId, frames);
1059
+ }
1060
+ });
1061
+ this.runner.timingSampler.setLogger(this.ctx.logger.child("timing"));
1062
+ this.runner.onDetectionStreamChange((deviceId, action) => {
1063
+ this.handleDetectionStreamChange(deviceId, action);
1064
+ });
1065
+ this.runner.onResult(async (deviceId, frame, result, _streamType, handle) => {
1066
+ this.emitInferenceResult(deviceId, frame, result, handle);
1067
+ });
1068
+ this.runner.start();
1069
+ this.ctx.logger.info("Pipeline runner started", {
1070
+ tags: { nodeId: this.nodeId },
1071
+ meta: {
1072
+ maxConcurrent: this.config.maxConcurrentInferences,
1073
+ queueDepth: this.config.maxQueueDepth
1074
+ }
1075
+ });
1076
+ if (this.ctx.eventBus) this.unsubMotionEvents = this.ctx.eventBus.subscribe({ category: EventCategory.MotionOnMotionChanged }, (event) => {
1077
+ const data = event.data;
1078
+ const deviceId = data.deviceId;
1079
+ const attachment = this.attached.get(deviceId);
1080
+ if (!attachment) return;
1081
+ const source = data.source;
1082
+ if (source === "onboard") this.handleOnboardMotionAnalyzer(deviceId, data.detected);
1083
+ if (!attachment.config.motionSources.includes(source)) return;
1084
+ this.runner?.reportMotion(deviceId, data.detected, source, data.regions ? [...data.regions] : void 0);
1085
+ });
1086
+ this.metricsSnapshotTimer = setInterval(() => this.emitMetricsSnapshot(), METRICS_SNAPSHOT_INTERVAL_MS);
1087
+ return {
1088
+ providers: [{
1089
+ capability: pipelineRunnerCapability,
1090
+ provider: this
1091
+ }],
1092
+ customActions: pipelineRunnerBenchActions,
1093
+ actionHandlers: {
1094
+ cacheBenchFrame: async (input) => this.cacheBenchFrame(input),
1095
+ releaseBenchFrame: async (input) => this.releaseBenchFrame(input),
1096
+ runSyntheticBench: async (input) => this.runSyntheticBench(input)
1097
+ }
1098
+ };
1099
+ }
1100
+ async onShutdown() {
1101
+ if (this.metricsSnapshotTimer) {
1102
+ clearInterval(this.metricsSnapshotTimer);
1103
+ this.metricsSnapshotTimer = null;
1104
+ }
1105
+ if (this.benchFrameSweeper) {
1106
+ clearInterval(this.benchFrameSweeper);
1107
+ this.benchFrameSweeper = null;
1108
+ }
1109
+ this.benchFrameCache.clear();
1110
+ if (this.unsubMotionEvents) {
1111
+ this.unsubMotionEvents();
1112
+ this.unsubMotionEvents = null;
1113
+ }
1114
+ this.lastAnalyzerDetected.clear();
1115
+ for (const deviceId of [...this.onboardAnalyzerTeardownTimers.keys(), ...this.onboardAnalyzerSubs.keys()]) this.clearOnboardAnalyzer(deviceId);
1116
+ if (this.runner) {
1117
+ this.runner.stop();
1118
+ this.runner = null;
1119
+ }
1120
+ for (const attachment of this.attached.values()) {
1121
+ attachment.motionUnsubscribe?.();
1122
+ attachment.detectionUnsubscribe?.();
1123
+ }
1124
+ this.attached.clear();
1125
+ }
1126
+ async cacheBenchFrame(input) {
1127
+ const sharp = (await import("sharp")).default;
1128
+ const { data, info } = await sharp(Buffer.from(input.imageBase64, "base64")).raw().toBuffer({ resolveWithObject: true });
1129
+ if (info.channels !== 3) throw new Error(`cacheBenchFrame: expected 3 channels (rgb), got ${info.channels}`);
1130
+ const rgb = new Uint8Array(data);
1131
+ const ttlMs = Math.max(6e4, (input.ttlSeconds ?? 600) * 1e3);
1132
+ const frameId = `runner-bench-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
1133
+ const expiresAt = Date.now() + ttlMs;
1134
+ this.benchFrameCache.set(frameId, {
1135
+ data: rgb,
1136
+ width: info.width,
1137
+ height: info.height,
1138
+ format: "rgb",
1139
+ expiresAt
1140
+ });
1141
+ if (!this.benchFrameSweeper) {
1142
+ this.benchFrameSweeper = setInterval(() => this.sweepBenchFrameCache(), 6e4);
1143
+ this.benchFrameSweeper.unref?.();
1144
+ }
1145
+ this.ctx.logger.info("cached bench frame", { meta: {
1146
+ frameId,
1147
+ width: info.width,
1148
+ height: info.height,
1149
+ bytes: rgb.length,
1150
+ ttlMs
1151
+ } });
1152
+ return {
1153
+ frameId,
1154
+ width: info.width,
1155
+ height: info.height,
1156
+ expiresAt
1157
+ };
1158
+ }
1159
+ async releaseBenchFrame(input) {
1160
+ return { released: this.benchFrameCache.delete(input.frameId) };
1161
+ }
1162
+ sweepBenchFrameCache() {
1163
+ const now = Date.now();
1164
+ for (const [id, entry] of this.benchFrameCache) if (entry.expiresAt < now) this.benchFrameCache.delete(id);
1165
+ }
1166
+ async runSyntheticBench(input) {
1167
+ const ctx = this.ctx;
1168
+ const api = ctx.api;
1169
+ if (!api) throw new Error("runSyntheticBench: ctx.api unavailable");
1170
+ ctx.logger.info("runSyntheticBench input", { meta: {
1171
+ frameId: input.frameId,
1172
+ parallel: input.parallel,
1173
+ iterations: input.iterations
1174
+ } });
1175
+ const cached = this.benchFrameCache.get(input.frameId);
1176
+ if (!cached) throw new Error(`runSyntheticBench: frameId ${input.frameId} not cached (call cacheBenchFrame first)`);
1177
+ const stepsToRun = input.steps.map((s) => ({
1178
+ addonId: s.addonId,
1179
+ modelId: s.modelId,
1180
+ enabled: s.enabled,
1181
+ children: s.children ?? []
1182
+ }));
1183
+ const enabledSteps = stepsToRun.filter((s) => s.enabled);
1184
+ const useFastPath = enabledSteps.length === 1 && (!enabledSteps[0].children || enabledSteps[0].children.filter((c) => c.enabled).length === 0) && !input.simulatePipeline;
1185
+ const rootStep = enabledSteps[0];
1186
+ const sharedFrame = {
1187
+ data: cached.data,
1188
+ format: cached.format,
1189
+ width: cached.width,
1190
+ height: cached.height,
1191
+ timestamp: Date.now()
1192
+ };
1193
+ let poolFrameId = null;
1194
+ if (useFastPath && rootStep) {
1195
+ ctx.logger.info("synthetic bench: using Python cache path", { meta: {
1196
+ step: rootStep.addonId,
1197
+ model: rootStep.modelId
1198
+ } });
1199
+ poolFrameId = (await api.pipelineExecutor.cacheFrameInPool.mutate({
1200
+ data: new Uint8Array(cached.data.slice().buffer),
1201
+ width: cached.width,
1202
+ height: cached.height,
1203
+ format: cached.format
1204
+ })).frameId;
1205
+ await api.pipelineExecutor.runPipeline.mutate({
1206
+ steps: stepsToRun,
1207
+ frame: sharedFrame,
1208
+ ...input.engine ? { engine: input.engine } : {}
1209
+ });
1210
+ const warmupCount = input.warmup ?? 1;
1211
+ for (let w = 0; w < warmupCount; w++) await api.pipelineExecutor.inferCached.mutate({
1212
+ stepId: rootStep.addonId,
1213
+ frameId: poolFrameId
1214
+ });
1215
+ const wallTimings = [];
1216
+ const inferTimings = [];
1217
+ const preprocessTimings = [];
1218
+ const predictTimings = [];
1219
+ const batchSizes = [];
1220
+ const detCounts = [];
1221
+ let _n = 0;
1222
+ const sessionId = input.sessionId ?? `synth-${Date.now().toString(36)}`;
1223
+ const totalRuns = input.parallel * input.iterations;
1224
+ const wallStart = performance.now();
1225
+ const worker = async () => {
1226
+ for (let i = 0; i < input.iterations; i++) {
1227
+ const t0 = performance.now();
1228
+ const result = await api.pipelineExecutor.inferCached.mutate({
1229
+ stepId: rootStep.addonId,
1230
+ frameId: poolFrameId
1231
+ });
1232
+ const wallMs = performance.now() - t0;
1233
+ const r = result;
1234
+ const inferMs = typeof r["inferenceMs"] === "number" ? r["inferenceMs"] : wallMs;
1235
+ const preMs = typeof r["preprocessMs"] === "number" ? r["preprocessMs"] : 0;
1236
+ const predMs = typeof r["predictMs"] === "number" ? r["predictMs"] : 0;
1237
+ const bs = typeof r["batchSize"] === "number" ? r["batchSize"] : 1;
1238
+ const dets = Array.isArray(r["detections"]) ? r["detections"].length : 0;
1239
+ wallTimings.push(wallMs);
1240
+ inferTimings.push(inferMs);
1241
+ preprocessTimings.push(preMs);
1242
+ predictTimings.push(predMs);
1243
+ batchSizes.push(bs);
1244
+ detCounts.push(dets);
1245
+ const n = ++_n;
1246
+ if (n <= 20) ctx.logger.info("bench call trace (cached)", { meta: {
1247
+ n,
1248
+ wallMs: Math.round(wallMs),
1249
+ inferMs: Math.round(inferMs),
1250
+ preMs: Math.round(preMs * 10) / 10,
1251
+ predMs: Math.round(predMs * 10) / 10,
1252
+ bs
1253
+ } });
1254
+ if (n % Math.max(1, input.parallel) === 0) {
1255
+ const elapsed = (performance.now() - wallStart) / 1e3;
1256
+ const fps = elapsed > 0 ? n / elapsed : 0;
1257
+ const meanCallMs = wallTimings.reduce((s, v) => s + v, 0) / wallTimings.length;
1258
+ const sorted = [...wallTimings].toSorted((a, b) => a - b);
1259
+ const p95 = sorted[Math.min(sorted.length - 1, Math.floor(.95 * sorted.length))] ?? 0;
1260
+ const totalDet = detCounts.reduce((s, v) => s + v, 0);
1261
+ const avgDet = detCounts.length > 0 ? totalDet / detCounts.length : 0;
1262
+ const bsMean = batchSizes.reduce((s, v) => s + v, 0) / batchSizes.length;
1263
+ const msg = `runs ${n}/${totalRuns} \u00b7 ${fps.toFixed(1)} fps \u00b7 call ${meanCallMs.toFixed(1)}ms \u00b7 batch ${bsMean.toFixed(1)}`;
1264
+ if (ctx.eventBus) ctx.eventBus.emit({
1265
+ id: `bench-${n}`,
1266
+ timestamp: /* @__PURE__ */ new Date(),
1267
+ source: {
1268
+ type: "pipeline",
1269
+ id: "synthetic-bench"
1270
+ },
1271
+ category: EventCategory.PipelineProgress,
1272
+ data: {
1273
+ nodeId: "hub",
1274
+ sessionId,
1275
+ step: "synthetic-bench",
1276
+ message: msg,
1277
+ benchProgress: true,
1278
+ runs: n,
1279
+ totalRuns,
1280
+ fps: Math.round(fps * 100) / 100,
1281
+ meanMs: Math.round(meanCallMs * 100) / 100,
1282
+ p95Ms: Math.round(p95 * 100) / 100,
1283
+ inferMeanMs: Math.round(inferTimings.reduce((s, v) => s + v, 0) / inferTimings.length * 100) / 100,
1284
+ preprocessMeanMs: Math.round(preprocessTimings.reduce((s, v) => s + v, 0) / preprocessTimings.length * 100) / 100,
1285
+ predictMeanMs: Math.round(predictTimings.reduce((s, v) => s + v, 0) / predictTimings.length * 100) / 100,
1286
+ batchSizeMean: Math.round(bsMean * 100) / 100,
1287
+ detPerSec: elapsed > 0 ? Math.round(totalDet / elapsed * 100) / 100 : 0,
1288
+ avgDetections: Math.round(avgDet * 100) / 100
1289
+ }
1290
+ });
1291
+ else ctx.logger.warn("emitProgress: NO eventBus");
1292
+ }
1293
+ }
1294
+ };
1295
+ await Promise.all(Array.from({ length: input.parallel }, () => worker()));
1296
+ const wallSec = (performance.now() - wallStart) / 1e3;
1297
+ await api.pipelineExecutor.uncacheFrame.mutate({ frameId: poolFrameId }).catch(() => {});
1298
+ return this.buildBenchResult(wallTimings, inferTimings, preprocessTimings, predictTimings, batchSizes, detCounts, wallSec, "cached");
1299
+ }
1300
+ ctx.logger.info("synthetic bench: using full runPipeline path", { meta: {
1301
+ steps: enabledSteps.length,
1302
+ simulatePipeline: !!input.simulatePipeline
1303
+ } });
1304
+ let _callCount = 0;
1305
+ const callOnce = async () => {
1306
+ const t0 = performance.now();
1307
+ const result = await api.pipelineExecutor.runPipeline.mutate({
1308
+ steps: stepsToRun,
1309
+ frame: sharedFrame,
1310
+ ...input.engine ? { engine: input.engine } : {}
1311
+ });
1312
+ const wallMs = performance.now() - t0;
1313
+ const n = ++_callCount;
1314
+ if (n <= 20) ctx.logger.info("bench call trace", { meta: {
1315
+ n,
1316
+ wallMs: Math.round(wallMs),
1317
+ totalInferenceMs: Math.round(result.debug?.totalInferenceMs ?? 0),
1318
+ predictMs: Math.round((result.debug?.predictMs ?? 0) * 10) / 10,
1319
+ preprocessMs: Math.round((result.debug?.preprocessMs ?? 0) * 10) / 10,
1320
+ batchSize: result.debug?.batchSize ?? 1
1321
+ } });
1322
+ return {
1323
+ wallMs,
1324
+ result
1325
+ };
1326
+ };
1327
+ const warmupCount = input.warmup ?? 1;
1328
+ for (let i = 0; i < warmupCount; i++) await callOnce();
1329
+ const wallTimings = [];
1330
+ const serverWallTimings = [];
1331
+ const inferTimings = [];
1332
+ const preprocessTimings = [];
1333
+ const predictTimings = [];
1334
+ const batchSizes = [];
1335
+ const detCounts = [];
1336
+ const sessionId = input.sessionId ?? `synth-${Date.now().toString(36)}`;
1337
+ const totalRuns = input.parallel * input.iterations;
1338
+ const wallStart = performance.now();
1339
+ const worker = async () => {
1340
+ for (let i = 0; i < input.iterations; i++) {
1341
+ const { wallMs, result } = await callOnce();
1342
+ wallTimings.push(wallMs);
1343
+ serverWallTimings.push(result.debug?.wallMs ?? 0);
1344
+ inferTimings.push(result.debug?.totalInferenceMs ?? 0);
1345
+ preprocessTimings.push(result.debug?.preprocessMs ?? 0);
1346
+ predictTimings.push(result.debug?.predictMs ?? 0);
1347
+ batchSizes.push(result.debug?.batchSize ?? 1);
1348
+ detCounts.push(result.detections?.length ?? 0);
1349
+ const n = wallTimings.length;
1350
+ if (n % Math.max(1, input.parallel) === 0 && ctx.eventBus) {
1351
+ const elapsed = (performance.now() - wallStart) / 1e3;
1352
+ const fps = elapsed > 0 ? n / elapsed : 0;
1353
+ const meanMs = wallTimings.reduce((s, v) => s + v, 0) / n;
1354
+ const sorted = [...wallTimings].toSorted((a, b) => a - b);
1355
+ const p95 = sorted[Math.min(sorted.length - 1, Math.floor(.95 * sorted.length))] ?? 0;
1356
+ const totalDet = detCounts.reduce((s, v) => s + v, 0);
1357
+ const bsMean = batchSizes.reduce((s, v) => s + v, 0) / n;
1358
+ ctx.eventBus.emit({
1359
+ id: `bench-${n}`,
1360
+ timestamp: /* @__PURE__ */ new Date(),
1361
+ source: {
1362
+ type: "pipeline",
1363
+ id: "synthetic-bench"
1364
+ },
1365
+ category: EventCategory.PipelineProgress,
1366
+ data: {
1367
+ nodeId: "hub",
1368
+ sessionId,
1369
+ step: "synthetic-bench",
1370
+ message: `runs ${n}/${totalRuns} \u00b7 ${fps.toFixed(1)} fps \u00b7 call ${meanMs.toFixed(1)}ms \u00b7 batch ${bsMean.toFixed(1)}`,
1371
+ benchProgress: true,
1372
+ runs: n,
1373
+ totalRuns,
1374
+ fps: Math.round(fps * 100) / 100,
1375
+ meanMs: Math.round(meanMs * 100) / 100,
1376
+ p95Ms: Math.round(p95 * 100) / 100,
1377
+ inferMeanMs: Math.round(inferTimings.reduce((s, v) => s + v, 0) / n * 100) / 100,
1378
+ preprocessMeanMs: Math.round(preprocessTimings.reduce((s, v) => s + v, 0) / n * 100) / 100,
1379
+ predictMeanMs: Math.round(predictTimings.reduce((s, v) => s + v, 0) / n * 100) / 100,
1380
+ batchSizeMean: Math.round(bsMean * 100) / 100,
1381
+ detPerSec: elapsed > 0 ? Math.round(totalDet / elapsed * 100) / 100 : 0,
1382
+ avgDetections: n > 0 ? Math.round(totalDet / n * 100) / 100 : 0
1383
+ }
1384
+ });
1385
+ }
1386
+ }
1387
+ };
1388
+ await Promise.all(Array.from({ length: input.parallel }, () => worker()));
1389
+ const wallSec = (performance.now() - wallStart) / 1e3;
1390
+ return this.buildBenchResult(wallTimings, inferTimings, preprocessTimings, predictTimings, batchSizes, detCounts, wallSec, "pipeline");
1391
+ }
1392
+ async buildBenchResult(wallTimings, inferTimings, preprocessTimings, predictTimings, batchSizes, detCounts, wallSec, path) {
1393
+ const meanOfArr = (xs) => xs.length > 0 ? xs.reduce((s, v) => s + v, 0) / xs.length : 0;
1394
+ this.ctx.logger.info("synthetic bench summary", { meta: {
1395
+ runs: wallTimings.length,
1396
+ wallSec: Math.round(wallSec * 100) / 100,
1397
+ fps: Math.round(wallTimings.length / wallSec * 100) / 100,
1398
+ callMeanMs: Math.round(meanOfArr(wallTimings)),
1399
+ inferMeanMs: Math.round(meanOfArr(inferTimings)),
1400
+ preprocessMeanMs: Math.round(meanOfArr(preprocessTimings)),
1401
+ predictMeanMs: Math.round(meanOfArr(predictTimings)),
1402
+ batchSizeMean: Math.round(meanOfArr(batchSizes) * 100) / 100,
1403
+ batchSizeMax: batchSizes.length > 0 ? Math.max(...batchSizes) : 0
1404
+ } });
1405
+ const sorted = [...wallTimings].toSorted((a, b) => a - b);
1406
+ const pick = (q) => sorted.length > 0 ? sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))] : 0;
1407
+ const meanOf = (xs) => xs.length > 0 ? xs.reduce((s, v) => s + v, 0) / xs.length : 0;
1408
+ const totalRuns = wallTimings.length;
1409
+ const totalDet = detCounts.reduce((s, v) => s + v, 0);
1410
+ return {
1411
+ runs: totalRuns,
1412
+ wallSec: Math.round(wallSec * 1e3) / 1e3,
1413
+ fps: wallSec > 0 ? Math.round(totalRuns / wallSec * 100) / 100 : 0,
1414
+ detectionsPerSec: wallSec > 0 ? Math.round(totalDet / wallSec * 100) / 100 : 0,
1415
+ avgDetections: totalRuns > 0 ? Math.round(totalDet / totalRuns * 100) / 100 : 0,
1416
+ callMs: {
1417
+ mean: Math.round(meanOf(wallTimings) * 100) / 100,
1418
+ p50: Math.round(pick(.5) * 100) / 100,
1419
+ p95: Math.round(pick(.95) * 100) / 100,
1420
+ p99: Math.round(pick(.99) * 100) / 100
1421
+ },
1422
+ inferMs: Math.round(meanOf(inferTimings) * 100) / 100,
1423
+ preprocessMs: Math.round(meanOf(preprocessTimings) * 100) / 100,
1424
+ predictMs: Math.round(meanOf(predictTimings) * 100) / 100,
1425
+ batchSizeMean: Math.round(meanOf(batchSizes) * 100) / 100,
1426
+ batchSizeMax: batchSizes.length > 0 ? Math.max(...batchSizes) : 0,
1427
+ path,
1428
+ ...await this.getEngineAndTuning()
1429
+ };
1430
+ }
1431
+ async getEngineAndTuning() {
1432
+ try {
1433
+ const api = this.ctx.api;
1434
+ if (!api) return {};
1435
+ const [eng, tuning] = await Promise.all([api.pipelineExecutor.getSelectedEngine.query(), api.pipelineExecutor.getEffectiveTuning.query()]);
1436
+ return {
1437
+ engine: eng ? {
1438
+ runtime: eng.runtime,
1439
+ backend: eng.backend,
1440
+ device: eng.device
1441
+ } : void 0,
1442
+ tuning: tuning ?? void 0
1443
+ };
1444
+ } catch {
1445
+ return {};
1446
+ }
1447
+ }
1448
+ async attachCamera(config) {
1449
+ const runner = this.runner;
1450
+ const ctx = this.ctx;
1451
+ if (!runner || !ctx) throw new Error("PipelineRunnerAddon: attachCamera called before initialize completed");
1452
+ this.ctx.logger.info("attachCamera received config", {
1453
+ tags: { deviceId: config.deviceId },
1454
+ meta: {
1455
+ motionSources: config.motionSources,
1456
+ motionSourcesType: Array.isArray(config.motionSources) ? `array(${config.motionSources.length})` : typeof config.motionSources,
1457
+ motionStreamId: config.motionStreamId,
1458
+ detectionStreamId: config.detectionStreamId,
1459
+ keys: Object.keys(config)
1460
+ }
1461
+ });
1462
+ if (this.attached.has(config.deviceId)) this.detachInternal(config.deviceId);
1463
+ runner.registerCamera(config.deviceId, {
1464
+ detectionMode: config.detectionMode,
1465
+ fps: config.detectionFps,
1466
+ motionCooldownMs: config.motionCooldownMs,
1467
+ occupancyRecheckSec: config.occupancyRecheckSec,
1468
+ occupancyRecheckFrames: config.occupancyRecheckFrames
1469
+ });
1470
+ const attachment = {
1471
+ config,
1472
+ motionUnsubscribe: null,
1473
+ detectionUnsubscribe: null
1474
+ };
1475
+ this.attached.set(config.deviceId, attachment);
1476
+ if (config.motionSources.includes("analyzer")) attachment.motionUnsubscribe = await this.subscribeMotionFrames(config);
1477
+ const stepsCount = config.steps?.length ?? 0;
1478
+ const dispatch = stepsCount > 0 ? `runPipeline(${stepsCount}step${stepsCount === 1 ? "" : "s"})` : config.steps !== void 0 ? "skip(0steps)" : "runFrame(legacy)";
1479
+ const engineLabel = config.engine ? `${config.engine.runtime}+${config.engine.backend}/${config.engine.format}` : "default";
1480
+ this.ctx.logger.info("attachCamera", {
1481
+ tags: { deviceId: config.deviceId },
1482
+ meta: {
1483
+ detectionMode: config.detectionMode,
1484
+ audioMode: config.audioMode,
1485
+ motionFps: config.motionFps,
1486
+ detectionFps: config.detectionFps,
1487
+ motionSources: config.motionSources,
1488
+ dispatch,
1489
+ engine: engineLabel
1490
+ }
1491
+ });
1492
+ return { success: true };
1493
+ }
1494
+ async detachCamera(input) {
1495
+ this.detachInternal(input.deviceId);
1496
+ return { success: true };
1497
+ }
1498
+ async reportMotion(input) {
1499
+ this.runner?.reportMotion(input.deviceId, input.detected, input.source, input.regions);
1500
+ return { success: true };
1501
+ }
1502
+ detachInternal(deviceId) {
1503
+ const attachment = this.attached.get(deviceId);
1504
+ if (!attachment) return;
1505
+ this.clearOnboardAnalyzer(deviceId);
1506
+ attachment.motionUnsubscribe?.();
1507
+ attachment.detectionUnsubscribe?.();
1508
+ this.attached.delete(deviceId);
1509
+ this.lastMotionAt.delete(deviceId);
1510
+ this.lastEmittedCameraMetrics.delete(deviceId);
1511
+ this.runner?.unregisterCamera(deviceId);
1512
+ this.ctx?.logger.info("detachCamera", { tags: { deviceId } });
1513
+ }
1514
+ /**
1515
+ * Synchronously cancel the teardown timer and call the unsubscribe
1516
+ * handle for the dynamic onboard analyzer, if one is open. Safe to
1517
+ * call when no subscription exists.
1518
+ */
1519
+ clearOnboardAnalyzer(deviceId) {
1520
+ const timer = this.onboardAnalyzerTeardownTimers.get(deviceId);
1521
+ if (timer !== void 0) {
1522
+ clearTimeout(timer);
1523
+ this.onboardAnalyzerTeardownTimers.delete(deviceId);
1524
+ }
1525
+ const unsub = this.onboardAnalyzerSubs.get(deviceId);
1526
+ if (unsub !== void 0) {
1527
+ try {
1528
+ unsub();
1529
+ } catch {}
1530
+ this.onboardAnalyzerSubs.delete(deviceId);
1531
+ }
1532
+ }
1533
+ /**
1534
+ * Dynamic analyzer gate for onboard-motion cameras.
1535
+ *
1536
+ * Called from the `MotionOnMotionChanged` subscriber whenever
1537
+ * `source === 'onboard'`. Opens a `subscribeMotionFrames` subscription
1538
+ * the first time motion is detected (idempotent — a second `detected:true`
1539
+ * while the sub is already open is a no-op). Always re-arms the teardown
1540
+ * timer so the subscription stays open as long as motion events keep
1541
+ * arriving and tears down `motionCooldownMs` after the last event.
1542
+ *
1543
+ * No-op when:
1544
+ * - The camera is not currently attached.
1545
+ * - `shouldStartOnboardAnalyzer(config)` returns false (flag off or
1546
+ * `motionSources` already includes `'analyzer'`).
1547
+ */
1548
+ async handleOnboardMotionAnalyzer(deviceId, detected) {
1549
+ const attachment = this.attached.get(deviceId);
1550
+ if (!attachment) return;
1551
+ const config = attachment.config;
1552
+ if (!shouldStartOnboardAnalyzer(config)) return;
1553
+ const log = this.ctx.logger.withTags({ deviceId });
1554
+ if (detected && !this.onboardAnalyzerSubs.has(deviceId)) {
1555
+ const unsub = await this.subscribeMotionFrames(config);
1556
+ if (unsub) {
1557
+ this.onboardAnalyzerSubs.set(deviceId, unsub);
1558
+ log.debug("onboard-analyzer: opened motion-frame subscription");
1559
+ }
1560
+ }
1561
+ const existing = this.onboardAnalyzerTeardownTimers.get(deviceId);
1562
+ if (existing !== void 0) clearTimeout(existing);
1563
+ const cooldownMs = config.motionCooldownMs;
1564
+ const timer = setTimeout(() => {
1565
+ this.onboardAnalyzerTeardownTimers.delete(deviceId);
1566
+ const unsub = this.onboardAnalyzerSubs.get(deviceId);
1567
+ if (!unsub) return;
1568
+ try {
1569
+ unsub();
1570
+ } catch {}
1571
+ this.onboardAnalyzerSubs.delete(deviceId);
1572
+ log.debug("onboard-analyzer: closed after motion cooldown", { meta: { cooldownMs } });
1573
+ }, cooldownMs);
1574
+ this.onboardAnalyzerTeardownTimers.set(deviceId, timer);
1575
+ }
1576
+ async getLocalLoad() {
1577
+ const metrics = this.runner?.getMetrics() ?? {
1578
+ activeCameras: 0,
1579
+ throttledCameras: 0,
1580
+ avgInferenceTimeMs: 0,
1581
+ queueDepth: 0
1582
+ };
1583
+ const allCameraMetrics = this.runner?.getAllCameraMetrics() ?? [];
1584
+ let activeCameras = 0;
1585
+ let totalActualFps = 0;
1586
+ for (const cm of allCameraMetrics) {
1587
+ if (cm.phase === "active") activeCameras++;
1588
+ totalActualFps += cm.actualFps;
1589
+ }
1590
+ return {
1591
+ nodeId: this.nodeId,
1592
+ attachedCameras: this.attached.size,
1593
+ activeCameras,
1594
+ avgInferenceFps: totalActualFps,
1595
+ avgInferenceTimeMs: metrics.avgInferenceTimeMs,
1596
+ queueDepthTotal: metrics.queueDepth,
1597
+ hardware: {
1598
+ hasGpu: false,
1599
+ inferenceBackend: void 0
1600
+ }
1601
+ };
1602
+ }
1603
+ async getLocalMetrics() {
1604
+ const m = this.runner?.getMetrics() ?? {
1605
+ activeCameras: 0,
1606
+ throttledCameras: 0,
1607
+ avgInferenceTimeMs: 0,
1608
+ queueDepth: 0
1609
+ };
1610
+ return {
1611
+ nodeId: this.nodeId,
1612
+ ...m
1613
+ };
1614
+ }
1615
+ async getCameraMetrics(input) {
1616
+ return this.runner?.getCameraMetrics(input.deviceId) ?? null;
1617
+ }
1618
+ getAllCameraMetrics() {
1619
+ return this.runner?.getAllCameraMetrics() ?? [];
1620
+ }
1621
+ getLocalCameras() {
1622
+ return [...this.attached.keys()];
1623
+ }
1624
+ async subscribeMotionFrames(config) {
1625
+ const ctx = this.ctx;
1626
+ const runner = this.runner;
1627
+ if (!ctx || !runner) return null;
1628
+ const log = this.ctx.logger.withTags({ deviceId: config.deviceId });
1629
+ const api = this.ctx.api;
1630
+ if (!api) {
1631
+ log.warn("subscribeMotionFrames: this.ctx.api not available");
1632
+ return null;
1633
+ }
1634
+ return startFrameHandlePoller({
1635
+ api,
1636
+ brokerId: makeSourceBrokerId(config.deviceId, config.motionStreamId),
1637
+ format: "gray",
1638
+ maxFps: config.motionFps,
1639
+ tag: "motion",
1640
+ logger: log,
1641
+ onFrame: (frame, _handle) => {
1642
+ runner.enqueueMotionFrame(config.deviceId, frame);
1643
+ }
1644
+ });
1645
+ }
1646
+ handleDetectionStreamChange(deviceId, action) {
1647
+ const attachment = this.attached.get(deviceId);
1648
+ if (!attachment) return;
1649
+ if (action === "subscribe") this.subscribeDetectionFrames(attachment.config).then((unsub) => {
1650
+ attachment.detectionUnsubscribe = unsub;
1651
+ });
1652
+ else {
1653
+ attachment.detectionUnsubscribe?.();
1654
+ attachment.detectionUnsubscribe = null;
1655
+ }
1656
+ }
1657
+ /**
1658
+ * Bridge runner phase transitions to the device's `motion` runtime
1659
+ * state + the bus. Single ownership point — every motion source
1660
+ * (analyzer, onboard, future variants) funnels through the runner's
1661
+ * phase machine and lands here.
1662
+ *
1663
+ * - Cap-state via the unified `device-state.setCapSlice` API.
1664
+ * `autoClearAfterMs = cooldownMs` on ON, `null` on OFF.
1665
+ * `lastDetectedAt` is preserved across OFF using `lastMotionAt`.
1666
+ * - Bus event `MotionOnMotionChanged` fires alongside for consumers
1667
+ * that prefer event-driven over runtime-state polling.
1668
+ */
1669
+ handlePhaseChanged(deviceId, phase, meta) {
1670
+ const detected = phase === "active";
1671
+ if (detected) this.lastMotionAt.set(deviceId, meta.timestamp);
1672
+ const slice = {
1673
+ detected,
1674
+ lastDetectedAt: this.lastMotionAt.get(deviceId) ?? null,
1675
+ autoClearAfterMs: detected ? meta.cooldownMs : null
1676
+ };
1677
+ (async () => {
1678
+ await (await this.ctx.fetchDevice(deviceId)).deviceState.setCapSlice({
1679
+ capName: "motion",
1680
+ slice
1681
+ });
1682
+ })().catch((err) => {
1683
+ this.ctx.logger.debug("motion cap-state write failed", {
1684
+ tags: { deviceId },
1685
+ meta: { error: errMsg(err) }
1686
+ });
1687
+ });
1688
+ if (this.ctx.eventBus) {
1689
+ const payload = {
1690
+ deviceId,
1691
+ from: detected ? "watching" : "active",
1692
+ to: detected ? "active" : "watching",
1693
+ reason: detected ? "motion_detected" : "cooldown_expired",
1694
+ source: meta.source,
1695
+ cooldownMs: meta.cooldownMs,
1696
+ timestamp: meta.timestamp
1697
+ };
1698
+ this.ctx.eventBus.emit(createEvent(EventCategory.DetectionPhaseTransition, {
1699
+ type: "device",
1700
+ id: deviceId,
1701
+ addonId: this.ctx.id,
1702
+ deviceId,
1703
+ nodeId: this.nodeId
1704
+ }, payload));
1705
+ }
1706
+ }
1707
+ async subscribeDetectionFrames(config) {
1708
+ const ctx = this.ctx;
1709
+ const runner = this.runner;
1710
+ if (!ctx || !runner) return null;
1711
+ const log = this.ctx.logger.withTags({ deviceId: config.deviceId });
1712
+ const api = this.ctx.api;
1713
+ if (!api) {
1714
+ log.warn("subscribeDetectionFrames: this.ctx.api not available");
1715
+ return null;
1716
+ }
1717
+ return startFrameHandlePoller({
1718
+ api,
1719
+ brokerId: makeSourceBrokerId(config.deviceId, config.detectionStreamId),
1720
+ format: "rgb",
1721
+ maxFps: config.detectionFps,
1722
+ tag: "detection",
1723
+ logger: log,
1724
+ onFrame: (frame, handle) => {
1725
+ runner.enqueueDetectionFrame(config.deviceId, frame, handle);
1726
+ }
1727
+ });
1728
+ }
1729
+ async runInference(deviceId, frame) {
1730
+ if (!this.ctx) return null;
1731
+ const log = this.ctx.logger.withTags({ deviceId });
1732
+ const api = this.ctx.api;
1733
+ if (!api) {
1734
+ log.error("runInference: this.ctx.api not available");
1735
+ return null;
1736
+ }
1737
+ const camConfig = this.attached.get(deviceId)?.config;
1738
+ const steps = camConfig?.steps;
1739
+ const engine = camConfig?.engine;
1740
+ if (!steps) {
1741
+ log.warn("runInference: no steps in attach config — skipping frame (legacy attach?)");
1742
+ return null;
1743
+ }
1744
+ if (steps.length === 0) return null;
1745
+ try {
1746
+ return await api.pipelineExecutor.runPipeline.mutate({
1747
+ steps: [...steps],
1748
+ frame,
1749
+ deviceId,
1750
+ ...engine ? { engine } : {}
1751
+ });
1752
+ } catch (err) {
1753
+ const msg = errMsg(err);
1754
+ log.error("runInference failed", { meta: { error: msg } });
1755
+ return null;
1756
+ }
1757
+ }
1758
+ async runMotionAnalysis(deviceId, frame) {
1759
+ const ctx = this.ctx;
1760
+ const runner = this.runner;
1761
+ if (!ctx || !runner) return;
1762
+ const log = this.ctx.logger.withTags({ deviceId });
1763
+ const motionStart = Date.now();
1764
+ try {
1765
+ const api = this.ctx.api;
1766
+ if (!api) {
1767
+ log.warn("runMotionAnalysis: this.ctx.api not available");
1768
+ return;
1769
+ }
1770
+ const result = await api.motionDetection.analyze.mutate({
1771
+ deviceId,
1772
+ frame: toFrameInput(frame)
1773
+ });
1774
+ if (!result) return;
1775
+ const detected = result.regions.length > 0;
1776
+ if (detected !== (this.lastAnalyzerDetected.get(deviceId) ?? false)) {
1777
+ this.lastAnalyzerDetected.set(deviceId, detected);
1778
+ if (this.ctx.eventBus) this.ctx.eventBus.emit(createEvent(EventCategory.MotionOnMotionChanged, {
1779
+ type: "device",
1780
+ id: deviceId,
1781
+ addonId: this.ctx.id,
1782
+ deviceId,
1783
+ nodeId: this.nodeId
1784
+ }, {
1785
+ deviceId,
1786
+ detected,
1787
+ timestamp: frame.timestamp,
1788
+ source: "analyzer",
1789
+ ...detected ? { regions: result.regions } : {}
1790
+ }));
1791
+ }
1792
+ if (this.ctx.eventBus) {
1793
+ const motionPayload = {
1794
+ detected,
1795
+ regionCount: result.regions.length,
1796
+ regions: result.regions.map((r) => ({
1797
+ bbox: {
1798
+ x: r.bbox.x,
1799
+ y: r.bbox.y,
1800
+ w: r.bbox.w,
1801
+ h: r.bbox.h
1802
+ },
1803
+ pixelCount: r.pixelCount,
1804
+ intensity: r.intensity
1805
+ })),
1806
+ frameWidth: frame.width,
1807
+ frameHeight: frame.height,
1808
+ analysisMs: result.analysisMs
1809
+ };
1810
+ const analyzerSource = {
1811
+ type: "device",
1812
+ id: deviceId,
1813
+ addonId: this.ctx.id,
1814
+ deviceId,
1815
+ nodeId: this.nodeId
1816
+ };
1817
+ this.ctx.eventBus.emit(createEvent(EventCategory.MotionAnalysis, analyzerSource, motionPayload));
1818
+ const zonesPayload = {
1819
+ deviceId,
1820
+ timestamp: frame.timestamp,
1821
+ zones: result.rawRegions.map((r) => ({
1822
+ bbox: [
1823
+ r.bbox.x,
1824
+ r.bbox.y,
1825
+ r.bbox.x + r.bbox.w,
1826
+ r.bbox.y + r.bbox.h
1827
+ ],
1828
+ pixelCount: r.pixelCount,
1829
+ changeScore: r.intensity / 255
1830
+ })),
1831
+ frameSize: {
1832
+ width: frame.width,
1833
+ height: frame.height
1834
+ }
1835
+ };
1836
+ this.ctx.eventBus.emit(createEvent(EventCategory.MotionZonesRaw, analyzerSource, zonesPayload));
1837
+ }
1838
+ const capturedAt = frame.capturedAt;
1839
+ const motionFrameAge = typeof capturedAt === "number" && capturedAt > 0 ? motionStart - capturedAt : -1;
1840
+ runner.timingSampler.addMotionSample(deviceId, Date.now() - motionStart, motionFrameAge);
1841
+ } catch (error) {
1842
+ const msg = errMsg(error);
1843
+ log.error("runMotionAnalysis failed", { meta: { error: msg } });
1844
+ }
1845
+ }
1846
+ async runOccupancyBurst(deviceId, frames) {
1847
+ if (this.occupancyBurstInFlight.has(deviceId)) return;
1848
+ const attachment = this.attached.get(deviceId);
1849
+ if (!attachment) return;
1850
+ const config = attachment.config;
1851
+ const runner = this.runner;
1852
+ if (!runner) return;
1853
+ const log = this.ctx.logger.withTags({ deviceId });
1854
+ const api = this.ctx.api;
1855
+ if (!api) {
1856
+ log.debug("runOccupancyBurst: ctx.api not available");
1857
+ return;
1858
+ }
1859
+ this.occupancyBurstInFlight.add(deviceId);
1860
+ log.debug("occupancy re-check: starting burst", { meta: { frames } });
1861
+ let count = 0;
1862
+ let unsubscribe = null;
1863
+ let unsubbed = false;
1864
+ let settled = false;
1865
+ const doUnsub = () => {
1866
+ if (!unsubbed && unsubscribe) {
1867
+ unsubbed = true;
1868
+ unsubscribe();
1869
+ }
1870
+ };
1871
+ const cleanup = () => {
1872
+ if (settled) return;
1873
+ settled = true;
1874
+ doUnsub();
1875
+ this.occupancyBurstInFlight.delete(deviceId);
1876
+ log.debug("occupancy re-check: burst complete", { meta: { collected: count } });
1877
+ };
1878
+ const safetyTimer = setTimeout(() => {
1879
+ log.debug("occupancy re-check: safety timeout reached", { meta: { collected: count } });
1880
+ cleanup();
1881
+ }, 8e3);
1882
+ try {
1883
+ unsubscribe = await startFrameHandlePoller({
1884
+ api,
1885
+ brokerId: makeSourceBrokerId(config.deviceId, config.detectionStreamId),
1886
+ format: "rgb",
1887
+ maxFps: config.detectionFps,
1888
+ tag: "occupancy",
1889
+ logger: log,
1890
+ onFrame: (frame, handle) => {
1891
+ if (settled) return;
1892
+ runner.enqueueOccupancyFrame(deviceId, frame, handle);
1893
+ count++;
1894
+ if (count >= frames) {
1895
+ clearTimeout(safetyTimer);
1896
+ cleanup();
1897
+ }
1898
+ }
1899
+ });
1900
+ } catch (err) {
1901
+ clearTimeout(safetyTimer);
1902
+ this.occupancyBurstInFlight.delete(deviceId);
1903
+ log.debug("occupancy re-check: poller start failed", { meta: { error: errMsg(err) } });
1904
+ return;
1905
+ }
1906
+ if (settled) {
1907
+ clearTimeout(safetyTimer);
1908
+ doUnsub();
1909
+ }
1910
+ }
1911
+ emitInferenceResult(deviceId, frame, result, handle) {
1912
+ if (!this.ctx?.eventBus) return;
1913
+ const capturedAt = frame.capturedAt;
1914
+ const payload = {
1915
+ deviceId,
1916
+ frame: result,
1917
+ nodeId: this.nodeId,
1918
+ frameHandle: handle,
1919
+ ...typeof capturedAt === "number" && capturedAt > 0 ? { capturedAt } : {}
1920
+ };
1921
+ this.ctx.eventBus.emit(createEvent(EventCategory.PipelineInferenceResult, {
1922
+ type: "device",
1923
+ id: deviceId,
1924
+ nodeId: this.nodeId
1925
+ }, payload));
1926
+ }
1927
+ /**
1928
+ * Emit periodic metric snapshots: one runner-load event for the
1929
+ * node + one camera-metrics event per attached camera. Subscribed
1930
+ * by admin-ui dashboards (LiveLoadPanel, NodeDetailHeader,
1931
+ * CameraStreamPanel) to drive live overlays without polling.
1932
+ *
1933
+ * Skipped when there are no cameras attached so quiet dev runs
1934
+ * don't emit needless bus traffic. The runner-load event is still
1935
+ * emitted in that case because the dashboards rely on it to see
1936
+ * "agent reachable, idle".
1937
+ */
1938
+ emitMetricsSnapshot() {
1939
+ const ctx = this.ctx;
1940
+ const runner = this.runner;
1941
+ if (!ctx?.eventBus || !runner) return;
1942
+ const timestamp = Date.now();
1943
+ this.getLocalLoad().then((load) => {
1944
+ if (!ctx.eventBus) return;
1945
+ const json = JSON.stringify(load);
1946
+ const prev = this.lastEmittedRunnerLoad;
1947
+ const heartbeatDue = !prev || timestamp - prev.emittedAt >= METRICS_HEARTBEAT_MS;
1948
+ if (prev && prev.json === json && !heartbeatDue) return;
1949
+ this.lastEmittedRunnerLoad = {
1950
+ json,
1951
+ emittedAt: timestamp
1952
+ };
1953
+ ctx.eventBus.emit(createEvent(EventCategory.PipelineRunnerLoadSnapshot, {
1954
+ type: "node",
1955
+ id: this.nodeId,
1956
+ nodeId: this.nodeId
1957
+ }, {
1958
+ nodeId: this.nodeId,
1959
+ load,
1960
+ timestamp
1961
+ }));
1962
+ }).catch(() => {});
1963
+ if (this.attached.size === 0) return;
1964
+ for (const deviceId of this.attached.keys()) {
1965
+ const metrics = runner.getCameraMetrics(deviceId);
1966
+ if (!metrics) continue;
1967
+ const json = JSON.stringify(metrics);
1968
+ const prev = this.lastEmittedCameraMetrics.get(deviceId);
1969
+ const heartbeatDue = !prev || timestamp - prev.emittedAt >= METRICS_HEARTBEAT_MS;
1970
+ if (prev && prev.json === json && !heartbeatDue) continue;
1971
+ this.lastEmittedCameraMetrics.set(deviceId, {
1972
+ json,
1973
+ emittedAt: timestamp
1974
+ });
1975
+ ctx.eventBus.emit(createEvent(EventCategory.PipelineCameraMetricsSnapshot, {
1976
+ type: "device",
1977
+ id: deviceId,
1978
+ nodeId: this.nodeId
1979
+ }, {
1980
+ deviceId,
1981
+ nodeId: this.nodeId,
1982
+ metrics,
1983
+ timestamp
1984
+ }));
1985
+ }
1986
+ }
1987
+ globalSettingsSchema() {
1988
+ return this.schema({ sections: [{
1989
+ id: "pipeline-runner-tuning",
1990
+ title: "Pipeline Runner",
1991
+ tab: "scheduler",
1992
+ description: "Per-node detection scheduler tuning. Change only if you understand the pipeline internals.",
1993
+ columns: 2,
1994
+ fields: [
1995
+ {
1996
+ type: "slider",
1997
+ key: "maxConcurrentInferences",
1998
+ label: "Scheduler concurrency",
1999
+ description: "Max parallel inferences the runner scheduler allows across all cameras on this node. Distinct from the detection-pipeline inference-pool worker count (Pipeline tab → \"Worker concurrency\"), which controls Python-side thread pool sizing inside a single inference job.",
2000
+ min: 1,
2001
+ max: 16,
2002
+ step: 1,
2003
+ default: DEFAULT_CONFIG.maxConcurrentInferences,
2004
+ showValue: true
2005
+ },
2006
+ {
2007
+ type: "slider",
2008
+ key: "maxQueueDepth",
2009
+ label: "Max queue depth",
2010
+ description: "Maximum frames held per camera before dropping.",
2011
+ min: 5,
2012
+ max: 100,
2013
+ step: 5,
2014
+ default: DEFAULT_CONFIG.maxQueueDepth,
2015
+ showValue: true
2016
+ },
2017
+ {
2018
+ type: "slider",
2019
+ key: "targetLoadPercent",
2020
+ label: "Target load",
2021
+ description: "Percentage of inference capacity to target before throttling FPS.",
2022
+ min: 50,
2023
+ max: 100,
2024
+ step: 5,
2025
+ default: DEFAULT_CONFIG.targetLoadPercent,
2026
+ unit: "%",
2027
+ showValue: true
2028
+ },
2029
+ {
2030
+ type: "slider",
2031
+ key: "minThrottledFps",
2032
+ label: "Min throttled FPS",
2033
+ description: "Lowest FPS the runner will allow when load-shedding.",
2034
+ min: 1,
2035
+ max: 10,
2036
+ step: 1,
2037
+ default: DEFAULT_CONFIG.minThrottledFps,
2038
+ showValue: true
2039
+ }
2040
+ ]
2041
+ }] });
2042
+ }
2043
+ async onConfigChanged() {
2044
+ this.runner?.updateLimits(this.config);
2045
+ this.ctx.logger.info("pipeline-runner tuning updated", { meta: {
2046
+ maxQueueDepth: this.config.maxQueueDepth,
2047
+ maxConcurrentInferences: this.config.maxConcurrentInferences,
2048
+ targetLoadPercent: this.config.targetLoadPercent,
2049
+ minThrottledFps: this.config.minThrottledFps
2050
+ } });
2051
+ }
1842
2052
  };
1843
- //# sourceMappingURL=index.mjs.map
2053
+ //#endregion
2054
+ export { FrameQueue, PipelineRunner, PipelineTimingSampler, Semaphore, pipelineRunnerBenchActions as customActions, PipelineRunnerAddon as default, shouldStartOnboardAnalyzer };