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