@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,474 +1,476 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
1
|
+
Object.defineProperties(exports, {
|
|
2
|
+
__esModule: { value: true },
|
|
3
|
+
[Symbol.toStringTag]: { value: "Module" }
|
|
4
|
+
});
|
|
5
|
+
const require_dist = require("../dist-7ewQjTle.js");
|
|
6
|
+
let node_fs = require("node:fs");
|
|
7
|
+
let node_path = require("node:path");
|
|
8
|
+
//#region src/motion-wasm/wasm-motion-detector.ts
|
|
9
|
+
/**
|
|
10
|
+
* WasmMotionDetector — loads the AssemblyScript WASM module and provides
|
|
11
|
+
* a typed interface for motion detection.
|
|
12
|
+
*
|
|
13
|
+
* Pipeline: blur → diff → threshold → dilate → CCL → bounding boxes
|
|
14
|
+
* All in one WASM call, ~3ms for 640×360 grayscale frames.
|
|
15
|
+
*/
|
|
16
|
+
var DEFAULT_CONFIG$1 = {
|
|
17
|
+
threshold: 45,
|
|
18
|
+
blurRadius: 1,
|
|
19
|
+
dilateRadius: 4,
|
|
20
|
+
minArea: 3e3
|
|
15
21
|
};
|
|
16
|
-
var
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
));
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
22
|
+
var WasmMotionDetector = class {
|
|
23
|
+
wasm = null;
|
|
24
|
+
prevOffset = 0;
|
|
25
|
+
currOffset = 0;
|
|
26
|
+
regionOffset = 0;
|
|
27
|
+
/** Load the WASM module. Call once before detect(). */
|
|
28
|
+
async load() {
|
|
29
|
+
const wasmBytes = (0, node_fs.readFileSync)((0, node_path.join)(__dirname, "..", "..", "wasm", "motion.wasm"));
|
|
30
|
+
const { instance } = await WebAssembly.instantiate(wasmBytes, { env: { abort: () => {
|
|
31
|
+
throw new Error("WASM abort");
|
|
32
|
+
} } });
|
|
33
|
+
const exports = instance.exports;
|
|
34
|
+
for (const name of [
|
|
35
|
+
"memory",
|
|
36
|
+
"init",
|
|
37
|
+
"getPrevOffset",
|
|
38
|
+
"getCurrOffset",
|
|
39
|
+
"getRegionOffset",
|
|
40
|
+
"detectMotion"
|
|
41
|
+
]) {
|
|
42
|
+
const v = exports[name];
|
|
43
|
+
if (v === void 0) throw new Error(`motion.wasm contract violation: missing export "${String(name)}"`);
|
|
44
|
+
if (name === "memory") {
|
|
45
|
+
if (!(v instanceof WebAssembly.Memory)) throw new Error("motion.wasm contract violation: \"memory\" is not a WebAssembly.Memory");
|
|
46
|
+
} else if (typeof v !== "function") throw new Error(`motion.wasm contract violation: "${String(name)}" is not callable`);
|
|
47
|
+
}
|
|
48
|
+
this.wasm = exports;
|
|
49
|
+
}
|
|
50
|
+
/** Initialize for given frame dimensions. Call when resolution changes. */
|
|
51
|
+
init(w, h) {
|
|
52
|
+
if (!this.wasm) throw new Error("WASM not loaded — call load() first");
|
|
53
|
+
this.wasm.init(w, h);
|
|
54
|
+
this.prevOffset = this.wasm.getPrevOffset();
|
|
55
|
+
this.currOffset = this.wasm.getCurrOffset();
|
|
56
|
+
this.regionOffset = this.wasm.getRegionOffset();
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Detect motion between previous and current grayscale frames.
|
|
60
|
+
*
|
|
61
|
+
* @param prevGray - previous frame (Uint8Array, width×height)
|
|
62
|
+
* @param currGray - current frame (Uint8Array, width×height)
|
|
63
|
+
* @param config - detection parameters (optional, uses defaults)
|
|
64
|
+
* @returns raw (all CCL regions) + filtered (passing minArea) regions
|
|
65
|
+
*/
|
|
66
|
+
detect(prevGray, currGray, config = {}) {
|
|
67
|
+
if (!this.wasm) throw new Error("WASM not loaded");
|
|
68
|
+
const cfg = {
|
|
69
|
+
...DEFAULT_CONFIG$1,
|
|
70
|
+
...config
|
|
71
|
+
};
|
|
72
|
+
const mem = new Uint8Array(this.wasm.memory.buffer);
|
|
73
|
+
mem.set(prevGray, this.prevOffset);
|
|
74
|
+
mem.set(currGray, this.currOffset);
|
|
75
|
+
const numRegions = this.wasm.detectMotion(cfg.threshold, cfg.blurRadius, cfg.dilateRadius, 0);
|
|
76
|
+
if (numRegions === 0) return {
|
|
77
|
+
raw: [],
|
|
78
|
+
filtered: []
|
|
79
|
+
};
|
|
80
|
+
const view = new DataView(this.wasm.memory.buffer);
|
|
81
|
+
const raw = [];
|
|
82
|
+
for (let i = 0; i < numRegions; i++) {
|
|
83
|
+
const base = this.regionOffset + i * 20;
|
|
84
|
+
raw.push({
|
|
85
|
+
x: view.getInt32(base, true),
|
|
86
|
+
y: view.getInt32(base + 4, true),
|
|
87
|
+
w: view.getInt32(base + 8, true),
|
|
88
|
+
h: view.getInt32(base + 12, true),
|
|
89
|
+
pixels: view.getInt32(base + 16, true)
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
raw,
|
|
94
|
+
filtered: raw.filter((r) => r.pixels >= cfg.minArea)
|
|
95
|
+
};
|
|
96
|
+
}
|
|
33
97
|
};
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
prevOffset = 0;
|
|
37
|
-
currOffset = 0;
|
|
38
|
-
regionOffset = 0;
|
|
39
|
-
/** Load the WASM module. Call once before detect(). */
|
|
40
|
-
async load() {
|
|
41
|
-
const wasmPath = path.join(__dirname, "..", "..", "wasm", "motion.wasm");
|
|
42
|
-
const wasmBytes = fs.readFileSync(wasmPath);
|
|
43
|
-
const { instance } = await WebAssembly.instantiate(wasmBytes, {
|
|
44
|
-
env: { abort: () => {
|
|
45
|
-
throw new Error("WASM abort");
|
|
46
|
-
} }
|
|
47
|
-
});
|
|
48
|
-
const exports2 = instance.exports;
|
|
49
|
-
const required = [
|
|
50
|
-
"memory",
|
|
51
|
-
"init",
|
|
52
|
-
"getPrevOffset",
|
|
53
|
-
"getCurrOffset",
|
|
54
|
-
"getRegionOffset",
|
|
55
|
-
"detectMotion"
|
|
56
|
-
];
|
|
57
|
-
for (const name of required) {
|
|
58
|
-
const v = exports2[name];
|
|
59
|
-
if (v === void 0) {
|
|
60
|
-
throw new Error(`motion.wasm contract violation: missing export "${String(name)}"`);
|
|
61
|
-
}
|
|
62
|
-
if (name === "memory") {
|
|
63
|
-
if (!(v instanceof WebAssembly.Memory)) {
|
|
64
|
-
throw new Error('motion.wasm contract violation: "memory" is not a WebAssembly.Memory');
|
|
65
|
-
}
|
|
66
|
-
} else if (typeof v !== "function") {
|
|
67
|
-
throw new Error(`motion.wasm contract violation: "${String(name)}" is not callable`);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
this.wasm = exports2;
|
|
71
|
-
}
|
|
72
|
-
/** Initialize for given frame dimensions. Call when resolution changes. */
|
|
73
|
-
init(w, h) {
|
|
74
|
-
if (!this.wasm) throw new Error("WASM not loaded — call load() first");
|
|
75
|
-
this.wasm.init(w, h);
|
|
76
|
-
this.prevOffset = this.wasm.getPrevOffset();
|
|
77
|
-
this.currOffset = this.wasm.getCurrOffset();
|
|
78
|
-
this.regionOffset = this.wasm.getRegionOffset();
|
|
79
|
-
}
|
|
80
|
-
/**
|
|
81
|
-
* Detect motion between previous and current grayscale frames.
|
|
82
|
-
*
|
|
83
|
-
* @param prevGray - previous frame (Uint8Array, width×height)
|
|
84
|
-
* @param currGray - current frame (Uint8Array, width×height)
|
|
85
|
-
* @param config - detection parameters (optional, uses defaults)
|
|
86
|
-
* @returns raw (all CCL regions) + filtered (passing minArea) regions
|
|
87
|
-
*/
|
|
88
|
-
detect(prevGray, currGray, config = {}) {
|
|
89
|
-
if (!this.wasm) throw new Error("WASM not loaded");
|
|
90
|
-
const cfg = { ...DEFAULT_CONFIG$1, ...config };
|
|
91
|
-
const mem = new Uint8Array(this.wasm.memory.buffer);
|
|
92
|
-
mem.set(prevGray, this.prevOffset);
|
|
93
|
-
mem.set(currGray, this.currOffset);
|
|
94
|
-
const numRegions = this.wasm.detectMotion(
|
|
95
|
-
cfg.threshold,
|
|
96
|
-
cfg.blurRadius,
|
|
97
|
-
cfg.dilateRadius,
|
|
98
|
-
0
|
|
99
|
-
);
|
|
100
|
-
if (numRegions === 0) return { raw: [], filtered: [] };
|
|
101
|
-
const view = new DataView(this.wasm.memory.buffer);
|
|
102
|
-
const raw = [];
|
|
103
|
-
for (let i = 0; i < numRegions; i++) {
|
|
104
|
-
const base = this.regionOffset + i * 20;
|
|
105
|
-
raw.push({
|
|
106
|
-
x: view.getInt32(base, true),
|
|
107
|
-
y: view.getInt32(base + 4, true),
|
|
108
|
-
w: view.getInt32(base + 8, true),
|
|
109
|
-
h: view.getInt32(base + 12, true),
|
|
110
|
-
pixels: view.getInt32(base + 16, true)
|
|
111
|
-
});
|
|
112
|
-
}
|
|
113
|
-
const filtered = raw.filter((r) => r.pixels >= cfg.minArea);
|
|
114
|
-
return { raw, filtered };
|
|
115
|
-
}
|
|
116
|
-
}
|
|
98
|
+
//#endregion
|
|
99
|
+
//#region src/motion-wasm/zone-gate.ts
|
|
117
100
|
function gateMotionRegions(regions, zones, rules, frameWidth, frameHeight) {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}
|
|
126
|
-
const DEFAULT_CONFIG = { threshold: 45, blurRadius: 1, dilateRadius: 4, minArea: 1500 };
|
|
127
|
-
class MotionWasmAddon extends index.BaseAddon {
|
|
128
|
-
detector = null;
|
|
129
|
-
cameras = /* @__PURE__ */ new Map();
|
|
130
|
-
deviceConfigCache = /* @__PURE__ */ new Map();
|
|
131
|
-
static DEVICE_CONFIG_TTL_MS = 6e4;
|
|
132
|
-
/**
|
|
133
|
-
* Per-device {@link DeviceProxy} used for zone gating. Built lazily
|
|
134
|
-
* on first analyze() for a device; the proxy's reactive state
|
|
135
|
-
* handles (`state.zones`, `state.zoneRules`) keep their `.value`
|
|
136
|
-
* fresh via the kernel's runtime-state mirror, so the gate reads
|
|
137
|
-
* are sync and free of bus plumbing inside this addon.
|
|
138
|
-
*/
|
|
139
|
-
proxies = /* @__PURE__ */ new Map();
|
|
140
|
-
/** Unsubscribe pins kept so the slice handles stay subscribed for
|
|
141
|
-
* the camera's lifetime — `.value` only flows through the cache
|
|
142
|
-
* when at least one watcher is active. */
|
|
143
|
-
proxyUnsubs = /* @__PURE__ */ new Map();
|
|
144
|
-
constructor() {
|
|
145
|
-
super({});
|
|
146
|
-
}
|
|
147
|
-
async onInitialize() {
|
|
148
|
-
this.detector = new WasmMotionDetector();
|
|
149
|
-
await this.detector.load();
|
|
150
|
-
this.ctx.logger.info("WASM motion detector loaded");
|
|
151
|
-
return [{
|
|
152
|
-
capability: index.motionDetectionCapability,
|
|
153
|
-
provider: this
|
|
154
|
-
}];
|
|
155
|
-
}
|
|
156
|
-
// Sentinel key used by the pipeline-step `process()` path, which owns a
|
|
157
|
-
// dedicated per-addon instance (one per camera via AddonEngineManager)
|
|
158
|
-
// and therefore does not need a real deviceId.
|
|
159
|
-
static PIPELINE_STEP_KEY = "__pipeline__";
|
|
160
|
-
/**
|
|
161
|
-
* Pipeline step interface — called by PipelineRunner.
|
|
162
|
-
* Uses single-camera state (one instance per camera via AddonEngineManager).
|
|
163
|
-
*/
|
|
164
|
-
async process(input) {
|
|
165
|
-
const start = performance.now();
|
|
166
|
-
const result = await this.analyzeInternal(MotionWasmAddon.PIPELINE_STEP_KEY, input);
|
|
167
|
-
const toSpatial = (r) => ({
|
|
168
|
-
class: "motion",
|
|
169
|
-
originalClass: "motion",
|
|
170
|
-
score: Math.min(1, r.intensity / 128),
|
|
171
|
-
bbox: r.bbox
|
|
172
|
-
});
|
|
173
|
-
const detections = result.regions.map(toSpatial);
|
|
174
|
-
const rawDetections = result.rawRegions.map(toSpatial);
|
|
175
|
-
return { detections, rawDetections, inferenceMs: performance.now() - start, modelId: "wasm-motion" };
|
|
176
|
-
}
|
|
177
|
-
/**
|
|
178
|
-
* motion-detection cap: analyze a frame, return regions + stats.
|
|
179
|
-
* Per-device state keyed by the numeric deviceId (stringified internally
|
|
180
|
-
* so the pipeline-step sentinel shares the same Map).
|
|
181
|
-
*/
|
|
182
|
-
async analyze({ deviceId, frame }) {
|
|
183
|
-
return this.analyzeInternal(String(deviceId), frame);
|
|
184
|
-
}
|
|
185
|
-
async analyzeInternal(cameraId, frame) {
|
|
186
|
-
if (!this.detector) {
|
|
187
|
-
return { detected: false, regionCount: 0, regions: [], rawRegions: [], frameWidth: 0, frameHeight: 0, analysisMs: 0 };
|
|
188
|
-
}
|
|
189
|
-
const start = performance.now();
|
|
190
|
-
let gray;
|
|
191
|
-
let width;
|
|
192
|
-
let height;
|
|
193
|
-
if (frame.format === "jpeg") {
|
|
194
|
-
const sharp = (await import("sharp")).default;
|
|
195
|
-
const { data, info } = await sharp(Buffer.from(frame.data)).grayscale().raw().toBuffer({ resolveWithObject: true });
|
|
196
|
-
gray = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
197
|
-
width = info.width;
|
|
198
|
-
height = info.height;
|
|
199
|
-
} else {
|
|
200
|
-
gray = frame.data instanceof Uint8Array ? frame.data : new Uint8Array(frame.data);
|
|
201
|
-
width = frame.width;
|
|
202
|
-
height = frame.height;
|
|
203
|
-
}
|
|
204
|
-
let state = this.cameras.get(cameraId);
|
|
205
|
-
if (!state || state.width !== width || state.height !== height) {
|
|
206
|
-
this.detector.init(width, height);
|
|
207
|
-
this.cameras.set(cameraId, { prevGray: gray, width, height });
|
|
208
|
-
return {
|
|
209
|
-
detected: false,
|
|
210
|
-
regionCount: 0,
|
|
211
|
-
regions: [],
|
|
212
|
-
rawRegions: [],
|
|
213
|
-
frameWidth: width,
|
|
214
|
-
frameHeight: height,
|
|
215
|
-
analysisMs: performance.now() - start
|
|
216
|
-
};
|
|
217
|
-
}
|
|
218
|
-
state = this.cameras.get(cameraId);
|
|
219
|
-
const deviceCfg = await this.getDeviceConfig(cameraId);
|
|
220
|
-
const { raw, filtered } = this.detector.detect(state.prevGray, gray, deviceCfg);
|
|
221
|
-
state.prevGray = Buffer.from(gray);
|
|
222
|
-
const toRegion = (r) => ({
|
|
223
|
-
bbox: { x: r.x, y: r.y, w: r.w, h: r.h },
|
|
224
|
-
pixelCount: r.pixels,
|
|
225
|
-
intensity: Math.min(255, Math.round(r.pixels / (r.w * r.h) * 255))
|
|
226
|
-
});
|
|
227
|
-
let regions = filtered.map(toRegion);
|
|
228
|
-
const rawRegions = raw.map(toRegion);
|
|
229
|
-
const numericDeviceId = Number(cameraId);
|
|
230
|
-
if (Number.isFinite(numericDeviceId)) {
|
|
231
|
-
const proxy = await this.ensureProxy(numericDeviceId);
|
|
232
|
-
if (proxy) {
|
|
233
|
-
const zones = proxy.state.zones.value?.zones ?? [];
|
|
234
|
-
const rules = proxy.state.zoneRules.value?.motion ?? [];
|
|
235
|
-
if (rules.length > 0 && zones.length > 0) {
|
|
236
|
-
const gated = gateMotionRegions(regions, zones, rules, width, height);
|
|
237
|
-
regions = [...gated.passed];
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
return {
|
|
242
|
-
detected: regions.length > 0,
|
|
243
|
-
regionCount: regions.length,
|
|
244
|
-
regions,
|
|
245
|
-
rawRegions,
|
|
246
|
-
frameWidth: width,
|
|
247
|
-
frameHeight: height,
|
|
248
|
-
analysisMs: performance.now() - start
|
|
249
|
-
};
|
|
250
|
-
}
|
|
251
|
-
/**
|
|
252
|
-
* Resolve (and cache) a {@link DeviceProxy} for the given device.
|
|
253
|
-
* Pins the `state.zones` + `state.zoneRules` slice handles so the
|
|
254
|
-
* kernel runtime-state mirror keeps `.value` warm — subsequent
|
|
255
|
-
* analyze() calls read both slices synchronously without any bus
|
|
256
|
-
* plumbing or per-frame cap query. Returns null on first-call
|
|
257
|
-
* failure (logged) so motion analysis never blocks on zone
|
|
258
|
-
* resolution.
|
|
259
|
-
*/
|
|
260
|
-
async ensureProxy(deviceId) {
|
|
261
|
-
const cached = this.proxies.get(deviceId);
|
|
262
|
-
if (cached) return cached;
|
|
263
|
-
try {
|
|
264
|
-
const proxy = await this.ctx.fetchDevice(deviceId);
|
|
265
|
-
this.proxies.set(deviceId, proxy);
|
|
266
|
-
const unsubs = [
|
|
267
|
-
proxy.state.zones.subscribe(() => {
|
|
268
|
-
}),
|
|
269
|
-
proxy.state.zoneRules.subscribe(() => {
|
|
270
|
-
})
|
|
271
|
-
];
|
|
272
|
-
this.proxyUnsubs.set(deviceId, unsubs);
|
|
273
|
-
return proxy;
|
|
274
|
-
} catch (err) {
|
|
275
|
-
this.ctx.logger.debug("ensureProxy failed — gating skipped", {
|
|
276
|
-
tags: { deviceId },
|
|
277
|
-
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
278
|
-
});
|
|
279
|
-
return null;
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
/** Drop the proxy + slice subscriptions for a device. */
|
|
283
|
-
releaseProxy(deviceId) {
|
|
284
|
-
const unsubs = this.proxyUnsubs.get(deviceId);
|
|
285
|
-
if (unsubs) {
|
|
286
|
-
for (const u of unsubs) {
|
|
287
|
-
try {
|
|
288
|
-
u();
|
|
289
|
-
} catch {
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
this.proxyUnsubs.delete(deviceId);
|
|
294
|
-
this.proxies.delete(deviceId);
|
|
295
|
-
}
|
|
296
|
-
/**
|
|
297
|
-
* Resolve the effective per-device config for a camera. Reads the raw
|
|
298
|
-
* device store and overlays it on top of the schema defaults via
|
|
299
|
-
* `hydrateSchema()`, then narrows to the typed `WasmMotionConfig` shape.
|
|
300
|
-
* Cached with a TTL to avoid hammering the settings store on every frame.
|
|
301
|
-
*/
|
|
302
|
-
async getDeviceConfig(cameraId) {
|
|
303
|
-
const now = Date.now();
|
|
304
|
-
const cached = this.deviceConfigCache.get(cameraId);
|
|
305
|
-
if (cached && now - cached.fetchedAt < MotionWasmAddon.DEVICE_CONFIG_TTL_MS) {
|
|
306
|
-
return cached.config;
|
|
307
|
-
}
|
|
308
|
-
if (!this.ctx?.settings) return DEFAULT_CONFIG;
|
|
309
|
-
const raw = await this.ctx.settings.readDeviceStore(Number(cameraId));
|
|
310
|
-
const hydrated = index.hydrateSchema(this.deviceSettingsSchema(), raw);
|
|
311
|
-
const flat = {};
|
|
312
|
-
for (const section of hydrated.sections) {
|
|
313
|
-
for (const field of section.fields) {
|
|
314
|
-
if (field.type === "separator" || field.type === "info" || field.type === "button") continue;
|
|
315
|
-
if (field.type === "group") continue;
|
|
316
|
-
flat[field.key] = field.value;
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
const resolved = {
|
|
320
|
-
threshold: typeof flat["threshold"] === "number" ? flat["threshold"] : DEFAULT_CONFIG.threshold,
|
|
321
|
-
blurRadius: typeof flat["blurRadius"] === "number" ? flat["blurRadius"] : DEFAULT_CONFIG.blurRadius,
|
|
322
|
-
dilateRadius: typeof flat["dilateRadius"] === "number" ? flat["dilateRadius"] : DEFAULT_CONFIG.dilateRadius,
|
|
323
|
-
minArea: typeof flat["minArea"] === "number" ? flat["minArea"] : DEFAULT_CONFIG.minArea
|
|
324
|
-
};
|
|
325
|
-
this.deviceConfigCache.set(cameraId, { config: resolved, fetchedAt: now });
|
|
326
|
-
return resolved;
|
|
327
|
-
}
|
|
328
|
-
async removeCamera({ deviceId }) {
|
|
329
|
-
const key = String(deviceId);
|
|
330
|
-
this.cameras.delete(key);
|
|
331
|
-
this.deviceConfigCache.delete(key);
|
|
332
|
-
this.releaseProxy(deviceId);
|
|
333
|
-
}
|
|
334
|
-
async reset() {
|
|
335
|
-
this.cameras.clear();
|
|
336
|
-
this.deviceConfigCache.clear();
|
|
337
|
-
for (const id of this.proxies.keys()) this.releaseProxy(id);
|
|
338
|
-
}
|
|
339
|
-
async onShutdown() {
|
|
340
|
-
this.cameras.clear();
|
|
341
|
-
this.deviceConfigCache.clear();
|
|
342
|
-
for (const id of this.proxies.keys()) this.releaseProxy(id);
|
|
343
|
-
this.detector = null;
|
|
344
|
-
}
|
|
345
|
-
// ── Standard ICamstackAddon — three-level settings API (Phase 3) ─────
|
|
346
|
-
//
|
|
347
|
-
// motion-wasm is a pure per-device addon: every parameter is tuned on
|
|
348
|
-
// a per-camera basis. It implements only `getDeviceSettings` /
|
|
349
|
-
// `updateDeviceSettings`. No addon-level, no node-global.
|
|
350
|
-
deviceSettingsSchema() {
|
|
351
|
-
return this.schema({
|
|
352
|
-
sections: [
|
|
353
|
-
{
|
|
354
|
-
id: "motion-wasm-settings",
|
|
355
|
-
title: "",
|
|
356
|
-
tab: "motion",
|
|
357
|
-
order: 10,
|
|
358
|
-
columns: 2,
|
|
359
|
-
fields: [
|
|
360
|
-
// Every motion-wasm knob is an analyzer-side tuning param —
|
|
361
|
-
// when the operator picks `onboard`-only on the orchestrator's
|
|
362
|
-
// motionSources field, the wasm analyzer doesn't run, so
|
|
363
|
-
// exposing the threshold/minArea/blur/dilate fields would be
|
|
364
|
-
// misleading. Gate on the same key the orchestrator reads
|
|
365
|
-
// (`motionSources`) so all four hide together.
|
|
366
|
-
{
|
|
367
|
-
type: "slider",
|
|
368
|
-
key: "threshold",
|
|
369
|
-
label: "Threshold",
|
|
370
|
-
description: "Pixel intensity difference to count as changed (higher = less sensitive)",
|
|
371
|
-
min: 1,
|
|
372
|
-
max: 255,
|
|
373
|
-
step: 1,
|
|
374
|
-
default: DEFAULT_CONFIG.threshold,
|
|
375
|
-
showValue: true,
|
|
376
|
-
showWhen: { field: "motionSources", includes: "analyzer" }
|
|
377
|
-
},
|
|
378
|
-
{
|
|
379
|
-
type: "number",
|
|
380
|
-
key: "minArea",
|
|
381
|
-
label: "Min Area",
|
|
382
|
-
description: "Minimum pixel area of a motion region to trigger detection",
|
|
383
|
-
min: 0,
|
|
384
|
-
max: 5e4,
|
|
385
|
-
step: 100,
|
|
386
|
-
default: DEFAULT_CONFIG.minArea,
|
|
387
|
-
unit: "px",
|
|
388
|
-
showWhen: { field: "motionSources", includes: "analyzer" }
|
|
389
|
-
},
|
|
390
|
-
{
|
|
391
|
-
type: "slider",
|
|
392
|
-
key: "blurRadius",
|
|
393
|
-
label: "Blur Radius",
|
|
394
|
-
description: "Gaussian blur before diff (reduces noise)",
|
|
395
|
-
min: 0,
|
|
396
|
-
max: 10,
|
|
397
|
-
step: 1,
|
|
398
|
-
default: DEFAULT_CONFIG.blurRadius,
|
|
399
|
-
showValue: true,
|
|
400
|
-
showWhen: { field: "motionSources", includes: "analyzer" }
|
|
401
|
-
},
|
|
402
|
-
{
|
|
403
|
-
type: "slider",
|
|
404
|
-
key: "dilateRadius",
|
|
405
|
-
label: "Dilation Radius",
|
|
406
|
-
description: "Expand motion regions to merge nearby changes",
|
|
407
|
-
min: 0,
|
|
408
|
-
max: 10,
|
|
409
|
-
step: 1,
|
|
410
|
-
default: DEFAULT_CONFIG.dilateRadius,
|
|
411
|
-
showValue: true,
|
|
412
|
-
showWhen: { field: "motionSources", includes: "analyzer" }
|
|
413
|
-
}
|
|
414
|
-
]
|
|
415
|
-
}
|
|
416
|
-
]
|
|
417
|
-
});
|
|
418
|
-
}
|
|
419
|
-
async getDeviceSettings(deviceId) {
|
|
420
|
-
const ctx = this.ctxIfReady;
|
|
421
|
-
const raw = await ctx?.settings?.readDeviceStore(deviceId) ?? {};
|
|
422
|
-
return index.hydrateSchema(this.deviceSettingsSchema(), raw);
|
|
423
|
-
}
|
|
424
|
-
async updateDeviceSettings(deviceId, patch) {
|
|
425
|
-
await this.ctx.settings?.writeDeviceStore(deviceId, patch);
|
|
426
|
-
this.deviceConfigCache.delete(String(deviceId));
|
|
427
|
-
}
|
|
428
|
-
// ── Device-details aggregator contribution (motion-detection cap) ────────
|
|
429
|
-
//
|
|
430
|
-
// motion-wasm contributes its 4 tuning knobs (threshold, blurRadius,
|
|
431
|
-
// dilateRadius, minArea) to the `detection` tab for every camera. Live
|
|
432
|
-
// info is skipped — motion runtime stats are already streamed via the
|
|
433
|
-
// pipeline-orchestrator live contribution.
|
|
434
|
-
async getDeviceSettingsContribution(input) {
|
|
435
|
-
if (!await this.isCameraDevice(input.deviceId)) return null;
|
|
436
|
-
const schema = this.deviceSettingsSchema();
|
|
437
|
-
if (!schema) return null;
|
|
438
|
-
const raw = await this.ctx?.settings?.readDeviceStore(input.deviceId) ?? {};
|
|
439
|
-
const withTab = {
|
|
440
|
-
...schema,
|
|
441
|
-
sections: schema.sections.map((s) => ({ ...s, tab: s.tab ?? "detection" }))
|
|
442
|
-
};
|
|
443
|
-
return index.hydrateSchema(withTab, raw);
|
|
444
|
-
}
|
|
445
|
-
async getDeviceLiveContribution(_input) {
|
|
446
|
-
return null;
|
|
447
|
-
}
|
|
448
|
-
async applyDeviceSettingsPatch(input) {
|
|
449
|
-
await this.updateDeviceSettings(input.deviceId, input.patch);
|
|
450
|
-
return { success: true };
|
|
451
|
-
}
|
|
452
|
-
/**
|
|
453
|
-
* Best-effort camera-type check. Used by the settings/live contribution
|
|
454
|
-
* methods to short-circuit on non-camera devices (Lights, Switches,
|
|
455
|
-
* Sensors, Buttons). Returns `true` on lookup failure so a transient
|
|
456
|
-
* device-manager hiccup never silently hides legitimate camera
|
|
457
|
-
* sections.
|
|
458
|
-
*/
|
|
459
|
-
async isCameraDevice(deviceId) {
|
|
460
|
-
const api = this.ctx?.api;
|
|
461
|
-
if (!api) return true;
|
|
462
|
-
try {
|
|
463
|
-
const dev = await api.deviceManager.getDevice.query({ deviceId });
|
|
464
|
-
if (!dev) return true;
|
|
465
|
-
return dev.type === index.DeviceType.Camera;
|
|
466
|
-
} catch {
|
|
467
|
-
return true;
|
|
468
|
-
}
|
|
469
|
-
}
|
|
101
|
+
if (frameWidth === 0 || frameHeight === 0) return {
|
|
102
|
+
passed: regions,
|
|
103
|
+
excluded: []
|
|
104
|
+
};
|
|
105
|
+
return require_dist.evaluateZoneRules(regions, zones, rules, (region) => ({
|
|
106
|
+
x: (region.bbox.x + region.bbox.w / 2) / frameWidth,
|
|
107
|
+
y: (region.bbox.y + region.bbox.h / 2) / frameHeight
|
|
108
|
+
}));
|
|
470
109
|
}
|
|
110
|
+
//#endregion
|
|
111
|
+
//#region src/motion-wasm/addon/index.ts
|
|
112
|
+
/**
|
|
113
|
+
* Motion detection addon using WebAssembly.
|
|
114
|
+
*
|
|
115
|
+
* Drop-in replacement for addon-motion-detection. Same IMotionDetector interface,
|
|
116
|
+
* but uses WASM blur+diff+dilate+CCL instead of JS pixel loop.
|
|
117
|
+
*
|
|
118
|
+
* Produces bounding box regions (not just changed pixel count).
|
|
119
|
+
* ~3ms/frame at 640×360 with full pipeline.
|
|
120
|
+
*
|
|
121
|
+
* Settings redesign Phase 3: motion-wasm has ONLY device-level settings
|
|
122
|
+
* (all four fields were `scope: 'device'` in the legacy schema — every
|
|
123
|
+
* camera tunes threshold/minArea/blurRadius/dilateRadius independently
|
|
124
|
+
* for its scene). So the addon implements `getDeviceSettings` /
|
|
125
|
+
* `updateDeviceSettings` and nothing else. Fresh cameras with no
|
|
126
|
+
* overrides get the schema defaults via `hydrateSchema()` at read time.
|
|
127
|
+
* There is no cluster-wide "default threshold" knob — if operators want
|
|
128
|
+
* to change the baseline, they edit the schema defaults in code.
|
|
129
|
+
*/
|
|
130
|
+
var DEFAULT_CONFIG = {
|
|
131
|
+
threshold: 45,
|
|
132
|
+
blurRadius: 1,
|
|
133
|
+
dilateRadius: 4,
|
|
134
|
+
minArea: 1500
|
|
135
|
+
};
|
|
136
|
+
var MotionWasmAddon = class MotionWasmAddon extends require_dist.BaseAddon {
|
|
137
|
+
detector = null;
|
|
138
|
+
cameras = /* @__PURE__ */ new Map();
|
|
139
|
+
deviceConfigCache = /* @__PURE__ */ new Map();
|
|
140
|
+
static DEVICE_CONFIG_TTL_MS = 6e4;
|
|
141
|
+
/**
|
|
142
|
+
* Per-device {@link DeviceProxy} used for zone gating. Built lazily
|
|
143
|
+
* on first analyze() for a device; the proxy's reactive state
|
|
144
|
+
* handles (`state.zones`, `state.zoneRules`) keep their `.value`
|
|
145
|
+
* fresh via the kernel's runtime-state mirror, so the gate reads
|
|
146
|
+
* are sync and free of bus plumbing inside this addon.
|
|
147
|
+
*/
|
|
148
|
+
proxies = /* @__PURE__ */ new Map();
|
|
149
|
+
/** Unsubscribe pins kept so the slice handles stay subscribed for
|
|
150
|
+
* the camera's lifetime — `.value` only flows through the cache
|
|
151
|
+
* when at least one watcher is active. */
|
|
152
|
+
proxyUnsubs = /* @__PURE__ */ new Map();
|
|
153
|
+
constructor() {
|
|
154
|
+
super({});
|
|
155
|
+
}
|
|
156
|
+
async onInitialize() {
|
|
157
|
+
this.detector = new WasmMotionDetector();
|
|
158
|
+
await this.detector.load();
|
|
159
|
+
this.ctx.logger.info("WASM motion detector loaded");
|
|
160
|
+
return [{
|
|
161
|
+
capability: require_dist.motionDetectionCapability,
|
|
162
|
+
provider: this
|
|
163
|
+
}];
|
|
164
|
+
}
|
|
165
|
+
static PIPELINE_STEP_KEY = "__pipeline__";
|
|
166
|
+
/**
|
|
167
|
+
* Pipeline step interface — called by PipelineRunner.
|
|
168
|
+
* Uses single-camera state (one instance per camera via AddonEngineManager).
|
|
169
|
+
*/
|
|
170
|
+
async process(input) {
|
|
171
|
+
const start = performance.now();
|
|
172
|
+
const result = await this.analyzeInternal(MotionWasmAddon.PIPELINE_STEP_KEY, input);
|
|
173
|
+
const toSpatial = (r) => ({
|
|
174
|
+
class: "motion",
|
|
175
|
+
originalClass: "motion",
|
|
176
|
+
score: Math.min(1, r.intensity / 128),
|
|
177
|
+
bbox: r.bbox
|
|
178
|
+
});
|
|
179
|
+
return {
|
|
180
|
+
detections: result.regions.map(toSpatial),
|
|
181
|
+
rawDetections: result.rawRegions.map(toSpatial),
|
|
182
|
+
inferenceMs: performance.now() - start,
|
|
183
|
+
modelId: "wasm-motion"
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* motion-detection cap: analyze a frame, return regions + stats.
|
|
188
|
+
* Per-device state keyed by the numeric deviceId (stringified internally
|
|
189
|
+
* so the pipeline-step sentinel shares the same Map).
|
|
190
|
+
*/
|
|
191
|
+
async analyze({ deviceId, frame }) {
|
|
192
|
+
return this.analyzeInternal(String(deviceId), frame);
|
|
193
|
+
}
|
|
194
|
+
async analyzeInternal(cameraId, frame) {
|
|
195
|
+
if (!this.detector) return {
|
|
196
|
+
detected: false,
|
|
197
|
+
regionCount: 0,
|
|
198
|
+
regions: [],
|
|
199
|
+
rawRegions: [],
|
|
200
|
+
frameWidth: 0,
|
|
201
|
+
frameHeight: 0,
|
|
202
|
+
analysisMs: 0
|
|
203
|
+
};
|
|
204
|
+
const start = performance.now();
|
|
205
|
+
let gray;
|
|
206
|
+
let width;
|
|
207
|
+
let height;
|
|
208
|
+
if (frame.format === "jpeg") {
|
|
209
|
+
const sharp = (await import("sharp")).default;
|
|
210
|
+
const { data, info } = await sharp(Buffer.from(frame.data)).grayscale().raw().toBuffer({ resolveWithObject: true });
|
|
211
|
+
gray = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
212
|
+
width = info.width;
|
|
213
|
+
height = info.height;
|
|
214
|
+
} else {
|
|
215
|
+
gray = frame.data instanceof Uint8Array ? frame.data : new Uint8Array(frame.data);
|
|
216
|
+
width = frame.width;
|
|
217
|
+
height = frame.height;
|
|
218
|
+
}
|
|
219
|
+
let state = this.cameras.get(cameraId);
|
|
220
|
+
if (!state || state.width !== width || state.height !== height) {
|
|
221
|
+
this.detector.init(width, height);
|
|
222
|
+
this.cameras.set(cameraId, {
|
|
223
|
+
prevGray: gray,
|
|
224
|
+
width,
|
|
225
|
+
height
|
|
226
|
+
});
|
|
227
|
+
return {
|
|
228
|
+
detected: false,
|
|
229
|
+
regionCount: 0,
|
|
230
|
+
regions: [],
|
|
231
|
+
rawRegions: [],
|
|
232
|
+
frameWidth: width,
|
|
233
|
+
frameHeight: height,
|
|
234
|
+
analysisMs: performance.now() - start
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
state = this.cameras.get(cameraId);
|
|
238
|
+
const deviceCfg = await this.getDeviceConfig(cameraId);
|
|
239
|
+
const { raw, filtered } = this.detector.detect(state.prevGray, gray, deviceCfg);
|
|
240
|
+
state.prevGray = Buffer.from(gray);
|
|
241
|
+
const toRegion = (r) => ({
|
|
242
|
+
bbox: {
|
|
243
|
+
x: r.x,
|
|
244
|
+
y: r.y,
|
|
245
|
+
w: r.w,
|
|
246
|
+
h: r.h
|
|
247
|
+
},
|
|
248
|
+
pixelCount: r.pixels,
|
|
249
|
+
intensity: Math.min(255, Math.round(r.pixels / (r.w * r.h) * 255))
|
|
250
|
+
});
|
|
251
|
+
let regions = filtered.map(toRegion);
|
|
252
|
+
const rawRegions = raw.map(toRegion);
|
|
253
|
+
const numericDeviceId = Number(cameraId);
|
|
254
|
+
if (Number.isFinite(numericDeviceId)) {
|
|
255
|
+
const proxy = await this.ensureProxy(numericDeviceId);
|
|
256
|
+
if (proxy) {
|
|
257
|
+
const zones = proxy.state.zones.value?.zones ?? [];
|
|
258
|
+
const rules = proxy.state.zoneRules.value?.motion ?? [];
|
|
259
|
+
if (rules.length > 0 && zones.length > 0) regions = [...gateMotionRegions(regions, zones, rules, width, height).passed];
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return {
|
|
263
|
+
detected: regions.length > 0,
|
|
264
|
+
regionCount: regions.length,
|
|
265
|
+
regions,
|
|
266
|
+
rawRegions,
|
|
267
|
+
frameWidth: width,
|
|
268
|
+
frameHeight: height,
|
|
269
|
+
analysisMs: performance.now() - start
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Resolve (and cache) a {@link DeviceProxy} for the given device.
|
|
274
|
+
* Pins the `state.zones` + `state.zoneRules` slice handles so the
|
|
275
|
+
* kernel runtime-state mirror keeps `.value` warm — subsequent
|
|
276
|
+
* analyze() calls read both slices synchronously without any bus
|
|
277
|
+
* plumbing or per-frame cap query. Returns null on first-call
|
|
278
|
+
* failure (logged) so motion analysis never blocks on zone
|
|
279
|
+
* resolution.
|
|
280
|
+
*/
|
|
281
|
+
async ensureProxy(deviceId) {
|
|
282
|
+
const cached = this.proxies.get(deviceId);
|
|
283
|
+
if (cached) return cached;
|
|
284
|
+
try {
|
|
285
|
+
const proxy = await this.ctx.fetchDevice(deviceId);
|
|
286
|
+
this.proxies.set(deviceId, proxy);
|
|
287
|
+
const unsubs = [proxy.state.zones.subscribe(() => {}), proxy.state.zoneRules.subscribe(() => {})];
|
|
288
|
+
this.proxyUnsubs.set(deviceId, unsubs);
|
|
289
|
+
return proxy;
|
|
290
|
+
} catch (err) {
|
|
291
|
+
this.ctx.logger.debug("ensureProxy failed — gating skipped", {
|
|
292
|
+
tags: { deviceId },
|
|
293
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
294
|
+
});
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
/** Drop the proxy + slice subscriptions for a device. */
|
|
299
|
+
releaseProxy(deviceId) {
|
|
300
|
+
const unsubs = this.proxyUnsubs.get(deviceId);
|
|
301
|
+
if (unsubs) for (const u of unsubs) try {
|
|
302
|
+
u();
|
|
303
|
+
} catch {}
|
|
304
|
+
this.proxyUnsubs.delete(deviceId);
|
|
305
|
+
this.proxies.delete(deviceId);
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Resolve the effective per-device config for a camera. Reads the raw
|
|
309
|
+
* device store and overlays it on top of the schema defaults via
|
|
310
|
+
* `hydrateSchema()`, then narrows to the typed `WasmMotionConfig` shape.
|
|
311
|
+
* Cached with a TTL to avoid hammering the settings store on every frame.
|
|
312
|
+
*/
|
|
313
|
+
async getDeviceConfig(cameraId) {
|
|
314
|
+
const now = Date.now();
|
|
315
|
+
const cached = this.deviceConfigCache.get(cameraId);
|
|
316
|
+
if (cached && now - cached.fetchedAt < MotionWasmAddon.DEVICE_CONFIG_TTL_MS) return cached.config;
|
|
317
|
+
if (!this.ctx?.settings) return DEFAULT_CONFIG;
|
|
318
|
+
const raw = await this.ctx.settings.readDeviceStore(Number(cameraId));
|
|
319
|
+
const hydrated = require_dist.hydrateSchema(this.deviceSettingsSchema(), raw);
|
|
320
|
+
const flat = {};
|
|
321
|
+
for (const section of hydrated.sections) for (const field of section.fields) {
|
|
322
|
+
if (field.type === "separator" || field.type === "info" || field.type === "button") continue;
|
|
323
|
+
if (field.type === "group") continue;
|
|
324
|
+
flat[field.key] = field.value;
|
|
325
|
+
}
|
|
326
|
+
const resolved = {
|
|
327
|
+
threshold: typeof flat["threshold"] === "number" ? flat["threshold"] : DEFAULT_CONFIG.threshold,
|
|
328
|
+
blurRadius: typeof flat["blurRadius"] === "number" ? flat["blurRadius"] : DEFAULT_CONFIG.blurRadius,
|
|
329
|
+
dilateRadius: typeof flat["dilateRadius"] === "number" ? flat["dilateRadius"] : DEFAULT_CONFIG.dilateRadius,
|
|
330
|
+
minArea: typeof flat["minArea"] === "number" ? flat["minArea"] : DEFAULT_CONFIG.minArea
|
|
331
|
+
};
|
|
332
|
+
this.deviceConfigCache.set(cameraId, {
|
|
333
|
+
config: resolved,
|
|
334
|
+
fetchedAt: now
|
|
335
|
+
});
|
|
336
|
+
return resolved;
|
|
337
|
+
}
|
|
338
|
+
async removeCamera({ deviceId }) {
|
|
339
|
+
const key = String(deviceId);
|
|
340
|
+
this.cameras.delete(key);
|
|
341
|
+
this.deviceConfigCache.delete(key);
|
|
342
|
+
this.releaseProxy(deviceId);
|
|
343
|
+
}
|
|
344
|
+
async reset() {
|
|
345
|
+
this.cameras.clear();
|
|
346
|
+
this.deviceConfigCache.clear();
|
|
347
|
+
for (const id of this.proxies.keys()) this.releaseProxy(id);
|
|
348
|
+
}
|
|
349
|
+
async onShutdown() {
|
|
350
|
+
this.cameras.clear();
|
|
351
|
+
this.deviceConfigCache.clear();
|
|
352
|
+
for (const id of this.proxies.keys()) this.releaseProxy(id);
|
|
353
|
+
this.detector = null;
|
|
354
|
+
}
|
|
355
|
+
deviceSettingsSchema() {
|
|
356
|
+
return this.schema({ sections: [{
|
|
357
|
+
id: "motion-wasm-settings",
|
|
358
|
+
title: "",
|
|
359
|
+
tab: "motion",
|
|
360
|
+
order: 10,
|
|
361
|
+
columns: 2,
|
|
362
|
+
fields: [
|
|
363
|
+
{
|
|
364
|
+
type: "slider",
|
|
365
|
+
key: "threshold",
|
|
366
|
+
label: "Threshold",
|
|
367
|
+
description: "Pixel intensity difference to count as changed (higher = less sensitive)",
|
|
368
|
+
min: 1,
|
|
369
|
+
max: 255,
|
|
370
|
+
step: 1,
|
|
371
|
+
default: DEFAULT_CONFIG.threshold,
|
|
372
|
+
showValue: true,
|
|
373
|
+
showWhen: {
|
|
374
|
+
field: "motionSources",
|
|
375
|
+
includes: "analyzer"
|
|
376
|
+
}
|
|
377
|
+
},
|
|
378
|
+
{
|
|
379
|
+
type: "number",
|
|
380
|
+
key: "minArea",
|
|
381
|
+
label: "Min Area",
|
|
382
|
+
description: "Minimum pixel area of a motion region to trigger detection",
|
|
383
|
+
min: 0,
|
|
384
|
+
max: 5e4,
|
|
385
|
+
step: 100,
|
|
386
|
+
default: DEFAULT_CONFIG.minArea,
|
|
387
|
+
unit: "px",
|
|
388
|
+
showWhen: {
|
|
389
|
+
field: "motionSources",
|
|
390
|
+
includes: "analyzer"
|
|
391
|
+
}
|
|
392
|
+
},
|
|
393
|
+
{
|
|
394
|
+
type: "slider",
|
|
395
|
+
key: "blurRadius",
|
|
396
|
+
label: "Blur Radius",
|
|
397
|
+
description: "Gaussian blur before diff (reduces noise)",
|
|
398
|
+
min: 0,
|
|
399
|
+
max: 10,
|
|
400
|
+
step: 1,
|
|
401
|
+
default: DEFAULT_CONFIG.blurRadius,
|
|
402
|
+
showValue: true,
|
|
403
|
+
showWhen: {
|
|
404
|
+
field: "motionSources",
|
|
405
|
+
includes: "analyzer"
|
|
406
|
+
}
|
|
407
|
+
},
|
|
408
|
+
{
|
|
409
|
+
type: "slider",
|
|
410
|
+
key: "dilateRadius",
|
|
411
|
+
label: "Dilation Radius",
|
|
412
|
+
description: "Expand motion regions to merge nearby changes",
|
|
413
|
+
min: 0,
|
|
414
|
+
max: 10,
|
|
415
|
+
step: 1,
|
|
416
|
+
default: DEFAULT_CONFIG.dilateRadius,
|
|
417
|
+
showValue: true,
|
|
418
|
+
showWhen: {
|
|
419
|
+
field: "motionSources",
|
|
420
|
+
includes: "analyzer"
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
]
|
|
424
|
+
}] });
|
|
425
|
+
}
|
|
426
|
+
async getDeviceSettings(deviceId) {
|
|
427
|
+
const raw = await this.ctxIfReady?.settings?.readDeviceStore(deviceId) ?? {};
|
|
428
|
+
return require_dist.hydrateSchema(this.deviceSettingsSchema(), raw);
|
|
429
|
+
}
|
|
430
|
+
async updateDeviceSettings(deviceId, patch) {
|
|
431
|
+
await this.ctx.settings?.writeDeviceStore(deviceId, patch);
|
|
432
|
+
this.deviceConfigCache.delete(String(deviceId));
|
|
433
|
+
}
|
|
434
|
+
async getDeviceSettingsContribution(input) {
|
|
435
|
+
if (!await this.isCameraDevice(input.deviceId)) return null;
|
|
436
|
+
const schema = this.deviceSettingsSchema();
|
|
437
|
+
if (!schema) return null;
|
|
438
|
+
const raw = await this.ctx?.settings?.readDeviceStore(input.deviceId) ?? {};
|
|
439
|
+
return require_dist.hydrateSchema({
|
|
440
|
+
...schema,
|
|
441
|
+
sections: schema.sections.map((s) => ({
|
|
442
|
+
...s,
|
|
443
|
+
tab: s.tab ?? "detection"
|
|
444
|
+
}))
|
|
445
|
+
}, raw);
|
|
446
|
+
}
|
|
447
|
+
async getDeviceLiveContribution(_input) {
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
async applyDeviceSettingsPatch(input) {
|
|
451
|
+
await this.updateDeviceSettings(input.deviceId, input.patch);
|
|
452
|
+
return { success: true };
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Best-effort camera-type check. Used by the settings/live contribution
|
|
456
|
+
* methods to short-circuit on non-camera devices (Lights, Switches,
|
|
457
|
+
* Sensors, Buttons). Returns `true` on lookup failure so a transient
|
|
458
|
+
* device-manager hiccup never silently hides legitimate camera
|
|
459
|
+
* sections.
|
|
460
|
+
*/
|
|
461
|
+
async isCameraDevice(deviceId) {
|
|
462
|
+
const api = this.ctx?.api;
|
|
463
|
+
if (!api) return true;
|
|
464
|
+
try {
|
|
465
|
+
const dev = await api.deviceManager.getDevice.query({ deviceId });
|
|
466
|
+
if (!dev) return true;
|
|
467
|
+
return dev.type === require_dist.DeviceType.Camera;
|
|
468
|
+
} catch {
|
|
469
|
+
return true;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
//#endregion
|
|
471
474
|
exports.MotionWasmAddon = MotionWasmAddon;
|
|
472
475
|
exports.WasmMotionDetector = WasmMotionDetector;
|
|
473
476
|
exports.default = MotionWasmAddon;
|
|
474
|
-
//# sourceMappingURL=index.js.map
|