@camstack/addon-pipeline 1.2.204 → 1.2.205

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/audio-analyzer/index.js +2 -2
  2. package/dist/audio-analyzer/index.mjs +2 -2
  3. package/dist/{default-detection-model-ClY8fk-K.mjs → default-detection-model-23wBHkOQ.mjs} +1 -1
  4. package/dist/{default-detection-model-CfIEdJop.js → default-detection-model-BGsoboIK.js} +1 -1
  5. package/dist/detection-pipeline/index.js +5 -5
  6. package/dist/detection-pipeline/index.mjs +4 -4
  7. package/dist/{dist-Czm3CW8M.js → dist-CR3_dWir.js} +17 -7
  8. package/dist/{dist-B5x2ogXC.mjs → dist-FaCzok3C.mjs} +17 -7
  9. package/dist/{lazy-sharp-AhHg_Hb-.js → lazy-sharp-CKXcDP-V.js} +1 -1
  10. package/dist/motion-wasm/index.js +296 -18
  11. package/dist/motion-wasm/index.mjs +295 -17
  12. package/dist/{node-u0M7kQe-.js → node-Re3uLz8K.js} +1 -1
  13. package/dist/{node-Bx76sJbU.mjs → node-f4fUlGHQ.mjs} +1 -1
  14. package/dist/pipeline-runner/index.js +5 -5
  15. package/dist/pipeline-runner/index.mjs +4 -4
  16. package/dist/{process-memory-D5JcMSDA.js → process-memory-DNheSoSc.js} +1 -1
  17. package/dist/{process-memory-iiE4Q2mt.mjs → process-memory-MC7wcxqk.mjs} +1 -1
  18. package/dist/recorder/index.js +2 -2
  19. package/dist/recorder/index.mjs +2 -2
  20. package/dist/{segment-demux-js-CSt1T2Vi.js → segment-demux-js-CQYpP1SU.js} +1 -1
  21. package/dist/{segment-demux-js-BcbB_hcJ.mjs → segment-demux-js-DUpovUKi.mjs} +1 -1
  22. package/dist/session-decode/decode-worker-child.js +2 -2
  23. package/dist/session-decode/decode-worker-child.mjs +1 -1
  24. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BSWELhoJ.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-D_m12d-s.mjs} +3 -3
  25. package/dist/stream-broker/demux-worker-child.js +1 -1
  26. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  27. package/dist/stream-broker/{hostInit-BJlv_j8i.mjs → hostInit-Dfu865rx.mjs} +3 -3
  28. package/dist/stream-broker/index.js +3 -3
  29. package/dist/stream-broker/index.mjs +3 -3
  30. package/dist/stream-broker/remoteEntry.js +1 -1
  31. package/dist/{worker-protocol-ClwpoGoP.mjs → worker-protocol-Dz6_u2y6.mjs} +1 -1
  32. package/dist/{worker-protocol-B4C4seRh.js → worker-protocol-lIjbPNbH.js} +1 -1
  33. package/package.json +1 -1
@@ -2,8 +2,8 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-Czm3CW8M.js");
6
- const require_process_memory = require("../process-memory-D5JcMSDA.js");
5
+ const require_dist = require("../dist-CR3_dWir.js");
6
+ const require_process_memory = require("../process-memory-DNheSoSc.js");
7
7
  let node_fs = require("node:fs");
8
8
  node_fs = require_dist.__toESM(node_fs);
9
9
  let node_path = require("node:path");
@@ -1,6 +1,6 @@
1
1
  import { n as __require } from "../chunk-DnnnRqeS.mjs";
2
- import { Dt as resolvePoolMemoryPolicy, It as errMsg, Jt as hydrateSchema, M as audioAnalyzerCapability, S as PoolMemoryWatchdog, Ut as BaseAddon, j as audioAnalysisCapability, n as AUDIO_BACKEND_CHOICES, ot as mapAudioLabelToMacro, s as DEFAULT_AUDIO_ANALYZER_CONFIG, v as HF_BASE_URL } from "../dist-B5x2ogXC.mjs";
3
- import { n as pickNodePlatformArch, t as readProcessMemory } from "../process-memory-iiE4Q2mt.mjs";
2
+ import { Dt as resolvePoolMemoryPolicy, It as errMsg, Jt as hydrateSchema, M as audioAnalyzerCapability, S as PoolMemoryWatchdog, Ut as BaseAddon, j as audioAnalysisCapability, n as AUDIO_BACKEND_CHOICES, ot as mapAudioLabelToMacro, s as DEFAULT_AUDIO_ANALYZER_CONFIG, v as HF_BASE_URL } from "../dist-FaCzok3C.mjs";
3
+ import { n as pickNodePlatformArch, t as readProcessMemory } from "../process-memory-MC7wcxqk.mjs";
4
4
  import * as fs from "node:fs";
5
5
  import * as path$1 from "node:path";
6
6
  import { downloadFile } from "@camstack/system/addon-utils";
@@ -1,4 +1,4 @@
1
- import { N as audioKindId, a as COCO_80_LABELS, o as COCO_TO_MACRO, r as AUDIO_MACRO_LABELS, tt as hfModelUrl } from "./dist-B5x2ogXC.mjs";
1
+ import { N as audioKindId, a as COCO_80_LABELS, o as COCO_TO_MACRO, r as AUDIO_MACRO_LABELS, tt as hfModelUrl } from "./dist-FaCzok3C.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  //#region src/detection-pipeline/registry/model-catalogs.ts
4
4
  var HF_REPO = "camstack/camstack-models";
@@ -1,4 +1,4 @@
1
- const require_dist = require("./dist-Czm3CW8M.js");
1
+ const require_dist = require("./dist-CR3_dWir.js");
2
2
  let node_crypto = require("node:crypto");
3
3
  //#region src/detection-pipeline/registry/model-catalogs.ts
4
4
  var HF_REPO = "camstack/camstack-models";
@@ -2,12 +2,12 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-Czm3CW8M.js");
6
- const require_node = require("../node-u0M7kQe-.js");
7
- const require_default_detection_model = require("../default-detection-model-CfIEdJop.js");
8
- const require_lazy_sharp = require("../lazy-sharp-AhHg_Hb-.js");
5
+ const require_dist = require("../dist-CR3_dWir.js");
6
+ const require_node = require("../node-Re3uLz8K.js");
7
+ const require_default_detection_model = require("../default-detection-model-BGsoboIK.js");
8
+ const require_lazy_sharp = require("../lazy-sharp-CKXcDP-V.js");
9
9
  const require_event_loop_stall_monitor = require("../event-loop-stall-monitor-DaUBcb13.js");
10
- const require_process_memory = require("../process-memory-D5JcMSDA.js");
10
+ const require_process_memory = require("../process-memory-DNheSoSc.js");
11
11
  let node_os = require("node:os");
12
12
  node_os = require_dist.__toESM(node_os);
13
13
  let node_fs = require("node:fs");
@@ -1,10 +1,10 @@
1
1
  import { n as __require } from "../chunk-DnnnRqeS.mjs";
2
- import { $t as parseJsonUnknown, B as defaultDeviceFor$1, Dt as resolvePoolMemoryPolicy, G as detectionPipelineCapability, It as errMsg, J as enumerateInferenceDevices, Jt as hydrateSchema, Pt as supportedRuntimes$1, Qt as nodePin, S as PoolMemoryWatchdog, Ut as BaseAddon, X as evaluateZoneRules, c as DEFAULT_CLUSTER_STEP_MODELS, gt as pickClusterStepSettings, hn as EventCategory, ht as pickClusterStepModels, in as array, it as loadContributionCapability, k as YAMNET_TO_MACRO, kt as runtimeDevices$1, l as DEFAULT_CLUSTER_STEP_SETTINGS, m as DEVICE_BACKEND_TO_FORMAT, mn as union, nn as sleep, nt as inferModelProvider, pn as string, pt as overlayClusterStepSettings, qt as createEvent, t as APPLE_SA_TO_MACRO, un as object, wt as resolveClusterStepModelId, yt as pipelineExecutorCapability } from "../dist-B5x2ogXC.mjs";
3
- import { o as readProcessCost } from "../node-Bx76sJbU.mjs";
4
- import { a as ALL_PIPELINE_STEPS, c as getStep, d as landmarkPrecisionVerdict, l as getStepDefinition, n as resolveDefaultDetectionModel, o as ALL_STEPS, r as localFrameRegistry, s as getDefaultModelForFormat, u as resolveModelForFormat } from "../default-detection-model-ClY8fk-K.mjs";
2
+ import { $t as parseJsonUnknown, B as defaultDeviceFor$1, Dt as resolvePoolMemoryPolicy, G as detectionPipelineCapability, It as errMsg, J as enumerateInferenceDevices, Jt as hydrateSchema, Pt as supportedRuntimes$1, Qt as nodePin, S as PoolMemoryWatchdog, Ut as BaseAddon, X as evaluateZoneRules, c as DEFAULT_CLUSTER_STEP_MODELS, gt as pickClusterStepSettings, hn as EventCategory, ht as pickClusterStepModels, in as array, it as loadContributionCapability, k as YAMNET_TO_MACRO, kt as runtimeDevices$1, l as DEFAULT_CLUSTER_STEP_SETTINGS, m as DEVICE_BACKEND_TO_FORMAT, mn as union, nn as sleep, nt as inferModelProvider, pn as string, pt as overlayClusterStepSettings, qt as createEvent, t as APPLE_SA_TO_MACRO, un as object, wt as resolveClusterStepModelId, yt as pipelineExecutorCapability } from "../dist-FaCzok3C.mjs";
3
+ import { o as readProcessCost } from "../node-f4fUlGHQ.mjs";
4
+ import { a as ALL_PIPELINE_STEPS, c as getStep, d as landmarkPrecisionVerdict, l as getStepDefinition, n as resolveDefaultDetectionModel, o as ALL_STEPS, r as localFrameRegistry, s as getDefaultModelForFormat, u as resolveModelForFormat } from "../default-detection-model-23wBHkOQ.mjs";
5
5
  import { t as getSharp } from "../lazy-sharp-DXsqwpph.mjs";
6
6
  import { n as startEventLoopStallMonitor } from "../event-loop-stall-monitor-DXLNBMkY.mjs";
7
- import { n as pickNodePlatformArch, t as readProcessMemory } from "../process-memory-iiE4Q2mt.mjs";
7
+ import { n as pickNodePlatformArch, t as readProcessMemory } from "../process-memory-MC7wcxqk.mjs";
8
8
  import * as os from "node:os";
9
9
  import * as fs from "node:fs";
10
10
  import * as path$1 from "node:path";
@@ -14074,6 +14074,9 @@ var QueryFilterSchema = object({
14074
14074
  whereBetween: record(string(), tuple([unknown(), unknown()])).optional(),
14075
14075
  /** NULL-safe exclusion: matches rows whose field is NULL OR != the value. */
14076
14076
  whereNot: record(string(), unknown()).optional(),
14077
+ /** NULL-safe exclusion of a SET — `whereNot` for more than one value. An
14078
+ * empty list excludes nothing. See `QueryFilter.whereNotIn`. */
14079
+ whereNotIn: record(string(), array(unknown())).optional(),
14077
14080
  orderBy: object({
14078
14081
  field: string(),
14079
14082
  direction: _enum(["asc", "desc"])
@@ -14094,7 +14097,8 @@ var MutationFilterSchema = object({
14094
14097
  where: record(string(), unknown()).optional(),
14095
14098
  whereIn: record(string(), array(unknown())).optional(),
14096
14099
  whereBetween: record(string(), tuple([unknown(), unknown()])).optional(),
14097
- whereNot: record(string(), unknown()).optional()
14100
+ whereNot: record(string(), unknown()).optional(),
14101
+ whereNotIn: record(string(), array(unknown())).optional()
14098
14102
  });
14099
14103
  /**
14100
14104
  * One scalar an {@link settingsStoreCapability.methods.aggregate} call asks for.
@@ -21196,10 +21200,12 @@ var TrackSourceSchema = _enum([
21196
21200
  * Terminal for the plain `markForTrain` toggle: returning it to `staging` is
21197
21201
  * a deliberate action of the retrain page, not a side effect of a checkbox.
21198
21202
  *
21199
- * There is no `null`. The state is stored `TEXT NOT NULL DEFAULT 'none'` because
21200
- * the store's filter language has only positive equality and `whereIn` — no
21201
- * negation, no IS NULL — so a NULL would be unselectable by ANY predicate and
21202
- * would make the entire pre-column history immortal in one deploy.
21203
+ * There is no `null`. The state is stored `TEXT NOT NULL DEFAULT 'none'` so that
21204
+ * every row is selectable by a positive predicate. (The filter language has
21205
+ * since grown the NULL-safe `whereNot` / `whereNotIn` and an `IS NULL` reading
21206
+ * of `where: { f: null }`, which is how {@link TrackSourceSchema} can be
21207
+ * filtered on a NULLABLE column — see `excludeSources`. It did not when this
21208
+ * column was designed, and a NOT NULL column is still the better shape.)
21203
21209
  */
21204
21210
  var RetrainStatusSchema = _enum([
21205
21211
  "none",
@@ -21935,7 +21941,9 @@ var RecentTracksQueryInput = object({
21935
21941
  * whose class list is unreadable are kept, and the client's rule stays the
21936
21942
  * exact one.
21937
21943
  */
21938
- classes: array(string()).optional()
21944
+ classes: array(string()).optional(),
21945
+ /** See {@link ExcludeSourcesDoc}. */
21946
+ excludeSources: array(TrackSourceSchema).optional()
21939
21947
  });
21940
21948
  /**
21941
21949
  * A Summary (D359): the per-camera session envelope that references tracks.
@@ -22363,7 +22371,9 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
22363
22371
  * selected is an operator who has not narrowed anything, and an empty
22364
22372
  * timeline would read as a camera that saw nothing.
22365
22373
  */
22366
- classes: array(string()).optional()
22374
+ classes: array(string()).optional(),
22375
+ /** See {@link ExcludeSourcesDoc}. */
22376
+ excludeSources: array(TrackSourceSchema).optional()
22367
22377
  }), array(TrackSchema).readonly()), method(RecentTracksQueryInput, RecentTracksPageSchema), method(SummariesQueryInput, SummariesPageSchema), method(object({ id: string() }), SummaryDetailSchema.nullable()), method(object({ deviceId: number() }), _void(), {
22368
22378
  kind: "mutation",
22369
22379
  auth: "admin"
@@ -14051,6 +14051,9 @@ var QueryFilterSchema = object({
14051
14051
  whereBetween: record(string(), tuple([unknown(), unknown()])).optional(),
14052
14052
  /** NULL-safe exclusion: matches rows whose field is NULL OR != the value. */
14053
14053
  whereNot: record(string(), unknown()).optional(),
14054
+ /** NULL-safe exclusion of a SET — `whereNot` for more than one value. An
14055
+ * empty list excludes nothing. See `QueryFilter.whereNotIn`. */
14056
+ whereNotIn: record(string(), array(unknown())).optional(),
14054
14057
  orderBy: object({
14055
14058
  field: string(),
14056
14059
  direction: _enum(["asc", "desc"])
@@ -14071,7 +14074,8 @@ var MutationFilterSchema = object({
14071
14074
  where: record(string(), unknown()).optional(),
14072
14075
  whereIn: record(string(), array(unknown())).optional(),
14073
14076
  whereBetween: record(string(), tuple([unknown(), unknown()])).optional(),
14074
- whereNot: record(string(), unknown()).optional()
14077
+ whereNot: record(string(), unknown()).optional(),
14078
+ whereNotIn: record(string(), array(unknown())).optional()
14075
14079
  });
14076
14080
  /**
14077
14081
  * One scalar an {@link settingsStoreCapability.methods.aggregate} call asks for.
@@ -21173,10 +21177,12 @@ var TrackSourceSchema = _enum([
21173
21177
  * Terminal for the plain `markForTrain` toggle: returning it to `staging` is
21174
21178
  * a deliberate action of the retrain page, not a side effect of a checkbox.
21175
21179
  *
21176
- * There is no `null`. The state is stored `TEXT NOT NULL DEFAULT 'none'` because
21177
- * the store's filter language has only positive equality and `whereIn` — no
21178
- * negation, no IS NULL — so a NULL would be unselectable by ANY predicate and
21179
- * would make the entire pre-column history immortal in one deploy.
21180
+ * There is no `null`. The state is stored `TEXT NOT NULL DEFAULT 'none'` so that
21181
+ * every row is selectable by a positive predicate. (The filter language has
21182
+ * since grown the NULL-safe `whereNot` / `whereNotIn` and an `IS NULL` reading
21183
+ * of `where: { f: null }`, which is how {@link TrackSourceSchema} can be
21184
+ * filtered on a NULLABLE column — see `excludeSources`. It did not when this
21185
+ * column was designed, and a NOT NULL column is still the better shape.)
21180
21186
  */
21181
21187
  var RetrainStatusSchema = _enum([
21182
21188
  "none",
@@ -21912,7 +21918,9 @@ var RecentTracksQueryInput = object({
21912
21918
  * whose class list is unreadable are kept, and the client's rule stays the
21913
21919
  * exact one.
21914
21920
  */
21915
- classes: array(string()).optional()
21921
+ classes: array(string()).optional(),
21922
+ /** See {@link ExcludeSourcesDoc}. */
21923
+ excludeSources: array(TrackSourceSchema).optional()
21916
21924
  });
21917
21925
  /**
21918
21926
  * A Summary (D359): the per-camera session envelope that references tracks.
@@ -22340,7 +22348,9 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
22340
22348
  * selected is an operator who has not narrowed anything, and an empty
22341
22349
  * timeline would read as a camera that saw nothing.
22342
22350
  */
22343
- classes: array(string()).optional()
22351
+ classes: array(string()).optional(),
22352
+ /** See {@link ExcludeSourcesDoc}. */
22353
+ excludeSources: array(TrackSourceSchema).optional()
22344
22354
  }), array(TrackSchema).readonly()), method(RecentTracksQueryInput, RecentTracksPageSchema), method(SummariesQueryInput, SummariesPageSchema), method(object({ id: string() }), SummaryDetailSchema.nullable()), method(object({ deviceId: number() }), _void(), {
22345
22355
  kind: "mutation",
22346
22356
  auth: "admin"
@@ -1,4 +1,4 @@
1
- const require_dist = require("./dist-Czm3CW8M.js");
1
+ const require_dist = require("./dist-CR3_dWir.js");
2
2
  let node_path = require("node:path");
3
3
  node_path = require_dist.__toESM(node_path);
4
4
  let node_url = require("node:url");
@@ -2,10 +2,185 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-Czm3CW8M.js");
6
- const require_lazy_sharp = require("../lazy-sharp-AhHg_Hb-.js");
5
+ const require_dist = require("../dist-CR3_dWir.js");
6
+ const require_lazy_sharp = require("../lazy-sharp-CKXcDP-V.js");
7
7
  let node_fs = require("node:fs");
8
8
  let node_path = require("node:path");
9
+ //#region src/motion-wasm/region-grouping.ts
10
+ /**
11
+ * Default area bar, as a fraction of the motion view.
12
+ *
13
+ * Chosen from the measured separation between true-positive group areas and
14
+ * quiet-footage group areas — see `docs/benchmarks/motion-region-area.md`.
15
+ * On the 320×180 production motion view this is ~490 px, against the 1500 px
16
+ * absolute filter it replaces.
17
+ */
18
+ var DEFAULT_MIN_AREA_FRACTION = .0085;
19
+ /**
20
+ * The DEFAULT is the rule that shipped before D394: a 1500 px absolute bar per
21
+ * component. The measured rule is per-camera opt-in (`minArea: 0`), because
22
+ * the measurement says duty cycle rises between ×1.45 and ×2.82 and ten fleet
23
+ * cameras turn that into recorded hours that cannot be measured from their own
24
+ * footage. The end state is still one relative constant for the fleet; the
25
+ * path there is one camera at a time.
26
+ */
27
+ var DEFAULT_MIN_AREA = 1500;
28
+ /**
29
+ * Default storm bar. A group of changed pixels covering more than this
30
+ * fraction of the view is not a subject — it is weather, an exposure step or
31
+ * an IR cut. See the storm section of `docs/benchmarks/motion-region-area.md`.
32
+ */
33
+ var DEFAULT_MAX_COVERAGE_FRACTION = .5;
34
+ /** The bar in pixels: the absolute one when set, the relative rule otherwise. */
35
+ function resolveMinGroupArea(config) {
36
+ if (config.minArea > 0) return config.minArea;
37
+ const area = Math.max(0, config.viewWidth) * Math.max(0, config.viewHeight);
38
+ return Math.round(config.minAreaFraction * area);
39
+ }
40
+ function grow(region, radius) {
41
+ const cx = region.x + region.w / 2;
42
+ const cy = region.y + region.h / 2;
43
+ const halfW = Math.max(region.w, radius) / 2;
44
+ const halfH = Math.max(region.h, radius) / 2;
45
+ return {
46
+ x0: cx - halfW,
47
+ y0: cy - halfH,
48
+ x1: cx + halfW,
49
+ y1: cy + halfH
50
+ };
51
+ }
52
+ function overlaps(a, b) {
53
+ return a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1;
54
+ }
55
+ /**
56
+ * Merge fragments into subjects and judge each subject on its own summed area.
57
+ *
58
+ * Groups come back in the order of their first member, so the frame's reading
59
+ * order is stable across calls — a UI overlay does not reshuffle.
60
+ *
61
+ * A group over `maxCoverageFraction` is a storm and does not pass as a group;
62
+ * its components are then judged individually, so a subject the storm merged
63
+ * with is not silenced along with it.
64
+ *
65
+ * A positive `config.minArea` short-circuits all of it and runs the pre-D394
66
+ * rule instead — see {@link RegionGroupingConfig.minArea}.
67
+ */
68
+ function groupMotionRegions(regions, config) {
69
+ if (regions.length === 0) return {
70
+ groups: [],
71
+ passed: [],
72
+ suppressedByCoverage: 0
73
+ };
74
+ if (config.minArea > 0) {
75
+ const legacy = regions.map((r) => ({
76
+ x: r.x,
77
+ y: r.y,
78
+ w: r.w,
79
+ h: r.h,
80
+ pixels: r.pixels,
81
+ members: 1
82
+ }));
83
+ return {
84
+ groups: legacy,
85
+ passed: legacy.filter((g) => g.pixels >= config.minArea),
86
+ suppressedByCoverage: 0
87
+ };
88
+ }
89
+ const radius = Math.max(1, Math.floor(Math.min(config.viewWidth, config.viewHeight) / Math.max(1, config.mergeRadiusDivisor)));
90
+ const grown = regions.map((r) => grow(r, radius));
91
+ const parent = regions.map((_, i) => i);
92
+ const find = (i) => {
93
+ let root = i;
94
+ while (parent[root] !== root) root = parent[root];
95
+ let cur = i;
96
+ while (parent[cur] !== root) {
97
+ const next = parent[cur];
98
+ parent[cur] = root;
99
+ cur = next;
100
+ }
101
+ return root;
102
+ };
103
+ for (let i = 0; i < regions.length; i++) for (let j = i + 1; j < regions.length; j++) {
104
+ if (!overlaps(grown[i], grown[j])) continue;
105
+ const ri = find(i);
106
+ const rj = find(j);
107
+ if (ri !== rj) parent[rj] = ri;
108
+ }
109
+ const byRoot = /* @__PURE__ */ new Map();
110
+ const order = [];
111
+ for (let i = 0; i < regions.length; i++) {
112
+ const region = regions[i];
113
+ const root = find(i);
114
+ const existing = byRoot.get(root);
115
+ if (!existing) {
116
+ order.push(root);
117
+ byRoot.set(root, {
118
+ x0: region.x,
119
+ y0: region.y,
120
+ x1: region.x + region.w,
121
+ y1: region.y + region.h,
122
+ pixels: region.pixels,
123
+ members: 1
124
+ });
125
+ continue;
126
+ }
127
+ existing.x0 = Math.min(existing.x0, region.x);
128
+ existing.y0 = Math.min(existing.y0, region.y);
129
+ existing.x1 = Math.max(existing.x1, region.x + region.w);
130
+ existing.y1 = Math.max(existing.y1, region.y + region.h);
131
+ existing.pixels += region.pixels;
132
+ existing.members += 1;
133
+ }
134
+ const groups = order.map((root) => {
135
+ const a = byRoot.get(root);
136
+ return {
137
+ x: a.x0,
138
+ y: a.y0,
139
+ w: a.x1 - a.x0,
140
+ h: a.y1 - a.y0,
141
+ pixels: a.pixels,
142
+ members: a.members
143
+ };
144
+ });
145
+ const bar = resolveMinGroupArea(config);
146
+ const viewArea = Math.max(0, config.viewWidth) * Math.max(0, config.viewHeight);
147
+ const stormBar = config.maxCoverageFraction >= 1 ? Infinity : config.maxCoverageFraction * viewArea;
148
+ const membersByGroup = /* @__PURE__ */ new Map();
149
+ for (let i = 0; i < regions.length; i++) {
150
+ const root = find(i);
151
+ const list = membersByGroup.get(root) ?? [];
152
+ list.push(regions[i]);
153
+ membersByGroup.set(root, list);
154
+ }
155
+ const passed = [];
156
+ let suppressedByCoverage = 0;
157
+ for (let g = 0; g < groups.length; g++) {
158
+ const group = groups[g];
159
+ if (group.pixels < bar) continue;
160
+ if (group.pixels <= stormBar) {
161
+ passed.push(group);
162
+ continue;
163
+ }
164
+ suppressedByCoverage += 1;
165
+ for (const member of membersByGroup.get(order[g]) ?? []) {
166
+ if (member.pixels < bar || member.pixels > stormBar) continue;
167
+ passed.push({
168
+ x: member.x,
169
+ y: member.y,
170
+ w: member.w,
171
+ h: member.h,
172
+ pixels: member.pixels,
173
+ members: 1
174
+ });
175
+ }
176
+ }
177
+ return {
178
+ groups,
179
+ passed,
180
+ suppressedByCoverage
181
+ };
182
+ }
183
+ //#endregion
9
184
  //#region src/motion-wasm/wasm-motion-detector.ts
10
185
  /**
11
186
  * WasmMotionDetector — loads the AssemblyScript WASM module and provides
@@ -13,18 +188,33 @@ let node_path = require("node:path");
13
188
  *
14
189
  * Pipeline: blur → diff → threshold → dilate → CCL → bounding boxes
15
190
  * All in one WASM call, ~3ms for 640×360 grayscale frames.
191
+ *
192
+ * The area filter that follows is NOT applied per connected component — see
193
+ * {@link groupMotionRegions}. Fragments of one subject are merged first and the
194
+ * bar is applied to the merged subject, relative to the motion view. D394.
195
+ */
196
+ /**
197
+ * The one place the motion defaults live. The addon's schema defaults read
198
+ * from here — two copies of this object drifted apart once already (3000 here
199
+ * against 1500 in the addon), and the addon's copy is the one that ran.
16
200
  */
17
- var DEFAULT_CONFIG$1 = {
201
+ var DEFAULT_CONFIG = {
18
202
  threshold: 45,
19
203
  blurRadius: 1,
20
204
  dilateRadius: 4,
21
- minArea: 3e3
205
+ minArea: DEFAULT_MIN_AREA,
206
+ minAreaFraction: DEFAULT_MIN_AREA_FRACTION,
207
+ mergeRadiusDivisor: 6,
208
+ maxCoverageFraction: DEFAULT_MAX_COVERAGE_FRACTION
22
209
  };
23
210
  var WasmMotionDetector = class {
24
211
  wasm = null;
25
212
  prevOffset = 0;
26
213
  currOffset = 0;
27
214
  regionOffset = 0;
215
+ /** The motion view the regions are expressed in — the relative bar's denominator. */
216
+ width = 0;
217
+ height = 0;
28
218
  /** Load the WASM module. Call once before detect(). */
29
219
  async load() {
30
220
  const wasmBytes = (0, node_fs.readFileSync)((0, node_path.join)(__dirname, "..", "..", "wasm", "motion.wasm"));
@@ -48,10 +238,25 @@ var WasmMotionDetector = class {
48
238
  }
49
239
  this.wasm = exports;
50
240
  }
241
+ /**
242
+ * Initialize for these frame dimensions when they are not already the ones
243
+ * the WASM is sized for. One detector instance serves every camera on the
244
+ * node, so two cameras with different views interleaving would otherwise
245
+ * leave the WASM sized for whichever one called `init` last — and, since
246
+ * D394, would divide the relative area bar by the wrong view. `init` only
247
+ * recomputes offsets and grows memory, so a call that changes nothing is
248
+ * skipped and a call that does is cheap.
249
+ */
250
+ ensureSize(w, h) {
251
+ if (this.width === w && this.height === h) return;
252
+ this.init(w, h);
253
+ }
51
254
  /** Initialize for given frame dimensions. Call when resolution changes. */
52
255
  init(w, h) {
53
256
  if (!this.wasm) throw new Error("WASM not loaded — call load() first");
54
257
  this.wasm.init(w, h);
258
+ this.width = w;
259
+ this.height = h;
55
260
  this.prevOffset = this.wasm.getPrevOffset();
56
261
  this.currOffset = this.wasm.getCurrOffset();
57
262
  this.regionOffset = this.wasm.getRegionOffset();
@@ -62,12 +267,12 @@ var WasmMotionDetector = class {
62
267
  * @param prevGray - previous frame (Uint8Array, width×height)
63
268
  * @param currGray - current frame (Uint8Array, width×height)
64
269
  * @param config - detection parameters (optional, uses defaults)
65
- * @returns raw (all CCL regions) + filtered (passing minArea) regions
270
+ * @returns raw CCL regions, the subjects they merge into, and the survivors
66
271
  */
67
272
  detect(prevGray, currGray, config = {}) {
68
273
  if (!this.wasm) throw new Error("WASM not loaded");
69
274
  const cfg = {
70
- ...DEFAULT_CONFIG$1,
275
+ ...DEFAULT_CONFIG,
71
276
  ...config
72
277
  };
73
278
  const mem = new Uint8Array(this.wasm.memory.buffer);
@@ -76,7 +281,9 @@ var WasmMotionDetector = class {
76
281
  const numRegions = this.wasm.detectMotion(cfg.threshold, cfg.blurRadius, cfg.dilateRadius, 0);
77
282
  if (numRegions === 0) return {
78
283
  raw: [],
79
- filtered: []
284
+ filtered: [],
285
+ groups: [],
286
+ suppressedByCoverage: 0
80
287
  };
81
288
  const view = new DataView(this.wasm.memory.buffer);
82
289
  const raw = [];
@@ -90,9 +297,19 @@ var WasmMotionDetector = class {
90
297
  pixels: view.getInt32(base + 16, true)
91
298
  });
92
299
  }
300
+ const grouped = groupMotionRegions(raw, {
301
+ viewWidth: this.width,
302
+ viewHeight: this.height,
303
+ mergeRadiusDivisor: cfg.mergeRadiusDivisor,
304
+ minAreaFraction: cfg.minAreaFraction,
305
+ minArea: cfg.minArea,
306
+ maxCoverageFraction: cfg.maxCoverageFraction
307
+ });
93
308
  return {
94
309
  raw,
95
- filtered: raw.filter((r) => r.pixels >= cfg.minArea)
310
+ filtered: grouped.passed,
311
+ groups: grouped.groups,
312
+ suppressedByCoverage: grouped.suppressedByCoverage
96
313
  };
97
314
  }
98
315
  };
@@ -121,25 +338,27 @@ function gateMotionRegions(regions, zones, rules, frameWidth, frameHeight) {
121
338
  *
122
339
  * Settings redesign Phase 3: motion-wasm has ONLY device-level settings
123
340
  * (all four fields were `scope: 'device'` in the legacy schema — every
124
- * camera tunes threshold/minArea/blurRadius/dilateRadius independently
341
+ * camera tunes threshold/subject size/blurRadius/dilateRadius independently
125
342
  * for its scene). So the addon implements `getDeviceSettings` /
126
343
  * `updateDeviceSettings` and nothing else. Fresh cameras with no
127
344
  * overrides get the schema defaults via `hydrateSchema()` at read time.
128
345
  * There is no cluster-wide "default threshold" knob — if operators want
129
346
  * to change the baseline, they edit the schema defaults in code.
130
347
  */
131
- var DEFAULT_CONFIG = {
132
- threshold: 45,
133
- blurRadius: 1,
134
- dilateRadius: 4,
135
- minArea: 1500
136
- };
137
348
  var MotionWasmAddon = class MotionWasmAddon extends require_dist.BaseAddon {
138
349
  detector = null;
139
350
  cameras = /* @__PURE__ */ new Map();
140
351
  deviceConfigCache = /* @__PURE__ */ new Map();
141
352
  static DEVICE_CONFIG_TTL_MS = 6e4;
142
353
  /**
354
+ * Last logged "which rule is this camera on" signature, per camera. The
355
+ * operator's gesture for D394 is a settings write, and a settings write is
356
+ * invisible: this is how they confirm it took, on the next analysed frame.
357
+ * Keyed and logged only on CHANGE, so it costs one line per camera per
358
+ * change rather than one per TTL expiry.
359
+ */
360
+ loggedRule = /* @__PURE__ */ new Map();
361
+ /**
143
362
  * Per-device {@link DeviceProxy} used for zone gating. Built lazily
144
363
  * on first analyze() for a device; the proxy's reactive state
145
364
  * handles (`state.zones`, `state.zoneRules`) keep their `.value`
@@ -256,7 +475,16 @@ var MotionWasmAddon = class MotionWasmAddon extends require_dist.BaseAddon {
256
475
  }
257
476
  state = this.cameras.get(cameraId);
258
477
  const deviceCfg = await this.getDeviceConfig(cameraId);
259
- const { raw, filtered } = this.detector.detect(state.prevGray, gray, deviceCfg);
478
+ this.detector.ensureSize(width, height);
479
+ const { raw, filtered, suppressedByCoverage } = this.detector.detect(state.prevGray, gray, deviceCfg);
480
+ if (suppressedByCoverage > 0) this.ctx.logger.info("motion storm suppressed", {
481
+ tags: { deviceId: Number(cameraId) },
482
+ meta: {
483
+ groups: suppressedByCoverage,
484
+ maxCoverageFraction: deviceCfg.maxCoverageFraction,
485
+ view: `${width}x${height}`
486
+ }
487
+ });
260
488
  state.prevGray = Buffer.from(gray);
261
489
  const toRegion = (r) => ({
262
490
  bbox: {
@@ -347,28 +575,48 @@ var MotionWasmAddon = class MotionWasmAddon extends require_dist.BaseAddon {
347
575
  threshold: typeof flat["threshold"] === "number" ? flat["threshold"] : DEFAULT_CONFIG.threshold,
348
576
  blurRadius: typeof flat["blurRadius"] === "number" ? flat["blurRadius"] : DEFAULT_CONFIG.blurRadius,
349
577
  dilateRadius: typeof flat["dilateRadius"] === "number" ? flat["dilateRadius"] : DEFAULT_CONFIG.dilateRadius,
350
- minArea: typeof flat["minArea"] === "number" ? flat["minArea"] : DEFAULT_CONFIG.minArea
578
+ minArea: typeof flat["minArea"] === "number" ? flat["minArea"] : DEFAULT_CONFIG.minArea,
579
+ minAreaFraction: typeof flat["minAreaFraction"] === "number" ? flat["minAreaFraction"] : DEFAULT_CONFIG.minAreaFraction,
580
+ mergeRadiusDivisor: typeof flat["mergeRadiusDivisor"] === "number" ? flat["mergeRadiusDivisor"] : DEFAULT_CONFIG.mergeRadiusDivisor,
581
+ maxCoverageFraction: typeof flat["maxCoverageFraction"] === "number" ? flat["maxCoverageFraction"] : DEFAULT_CONFIG.maxCoverageFraction
351
582
  };
352
583
  this.deviceConfigCache.set(cameraId, {
353
584
  config: resolved,
354
585
  fetchedAt: now
355
586
  });
587
+ const rule = resolved.minArea > 0 ? "legacy-per-region" : "merged-relative";
588
+ const signature = rule === "legacy-per-region" ? `${rule}:${resolved.minArea}` : `${rule}:${resolved.minAreaFraction}:${resolved.maxCoverageFraction}`;
589
+ if (this.loggedRule.get(cameraId) !== signature) {
590
+ this.loggedRule.set(cameraId, signature);
591
+ this.ctx.logger.info("motion area rule resolved", {
592
+ tags: { deviceId: Number(cameraId) },
593
+ meta: {
594
+ rule,
595
+ minArea: resolved.minArea,
596
+ minAreaFraction: resolved.minAreaFraction,
597
+ maxCoverageFraction: resolved.maxCoverageFraction
598
+ }
599
+ });
600
+ }
356
601
  return resolved;
357
602
  }
358
603
  async removeCamera({ deviceId }) {
359
604
  const key = String(deviceId);
360
605
  this.cameras.delete(key);
361
606
  this.deviceConfigCache.delete(key);
607
+ this.loggedRule.delete(key);
362
608
  this.releaseProxy(deviceId);
363
609
  }
364
610
  async reset() {
365
611
  this.cameras.clear();
366
612
  this.deviceConfigCache.clear();
613
+ this.loggedRule.clear();
367
614
  for (const id of this.proxies.keys()) this.releaseProxy(id);
368
615
  }
369
616
  async onShutdown() {
370
617
  this.cameras.clear();
371
618
  this.deviceConfigCache.clear();
619
+ this.loggedRule.clear();
372
620
  for (const id of this.proxies.keys()) this.releaseProxy(id);
373
621
  this.detector = null;
374
622
  }
@@ -395,11 +643,26 @@ var MotionWasmAddon = class MotionWasmAddon extends require_dist.BaseAddon {
395
643
  includes: "analyzer"
396
644
  }
397
645
  },
646
+ {
647
+ type: "slider",
648
+ key: "minAreaFraction",
649
+ label: "Min Subject Size",
650
+ description: "Smallest subject that triggers motion, as a share of the analysed view. Fragments of one subject are merged before this is applied, so a partly hidden animal counts once at its full size. Only in effect when Min Area is 0.",
651
+ min: .001,
652
+ max: .05,
653
+ step: 5e-4,
654
+ default: DEFAULT_CONFIG.minAreaFraction,
655
+ showValue: true,
656
+ showWhen: {
657
+ field: "motionSources",
658
+ includes: "analyzer"
659
+ }
660
+ },
398
661
  {
399
662
  type: "number",
400
663
  key: "minArea",
401
664
  label: "Min Area",
402
- description: "Minimum pixel area of a motion region to trigger detection",
665
+ description: "Absolute pixel bar, applied to each motion region on its own. This is how motion has always worked, and it stays the default. Set it to 0 to move THIS camera to Min Subject Size, which merges the fragments of one subject before measuring it and keeps its meaning at any resolution — expect more motion, so move one camera at a time.",
403
666
  min: 0,
404
667
  max: 5e4,
405
668
  step: 100,
@@ -410,6 +673,21 @@ var MotionWasmAddon = class MotionWasmAddon extends require_dist.BaseAddon {
410
673
  includes: "analyzer"
411
674
  }
412
675
  },
676
+ {
677
+ type: "slider",
678
+ key: "maxCoverageFraction",
679
+ label: "Storm Cutoff",
680
+ description: "A merged region whose changed pixels cover more of the view than this is weather, an IR cut or an exposure step, not a subject, and does not fire motion. A real mover inside the storm still fires on its own region. Only in effect when Min Area is 0; 1 disables the cutoff.",
681
+ min: .2,
682
+ max: 1,
683
+ step: .05,
684
+ default: DEFAULT_CONFIG.maxCoverageFraction,
685
+ showValue: true,
686
+ showWhen: {
687
+ field: "motionSources",
688
+ includes: "analyzer"
689
+ }
690
+ },
413
691
  {
414
692
  type: "slider",
415
693
  key: "blurRadius",