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