@camstack/addon-post-analysis 1.2.17 → 1.2.19

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.
@@ -2,10 +2,351 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-BR-Tbrvb.js");
5
+ const require_dist = require("../dist-CjwPOJKc.js");
6
+ let node_fs = require("node:fs");
7
+ let node_path = require("node:path");
8
+ node_path = require_dist.__toESM(node_path);
6
9
  let node_crypto = require("node:crypto");
7
10
  let sharp = require("sharp");
8
11
  sharp = require_dist.__toESM(sharp);
12
+ //#region src/notification-center/artifact-url.ts
13
+ /**
14
+ * Signed, externally-reachable URLs for notification artifacts.
15
+ *
16
+ * WHY this exists: the dispatcher only ever produced attachment BYTES, and the
17
+ * degrade engine drops a bytes-only attachment for a `mode:'url'` target
18
+ * (`attachment:noUrl`) — so WhatsApp and gotify received no media at all,
19
+ * silently. A URL also lets an oversized attachment degrade to a link instead
20
+ * of being dropped on `maxBytes`.
21
+ *
22
+ * Two decisions the shape depends on:
23
+ *
24
+ * - **Signed, not authenticated.** The fetcher is a notifier BACKEND (or the
25
+ * recipient's phone) — it holds no session and no admin token. The route is
26
+ * therefore `access:'public'` and the authority is the signature: an
27
+ * unguessable HMAC over `(id, exp)` with a per-install secret, expiring on
28
+ * its own. Nothing else about the install is reachable through it.
29
+ * - **The base URL is CHOSEN, never assumed.** A notification carrying
30
+ * `http://127.0.0.1:...` is useless (the Alexa `hubUrl` bug, again). The
31
+ * ranked endpoint list the `local-network` cap already computes — public
32
+ * tunnel > mesh > LAN, loopback last — is the authority, and loopback is
33
+ * refused outright rather than shipped as a broken link.
34
+ */
35
+ /**
36
+ * Pick the base URL an artifact link should use: the LOWEST-priority-number
37
+ * endpoint that is not loopback (the list is already ranked public > mesh >
38
+ * LAN > loopback). `null` when only loopback is available — a link nobody
39
+ * outside the host could open is worse than no link, because the notification
40
+ * would arrive advertising media that 404s.
41
+ */
42
+ function pickArtifactBaseUrl(endpoints) {
43
+ return endpoints.filter((e) => e.kind !== "loopback").toSorted((a, b) => a.priority - b.priority)[0]?.baseUrl ?? null;
44
+ }
45
+ /** Sign `(id, exp)` — the exact string the verifier recomputes. */
46
+ function signArtifact(secret, id, expMs) {
47
+ return (0, node_crypto.createHmac)("sha256", secret).update(`${id}:${expMs}`).digest("hex");
48
+ }
49
+ /**
50
+ * Build the fully-qualified, signed URL for an artifact.
51
+ * `baseUrl` comes from {@link pickArtifactBaseUrl}, `routePrefix` from the
52
+ * data-plane handle (`/addon/<addonId>/nc-artifact`).
53
+ */
54
+ function buildArtifactUrl(input) {
55
+ const sig = signArtifact(input.secret, input.id, input.expMs);
56
+ return `${input.baseUrl.endsWith("/") ? input.baseUrl.slice(0, -1) : input.baseUrl}${input.routePrefix.startsWith("/") ? input.routePrefix : `/${input.routePrefix}`}/${encodeURIComponent(input.id)}?exp=${input.expMs}&sig=${sig}`;
57
+ }
58
+ /**
59
+ * Verify a request's `(id, exp, sig)`. Constant-time on the signature so a
60
+ * public route cannot be probed for it byte by byte, and expiry-checked before
61
+ * the compare so an expired link is rejected even with a valid signature.
62
+ */
63
+ function verifyArtifactSignature(input) {
64
+ if (!Number.isFinite(input.exp) || input.exp <= input.nowMs) return false;
65
+ const expected = signArtifact(input.secret, input.id, input.exp);
66
+ const a = Buffer.from(expected, "utf8");
67
+ const b = Buffer.from(input.sig, "utf8");
68
+ if (a.length !== b.length) return false;
69
+ return (0, node_crypto.timingSafeEqual)(a, b);
70
+ }
71
+ //#endregion
72
+ //#region src/notification-center/artifact-plane.ts
73
+ /**
74
+ * The hub's default HTTPS API port — the same constant `derive-hub-url.ts`
75
+ * documents. Only used for the LAN candidates; an operator who runs the hub on
76
+ * another port sets `CAMSTACK_HUB_PUBLIC_URL`, which wins outright.
77
+ */
78
+ var HUB_API_PORT = 4443;
79
+ /**
80
+ * Ranking of the public sources. All NEGATIVE: `local-network` gives its
81
+ * preferred LAN interface priority `0`, so a public candidate has to sit below
82
+ * zero to outrank it.
83
+ */
84
+ var PRIORITY_MARKED = -400;
85
+ var PRIORITY_CONFIGURED = -300;
86
+ var PRIORITY_CONNECTED = -200;
87
+ var PRIORITY_EXTERNAL_LIST = -100;
88
+ /**
89
+ * Build the ranked candidate list, in the operator's own order: an explicitly
90
+ * configured public origin, then the connected external ingress (tunnels),
91
+ * then the LAN addresses. Every source is best-effort — one that throws
92
+ * contributes nothing rather than costing the whole list.
93
+ */
94
+ async function collectArtifactEndpoints(sources) {
95
+ const out = [];
96
+ const marked = sources.markedBaseUrl;
97
+ if (marked !== void 0 && marked !== "") out.push({
98
+ baseUrl: marked,
99
+ kind: "public",
100
+ priority: PRIORITY_MARKED
101
+ });
102
+ const configured = sources.configuredPublicUrl;
103
+ if (configured !== void 0 && configured !== "" && !/127\.0\.0\.1|localhost/.test(configured)) out.push({
104
+ baseUrl: configured,
105
+ kind: "public",
106
+ priority: PRIORITY_CONFIGURED
107
+ });
108
+ try {
109
+ const connected = await sources.getConnected();
110
+ if (connected !== null && connected.protocol === "https") out.push({
111
+ baseUrl: connected.url,
112
+ kind: "public",
113
+ priority: PRIORITY_CONNECTED
114
+ });
115
+ } catch (err) {
116
+ sources.logger.debug("connected endpoint lookup failed", { meta: { error: String(err) } });
117
+ }
118
+ try {
119
+ const external = await sources.listExternal();
120
+ for (const [i, e] of external.entries()) {
121
+ if (e.protocol !== "https") continue;
122
+ out.push({
123
+ baseUrl: e.url,
124
+ kind: "public",
125
+ priority: PRIORITY_EXTERNAL_LIST + i
126
+ });
127
+ }
128
+ } catch (err) {
129
+ sources.logger.debug("external endpoint lookup failed", { meta: { error: String(err) } });
130
+ }
131
+ try {
132
+ out.push(...await sources.listLan(HUB_API_PORT));
133
+ } catch (err) {
134
+ sources.logger.debug("LAN endpoint lookup failed", { meta: { error: String(err) } });
135
+ }
136
+ return out;
137
+ }
138
+ var DEFAULT_TTL_MS = 24 * 36e5;
139
+ var DEFAULT_ENDPOINT_CACHE_MS = 6e4;
140
+ var NcArtifactPlane = class {
141
+ deps;
142
+ now;
143
+ cachedBaseUrl = null;
144
+ /** Last base URL announced in the log — so the line appears on CHANGE only. */
145
+ lastLoggedBaseUrl = null;
146
+ cachedAt = 0;
147
+ constructor(deps) {
148
+ this.deps = deps;
149
+ this.now = deps.now ?? (() => Date.now());
150
+ }
151
+ /**
152
+ * Store bytes and mint a signed, externally-reachable URL for them.
153
+ * Returns `null` when no usable base URL exists (loopback only) — the caller
154
+ * then ships bytes alone, exactly as before. Never throws: an artifact URL is
155
+ * an ENHANCEMENT, and failing to mint one must not cost the notification.
156
+ */
157
+ async publish(bytes, mime) {
158
+ try {
159
+ const baseUrl = await this.resolveBaseUrl();
160
+ if (baseUrl === null) return null;
161
+ const artifact = await this.deps.store.put(bytes, mime);
162
+ return buildArtifactUrl({
163
+ baseUrl,
164
+ routePrefix: this.deps.routePrefix,
165
+ id: artifact.id,
166
+ secret: this.deps.secret,
167
+ expMs: this.now() + (this.deps.ttlMs ?? DEFAULT_TTL_MS)
168
+ });
169
+ } catch (err) {
170
+ this.deps.logger.debug("artifact publish failed — shipping bytes only", { meta: { error: String(err) } });
171
+ return null;
172
+ }
173
+ }
174
+ /** The data-plane handler for `<prefix>/<id>?exp=&sig=`. */
175
+ handler = async (req, res) => {
176
+ const url = new URL(req.url ?? "/", "http://placeholder");
177
+ const id = decodeURIComponent(url.pathname.split("/").filter(Boolean).pop() ?? "");
178
+ if (!verifyArtifactSignature({
179
+ secret: this.deps.secret,
180
+ id,
181
+ exp: Number(url.searchParams.get("exp")),
182
+ sig: url.searchParams.get("sig") ?? "",
183
+ nowMs: this.now()
184
+ })) {
185
+ res.writeHead(404, { "content-type": "text/plain" });
186
+ res.end("not found");
187
+ return;
188
+ }
189
+ const found = await this.deps.store.read(id);
190
+ if (found === null) {
191
+ res.writeHead(404, { "content-type": "text/plain" });
192
+ res.end("not found");
193
+ return;
194
+ }
195
+ res.writeHead(200, {
196
+ "content-type": found.mime,
197
+ "content-length": String(found.bytes.byteLength),
198
+ "cache-control": "private, max-age=3600"
199
+ });
200
+ res.end(found.bytes);
201
+ };
202
+ /** The chosen base URL, cached briefly (the interface list barely moves). */
203
+ async resolveBaseUrl() {
204
+ const ttl = this.deps.endpointCacheMs ?? DEFAULT_ENDPOINT_CACHE_MS;
205
+ if (this.cachedBaseUrl !== null && this.now() - this.cachedAt < ttl) return this.cachedBaseUrl;
206
+ const picked = pickArtifactBaseUrl(await this.deps.listEndpoints());
207
+ if (picked === null) {
208
+ this.deps.logger.debug("no externally-reachable endpoint — artifacts ship as bytes only");
209
+ return null;
210
+ }
211
+ if (picked !== this.lastLoggedBaseUrl) {
212
+ this.deps.logger.info("artifact links will use", { meta: { baseUrl: picked } });
213
+ this.lastLoggedBaseUrl = picked;
214
+ }
215
+ this.cachedBaseUrl = picked;
216
+ this.cachedAt = this.now();
217
+ return picked;
218
+ }
219
+ };
220
+ //#endregion
221
+ //#region src/notification-center/artifact-store.ts
222
+ /**
223
+ * NcArtifactStore — the bytes behind a signed notification-artifact URL.
224
+ *
225
+ * A notification attachment is transient by nature: it exists to be fetched
226
+ * once, by a notifier backend or the recipient's phone, within minutes of the
227
+ * event. So this is a BOUNDED SCRATCH, not a media library — the durable copy
228
+ * of an event's media already lives in the media store, and duplicating it here
229
+ * permanently would grow without limit for no benefit.
230
+ *
231
+ * Two bounds, both enforced on every write (age first, then count): whichever
232
+ * bites first, the oldest artifacts go. A sweep on boot clears whatever a crash
233
+ * left behind — the directory is disposable by construction, so a lost file is
234
+ * a 404 on one link, never a corrupt state.
235
+ */
236
+ var DEFAULT_MAX_AGE_MS = 6 * 36e5;
237
+ var DEFAULT_MAX_ENTRIES = 500;
238
+ /** `image/jpeg` → `jpg`, so a fetched link has a name a client will accept. */
239
+ function extensionFor(mime) {
240
+ if (mime === "image/jpeg") return "jpg";
241
+ if (mime === "image/gif") return "gif";
242
+ if (mime === "video/mp4") return "mp4";
243
+ if (mime === "image/png") return "png";
244
+ return "bin";
245
+ }
246
+ var NcArtifactStore = class {
247
+ options;
248
+ now;
249
+ maxAgeMs;
250
+ maxEntries;
251
+ /** id → mime, so the route can answer with the right content-type without
252
+ * re-deriving it from the extension on every request. */
253
+ mimeById = /* @__PURE__ */ new Map();
254
+ constructor(options) {
255
+ this.options = options;
256
+ this.now = options.now ?? (() => Date.now());
257
+ this.maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
258
+ this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
259
+ }
260
+ /** Create the directory and sweep whatever a previous run left behind. */
261
+ async start() {
262
+ await node_fs.promises.mkdir(this.options.dir, { recursive: true });
263
+ await this.sweep();
264
+ }
265
+ /** Persist bytes and return the artifact handle (never throws on sweep). */
266
+ async put(bytes, mime) {
267
+ const id = `${this.now()}-${(0, node_crypto.randomUUID)()}`;
268
+ const file = node_path.default.join(this.options.dir, `${id}.${extensionFor(mime)}`);
269
+ await node_fs.promises.mkdir(this.options.dir, { recursive: true });
270
+ await node_fs.promises.writeFile(file, bytes);
271
+ this.mimeById.set(id, mime);
272
+ await this.sweep();
273
+ return {
274
+ id,
275
+ mime,
276
+ bytes: bytes.byteLength
277
+ };
278
+ }
279
+ /** Read an artifact's bytes + mime, or null when it is gone (expired/swept). */
280
+ async read(id) {
281
+ if (!/^[0-9]+-[0-9a-f-]{36}$/i.test(id)) return null;
282
+ const found = (await this.list()).find((e) => e.id === id);
283
+ if (!found) return null;
284
+ try {
285
+ return {
286
+ bytes: await node_fs.promises.readFile(found.file),
287
+ mime: this.mimeById.get(id) ?? mimeFromExtension(found.file)
288
+ };
289
+ } catch {
290
+ return null;
291
+ }
292
+ }
293
+ /** Delete every artifact (operator cleanup / shutdown). */
294
+ async clear() {
295
+ await node_fs.promises.rm(this.options.dir, {
296
+ recursive: true,
297
+ force: true
298
+ }).catch(() => {});
299
+ this.mimeById.clear();
300
+ }
301
+ /** Age- then count-bounded prune. Best-effort: a failed unlink is logged once. */
302
+ async sweep() {
303
+ const entries = await this.list();
304
+ const cutoff = this.now() - this.maxAgeMs;
305
+ const doomed = entries.filter((e) => e.mtimeMs < cutoff);
306
+ const survivors = entries.filter((e) => e.mtimeMs >= cutoff).toSorted((a, b) => b.mtimeMs - a.mtimeMs);
307
+ doomed.push(...survivors.slice(this.maxEntries));
308
+ for (const e of doomed) try {
309
+ await node_fs.promises.rm(e.file, { force: true });
310
+ this.mimeById.delete(e.id);
311
+ } catch (err) {
312
+ this.options.logger.debug("artifact sweep failed to unlink", { meta: {
313
+ file: e.file,
314
+ error: String(err)
315
+ } });
316
+ }
317
+ }
318
+ async list() {
319
+ let names;
320
+ try {
321
+ names = await node_fs.promises.readdir(this.options.dir);
322
+ } catch {
323
+ return [];
324
+ }
325
+ const out = [];
326
+ for (const name of names) {
327
+ const file = node_path.default.join(this.options.dir, name);
328
+ try {
329
+ const st = await node_fs.promises.stat(file);
330
+ if (!st.isFile()) continue;
331
+ out.push({
332
+ id: name.slice(0, name.lastIndexOf(".")),
333
+ file,
334
+ mtimeMs: st.mtimeMs
335
+ });
336
+ } catch {}
337
+ }
338
+ return out;
339
+ }
340
+ };
341
+ function mimeFromExtension(file) {
342
+ const ext = file.slice(file.lastIndexOf(".") + 1).toLowerCase();
343
+ if (ext === "jpg") return "image/jpeg";
344
+ if (ext === "gif") return "image/gif";
345
+ if (ext === "mp4") return "video/mp4";
346
+ if (ext === "png") return "image/png";
347
+ return "application/octet-stream";
348
+ }
349
+ //#endregion
9
350
  //#region src/pipeline-analytics/videoclips-provider.ts
10
351
  var SOURCE = "analytics";
11
352
  function clipIdFor(eventId, startMs, endMs) {
@@ -1961,16 +2302,28 @@ function resolveRuleThreshold(rule) {
1961
2302
  */
1962
2303
  var ZoneEngine = class {
1963
2304
  /**
1964
- * Annotate a single detection with its zone memberships.
1965
- * Returns zones where the detection overlaps above any active
1966
- * rule's threshold (or the engine default if no rule sets one).
2305
+ * Annotate a single detection with its zone memberships — every zone whose
2306
+ * polygon the detection overlaps by MORE than `minOverlap` (0–1 fraction of
2307
+ * the detection's own area).
2308
+ *
2309
+ * The previous version of this comment claimed memberships were returned
2310
+ * "above any active rule's threshold"; they were not — the threshold was
2311
+ * hardcoded to {@link MEMBERSHIP_MIN_OVERLAP} (zero) and no rule was ever
2312
+ * consulted. Read the parameter, not this paragraph.
2313
+ *
2314
+ * `minOverlap` matters because membership is what lands on an event as
2315
+ * `zones`, and a notification rule's `zones` condition is a plain set test
2316
+ * over that field — so this, not the zone-RULE threshold, is what decides
2317
+ * whether a zone-scoped notification fires. At the default of 0 a subject
2318
+ * clipping a zone by one pixel counts as inside it (measured 2026-07-30: a
2319
+ * dog overlapping `Aiuola` by 4.8% would have been stamped as in it).
1967
2320
  */
1968
- annotateDetection(bbox, zones, frameWidth, frameHeight, mask, maskWidth, maskHeight) {
2321
+ annotateDetection(bbox, zones, frameWidth, frameHeight, mask, maskWidth, maskHeight, minOverlap = MEMBERSHIP_MIN_OVERLAP) {
1969
2322
  const memberships = [];
1970
2323
  for (const zone of zones) {
1971
2324
  const pixelPolygon = zone.polygon.map((p) => normalizeToPixel(p, frameWidth, frameHeight));
1972
2325
  const overlap = mask && maskWidth && maskHeight ? maskPolygonOverlap(mask, maskWidth, maskHeight, bbox, pixelPolygon, frameWidth, frameHeight) : bboxPolygonOverlap(bbox, pixelPolygon);
1973
- if (overlap > MEMBERSHIP_MIN_OVERLAP) memberships.push({
2326
+ if (overlap > minOverlap) memberships.push({
1974
2327
  zoneId: zone.id,
1975
2328
  zoneName: zone.name,
1976
2329
  overlap
@@ -2070,6 +2423,10 @@ var FrameProcessor = class {
2070
2423
  * runners that haven't picked up the new gating yet.
2071
2424
  */
2072
2425
  detectionRules;
2426
+ /** See {@link setZoneMembershipMinOverlap}. 0 = any positive overlap. */
2427
+ zoneMembershipMinOverlap;
2428
+ /** See {@link getLastZoneOverlaps}. */
2429
+ lastZoneOverlaps;
2073
2430
  zoneEngine = new ZoneEngine();
2074
2431
  /** Optional stationary-object gate (parked-object suppression). Null until
2075
2432
  * the addon wires it via {@link setStationaryGate}. */
@@ -2082,10 +2439,32 @@ var FrameProcessor = class {
2082
2439
  this.eventEmitter = new DetectionEventEmitter(emitterConfig);
2083
2440
  this.zones = [];
2084
2441
  this.detectionRules = [];
2442
+ this.zoneMembershipMinOverlap = 0;
2443
+ this.lastZoneOverlaps = /* @__PURE__ */ new Map();
2085
2444
  }
2086
2445
  setZones(zones) {
2087
2446
  this.zones = zones;
2088
2447
  }
2448
+ /**
2449
+ * How much of a detection's box must lie inside a zone for the zone to be
2450
+ * stamped onto it (0–1 fraction of the box's own area).
2451
+ *
2452
+ * DEFAULT 0 — byte-identical to the behaviour before 2026-07-31, where any
2453
+ * positive overlap counted. Raising it is an operator decision and needs
2454
+ * evidence: a bar set blind removes notifications silently, which is the
2455
+ * failure mode this whole area keeps producing. {@link lastZoneOverlaps}
2456
+ * exists so the distribution can be read before a number is picked.
2457
+ */
2458
+ setZoneMembershipMinOverlap(minOverlap) {
2459
+ this.zoneMembershipMinOverlap = Number.isFinite(minOverlap) && minOverlap >= 0 && minOverlap <= 1 ? minOverlap : 0;
2460
+ }
2461
+ /** Per-track zone memberships WITH their overlap fractions, from the most
2462
+ * recent frame. The engine computes these and the pipeline previously
2463
+ * discarded everything but the ids — which is why no amount of production
2464
+ * data could say how far inside the zone a notifying subject actually was. */
2465
+ getLastZoneOverlaps() {
2466
+ return this.lastZoneOverlaps;
2467
+ }
2089
2468
  setDetectionRules(rules) {
2090
2469
  this.detectionRules = rules;
2091
2470
  }
@@ -2196,11 +2575,14 @@ var FrameProcessor = class {
2196
2575
  const objectStates = this.stateAnalyzer.analyze(trackedDetections, timestamp);
2197
2576
  const rawEvents = this.eventEmitter.emit(trackedDetections, objectStates, [], [], String(this.deviceId));
2198
2577
  const zonesByTrack = /* @__PURE__ */ new Map();
2578
+ const overlapsByTrack = /* @__PURE__ */ new Map();
2199
2579
  for (const td of trackedDetections) {
2200
2580
  const m = maskByBbox.get(td.bbox);
2201
- const memberships = this.zoneEngine.annotateDetection(td.bbox, this.zones, frameWidth, frameHeight, m?.mask, m?.width, m?.height);
2581
+ const memberships = this.zoneEngine.annotateDetection(td.bbox, this.zones, frameWidth, frameHeight, m?.mask, m?.width, m?.height, this.zoneMembershipMinOverlap);
2202
2582
  zonesByTrack.set(td.trackId, memberships.map((m2) => m2.zoneId));
2583
+ if (memberships.length > 0) overlapsByTrack.set(td.trackId, memberships);
2203
2584
  }
2585
+ this.lastZoneOverlaps = overlapsByTrack;
2204
2586
  const tracked = trackedDetections.map((td) => {
2205
2587
  const state = mapObjectStateToTrackState(objectStates.find((o) => o.trackId === td.trackId)?.state);
2206
2588
  const label = resolveDetectionLabel({
@@ -4162,6 +4544,42 @@ function attachmentKindPreference(policy, ownerKind) {
4162
4544
  ];
4163
4545
  }
4164
4546
  /**
4547
+ * The kind ladder for an EXPLICIT frame choice. Strict by design — each option
4548
+ * stays inside its own family, because a rule that asked for the clean scene
4549
+ * and received the boxed one (or vice versa) is not "degrading gracefully", it
4550
+ * is answering a different question.
4551
+ *
4552
+ * `boxed` is the one exception: with no annotated frame stored, the CLEAN
4553
+ * scene is the honest fallback (same picture, no annotation) — never a subject
4554
+ * crop, which shows something else entirely.
4555
+ */
4556
+ function framePreference(frame, ownerKind) {
4557
+ if (frame === "cropped") return ownerKind === "track" ? [
4558
+ "thumbnail",
4559
+ "thumbnailSmall",
4560
+ "crop"
4561
+ ] : [
4562
+ "crop",
4563
+ "thumbnail",
4564
+ "thumbnailSmall"
4565
+ ];
4566
+ if (frame === "boxed") return [
4567
+ "fullFrameBoxed",
4568
+ "keyFrame",
4569
+ "keyFrameSmall",
4570
+ "fullFrame"
4571
+ ];
4572
+ return ownerKind === "track" ? [
4573
+ "keyFrame",
4574
+ "keyFrameSmall",
4575
+ "firstFrame"
4576
+ ] : [
4577
+ "fullFrame",
4578
+ "keyFrame",
4579
+ "keyFrameSmall"
4580
+ ];
4581
+ }
4582
+ /**
4165
4583
  * Derive the `best-matching` media signal from a matched rule's condition
4166
4584
  * summary ({@link NcEvaluation.matchedOn}). Identity takes priority over plate
4167
4585
  * (D-3 ordering: a face rule that ALSO plate-matched attaches the face crop).
@@ -4190,6 +4608,20 @@ function bestMatchingKindPreference(signal, ownerKind) {
4190
4608
  //#endregion
4191
4609
  //#region src/notification-center/dispatcher.ts
4192
4610
  var DEFAULT_TARGET_CACHE_TTL_MS = 6e4;
4611
+ /** GIF and MP4 are the same cut in two containers — one render request each. */
4612
+ var FOOTAGE_FORMATS = [{
4613
+ flag: "mediaGif",
4614
+ format: "gif",
4615
+ mediaType: "gif",
4616
+ mime: "image/gif",
4617
+ name: "event.gif"
4618
+ }, {
4619
+ flag: "mediaClip",
4620
+ format: "mp4",
4621
+ mediaType: "video",
4622
+ mime: "video/mp4",
4623
+ name: "event.mp4"
4624
+ }];
4193
4625
  var NcDispatcher = class {
4194
4626
  deps;
4195
4627
  targetCache = null;
@@ -4244,7 +4676,9 @@ var NcDispatcher = class {
4244
4676
  ruleId: entry.ruleId,
4245
4677
  target: target.name,
4246
4678
  kind: target.kind,
4247
- recordKind: entry.recordKind
4679
+ recordKind: entry.recordKind,
4680
+ eventId: entry.recordId,
4681
+ ...entry.trackId !== void 0 ? { trackId: entry.trackId } : {}
4248
4682
  }
4249
4683
  });
4250
4684
  return { ok: true };
@@ -4259,14 +4693,19 @@ var NcDispatcher = class {
4259
4693
  async resolveTarget(targetId) {
4260
4694
  const cached = this.cachedTarget(targetId);
4261
4695
  if (cached !== null) return cached;
4696
+ let targets;
4262
4697
  try {
4263
- const targets = await this.deps.listTargets();
4264
- this.targetCache = new Map(targets.map((t) => [t.id, t]));
4265
- this.targetCacheAt = this.now();
4698
+ targets = await this.deps.listTargets();
4266
4699
  } catch (err) {
4267
4700
  this.deps.logger.warn("target catalog refresh failed", { meta: { error: String(err) } });
4268
4701
  throw err instanceof Error ? err : new Error(String(err));
4269
4702
  }
4703
+ if (targets.length === 0) {
4704
+ this.deps.logger.warn("target catalog came back EMPTY — treating as transient", { meta: { targetId } });
4705
+ throw new Error(`target catalog empty (transient) while resolving ${targetId}`);
4706
+ }
4707
+ this.targetCache = new Map(targets.map((t) => [t.id, t]));
4708
+ this.targetCacheAt = this.now();
4270
4709
  return this.targetCache.get(targetId) ?? null;
4271
4710
  }
4272
4711
  cachedTarget(targetId) {
@@ -4277,10 +4716,11 @@ var NcDispatcher = class {
4277
4716
  async buildNotification(entry) {
4278
4717
  const subject = entry.payload.subject;
4279
4718
  const deviceName = await this.deps.getDeviceName(subject.deviceId).catch(() => null) ?? `camera ${subject.deviceId}`;
4280
- const vars = buildTemplateVars(entry, deviceName);
4719
+ const zoneLabels = await resolveZoneLabels(this.deps.getZoneNames, subject.deviceId, subject.zones);
4720
+ const vars = buildTemplateVars(entry, deviceName, zoneLabels);
4281
4721
  const title = renderTemplate(entry.payload.template?.title, vars) ?? entry.payload.ruleName;
4282
- const body = renderTemplate(entry.payload.template?.body, vars) ?? defaultBody(entry, deviceName);
4283
- const attachment = await this.resolveAttachment(entry);
4722
+ const body = renderTemplate(entry.payload.template?.body, vars) ?? defaultBody(entry, deviceName, zoneLabels);
4723
+ const attachments = await this.withArtifactUrls(await this.resolveAttachments(entry));
4284
4724
  const params = pickParams(entry.payload.params);
4285
4725
  return {
4286
4726
  body,
@@ -4290,7 +4730,7 @@ var NcDispatcher = class {
4290
4730
  tag: entry.ruleId,
4291
4731
  deviceId: subject.deviceId,
4292
4732
  ...subject.eventId !== void 0 ? { eventId: subject.eventId } : {},
4293
- ...attachment !== null ? { attachments: [attachment] } : {}
4733
+ ...attachments.length > 0 ? { attachments } : {}
4294
4734
  };
4295
4735
  }
4296
4736
  /**
@@ -4302,9 +4742,142 @@ var NcDispatcher = class {
4302
4742
  * explains the fired condition (`faceCrop`/`plateCrop`, both event-owned)
4303
4743
  * then degrades to the plain `best` → `keyFrame` ladders.
4304
4744
  */
4305
- async resolveAttachment(entry) {
4306
- const policy = entry.payload.media;
4745
+ /**
4746
+ * Mint a signed URL for each attachment, alongside the bytes it already
4747
+ * carries. Best-effort per attachment: a failed mint leaves that one
4748
+ * bytes-only rather than costing the notification. Without this a url-mode
4749
+ * target (WhatsApp, gotify) silently received NO media — the degrade engine
4750
+ * drops a bytes-only attachment for them (`attachment:noUrl`).
4751
+ */
4752
+ /**
4753
+ * A failed footage render is best-effort — ship the still, skip the video.
4754
+ *
4755
+ * There is nothing to wait for: the clip ring only grows FORWARD, so a window
4756
+ * it does not already cover will never be covered by retrying. (An earlier
4757
+ * revision deferred delivery here, when clips were cut from finalized
4758
+ * recording segments; retrying under the pre-buffer would just age the
4759
+ * pre-roll out of the ring.)
4760
+ */
4761
+ notePendingFootage(entry, err, what) {
4762
+ this.deps.logger.debug(`${what} attachment render failed`, {
4763
+ tags: { deviceId: entry.payload.subject.deviceId },
4764
+ meta: {
4765
+ error: String(err),
4766
+ action: "send-without-video"
4767
+ }
4768
+ });
4769
+ }
4770
+ async withArtifactUrls(attachments) {
4771
+ const publish = this.deps.publishArtifact;
4772
+ if (publish === void 0) return [...attachments];
4773
+ const out = [];
4774
+ for (const att of attachments) {
4775
+ const url = await publish(att.bytes, att.mime).catch(() => null);
4776
+ out.push(url === null ? { ...att } : {
4777
+ ...att,
4778
+ url
4779
+ });
4780
+ }
4781
+ return out;
4782
+ }
4783
+ /** The full attachment list: the policy still (zone-cropped when the rule
4784
+ * froze zone ids) plus the footage cut from the broker's clip ring when
4785
+ * requested. Each part is best-effort — a failed crop falls back to the
4786
+ * uncropped still, a failed render just omits the video. */
4787
+ async resolveAttachments(entry) {
4788
+ const out = [];
4789
+ const zoneIdsWanted = entry.payload.mediaZoneIds;
4790
+ const still = await this.resolveAttachment(entry, zoneIdsWanted !== void 0 && zoneIdsWanted.length > 0 ? "keyFrame" : void 0);
4791
+ if (still !== null) {
4792
+ const zoneIds = zoneIdsWanted;
4793
+ if (zoneIds !== void 0 && zoneIds.length > 0) {
4794
+ const cropped = await this.zoneCrop(entry.payload.subject.deviceId, zoneIds, still.bytes);
4795
+ out.push(cropped !== null ? {
4796
+ ...still,
4797
+ bytes: cropped,
4798
+ name: "zone.jpg"
4799
+ } : still);
4800
+ } else out.push(still);
4801
+ }
4802
+ for (const want of FOOTAGE_FORMATS) {
4803
+ if (entry.payload[want.flag] !== true || !this.deps.renderFootage) continue;
4804
+ try {
4805
+ const rendered = await this.deps.renderFootage({
4806
+ deviceId: entry.payload.subject.deviceId,
4807
+ aroundMs: entry.payload.subject.timestamp,
4808
+ format: want.format,
4809
+ ...entry.payload.mediaClipPreRollSec !== void 0 ? { preRollSec: entry.payload.mediaClipPreRollSec } : {},
4810
+ ...entry.payload.mediaClipPostRollSec !== void 0 ? { postRollSec: entry.payload.mediaClipPostRollSec } : {},
4811
+ ...entry.payload.mediaProfile !== void 0 ? { profile: entry.payload.mediaProfile } : {}
4812
+ });
4813
+ if (rendered !== null && rendered.byteLength > 0) {
4814
+ const bytes = new Uint8Array(rendered.byteLength);
4815
+ bytes.set(rendered);
4816
+ out.push({
4817
+ mediaType: want.mediaType,
4818
+ bytes,
4819
+ mime: want.mime,
4820
+ name: want.name
4821
+ });
4822
+ }
4823
+ } catch (err) {
4824
+ this.notePendingFootage(entry, err, want.format === "gif" ? "gif" : "clip");
4825
+ }
4826
+ }
4827
+ return out;
4828
+ }
4829
+ /** Crop a JPEG to the padded bbox of the given zones (normalized polygons →
4830
+ * pixel rect via sharp metadata). Null on any failure — caller falls back
4831
+ * to the uncropped still. */
4832
+ async zoneCrop(deviceId, zoneIds, jpeg) {
4833
+ try {
4834
+ const points = (await this.deps.getZonePolygons?.(deviceId, zoneIds) ?? []).flat();
4835
+ if (points.length === 0) return null;
4836
+ const { default: sharp$7 } = await import("sharp");
4837
+ const img = sharp$7(Buffer.from(jpeg));
4838
+ const meta = await img.metadata();
4839
+ const W = meta.width ?? 0;
4840
+ const H = meta.height ?? 0;
4841
+ if (W === 0 || H === 0) return null;
4842
+ let minX = 1;
4843
+ let minY = 1;
4844
+ let maxX = 0;
4845
+ let maxY = 0;
4846
+ for (const p of points) {
4847
+ if (p.x < minX) minX = p.x;
4848
+ if (p.y < minY) minY = p.y;
4849
+ if (p.x > maxX) maxX = p.x;
4850
+ if (p.y > maxY) maxY = p.y;
4851
+ }
4852
+ if (maxX <= minX || maxY <= minY) return null;
4853
+ const padX = (maxX - minX) * .1;
4854
+ const padY = (maxY - minY) * .1;
4855
+ const left = Math.max(0, Math.floor((minX - padX) * W));
4856
+ const top = Math.max(0, Math.floor((minY - padY) * H));
4857
+ const width = Math.min(W - left, Math.ceil((maxX - minX + 2 * padX) * W));
4858
+ const height = Math.min(H - top, Math.ceil((maxY - minY + 2 * padY) * H));
4859
+ if (width < 16 || height < 16) return null;
4860
+ const outBuf = await img.extract({
4861
+ left,
4862
+ top,
4863
+ width,
4864
+ height
4865
+ }).jpeg({ quality: 82 }).toBuffer();
4866
+ const bytes = new Uint8Array(outBuf.byteLength);
4867
+ bytes.set(outBuf);
4868
+ return bytes;
4869
+ } catch (err) {
4870
+ this.deps.logger.debug("zone crop failed — attaching uncropped still", { meta: {
4871
+ deviceId,
4872
+ error: String(err)
4873
+ } });
4874
+ return null;
4875
+ }
4876
+ }
4877
+ async resolveAttachment(entry, policyOverride) {
4878
+ const policy = policyOverride ?? entry.payload.media;
4307
4879
  if (policy === "none") return null;
4880
+ const frame = policyOverride === void 0 ? entry.payload.mediaFrame : void 0;
4308
4881
  const subject = entry.payload.subject;
4309
4882
  const signal = policy === "best-matching" ? matchSignal(entry.payload.matchedOn) : null;
4310
4883
  const owners = [];
@@ -4316,11 +4889,11 @@ var NcDispatcher = class {
4316
4889
  kind: "track",
4317
4890
  id: subject.trackId
4318
4891
  });
4319
- if (policy === "keyFrame") owners.reverse();
4892
+ if (policy === "keyFrame" || frame === "full" || frame === "boxed") owners.reverse();
4320
4893
  for (const owner of owners) try {
4321
4894
  const files = await this.deps.getMediaForOwner(owner.kind, owner.id);
4322
4895
  if (files.length === 0) continue;
4323
- const preference = policy === "best-matching" ? bestMatchingKindPreference(signal, owner.kind) : attachmentKindPreference(policy, owner.kind);
4896
+ const preference = frame !== void 0 ? framePreference(frame, owner.kind) : policy === "best-matching" ? bestMatchingKindPreference(signal, owner.kind) : attachmentKindPreference(policy, owner.kind);
4324
4897
  for (const kind of preference) {
4325
4898
  const file = files.find((f) => f.kind === kind);
4326
4899
  if (file === void 0) continue;
@@ -4345,15 +4918,37 @@ var NcDispatcher = class {
4345
4918
  return null;
4346
4919
  }
4347
4920
  };
4348
- function buildTemplateVars(entry, deviceName) {
4921
+ /**
4922
+ * Map admin zone IDs to their display names for rendering only.
4923
+ *
4924
+ * Order follows `zoneIds` (the order the track visited them), not the zone
4925
+ * catalog. Every failure mode degrades to the ID rather than dropping the
4926
+ * zone: an unknown id, a blank name, a throwing lookup, or no lookup wired at
4927
+ * all. A body that silently loses a zone is worse than one that shows a UUID.
4928
+ */
4929
+ async function resolveZoneLabels(getZoneNames, deviceId, zoneIds) {
4930
+ if (zoneIds.length === 0) return [];
4931
+ if (getZoneNames === void 0) return [...zoneIds];
4932
+ try {
4933
+ const zones = await getZoneNames(deviceId);
4934
+ const byId = new Map(zones.map((z) => [z.id, z.name]));
4935
+ return zoneIds.map((id) => {
4936
+ const name = byId.get(id);
4937
+ return name !== void 0 && name.trim().length > 0 ? name : id;
4938
+ });
4939
+ } catch {
4940
+ return [...zoneIds];
4941
+ }
4942
+ }
4943
+ function buildTemplateVars(entry, deviceName, zoneLabels) {
4349
4944
  const subject = entry.payload.subject;
4350
4945
  const occupancy = subject.occupancy;
4351
4946
  return {
4352
4947
  camera: deviceName,
4353
4948
  class: subject.className,
4354
4949
  label: subject.label ?? "",
4355
- zones: subject.zones.join(", "),
4356
- zone: occupancy?.zone ?? subject.zones[0] ?? "",
4950
+ zones: zoneLabels.join(", "),
4951
+ zone: occupancy?.zone ?? zoneLabels[0] ?? "",
4357
4952
  confidence: subject.confidence !== void 0 ? `${Math.round(subject.confidence * 100)}%` : "",
4358
4953
  time: new Date(subject.timestamp).toLocaleTimeString(),
4359
4954
  rule: entry.payload.ruleName,
@@ -4371,12 +4966,12 @@ function renderTemplate(template, vars) {
4371
4966
  if (template === void 0 || template.trim().length === 0) return null;
4372
4967
  return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (_m, name) => vars[name] ?? "");
4373
4968
  }
4374
- function defaultBody(entry, deviceName) {
4969
+ function defaultBody(entry, deviceName, zoneLabels) {
4375
4970
  const subject = entry.payload.subject;
4376
4971
  const occupancy = subject.occupancy;
4377
4972
  if (occupancy !== void 0) return `${occupancy.zone ?? deviceName} ${occupancyOpWord(occupancy.occupied)} (${occupancy.count}/${occupancy.capacity})`;
4378
4973
  const label = subject.label !== void 0 ? ` (${subject.label})` : "";
4379
- const zones = subject.zones.length > 0 ? ` in ${subject.zones.join(", ")}` : "";
4974
+ const zones = zoneLabels.length > 0 ? ` in ${zoneLabels.join(", ")}` : "";
4380
4975
  const suffix = entry.recordKind === "track-end" ? " — visit ended" : "";
4381
4976
  return `${subject.className}${label} on ${deviceName}${zones}${suffix}`;
4382
4977
  }
@@ -4642,7 +5237,7 @@ var NC_OCCUPANCY_INDEXES = [{
4642
5237
  }];
4643
5238
  /** Query cap — a per-(device, zone, class) key set is small; this is a
4644
5239
  * generous ceiling that still bounds a pathological read. */
4645
- var LOAD_LIMIT = 1e5;
5240
+ var LOAD_LIMIT$1 = 1e5;
4646
5241
  var OccupancyStore = class {
4647
5242
  cache = /* @__PURE__ */ new Map();
4648
5243
  store;
@@ -4669,7 +5264,7 @@ var OccupancyStore = class {
4669
5264
  try {
4670
5265
  const records = await this.store.query.query({
4671
5266
  collection: NC_OCCUPANCY_COLLECTION,
4672
- filter: { limit: LOAD_LIMIT }
5267
+ filter: { limit: LOAD_LIMIT$1 }
4673
5268
  });
4674
5269
  this.cache.clear();
4675
5270
  let skipped = 0;
@@ -5454,6 +6049,254 @@ var NcRuleStore = class {
5454
6049
  }
5455
6050
  };
5456
6051
  //#endregion
6052
+ //#region src/notification-center/timelapse/timelapse-store.ts
6053
+ var NC_TIMELAPSE_RULES_COLLECTION = "notification-center:timelapse-rules";
6054
+ var NC_TIMELAPSE_RULES_COLUMNS = [
6055
+ {
6056
+ name: "id",
6057
+ type: "TEXT",
6058
+ primaryKey: true,
6059
+ notNull: true
6060
+ },
6061
+ {
6062
+ name: "name",
6063
+ type: "TEXT",
6064
+ notNull: true
6065
+ },
6066
+ {
6067
+ name: "enabled",
6068
+ type: "BOOLEAN",
6069
+ notNull: true
6070
+ },
6071
+ {
6072
+ name: "updatedAt",
6073
+ type: "INTEGER",
6074
+ notNull: true
6075
+ },
6076
+ (
6077
+ /** The FULL rule object (Zod-validated on read) — scalars above are
6078
+ * indexed projections only. */
6079
+ {
6080
+ name: "rule",
6081
+ type: "JSON",
6082
+ notNull: true
6083
+ })
6084
+ ];
6085
+ var NC_TIMELAPSE_RULES_INDEXES = [{
6086
+ name: "idx_nc_timelapse_rules_enabled",
6087
+ columns: ["enabled"]
6088
+ }];
6089
+ /** Query cap — the rule set is operator-authored and tiny; a generous ceiling. */
6090
+ var LOAD_LIMIT = 1e4;
6091
+ /**
6092
+ * Resolve the three-way `template` patch signal onto a merged rule, immutably:
6093
+ * `undefined` (key absent) leaves it as-is, `null` DROPS the key, an object
6094
+ * replaces it. Keeping `null` out of the persisted rule is what lets
6095
+ * `TimelapseRuleSchema` stay a plain `.optional()`.
6096
+ */
6097
+ function applyTemplatePatch(merged, template) {
6098
+ if (template === void 0) return merged;
6099
+ if (template !== null) return {
6100
+ ...merged,
6101
+ template
6102
+ };
6103
+ const { template: _cleared, ...withoutTemplate } = merged;
6104
+ return withoutTemplate;
6105
+ }
6106
+ var TimelapseStore = class {
6107
+ byId = /* @__PURE__ */ new Map();
6108
+ store;
6109
+ logger;
6110
+ now;
6111
+ newId;
6112
+ constructor(deps) {
6113
+ this.store = deps.store;
6114
+ this.logger = deps.logger;
6115
+ this.now = deps.now ?? (() => Date.now());
6116
+ this.newId = deps.newId ?? (() => (0, node_crypto.randomUUID)());
6117
+ }
6118
+ static async declare(store) {
6119
+ await store.declareCollection.mutate({
6120
+ collection: NC_TIMELAPSE_RULES_COLLECTION,
6121
+ columns: [...NC_TIMELAPSE_RULES_COLUMNS],
6122
+ indexes: [...NC_TIMELAPSE_RULES_INDEXES]
6123
+ });
6124
+ }
6125
+ /**
6126
+ * (Re)hydrate the FULL rule set from the store — called at boot and on the
6127
+ * periodic refresh tick. Replaces the cache wholesale; a row whose JSON no
6128
+ * longer validates is skipped with a warning (a degraded rule must never
6129
+ * crash the scheduler).
6130
+ */
6131
+ async load() {
6132
+ try {
6133
+ const rows = await this.store.query.query({
6134
+ collection: NC_TIMELAPSE_RULES_COLLECTION,
6135
+ filter: { limit: LOAD_LIMIT }
6136
+ });
6137
+ this.byId.clear();
6138
+ let skipped = 0;
6139
+ for (const row of rows) {
6140
+ const parsed = require_dist.TimelapseRuleSchema.safeParse(row.data["rule"]);
6141
+ if (!parsed.success) {
6142
+ skipped += 1;
6143
+ continue;
6144
+ }
6145
+ this.byId.set(parsed.data.id, parsed.data);
6146
+ }
6147
+ this.logger.debug("timelapse rules loaded", { meta: {
6148
+ rules: this.byId.size,
6149
+ ...skipped > 0 ? { skippedInvalid: skipped } : {}
6150
+ } });
6151
+ } catch (err) {
6152
+ this.logger.warn("timelapse rules load failed", { meta: { error: String(err) } });
6153
+ }
6154
+ }
6155
+ /** Every rule, newest-first (admin path). */
6156
+ list() {
6157
+ return [...this.byId.values()].sort((a, b) => b.updatedAt - a.updatedAt);
6158
+ }
6159
+ /** The scheduler's read: only rules that should be ticked. */
6160
+ listEnabled() {
6161
+ return this.list().filter((r) => r.enabled);
6162
+ }
6163
+ get(ruleId) {
6164
+ return this.byId.get(ruleId) ?? null;
6165
+ }
6166
+ /**
6167
+ * Rules visible to `userId`: their OWN personal rules (`ownerUserId ===
6168
+ * userId`) plus every admin/global rule (`ownerUserId` absent). Never another
6169
+ * user's personal rows. Newest-first (inherits {@link list}).
6170
+ *
6171
+ * The caller identity is server-derived; an absent/undefined caller must be
6172
+ * resolved to a fail-closed value by the bridge action BEFORE calling here —
6173
+ * this store never treats a missing caller as admin/global.
6174
+ */
6175
+ listForOwner(userId) {
6176
+ return this.list().filter((r) => r.ownerUserId === void 0 || r.ownerUserId === userId);
6177
+ }
6178
+ /**
6179
+ * Mutation gate for a NON-admin caller: true only for a PERSONAL rule this
6180
+ * user owns. A global rule (no `ownerUserId`) returns false — global rules
6181
+ * are admin-only, and the bridge action grants admins the mutation without
6182
+ * consulting this check. An unknown rule id is false (fail-closed).
6183
+ */
6184
+ isOwnedBy(ruleId, userId) {
6185
+ const rule = this.byId.get(ruleId);
6186
+ return rule?.ownerUserId !== void 0 && rule.ownerUserId === userId;
6187
+ }
6188
+ /**
6189
+ * Create a rule. `createdBy` is the SERVER-injected caller userId;
6190
+ * `ownerUserId` is the server-derived owner (omit for an admin/global rule).
6191
+ * Neither is ever read from `input`.
6192
+ *
6193
+ * The input is re-parsed through {@link TimelapseRuleInputSchema} BEFORE the
6194
+ * spread — that schema carries no ownership/provenance keys, so it strips any
6195
+ * that rode in on the blob. Without it, an `ownerUserId` on `input` would
6196
+ * survive whenever the `ownerUserId` ARGUMENT is omitted (the admin/global
6197
+ * path): a `TimelapseRule` is structurally assignable to `TimelapseRuleInput`,
6198
+ * so a future "duplicate rule" action (`create(existingRule, caller)`) would
6199
+ * compile cleanly and silently clone the ORIGINAL owner.
6200
+ */
6201
+ async create(input, createdBy, ownerUserId) {
6202
+ const now = this.now();
6203
+ const rule = require_dist.TimelapseRuleSchema.parse({
6204
+ ...require_dist.TimelapseRuleInputSchema.parse(input),
6205
+ id: this.newId(),
6206
+ ...ownerUserId !== void 0 ? { ownerUserId } : {},
6207
+ createdBy,
6208
+ createdAt: now,
6209
+ updatedAt: now
6210
+ });
6211
+ await this.persist(rule);
6212
+ this.byId.set(rule.id, rule);
6213
+ return rule;
6214
+ }
6215
+ /**
6216
+ * Apply a partial patch. Immutable: returns the NEW rule object. Identity,
6217
+ * ownership and generation state are re-pinned from the existing rule AFTER
6218
+ * the spread — the patch schema carries none of them, and this makes a
6219
+ * hand-built (unparsed) patch object equally unable to re-own a rule.
6220
+ *
6221
+ * `template` is the one clearable field: an absent key leaves it unchanged,
6222
+ * an explicit `null` CLEARS it (the persisted rule loses the key — `null`
6223
+ * never reaches {@link TimelapseRuleSchema}). See the patch schema's wire
6224
+ * note.
6225
+ */
6226
+ async update(ruleId, patch) {
6227
+ const existing = this.byId.get(ruleId);
6228
+ if (!existing) throw new Error(`timelapse rule not found: ${ruleId}`);
6229
+ const { template, ...rest } = patch;
6230
+ const merged = {
6231
+ ...existing,
6232
+ ...rest,
6233
+ id: existing.id,
6234
+ ownerUserId: existing.ownerUserId,
6235
+ lastGeneratedAt: existing.lastGeneratedAt,
6236
+ createdBy: existing.createdBy,
6237
+ createdAt: existing.createdAt,
6238
+ updatedAt: this.now()
6239
+ };
6240
+ return this.write(applyTemplatePatch(merged, template));
6241
+ }
6242
+ async setEnabled(ruleId, enabled) {
6243
+ return this.update(ruleId, { enabled });
6244
+ }
6245
+ /**
6246
+ * Record a successful generation. `at` is the generation epoch-ms — the
6247
+ * durable state behind the 1-hour re-generation guard. Does NOT bump
6248
+ * `updatedAt` (generation is not an edit of the rule definition).
6249
+ */
6250
+ async markGenerated(ruleId, at) {
6251
+ const existing = this.byId.get(ruleId);
6252
+ if (!existing) throw new Error(`timelapse rule not found: ${ruleId}`);
6253
+ return this.write({
6254
+ ...existing,
6255
+ lastGeneratedAt: at
6256
+ });
6257
+ }
6258
+ /** Idempotent delete — unknown ids are a no-op. */
6259
+ async delete(ruleId) {
6260
+ this.byId.delete(ruleId);
6261
+ try {
6262
+ await this.store.delete.mutate({
6263
+ collection: NC_TIMELAPSE_RULES_COLLECTION,
6264
+ key: ruleId
6265
+ });
6266
+ } catch (err) {
6267
+ this.logger.warn("timelapse rule delete failed", { meta: {
6268
+ ruleId,
6269
+ error: String(err)
6270
+ } });
6271
+ throw err instanceof Error ? err : new Error(String(err));
6272
+ }
6273
+ }
6274
+ /**
6275
+ * Re-validate a merged candidate, persist it, then cache it. Re-validating
6276
+ * means a patch can never persist a rule that would be skipped at the next
6277
+ * `load()`; provenance/ownership survive because they are spread from the
6278
+ * existing rule and never present on a patch.
6279
+ */
6280
+ async write(candidate) {
6281
+ const rule = require_dist.TimelapseRuleSchema.parse(candidate);
6282
+ await this.persist(rule);
6283
+ this.byId.set(rule.id, rule);
6284
+ return rule;
6285
+ }
6286
+ async persist(rule) {
6287
+ await this.store.set.mutate({
6288
+ collection: NC_TIMELAPSE_RULES_COLLECTION,
6289
+ key: rule.id,
6290
+ value: {
6291
+ name: rule.name,
6292
+ enabled: rule.enabled,
6293
+ updatedAt: rule.updatedAt,
6294
+ rule
6295
+ }
6296
+ });
6297
+ }
6298
+ };
6299
+ //#endregion
5457
6300
  //#region src/notification-center/index.ts
5458
6301
  var DEFAULT_DRAIN_INTERVAL_MS = 2e3;
5459
6302
  var DEFAULT_RULE_RELOAD_INTERVAL_MS = 3e4;
@@ -5548,7 +6391,7 @@ function outboxEntryToHistory(entry) {
5548
6391
  }
5549
6392
  };
5550
6393
  }
5551
- var NotificationCenter = class {
6394
+ var NotificationCenter = class NotificationCenter {
5552
6395
  logger;
5553
6396
  rules;
5554
6397
  outbox;
@@ -5610,11 +6453,19 @@ var NotificationCenter = class {
5610
6453
  get ruleStore() {
5611
6454
  return this.rules;
5612
6455
  }
5613
- /** Declare every Notification Center collection (idempotent, boot-time). */
6456
+ /**
6457
+ * Declare every Notification Center collection (idempotent, boot-time).
6458
+ *
6459
+ * EVERY store the module can touch belongs here, including ones whose
6460
+ * feature is not wired yet: an UNDECLARED collection answers 412 on first
6461
+ * use and takes the whole runner down with it. The timelapse store shipped
6462
+ * without this line and was one enable away from doing exactly that.
6463
+ */
5614
6464
  static async declare(store) {
5615
6465
  await NcRuleStore.declare(store);
5616
6466
  await NcOutbox.declare(store);
5617
6467
  await OccupancyStore.declare(store);
6468
+ await TimelapseStore.declare(store);
5618
6469
  }
5619
6470
  /**
5620
6471
  * Load rules (every node — the cap provider serves CRUD from any node).
@@ -5799,6 +6650,7 @@ var NotificationCenter = class {
5799
6650
  listRules: async () => ({ rules: [...this.rules.list()] }),
5800
6651
  getRule: async ({ ruleId }) => ({ rule: this.rules.get(ruleId) }),
5801
6652
  createRule: async ({ rule, caller }) => {
6653
+ NotificationCenter.assertHasAddressee(rule.targets, rule.targetUsers);
5802
6654
  await this.validateTargetRefs(rule.targets.map((t) => t.targetId));
5803
6655
  const created = await this.rules.create(rule, caller.userId);
5804
6656
  this.logger.info("notification rule created", { meta: {
@@ -5810,6 +6662,10 @@ var NotificationCenter = class {
5810
6662
  },
5811
6663
  updateRule: async ({ ruleId, patch, caller }) => {
5812
6664
  if (patch.targets !== void 0) await this.validateTargetRefs(patch.targets.map((t) => t.targetId));
6665
+ if (patch.targets !== void 0 || patch.targetUsers !== void 0) {
6666
+ const existing = this.rules.get(ruleId);
6667
+ NotificationCenter.assertHasAddressee(patch.targets ?? existing?.targets ?? [], patch.targetUsers ?? existing?.targetUsers);
6668
+ }
5813
6669
  const { disabledTargetIds: _optOut, ...safePatch } = patch;
5814
6670
  const updated = await this.rules.update(ruleId, safePatch);
5815
6671
  this.logger.info("notification rule updated", { meta: {
@@ -5827,7 +6683,10 @@ var NotificationCenter = class {
5827
6683
  return { success: true };
5828
6684
  },
5829
6685
  testRule: async ({ rule, lookbackMinutes }) => ({ results: [...await this.dryRun(rule, lookbackMinutes)] }),
5830
- getConditionCatalog: async () => ({ catalog: [...require_dist.NC_CONDITION_CATALOG] }),
6686
+ getConditionCatalog: async () => ({
6687
+ catalog: [...require_dist.NC_CONDITION_CATALOG],
6688
+ taxonomy: require_dist.NC_TAXONOMY
6689
+ }),
5831
6690
  getHistory: async ({ filter }) => {
5832
6691
  const entries = await this.outbox.queryHistory({
5833
6692
  ...filter.ruleId !== void 0 ? { ruleId: filter.ruleId } : {},
@@ -5868,26 +6727,84 @@ var NotificationCenter = class {
5868
6727
  async evaluateAndEnqueue(subject, kind) {
5869
6728
  const delivery = kind === "object-event" || kind === "audio-event" ? "immediate" : kind === "occupancy-event" ? "device-event" : kind;
5870
6729
  const candidates = this.rules.listEnabled(delivery);
5871
- if (candidates.length === 0) return;
6730
+ if (candidates.length === 0) {
6731
+ this.logger.debug("no enabled rule for this trigger", {
6732
+ tags: { deviceId: subject.deviceId },
6733
+ meta: {
6734
+ delivery,
6735
+ kind,
6736
+ rulesLoaded: this.rules.list().length
6737
+ }
6738
+ });
6739
+ return;
6740
+ }
5872
6741
  const now = this.now();
5873
6742
  for (const rule of candidates) {
5874
6743
  const evaluation = evaluateRule(rule, subject);
5875
- if (!evaluation.matched) continue;
6744
+ if (!evaluation.matched) {
6745
+ this.logger.debug("rule did not match", {
6746
+ tags: { deviceId: subject.deviceId },
6747
+ meta: {
6748
+ ruleId: rule.id,
6749
+ rule: rule.name,
6750
+ kind,
6751
+ failed: evaluation.failedCondition,
6752
+ classes: subject.classNames,
6753
+ eventId: subject.recordId,
6754
+ ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
6755
+ }
6756
+ });
6757
+ continue;
6758
+ }
5876
6759
  const key = cooldownKey(rule, subject);
5877
- if (isCoolingDown(rule, this.lastFiredAt.get(key), now)) continue;
5878
- if (await this.outbox.enqueue(this.buildEntries(rule, subject, kind, evaluation.matchedOn)) > 0) this.lastFiredAt.set(key, now);
6760
+ if (isCoolingDown(rule, this.lastFiredAt.get(key), now)) {
6761
+ this.logger.debug("rule matched but is cooling down", {
6762
+ tags: { deviceId: subject.deviceId },
6763
+ meta: {
6764
+ ruleId: rule.id,
6765
+ rule: rule.name,
6766
+ key,
6767
+ eventId: subject.recordId,
6768
+ ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
6769
+ }
6770
+ });
6771
+ continue;
6772
+ }
6773
+ this.logger.info("rule matched — enqueueing", {
6774
+ tags: { deviceId: subject.deviceId },
6775
+ meta: {
6776
+ ruleId: rule.id,
6777
+ rule: rule.name,
6778
+ kind,
6779
+ targets: rule.targets.length,
6780
+ eventId: subject.recordId,
6781
+ ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
6782
+ ...subject.confidence !== void 0 ? { confidence: subject.confidence } : {},
6783
+ ...subject.zones.length > 0 ? { zones: subject.zones } : {}
6784
+ }
6785
+ });
6786
+ const userTargets = await this.resolveUserTargets(rule, subject.deviceId);
6787
+ if (await this.outbox.enqueue(this.buildEntries(rule, subject, kind, evaluation.matchedOn, userTargets)) > 0) this.lastFiredAt.set(key, now);
5879
6788
  }
5880
6789
  }
5881
- buildEntries(rule, subject, kind, matchedOn) {
6790
+ buildEntries(rule, subject, kind, matchedOn, userTargets = []) {
5882
6791
  const hasEventMedia = kind === "object-event" || kind === "package-event";
5883
6792
  const isTrackScoped = kind === "object-event" || kind === "track-end";
5884
- return rule.targets.filter((target) => !rule.disabledTargetIds.includes(target.targetId)).map((target) => {
6793
+ const direct = new Set(rule.targets.map((t) => t.targetId));
6794
+ return [...rule.targets, ...userTargets.filter((t) => !direct.has(t.targetId))].filter((target) => !rule.disabledTargetIds.includes(target.targetId)).map((target) => {
5885
6795
  const payload = {
5886
6796
  ruleName: rule.name,
5887
6797
  delivery: rule.delivery,
5888
6798
  priority: rule.priority,
5889
6799
  ...rule.template !== void 0 ? { template: rule.template } : {},
5890
6800
  media: rule.media.attach,
6801
+ ...rule.media.zoneCrop === true && rule.conditions.zones !== void 0 ? { mediaZoneIds: [...rule.conditions.zones.ids] } : {},
6802
+ ...rule.media.frame !== void 0 ? { mediaFrame: rule.media.frame } : {},
6803
+ ...rule.media.profile !== void 0 ? { mediaProfile: rule.media.profile } : {},
6804
+ ...rule.media.gif === true ? { mediaGif: true } : {},
6805
+ ...rule.media.clip === true ? { mediaClip: true } : {},
6806
+ ...rule.media.clipPreRollSec !== void 0 ? { mediaClipPreRollSec: rule.media.clipPreRollSec } : {},
6807
+ ...rule.media.clipPostRollSec !== void 0 ? { mediaClipPostRollSec: rule.media.clipPostRollSec } : {},
5891
6808
  ...matchedOn !== void 0 && matchedOn.length > 0 ? { matchedOn } : {},
5892
6809
  ...target.params !== void 0 ? { params: target.params } : {},
5893
6810
  subject: {
@@ -5913,6 +6830,63 @@ var NotificationCenter = class {
5913
6830
  };
5914
6831
  });
5915
6832
  }
6833
+ /** rule→user fan-out caches (60 s users/targets; device routes immutable). */
6834
+ userGrantsCache = null;
6835
+ userGrantsAt = 0;
6836
+ ownedTargetsCache = null;
6837
+ ownedTargetsAt = 0;
6838
+ deviceRouteCache = /* @__PURE__ */ new Map();
6839
+ userFanoutUnavailableLogged = false;
6840
+ /**
6841
+ * The personal targets a rule's `targetUsers` resolve to for THIS device:
6842
+ * each addressed user contributes the enabled targets they own — but only
6843
+ * when their `allowedDevices` grant covers the firing camera (admin users
6844
+ * always pass). A user must never be notified about a device they cannot
6845
+ * open.
6846
+ */
6847
+ async resolveUserTargets(rule, deviceId) {
6848
+ const users = rule.targetUsers;
6849
+ if (users === void 0 || users.length === 0) return [];
6850
+ const { listUsers, getDeviceRoute } = this.deps;
6851
+ if (listUsers === void 0 || getDeviceRoute === void 0) {
6852
+ if (!this.userFanoutUnavailableLogged) {
6853
+ this.userFanoutUnavailableLogged = true;
6854
+ this.logger.warn("rule addresses users but the user fan-out deps are not wired", { meta: { ruleId: rule.id } });
6855
+ }
6856
+ return [];
6857
+ }
6858
+ const now = this.now();
6859
+ if (this.userGrantsCache === null || now - this.userGrantsAt > 6e4) {
6860
+ this.userGrantsCache = await listUsers();
6861
+ this.userGrantsAt = now;
6862
+ }
6863
+ if (this.ownedTargetsCache === null || now - this.ownedTargetsAt > 6e4) {
6864
+ const targets = await this.deps.dispatcher.listTargets();
6865
+ const owned = /* @__PURE__ */ new Map();
6866
+ for (const t of targets) {
6867
+ if (t.ownerUserId === void 0 || !t.enabled) continue;
6868
+ const list = owned.get(t.ownerUserId) ?? [];
6869
+ list.push(t.id);
6870
+ owned.set(t.ownerUserId, list);
6871
+ }
6872
+ this.ownedTargetsCache = owned;
6873
+ this.ownedTargetsAt = now;
6874
+ }
6875
+ if (!this.deviceRouteCache.has(deviceId)) this.deviceRouteCache.set(deviceId, await getDeviceRoute(deviceId));
6876
+ const route = this.deviceRouteCache.get(deviceId) ?? null;
6877
+ const out = [];
6878
+ for (const userId of users) {
6879
+ const user = this.userGrantsCache.find((u) => u.id === userId);
6880
+ if (user === void 0) continue;
6881
+ if (!user.isAdmin) {
6882
+ if (route === null) continue;
6883
+ const grant = user.allowedDevices[route.addonId];
6884
+ if (!(grant === "*" || Array.isArray(grant) && grant.includes(route.stableId))) continue;
6885
+ }
6886
+ for (const targetId of this.ownedTargetsCache.get(userId) ?? []) out.push({ targetId });
6887
+ }
6888
+ return out;
6889
+ }
5916
6890
  /** Rebuild the cooldown map from persisted outbox rows (restart-proof). */
5917
6891
  async seedCooldowns() {
5918
6892
  const entries = await this.outbox.queryRecentPersisted(this.now() - 864e5);
@@ -6026,10 +7000,21 @@ var NotificationCenter = class {
6026
7000
  this.drainTicks += 1;
6027
7001
  if (this.drainTicks % WATERMARK_EVERY_TICKS === 0) await this.outbox.setWatermark(this.now());
6028
7002
  }
7003
+ /** The "at least one addressee" invariant lives here, not in Zod: the
7004
+ * schema allows empty `targets` (a users-only rule) and a cross-field
7005
+ * refine would break `NcRulePatchSchema.partial()`. */
7006
+ static assertHasAddressee(targets, targetUsers) {
7007
+ if (targets.length === 0 && (targetUsers?.length ?? 0) === 0) throw new Error("a rule needs at least one delivery target or user");
7008
+ }
6029
7009
  /** Rule-save referential check: every targetId must resolve in the
6030
7010
  * live notification-output catalog (spec §2.3 save-time validation). */
6031
7011
  async validateTargetRefs(targetIds) {
7012
+ if (targetIds.length === 0) return;
6032
7013
  const targets = await this.deps.dispatcher.listTargets();
7014
+ if (targets.length === 0) {
7015
+ this.logger.warn("target catalog came back EMPTY at rule save — the notification-output catalog is unreachable or under-reporting (collection fan-out regression?). Skipping the referential check; delivery re-checks with retry semantics.", { meta: { targetIds: [...targetIds] } });
7016
+ return;
7017
+ }
6033
7018
  const known = new Set(targets.map((t) => t.id));
6034
7019
  for (const id of targetIds) if (!known.has(id)) throw new Error(`unknown notification target: ${id}`);
6035
7020
  }
@@ -6144,15 +7129,28 @@ function makeNcActionHandlers(deps) {
6144
7129
  if (rule === null) throw new Error(`forbidden: rule not found: ${ruleId}`);
6145
7130
  return rule;
6146
7131
  };
6147
- const assertRuleOwned = (ruleId, userId) => {
7132
+ /**
7133
+ * A rule the caller may EDIT. Ownership binds non-admins; an ADMIN
7134
+ * administers every rule — global ones (`ownerUserId: undefined`, what the
7135
+ * admin UI creates) and, for support, a user's personal rule.
7136
+ *
7137
+ * Without the admin bypass the comparison `rule.ownerUserId === userId` is
7138
+ * false for EVERY caller on a global rule, so the viewer refused to let an
7139
+ * admin edit an admin rule (operator report, 2026-07-30). Same bypass shape
7140
+ * as `assertTargetsOwned`.
7141
+ */
7142
+ const assertRuleEditable = (ruleId, caller) => {
6148
7143
  const rule = assertOwnsRule(ruleId);
6149
- if (rule.ownerUserId !== userId) throw new Error(`forbidden: rule not owned: ${ruleId}`);
7144
+ if (caller.isAdmin) return rule;
7145
+ if (rule.ownerUserId !== caller.userId) throw new Error(`forbidden: rule not owned: ${ruleId}`);
6150
7146
  return rule;
6151
7147
  };
6152
- /** A rule the caller may SEE — his own personal rule OR a global/admin rule. */
6153
- const assertRuleVisible = (ruleId, userId) => {
7148
+ /** A rule the caller may SEE — his own personal rule, a global/admin rule, or
7149
+ * (for an admin) any rule at all. */
7150
+ const assertRuleVisible = (ruleId, caller) => {
6154
7151
  const rule = assertOwnsRule(ruleId);
6155
- if (rule.ownerUserId !== void 0 && rule.ownerUserId !== userId) throw new Error(`forbidden: rule not owned: ${ruleId}`);
7152
+ if (caller.isAdmin) return rule;
7153
+ if (rule.ownerUserId !== void 0 && rule.ownerUserId !== caller.userId) throw new Error(`forbidden: rule not owned: ${ruleId}`);
6156
7154
  return rule;
6157
7155
  };
6158
7156
  const assertTargetsOwned = async (targetIds, caller) => {
@@ -6163,9 +7161,9 @@ function makeNcActionHandlers(deps) {
6163
7161
  return {
6164
7162
  "nc.listRules": async (_input, caller) => {
6165
7163
  const c = requireCaller(caller);
6166
- return { rules: deps.ruleStore.listForOwner(c.userId).map((r) => ({
7164
+ return { rules: (c.isAdmin ? deps.ruleStore.list() : deps.ruleStore.listForOwner(c.userId)).map((r) => ({
6167
7165
  ...r,
6168
- readOnly: r.ownerUserId !== c.userId
7166
+ readOnly: !c.isAdmin && r.ownerUserId !== c.userId
6169
7167
  })) };
6170
7168
  },
6171
7169
  "nc.getConditionCatalog": async () => ({
@@ -6188,7 +7186,7 @@ function makeNcActionHandlers(deps) {
6188
7186
  },
6189
7187
  "nc.updateRule": async (input, caller) => {
6190
7188
  const c = requireCaller(caller);
6191
- assertRuleOwned(input.ruleId, c.userId);
7189
+ assertRuleEditable(input.ruleId, c);
6192
7190
  const patch = require_dist.NcRulePatchSchema.parse(input.patch);
6193
7191
  if (patch.targets !== void 0) await assertTargetsOwned(patch.targets.map((t) => t.targetId), c);
6194
7192
  const { ownerUserId: _owner, disabledTargetIds: _optOut, ...safe } = patch;
@@ -6201,7 +7199,7 @@ function makeNcActionHandlers(deps) {
6201
7199
  },
6202
7200
  "nc.deleteRule": async (input, caller) => {
6203
7201
  const c = requireCaller(caller);
6204
- assertRuleOwned(input.ruleId, c.userId);
7202
+ assertRuleEditable(input.ruleId, c);
6205
7203
  await deps.ruleStore.delete(input.ruleId);
6206
7204
  deps.logger.info("nc rule deleted", { meta: {
6207
7205
  ruleId: input.ruleId,
@@ -6211,7 +7209,7 @@ function makeNcActionHandlers(deps) {
6211
7209
  },
6212
7210
  "nc.setRuleTargetEnabled": async (input, caller) => {
6213
7211
  const c = requireCaller(caller);
6214
- assertRuleVisible(input.ruleId, c.userId);
7212
+ assertRuleVisible(input.ruleId, c);
6215
7213
  await assertTargetsOwned([input.targetId], c);
6216
7214
  await deps.ruleStore.setRuleTargetEnabled(input.ruleId, input.targetId, input.enabled);
6217
7215
  return { success: true };
@@ -8863,7 +9861,15 @@ var MEDIA_COLUMNS = [
8863
9861
  name: "sizeBytes",
8864
9862
  type: "INTEGER",
8865
9863
  notNull: true
8866
- }
9864
+ },
9865
+ (
9866
+ /** Storage location holding the blob. NULL = the default `eventMedia`
9867
+ * location (every pre-Phase-3 row, and every write until multi-location
9868
+ * events exist) — the relocate mover stamps real ids as it moves blobs. */
9869
+ {
9870
+ name: "locationId",
9871
+ type: "TEXT"
9872
+ })
8867
9873
  ];
8868
9874
  var MEDIA_INDEXES = [{
8869
9875
  name: "idx_media_owner",
@@ -8872,11 +9878,18 @@ var MEDIA_INDEXES = [{
8872
9878
  name: "idx_media_device_ts",
8873
9879
  columns: ["deviceId", "timestamp"]
8874
9880
  }];
9881
+ /** The storage location of a media row/record: its stamped `locationId`, or
9882
+ * the default `eventMedia` location for NULL (pre-multi-location) rows. */
9883
+ var DEFAULT_MEDIA_LOCATION = "eventMedia";
9884
+ function mediaRowLocation(data) {
9885
+ const id = data?.["locationId"];
9886
+ return typeof id === "string" && id.length > 0 ? id : DEFAULT_MEDIA_LOCATION;
9887
+ }
8875
9888
  function buildKey(params) {
8876
9889
  return isSingleInstanceKind(params.kind) ? `${params.ownerKind}:${params.ownerId}:${params.kind}` : `${params.ownerKind}:${params.ownerId}:${params.kind}:${params.timestamp}`;
8877
9890
  }
8878
9891
  function buildPath(params) {
8879
- const base = `pipeline-analytics/${params.deviceId}/${params.ownerKind}/${params.ownerId}`;
9892
+ const base = `${params.deviceId}/events/${params.ownerKind}/${params.ownerId}`;
8880
9893
  return isSingleInstanceKind(params.kind) ? `${base}/${params.kind}.jpg` : `${base}/${params.kind}-${params.timestamp}.jpg`;
8881
9894
  }
8882
9895
  var MediaStore = class {
@@ -8907,7 +9920,8 @@ var MediaStore = class {
8907
9920
  kind: params.kind,
8908
9921
  timestamp: params.timestamp,
8909
9922
  path,
8910
- sizeBytes: params.data.length
9923
+ sizeBytes: params.data.length,
9924
+ locationId: null
8911
9925
  };
8912
9926
  try {
8913
9927
  await this.storage.write({
@@ -8964,10 +9978,11 @@ var MediaStore = class {
8964
9978
  const newKey = await this.put(params);
8965
9979
  for (const row of existing) {
8966
9980
  if (row.id === newKey) continue;
8967
- const path = String(row.data["path"] ?? "");
9981
+ const rowData = row.data;
9982
+ const path = String(rowData["path"] ?? "");
8968
9983
  if (path) try {
8969
9984
  await this.storage.delete({
8970
- location: "eventMedia",
9985
+ location: mediaRowLocation(rowData),
8971
9986
  relativePath: path
8972
9987
  });
8973
9988
  } catch {}
@@ -9035,7 +10050,7 @@ var MediaStore = class {
9035
10050
  key,
9036
10051
  kind,
9037
10052
  base64: (await this.storage.read({
9038
- location: "eventMedia",
10053
+ location: mediaRowLocation(data),
9039
10054
  relativePath: path
9040
10055
  })).toString("base64"),
9041
10056
  sizeBytes,
@@ -9055,10 +10070,11 @@ var MediaStore = class {
9055
10070
  key
9056
10071
  });
9057
10072
  if (!row) return;
9058
- const path = String(row["path"] ?? "");
10073
+ const data = row;
10074
+ const path = String(data["path"] ?? "");
9059
10075
  if (path) try {
9060
10076
  await this.storage.delete({
9061
- location: "eventMedia",
10077
+ location: mediaRowLocation(data),
9062
10078
  relativePath: path
9063
10079
  });
9064
10080
  } catch {}
@@ -9097,7 +10113,7 @@ var MediaStore = class {
9097
10113
  const kind = String(data["kind"]);
9098
10114
  try {
9099
10115
  const buf = await this.storage.read({
9100
- location: "eventMedia",
10116
+ location: mediaRowLocation(data),
9101
10117
  relativePath: path
9102
10118
  });
9103
10119
  files.push({
@@ -9132,10 +10148,11 @@ var MediaStore = class {
9132
10148
  } }
9133
10149
  });
9134
10150
  for (const row of rows) {
9135
- const path = String(row.data["path"] ?? "");
10151
+ const rowData = row.data;
10152
+ const path = String(rowData["path"] ?? "");
9136
10153
  try {
9137
10154
  if (path) await this.storage.delete({
9138
- location: "eventMedia",
10155
+ location: mediaRowLocation(rowData),
9139
10156
  relativePath: path
9140
10157
  });
9141
10158
  } catch {}
@@ -9231,14 +10248,14 @@ var MediaStore = class {
9231
10248
  kind,
9232
10249
  timestamp,
9233
10250
  data: await this.storage.read({
9234
- location: "eventMedia",
10251
+ location: mediaRowLocation(meta),
9235
10252
  relativePath: oldPath
9236
10253
  })
9237
10254
  };
9238
10255
  const newKey = await this.put(newParams);
9239
10256
  try {
9240
10257
  await this.storage.delete({
9241
- location: "eventMedia",
10258
+ location: mediaRowLocation(meta),
9242
10259
  relativePath: oldPath
9243
10260
  });
9244
10261
  } catch (err) {
@@ -9308,10 +10325,11 @@ var MediaStore = class {
9308
10325
  if (rows.length === 0) break;
9309
10326
  let deletedInPage = 0;
9310
10327
  for (const row of rows) {
9311
- const path = String(row.data["path"] ?? "");
10328
+ const rowData = row.data;
10329
+ const path = String(rowData["path"] ?? "");
9312
10330
  try {
9313
10331
  if (path) await this.storage.delete({
9314
- location: "eventMedia",
10332
+ location: mediaRowLocation(rowData),
9315
10333
  relativePath: path
9316
10334
  });
9317
10335
  } catch {}
@@ -9867,6 +10885,7 @@ var EventStore = class {
9867
10885
  const rows = await this.store.query.query({
9868
10886
  collection,
9869
10887
  filter: {
10888
+ ...params.deviceId !== void 0 ? { where: { deviceId: params.deviceId } } : {},
9870
10889
  whereBetween: { timestamp: [0, cutoffMs] },
9871
10890
  limit: 500
9872
10891
  }
@@ -10267,6 +11286,183 @@ function stripNulls(data) {
10267
11286
  return out;
10268
11287
  }
10269
11288
  //#endregion
11289
+ //#region src/pipeline-analytics/location-aware-media-storage.ts
11290
+ /**
11291
+ * Location-aware blob storage for event media (entity-routing spec, Phase 3).
11292
+ *
11293
+ * Media rows may carry a `locationId` (stamped by the relocate mover when a
11294
+ * blob is moved off the default location). The write-rate bypass provider
11295
+ * only knows the default media root — this wrapper routes any OTHER location
11296
+ * id through a resolver (the storage cap's `resolve`, cached forever: a
11297
+ * location's root only changes via operator reconfig, which restarts us) and
11298
+ * does direct fs I/O against that root, keeping the bypass's
11299
+ * no-RPC-per-blob property for every location.
11300
+ */
11301
+ function createLocationAwareMediaStorage(deps) {
11302
+ const roots = /* @__PURE__ */ new Map();
11303
+ const rootOf = async (locationId) => {
11304
+ const cached = roots.get(locationId);
11305
+ if (cached !== void 0) return cached;
11306
+ const root = await deps.resolveRoot(locationId);
11307
+ roots.set(locationId, root);
11308
+ return root;
11309
+ };
11310
+ const absOf = async (locationId, relativePath) => node_path.default.join(await rootOf(locationId), relativePath);
11311
+ return {
11312
+ write: async (input) => {
11313
+ if (input.location === deps.defaultLocation) return deps.base.write(input);
11314
+ const abs = await absOf(input.location, input.relativePath);
11315
+ await node_fs.promises.mkdir(node_path.default.dirname(abs), { recursive: true });
11316
+ await node_fs.promises.writeFile(abs, input.data);
11317
+ },
11318
+ read: async (input) => {
11319
+ if (input.location === deps.defaultLocation) return deps.base.read(input);
11320
+ return node_fs.promises.readFile(await absOf(input.location, input.relativePath));
11321
+ },
11322
+ delete: async (input) => {
11323
+ if (input.location === deps.defaultLocation) return deps.base.delete(input);
11324
+ await node_fs.promises.rm(await absOf(input.location, input.relativePath), { force: true });
11325
+ }
11326
+ };
11327
+ }
11328
+ //#endregion
11329
+ //#region src/pipeline-analytics/media-relocate-engine.ts
11330
+ var PAGE_SIZE = 200;
11331
+ var DEFAULT_THROTTLE_MBPS = 40;
11332
+ function snapshot(j) {
11333
+ return {
11334
+ jobId: j.jobId,
11335
+ state: j.state,
11336
+ fromLocationId: j.fromLocationId,
11337
+ toLocationId: j.toLocationId,
11338
+ deviceId: j.deviceId,
11339
+ entities: ["media"],
11340
+ filesMoved: j.filesMoved,
11341
+ bytesMoved: j.bytesMoved,
11342
+ filesTotal: null,
11343
+ startedAt: j.startedAt,
11344
+ finishedAt: j.finishedAt,
11345
+ error: j.error
11346
+ };
11347
+ }
11348
+ var MediaRelocateEngine = class {
11349
+ deps;
11350
+ jobs = /* @__PURE__ */ new Map();
11351
+ constructor(deps) {
11352
+ this.deps = deps;
11353
+ }
11354
+ list() {
11355
+ return [...this.jobs.values()].sort((a, b) => b.startedAt - a.startedAt).map(snapshot);
11356
+ }
11357
+ cancel(jobId) {
11358
+ const job = this.jobs.get(jobId);
11359
+ if (!job || job.state !== "running") return false;
11360
+ job.cancelRequested = true;
11361
+ return true;
11362
+ }
11363
+ start(input) {
11364
+ for (const j of this.jobs.values()) if (j.state === "running") throw new Error(`a media relocation is already running (${j.jobId})`);
11365
+ const job = {
11366
+ jobId: this.deps.newId(),
11367
+ state: "running",
11368
+ fromLocationId: "*",
11369
+ toLocationId: input.toLocationId,
11370
+ deviceId: input.deviceId ?? null,
11371
+ filesMoved: 0,
11372
+ bytesMoved: 0,
11373
+ startedAt: this.deps.now(),
11374
+ finishedAt: null,
11375
+ error: null,
11376
+ cancelRequested: false
11377
+ };
11378
+ this.jobs.set(job.jobId, job);
11379
+ this.run(job, input.throttleMbps ?? DEFAULT_THROTTLE_MBPS);
11380
+ return job.jobId;
11381
+ }
11382
+ async run(job, throttleMbps) {
11383
+ const sleep = this.deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
11384
+ const bytesPerMs = throttleMbps * 1024 * 1024 / 1e3;
11385
+ try {
11386
+ await this.deps.resolveTargetRoot(job.toLocationId);
11387
+ let cursor = 0;
11388
+ let seenAtCursor = /* @__PURE__ */ new Set();
11389
+ for (;;) {
11390
+ if (job.cancelRequested) break;
11391
+ const fresh = (await this.deps.store.query.query({
11392
+ collection: MEDIA_COLLECTION,
11393
+ filter: {
11394
+ ...job.deviceId !== null ? { where: { deviceId: job.deviceId } } : {},
11395
+ whereBetween: { timestamp: [cursor, Number.MAX_SAFE_INTEGER] },
11396
+ orderBy: {
11397
+ field: "timestamp",
11398
+ direction: "asc"
11399
+ },
11400
+ limit: PAGE_SIZE
11401
+ }
11402
+ })).filter((r) => !seenAtCursor.has(r.id));
11403
+ if (fresh.length === 0) break;
11404
+ for (const row of fresh) {
11405
+ if (job.cancelRequested) break;
11406
+ const data = row.data;
11407
+ const from = mediaRowLocation(data);
11408
+ if (from === job.toLocationId) continue;
11409
+ const relativePath = String(data["path"] ?? "");
11410
+ if (relativePath.length === 0) continue;
11411
+ try {
11412
+ const bytes = await this.deps.storage.read({
11413
+ location: from,
11414
+ relativePath
11415
+ });
11416
+ await this.deps.storage.write({
11417
+ location: job.toLocationId,
11418
+ relativePath,
11419
+ data: bytes
11420
+ });
11421
+ await this.deps.store.set.mutate({
11422
+ collection: MEDIA_COLLECTION,
11423
+ key: row.id,
11424
+ value: {
11425
+ ...data,
11426
+ locationId: job.toLocationId
11427
+ }
11428
+ });
11429
+ await this.deps.storage.delete({
11430
+ location: from,
11431
+ relativePath
11432
+ });
11433
+ job.filesMoved++;
11434
+ job.bytesMoved += bytes.length;
11435
+ await sleep(bytes.length / bytesPerMs);
11436
+ } catch (err) {
11437
+ this.deps.logger.debug("media relocate row failed", { meta: {
11438
+ key: row.id,
11439
+ error: String(err)
11440
+ } });
11441
+ }
11442
+ }
11443
+ const last = fresh[fresh.length - 1];
11444
+ const lastTs = Number(last.data["timestamp"] ?? cursor);
11445
+ if (lastTs === cursor) for (const r of fresh) seenAtCursor.add(r.id);
11446
+ else {
11447
+ cursor = lastTs;
11448
+ seenAtCursor = new Set(fresh.filter((r) => Number(r.data["timestamp"]) === lastTs).map((r) => r.id));
11449
+ }
11450
+ }
11451
+ job.state = job.cancelRequested ? "cancelled" : "done";
11452
+ } catch (err) {
11453
+ job.state = "failed";
11454
+ job.error = err instanceof Error ? err.message : String(err);
11455
+ this.deps.logger.warn("media relocate job failed", { meta: {
11456
+ jobId: job.jobId,
11457
+ error: job.error
11458
+ } });
11459
+ } finally {
11460
+ job.finishedAt = this.deps.now();
11461
+ this.deps.onFinished?.(snapshot(job));
11462
+ }
11463
+ }
11464
+ };
11465
+ //#endregion
10270
11466
  //#region src/pipeline-analytics/store/sensor-event-store.ts
10271
11467
  var SENSOR_EVENTS_COLLECTION = "pipeline-analytics:sensor-events";
10272
11468
  var SENSOR_EVENT_COLUMNS = [
@@ -11393,7 +12589,7 @@ var EventMediaDispatcher = class {
11393
12589
  if (sn.rollingLastFrame && boxed) lastFrameWritten = await this.replaceKind(deviceId, sn.trackId, "lastFrame", sn.timestamp, boxed);
11394
12590
  let thumbnailWritten = false;
11395
12591
  if (sn.bestThumbnail) {
11396
- const variants = await this.cropSubjectVariants(frameHandle, fw, fh, sn.bbox);
12592
+ const variants = await this.cropSubjectVariants(deviceId, frameHandle, fw, fh, sn.bbox);
11397
12593
  if (variants) {
11398
12594
  thumbnailWritten = await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, variants.thumbnail);
11399
12595
  await this.replaceKind(deviceId, sn.trackId, "thumbnailSmall", sn.timestamp, variants.thumbnailSmall);
@@ -11495,12 +12691,15 @@ var EventMediaDispatcher = class {
11495
12691
  * per-frame retry lands a real native crop later. Never a local resize
11496
12692
  * upscale of a ≤640 tile (a blurred lie).
11497
12693
  */
11498
- async cropSubjectVariants(frameHandle, fw, fh, bbox) {
12694
+ async cropSubjectVariants(deviceId, frameHandle, fw, fh, bbox) {
11499
12695
  if (!this.deps.getNativeCropJpeg) {
11500
- this.deps.logger.warn("native subject crop miss — will retry, no upscale", { meta: {
11501
- shmId: frameHandle.shmId,
11502
- reason: "no-native-cap"
11503
- } });
12696
+ this.deps.logger.warn("native subject crop miss — will retry, no upscale", {
12697
+ tags: { deviceId },
12698
+ meta: {
12699
+ shmId: frameHandle.shmId,
12700
+ reason: "no-native-cap"
12701
+ }
12702
+ });
11504
12703
  return null;
11505
12704
  }
11506
12705
  try {
@@ -11508,12 +12707,19 @@ var EventMediaDispatcher = class {
11508
12707
  W: fw,
11509
12708
  H: fh
11510
12709
  });
12710
+ const askedAt = Date.now();
11511
12711
  const slab = await this.deps.getNativeCropJpeg(frameHandle, layout.fetch);
11512
12712
  if (!slab) {
11513
- this.deps.logger.warn("native subject crop miss — will retry, no upscale", { meta: {
11514
- shmId: frameHandle.shmId,
11515
- reason: "native-miss"
11516
- } });
12713
+ this.deps.logger.warn("native subject crop miss — will retry, no upscale", {
12714
+ tags: { deviceId },
12715
+ meta: {
12716
+ shmId: frameHandle.shmId,
12717
+ handle: `${frameHandle.shmId}#${frameHandle.slot}#${frameHandle.seq}`,
12718
+ handleNodeId: frameHandle.nodeId,
12719
+ roundTripMs: Date.now() - askedAt,
12720
+ reason: "native-miss"
12721
+ }
12722
+ });
11517
12723
  return null;
11518
12724
  }
11519
12725
  const thumbnail = await composeWideCentralSquareThumbnail(slab, layout);
@@ -11522,10 +12728,14 @@ var EventMediaDispatcher = class {
11522
12728
  thumbnailSmall: await deriveThumbnailSmall(thumbnail)
11523
12729
  };
11524
12730
  } catch (err) {
11525
- this.deps.logger.warn("native subject crop miss — will retry, no upscale", { meta: {
11526
- shmId: frameHandle.shmId,
11527
- error: err instanceof Error ? err.message : String(err)
11528
- } });
12731
+ this.deps.logger.warn("native subject crop miss — will retry, no upscale", {
12732
+ tags: { deviceId },
12733
+ meta: {
12734
+ shmId: frameHandle.shmId,
12735
+ reason: "threw",
12736
+ error: err instanceof Error ? err.message : String(err)
12737
+ }
12738
+ });
11529
12739
  return null;
11530
12740
  }
11531
12741
  }
@@ -12717,6 +13927,23 @@ function resolveDetectionSensitivitySettings(raw) {
12717
13927
  };
12718
13928
  }
12719
13929
  var TrackingSettingsSchema = require_dist.object({
13930
+ /**
13931
+ * How much of a detection's box must lie inside a zone (0-1 fraction of the
13932
+ * box's own area) for that zone to be STAMPED onto the detection.
13933
+ *
13934
+ * This is the field a zone-scoped notification rule ultimately depends on: a
13935
+ * rule's `zones` condition is a plain set test over the stamped zone ids, so
13936
+ * a subject that merely clips a zone edge satisfies it. Measured on
13937
+ * 2026-07-30: a dog overlapping `Aiuola` by 4.8% would have counted as
13938
+ * inside it.
13939
+ *
13940
+ * DEFAULT 0 — byte-identical to the behaviour before 2026-07-31. Raising it
13941
+ * is deliberately an operator decision: the overlap fractions are now logged
13942
+ * (`zone membership` lines), so the bar can be chosen from the distribution
13943
+ * instead of guessed. Distinct from a zone RULE's `bboxInclusionPct`, which
13944
+ * gates the DETECTION stage, not what gets stamped.
13945
+ */
13946
+ zoneMembershipMinOverlap: require_dist.number().min(0).max(1).default(0),
12720
13947
  /** IoU required to match a (predicted) track to a detection. */
12721
13948
  iouThreshold: require_dist.number().min(0).max(1).default(.3),
12722
13949
  /** Wall-clock coasting budget (ms) before a missed track is dropped. Time-
@@ -12853,6 +14080,7 @@ function resolveTrackingSettings(raw) {
12853
14080
  const maxMissedMs = raw.maxMissedMs !== void 0 ? s.maxMissedMs.catch(TRACKING_DEFAULTS.maxMissedMs).parse(raw.maxMissedMs) : raw.maxMissedFrames !== void 0 ? Math.round(maxMissedFrames * 133) : TRACKING_DEFAULTS.maxMissedMs;
12854
14081
  const occlusionMaxMissedMs = raw.occlusionMaxMissedMs !== void 0 ? s.occlusionMaxMissedMs.catch(TRACKING_DEFAULTS.occlusionMaxMissedMs).parse(raw.occlusionMaxMissedMs) : raw.occlusionMaxMissedFrames !== void 0 ? Math.round(occlusionMaxMissedFrames * 133) : TRACKING_DEFAULTS.occlusionMaxMissedMs;
12855
14082
  return {
14083
+ zoneMembershipMinOverlap: s.zoneMembershipMinOverlap.catch(TRACKING_DEFAULTS.zoneMembershipMinOverlap).parse(raw.zoneMembershipMinOverlap),
12856
14084
  iouThreshold: s.iouThreshold.catch(TRACKING_DEFAULTS.iouThreshold).parse(raw.iouThreshold),
12857
14085
  maxMissedMs,
12858
14086
  minTrackAgeMs: s.minTrackAgeMs.catch(TRACKING_DEFAULTS.minTrackAgeMs).parse(raw.minTrackAgeMs),
@@ -12907,7 +14135,11 @@ function resolveTrackingSettings(raw) {
12907
14135
  * low-res detection frame (a static "person" phantom, a parked-truck ghost, a
12908
14136
  * misclassified static object).
12909
14137
  *
12910
- * Ships DORMANT: `enabled` defaults to `false`, so behaviour is byte-identical
14138
+ * ON by default (`enabled` defaults to TRUE). An earlier version of this line
14139
+ * said "Ships DORMANT: `enabled` defaults to `false`" — that was stale, and on
14140
+ * 2026-07-30 it nearly produced the conclusion that the gate was not running at
14141
+ * all. It is: it suppressed several phantom births on device 615 that same day.
14142
+ * Read the schema, not this paragraph. Historically the intent was byte-identical
12911
14143
  * to today until an operator opts in per camera. The gate is fail-OPEN — any
12912
14144
  * missing frame handle, unavailable inference cap, crop-fetch miss, re-detection
12913
14145
  * error, or timeout ALLOWS the birth (a real track is never suppressed because
@@ -13003,10 +14235,11 @@ function isConfirmationCompatible(trackClassName, detectionMacroClass) {
13003
14235
  if (track === "other" || det === "other") return true;
13004
14236
  return track === det;
13005
14237
  }
13006
- var failOpen = (trackId, reason) => ({
13007
- trackId,
14238
+ var failOpen = (candidate, reason) => ({
14239
+ trackId: candidate.trackId,
13008
14240
  confirmed: true,
13009
- reason
14241
+ reason,
14242
+ className: candidate.className
13010
14243
  });
13011
14244
  function withTimeout(promise, timeoutMs) {
13012
14245
  return new Promise((resolve, reject) => {
@@ -13022,23 +14255,34 @@ function withTimeout(promise, timeoutMs) {
13022
14255
  }
13023
14256
  async function runConfirmation(candidate, config, deps) {
13024
14257
  const crop = await deps.fetchCrop(candidate);
13025
- if (!crop) return failOpen(candidate.trackId, "no-crop");
14258
+ if (!crop) return failOpen(candidate, "no-crop");
13026
14259
  const detections = await deps.redetect(crop);
13027
- if (detections === null) return failOpen(candidate.trackId, "redetect-error");
13028
- const confirmed = detections.some((d) => d.score >= config.minConfidence && isConfirmationCompatible(candidate.className, d.macroClass));
14260
+ if (detections === null) return failOpen(candidate, "redetect-error");
14261
+ let best;
14262
+ let bestIncompatible;
14263
+ for (const d of detections) if (isConfirmationCompatible(candidate.className, d.macroClass)) {
14264
+ if (!best || d.score > best.score) best = d;
14265
+ } else if (!bestIncompatible || d.score > bestIncompatible.score) bestIncompatible = d;
14266
+ const confirmed = best !== void 0 && best.score >= config.minConfidence;
13029
14267
  return {
13030
14268
  trackId: candidate.trackId,
13031
14269
  confirmed,
13032
- reason: confirmed ? "confirmed" : "suppressed"
14270
+ reason: confirmed ? "confirmed" : "suppressed",
14271
+ className: candidate.className,
14272
+ ...best ? { bestScore: best.score } : {},
14273
+ ...bestIncompatible ? {
14274
+ bestIncompatibleClass: bestIncompatible.macroClass,
14275
+ bestIncompatibleScore: bestIncompatible.score
14276
+ } : {}
13033
14277
  };
13034
14278
  }
13035
14279
  async function confirmOne(candidate, config, deps) {
13036
14280
  const cropPx = Math.max(candidate.bbox.w, candidate.bbox.h);
13037
- if (config.minCropPx > 0 && cropPx < config.minCropPx) return failOpen(candidate.trackId, "below-min-crop");
14281
+ if (config.minCropPx > 0 && cropPx < config.minCropPx) return failOpen(candidate, "below-min-crop");
13038
14282
  try {
13039
14283
  return await withTimeout(runConfirmation(candidate, config, deps), config.timeoutMs);
13040
14284
  } catch {
13041
- return failOpen(candidate.trackId, "timeout");
14285
+ return failOpen(candidate, "timeout");
13042
14286
  }
13043
14287
  }
13044
14288
  /**
@@ -13221,8 +14465,23 @@ function resolveMediaSettings(raw) {
13221
14465
  * §3.3 draft said 45s).
13222
14466
  */
13223
14467
  var PackageDropSettingsSchema = require_dist.object({
13224
- /** Master switch — off by default; opt-in per camera (porch/door cams). */
13225
- packageDropEnabled: require_dist.boolean().default(false),
14468
+ /**
14469
+ * Explicit per-camera OFF. **On by default: the ZONE RULE is what enables
14470
+ * package detection**, not a second switch.
14471
+ *
14472
+ * As an opt-in default-false this was a parallel source of truth. Device 615
14473
+ * on 2026-07-30 had the zone (`Uscio`), an enabled `package`-stage zone rule
14474
+ * (`Pacchetti`, classFilter `['package']`), and a notification rule on
14475
+ * `delivery: 'package-event'` — three layers of operator intent, all defeated
14476
+ * silently by a boolean none of them mentions.
14477
+ *
14478
+ * Defaulting to true costs nothing on cameras nobody configured:
14479
+ * `PackageDropDetector.onAppeared` still returns early when the device has no
14480
+ * enabled `package`-stage rule, so the work is a class check plus one cached
14481
+ * lookup. Set this false to force the feature off on a camera that HAS a zone
14482
+ * rule.
14483
+ */
14484
+ packageDropEnabled: require_dist.boolean().default(true),
13226
14485
  /**
13227
14486
  * Minimum OBSERVED dwell (seconds, since first-seen) before a newly
13228
14487
  * promoted stationary package counts as a delivery. Kills a bag briefly
@@ -13586,37 +14845,7 @@ var AnalyticsQueryFacade = class {
13586
14845
  }));
13587
14846
  }
13588
14847
  };
13589
- //#endregion
13590
- //#region src/pipeline-analytics/track-retention-sweep.ts
13591
- /**
13592
- * Periodic track retention sweep (design §6) — the piece that makes retention
13593
- * actually BOUNDED. Without it, `pruneTracksBefore` only runs when something
13594
- * calls it; this sweep ages persisted tracks out on the provider's existing
13595
- * retention interval.
13596
- *
13597
- * `retentionMs` is a PER-DEVICE setting (`trackRetentionDays`, default 7 days;
13598
- * `0` = keep forever → the device is skipped). Enrolled gallery is exempt by the
13599
- * cascade store layer (design §4), not here.
13600
- *
13601
- * Kept as a pure, dependency-injected function so the loop is unit-testable
13602
- * without booting the addon.
13603
- */
13604
- var DAY_MS = 1440 * 60 * 1e3;
13605
- /** Per-device track retention setting. Default 7 days; 0 = keep forever. */
13606
- var TrackRetentionSettingsSchema = require_dist.object({ trackRetentionDays: require_dist.number().min(0).default(7) });
13607
- var TRACK_RETENTION_DEFAULT_DAYS = TrackRetentionSettingsSchema.parse({}).trackRetentionDays;
13608
- /** Resolve `trackRetentionDays` from a raw per-device store blob — an invalid or
13609
- * missing value falls back to the default (parse never throws). */
13610
- function resolveTrackRetentionDays(raw) {
13611
- const parsed = TrackRetentionSettingsSchema.shape.trackRetentionDays.safeParse(raw["trackRetentionDays"]);
13612
- return parsed.success ? parsed.data : TRACK_RETENTION_DEFAULT_DAYS;
13613
- }
13614
- /** Cutoff timestamp for a retention window. `null` when retention is disabled
13615
- * (`retentionDays <= 0` → keep forever, the sweep skips the device). */
13616
- function trackRetentionCutoff(nowMs, retentionDays) {
13617
- if (retentionDays <= 0) return null;
13618
- return nowMs - retentionDays * DAY_MS;
13619
- }
14848
+ var TRACK_RETENTION_DEFAULT_DAYS = require_dist.object({ trackRetentionDays: require_dist.number().min(0).default(7) }).parse({}).trackRetentionDays;
13620
14849
  /**
13621
14850
  * Sweep every device with persisted tracks: skip retention-disabled devices,
13622
14851
  * prune the rest at `now − retentionMs`. Per-device ISOLATED — one bad device
@@ -13627,7 +14856,7 @@ async function sweepTrackRetention(deps) {
13627
14856
  const nowMs = deps.now();
13628
14857
  let totalTracks = 0;
13629
14858
  for (const deviceId of devices) try {
13630
- const cutoffMs = trackRetentionCutoff(nowMs, await deps.resolveRetentionDays(deviceId));
14859
+ const cutoffMs = await deps.resolveCutoffMs(deviceId, nowMs);
13631
14860
  if (cutoffMs === null) continue;
13632
14861
  const counts = await deps.pruneTracksBefore(deviceId, cutoffMs);
13633
14862
  totalTracks += counts.tracks;
@@ -14192,6 +15421,19 @@ function retagDetectionSections(sections) {
14192
15421
  } : s);
14193
15422
  }
14194
15423
  /**
15424
+ * Re-home the analytics `retention` section onto the recorder's `recording`
15425
+ * top-tab (operator ask, 2026-07-29): footage and analytics retention are ONE
15426
+ * unified policy since the follow-recordings default, so their controls
15427
+ * belong on ONE tab. Same pure-retag mechanism as the detection sections —
15428
+ * `DeviceDetail` folds matching-tab sections together, no admin-ui change.
15429
+ */
15430
+ function retagRetentionSection(sections) {
15431
+ return sections.map((s) => s.id === "retention" ? {
15432
+ ...s,
15433
+ tab: "recording"
15434
+ } : s);
15435
+ }
15436
+ /**
14195
15437
  * Fields that live ONLY on the global settings page and must never surface in a
14196
15438
  * per-device contribution. The face-recognition `enabled` switch is the GLOBAL
14197
15439
  * master kill for the whole subsystem — per-camera face production is governed
@@ -14370,9 +15612,23 @@ function buildGlobalSettingsSchema() {
14370
15612
  {
14371
15613
  id: "retention",
14372
15614
  title: "Retention",
14373
- description: "How long analytics history is kept in the SQL store. Media files follow the minimum of these.",
15615
+ description: "How long analytics history (tracks, events, media) is kept. Default: as long as the camera's recordings — one policy for footage and events. The day windows below apply in Custom mode, and as the bound when the camera has no footage.",
14374
15616
  columns: 3,
14375
15617
  fields: [
15618
+ {
15619
+ type: "select",
15620
+ key: "retentionMode",
15621
+ label: "Mode",
15622
+ description: "Follow recordings: tracks/events live exactly as long as retained footage (age retention AND disk eviction; never prunes the last 7 days). Custom: the day windows below.",
15623
+ default: "follow-recordings",
15624
+ options: [{
15625
+ value: "follow-recordings",
15626
+ label: "Follow recordings (default)"
15627
+ }, {
15628
+ value: "custom",
15629
+ label: "Custom windows"
15630
+ }]
15631
+ },
14376
15632
  {
14377
15633
  type: "number",
14378
15634
  key: "trackRetentionDays",
@@ -14907,6 +16163,86 @@ async function encodeKeyFrameVariants(native) {
14907
16163
  };
14908
16164
  }
14909
16165
  //#endregion
16166
+ //#region src/pipeline-analytics/retention-policy.ts
16167
+ /**
16168
+ * Unified analytics retention policy (storage entity-routing spec, Phase 1).
16169
+ *
16170
+ * Two modes, per device:
16171
+ *
16172
+ * - `follow-recordings` (the DEFAULT): tracks and events live exactly as long
16173
+ * as the camera's retained footage — ONE cutoff, the instant of the oldest
16174
+ * segment still on disk. That follows the recorder's age retention AND its
16175
+ * disk-pressure eviction automatically, with no second policy to keep in
16176
+ * sync. A camera with no footage at all (recording off / brand new) falls
16177
+ * back to the custom day windows below, so analytics stay bounded either
16178
+ * way.
16179
+ *
16180
+ * - `custom`: the per-kind day windows (tracks / motion / object / audio),
16181
+ * exactly the pre-unification behaviour — except the knobs are now actually
16182
+ * READ (they were exposed in settings and silently ignored by the sweep,
16183
+ * which used hardcoded 14/30/7).
16184
+ *
16185
+ * Follow-mode floor: events younger than {@link FOLLOW_FLOOR_DAYS} are never
16186
+ * pruned, even when footage is younger (a camera that STARTED recording
16187
+ * yesterday must not nuke its whole event history back to the first segment).
16188
+ */
16189
+ var DAY_MS = 1440 * 60 * 1e3;
16190
+ /** Per-device analytics retention settings as stored in the device blob.
16191
+ * `retentionMode` absent = follow-recordings (the new default). The day
16192
+ * windows keep their historical defaults and double as the no-footage
16193
+ * fallback in follow mode. */
16194
+ var RetentionSettingsSchema = require_dist.object({
16195
+ retentionMode: require_dist._enum(["follow-recordings", "custom"]).default("follow-recordings"),
16196
+ trackRetentionDays: require_dist.number().min(0).default(7),
16197
+ retentionMotionDays: require_dist.number().min(1).default(14),
16198
+ retentionObjectDays: require_dist.number().min(1).default(30),
16199
+ retentionAudioDays: require_dist.number().min(1).default(7)
16200
+ });
16201
+ /** Resolve the settings from a raw device-store blob — invalid or missing
16202
+ * fields fall back to defaults, never throw. */
16203
+ function resolveRetentionSettings(raw) {
16204
+ const parsed = RetentionSettingsSchema.safeParse(raw);
16205
+ if (parsed.success) return parsed.data;
16206
+ const shape = RetentionSettingsSchema.shape;
16207
+ const field = (k) => shape[k].safeParse(raw[k]).success ? shape[k].parse(raw[k]) : RetentionSettingsSchema.parse({})[k];
16208
+ return {
16209
+ retentionMode: field("retentionMode"),
16210
+ trackRetentionDays: field("trackRetentionDays"),
16211
+ retentionMotionDays: field("retentionMotionDays"),
16212
+ retentionObjectDays: field("retentionObjectDays"),
16213
+ retentionAudioDays: field("retentionAudioDays")
16214
+ };
16215
+ }
16216
+ /**
16217
+ * Follow-mode cutoff: the oldest retained footage instant, floored so
16218
+ * anything younger than {@link FOLLOW_FLOOR_DAYS} survives. `null` footage
16219
+ * (none on disk) → `null`, the caller falls back to custom windows.
16220
+ */
16221
+ function followCutoffMs(nowMs, earliestFootageMs) {
16222
+ if (earliestFootageMs === null) return null;
16223
+ return Math.min(earliestFootageMs, nowMs - 7 * DAY_MS);
16224
+ }
16225
+ /** Compute the effective cutoffs for one device at `nowMs`. */
16226
+ function resolveRetentionCutoffs(settings, nowMs, earliestFootageMs) {
16227
+ if (settings.retentionMode === "follow-recordings") {
16228
+ const cutoff = followCutoffMs(nowMs, earliestFootageMs);
16229
+ if (cutoff !== null) return {
16230
+ trackCutoffMs: cutoff,
16231
+ motionCutoffMs: cutoff,
16232
+ objectCutoffMs: cutoff,
16233
+ audioCutoffMs: cutoff,
16234
+ effectiveMode: "follow-recordings"
16235
+ };
16236
+ }
16237
+ return {
16238
+ trackCutoffMs: settings.trackRetentionDays <= 0 ? null : nowMs - settings.trackRetentionDays * DAY_MS,
16239
+ motionCutoffMs: nowMs - settings.retentionMotionDays * DAY_MS,
16240
+ objectCutoffMs: nowMs - settings.retentionObjectDays * DAY_MS,
16241
+ audioCutoffMs: nowMs - settings.retentionAudioDays * DAY_MS,
16242
+ effectiveMode: "custom"
16243
+ };
16244
+ }
16245
+ //#endregion
14910
16246
  //#region src/pipeline-analytics/store/identity-store.ts
14911
16247
  /**
14912
16248
  * IdentityStore — per-person identity registry for face recognition.
@@ -18293,6 +19629,17 @@ var KEY_EVENT_DEFAULT_LIMIT = 50;
18293
19629
  * Absent / empty / non-string all fall back to the hub default — the exact
18294
19630
  * narrowing the old raw read applied inline. */
18295
19631
  var PostProcessingNodeIdSchema = require_dist.string().min(1);
19632
+ /**
19633
+ * Footage-attachment window + geometry, used when the rule states none. The
19634
+ * window is CENTRED on the event, so the recipient sees the approach and what
19635
+ * followed rather than one side of it.
19636
+ */
19637
+ var NC_FOOTAGE_PRE_ROLL_SEC = 3;
19638
+ var NC_FOOTAGE_POST_ROLL_SEC = 5;
19639
+ var NC_FOOTAGE_MAX_WIDTH = 480;
19640
+ var NC_FOOTAGE_FPS = 5;
19641
+ /** Per-install HMAC secret behind the signed artifact links (minted once). */
19642
+ var NcArtifactSecretSchema = require_dist.string();
18296
19643
  var EmbeddingEnabledSchema = require_dist.boolean();
18297
19644
  var SILENCE_FLOOR_DBFS = -55;
18298
19645
  var RETENTION_SWEEP_INTERVAL_MS = 5 * 6e4;
@@ -18353,7 +19700,21 @@ function decodeEmbeddingBase64(base64) {
18353
19700
  const view = new Float32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));
18354
19701
  return Array.from(view);
18355
19702
  }
19703
+ /** A track shorter than this is a candidate phantom, not a subject that came
19704
+ * and went. Brackets the observed plant tracks (3.8-19.0 s). */
19705
+ var SHORT_TRACK_MAX_MS = 25e3;
19706
+ /** Total displacement under this is "did not move at all" (observed: 0-2.5 px). */
19707
+ var MOTIONLESS_MAX_PX = 8;
19708
+ /** Grid the spawn point is quantised to, so respawns whose boxes never repeat
19709
+ * to the pixel still land in one cell. */
19710
+ var PHANTOM_CELL_PX = 32;
19711
+ /** How long a cell remembers its closes. */
19712
+ var PHANTOM_CELL_WINDOW_MS = 360 * 6e4;
18356
19713
  var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
19714
+ /** Recent SHORT+MOTIONLESS track closes per `<device>:<class>:<cell>` —
19715
+ * see {@link noteShortMotionlessTrack}. Measurement only; each entry is
19716
+ * filtered against the 6-hour window on write, so it stays bounded. */
19717
+ shortMotionlessCells = /* @__PURE__ */ new Map();
18357
19718
  processors = /* @__PURE__ */ new Map();
18358
19719
  trackStore = null;
18359
19720
  /** Parked-object registry: promotes a track that stopped moving into a
@@ -18364,6 +19725,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
18364
19725
  eventStore = null;
18365
19726
  /** Events-domain ops-log (footprint/prune/manual-delete audit). Null until onInitialize. */
18366
19727
  eventsOpsLog = null;
19728
+ /** Event-media relocation engine (entity-routing Phase 4). */
19729
+ mediaRelocate = null;
18367
19730
  /** Per-camera history of LINKED-device sensor state changes (Part B). */
18368
19731
  sensorEventStore = null;
18369
19732
  /** Ingest-side reverse index (sensor device → linked camera ids), TTL-cached
@@ -18422,6 +19785,88 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
18422
19785
  retentionSweepTimer = null;
18423
19786
  /** Handle for the event-media data-plane listener (dispose on shutdown). */
18424
19787
  eventMediaDataPlane = null;
19788
+ /** The NC artifact plane (signed public links for notification media) and its
19789
+ * data-plane handle. Null until served / when the facility is absent. */
19790
+ ncArtifactPlane = null;
19791
+ ncArtifactDataPlane = null;
19792
+ /** The operator's marked notification endpoint, or null (AUTO / unavailable). */
19793
+ async markedNotificationEndpoint() {
19794
+ try {
19795
+ return (await this.ctx.api.localNetwork.getNotificationEndpoint.query()).baseUrl ?? void 0;
19796
+ } catch {
19797
+ return;
19798
+ }
19799
+ }
19800
+ /**
19801
+ * Serve the NC artifact plane: a PUBLIC route whose authority is the HMAC on
19802
+ * each link (see `notification-center/artifact-url.ts`). It exists because
19803
+ * attachments used to carry BYTES only, and the degrade engine drops a
19804
+ * bytes-only attachment for a url-mode backend — WhatsApp and gotify were
19805
+ * receiving no media at all.
19806
+ *
19807
+ * Best-effort at every step: no facility, no secret or no reachable base URL
19808
+ * ⇒ no plane, and the dispatcher keeps shipping bytes exactly as before.
19809
+ */
19810
+ async serveNcArtifactPlane() {
19811
+ try {
19812
+ const secretState = this.state("ncArtifactSecret", NcArtifactSecretSchema, "");
19813
+ let secret = await secretState.get();
19814
+ if (secret === "") {
19815
+ secret = (0, node_crypto.randomUUID)().replace(/-/g, "");
19816
+ await secretState.set(secret);
19817
+ }
19818
+ const store = new NcArtifactStore({
19819
+ dir: node_path.default.join(this.ctx.dataDir, "nc-artifacts"),
19820
+ logger: this.ctx.logger.child("nc-artifacts")
19821
+ });
19822
+ await store.start();
19823
+ const plane = new NcArtifactPlane({
19824
+ store,
19825
+ secret,
19826
+ logger: this.ctx.logger.child("nc-artifacts"),
19827
+ routePrefix: `/addon/${this.ctx.id}/nc-artifact`,
19828
+ listEndpoints: async () => collectArtifactEndpoints({
19829
+ markedBaseUrl: await this.markedNotificationEndpoint(),
19830
+ configuredPublicUrl: process.env["CAMSTACK_HUB_PUBLIC_URL"],
19831
+ getConnected: async () => {
19832
+ const status = await this.ctx.api.networkAccess.getStatus.query();
19833
+ this.ctx.logger.debug("artifact base-url: connected ingress", { meta: {
19834
+ connected: status.connected,
19835
+ url: status.endpoint?.url ?? null,
19836
+ protocol: status.endpoint?.protocol ?? null
19837
+ } });
19838
+ return status.connected && status.endpoint !== null ? {
19839
+ url: status.endpoint.url,
19840
+ protocol: status.endpoint.protocol
19841
+ } : null;
19842
+ },
19843
+ listExternal: async () => {
19844
+ return (await this.ctx.api.networkAccess.listEndpoints.query()).map((e) => ({
19845
+ url: e.url,
19846
+ protocol: e.protocol
19847
+ }));
19848
+ },
19849
+ listLan: async (port) => {
19850
+ return (await this.ctx.api.localNetwork.getConnectionEndpoints.query({ port })).endpoints.map((e) => ({
19851
+ baseUrl: e.baseUrl,
19852
+ kind: e.kind,
19853
+ priority: e.priority
19854
+ }));
19855
+ },
19856
+ logger: this.ctx.logger
19857
+ })
19858
+ });
19859
+ this.ncArtifactDataPlane = await this.ctx.dataPlane?.serve({
19860
+ prefix: "nc-artifact",
19861
+ access: "public",
19862
+ handler: plane.handler
19863
+ }) ?? null;
19864
+ this.ncArtifactPlane = this.ncArtifactDataPlane !== null ? plane : null;
19865
+ this.ctx.logger.info("nc-artifact data-plane served", { meta: { served: this.ncArtifactPlane !== null } });
19866
+ } catch (err) {
19867
+ this.ctx.logger.warn("nc-artifact data-plane failed to serve", { meta: { error: require_dist.errMsg(err) } });
19868
+ }
19869
+ }
18425
19870
  /** Public base URL for event thumbnails: `/addon/<addonId>/event-media`.
18426
19871
  * Set once the data-plane is registered; null until then (e.g. no
18427
19872
  * dataPlane facility in the current environment). */
@@ -18579,7 +20024,10 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
18579
20024
  });
18580
20025
  },
18581
20026
  emitTrackLifecycle: (payload, timestampMs) => this.emitTrackLifecycle(payload, timestampMs),
18582
- onTrackClosed: (track, ownedMedia, info) => this.notificationCenter?.onTrackClosed(track, ownedMedia, info),
20027
+ onTrackClosed: (track, ownedMedia, info) => {
20028
+ this.noteShortMotionlessTrack(track);
20029
+ return this.notificationCenter?.onTrackClosed(track, ownedMedia, info);
20030
+ },
18583
20031
  deriveThumbnailFromKeyFrame: async (input) => {
18584
20032
  const derived = await deriveKeyFrameThumbnailJpeg({
18585
20033
  ...input,
@@ -18725,12 +20173,19 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
18725
20173
  let storage = this.ctx.kernel.storage;
18726
20174
  const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
18727
20175
  if (mediaRoot) {
18728
- const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-DVltm5ai.js"));
18729
- storage = new FilesystemStorageProvider(mediaRoot);
20176
+ const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-Dwh-F2Zf.js"));
20177
+ storage = new FilesystemStorageProvider(mediaRoot, { eventMedia: mediaRoot });
18730
20178
  logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
18731
20179
  }
18732
20180
  if (!storage) throw new Error("pipeline-analytics requires ctx.kernel.storage");
18733
- return storage;
20181
+ return createLocationAwareMediaStorage({
20182
+ base: storage,
20183
+ defaultLocation: "eventMedia",
20184
+ resolveRoot: (locationId) => this.ctx.api.storage.resolve.query({
20185
+ location: locationId,
20186
+ relativePath: ""
20187
+ })
20188
+ });
18734
20189
  }
18735
20190
  /** Constructs every SQLite-backed store plus the stationary/package-drop/
18736
20191
  * sensor plumbing, in the exact pre-S7 order. Returns the non-null bundle
@@ -18778,6 +20233,27 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
18778
20233
  logger: logger.child("MediaStore")
18779
20234
  });
18780
20235
  this.mediaStore = mediaStore;
20236
+ this.mediaRelocate = new MediaRelocateEngine({
20237
+ store: api.settingsStore,
20238
+ storage,
20239
+ logger: logger.child("MediaRelocate"),
20240
+ now: () => Date.now(),
20241
+ newId: () => (0, node_crypto.randomUUID)(),
20242
+ resolveTargetRoot: (locationId) => api.storage.resolve.query({
20243
+ location: locationId,
20244
+ relativePath: ""
20245
+ }),
20246
+ onFinished: (job) => {
20247
+ this.eventsOpsLog?.append({
20248
+ op: "relocate",
20249
+ reason: "operator",
20250
+ deviceId: job.deviceId,
20251
+ itemsAffected: job.filesMoved,
20252
+ bytesReclaimed: 0,
20253
+ detail: `${job.state}: media → ${job.toLocationId} (${job.bytesMoved} bytes${job.error ? `; ${job.error}` : ""})`
20254
+ });
20255
+ }
20256
+ });
18781
20257
  const eventStore = new EventStore({
18782
20258
  store: api.settingsStore,
18783
20259
  logger: logger.child("EventStore"),
@@ -19023,16 +20499,66 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
19023
20499
  this.notificationCenter = new NotificationCenter({
19024
20500
  store: api.settingsStore,
19025
20501
  logger: logger.child("NotificationCenter"),
20502
+ listUsers: async () => {
20503
+ return (await api.userManagement.listUsers.query()).map((u) => ({
20504
+ id: u.id,
20505
+ isAdmin: u.isAdmin,
20506
+ allowedDevices: u.allowedDevices
20507
+ }));
20508
+ },
20509
+ getDeviceRoute: async (deviceId) => {
20510
+ const device = await api.deviceManager.getDevice.query({ deviceId });
20511
+ return device === null ? null : {
20512
+ stableId: device.stableId,
20513
+ addonId: device.addonId
20514
+ };
20515
+ },
19026
20516
  dispatcher: {
19027
- listTargets: async () => {
19028
- return (await api.notificationOutput.listTargets.query({})).map((t) => ({
19029
- id: t.id,
19030
- addonId: t.addonId,
19031
- name: t.name,
19032
- kind: t.kind,
19033
- enabled: t.enabled
20517
+ getZoneNames: async (deviceId) => {
20518
+ return (await api.zones.listZones.query({ deviceId })).map((z) => ({
20519
+ id: z.id,
20520
+ name: z.name
19034
20521
  }));
19035
20522
  },
20523
+ getZonePolygons: async (deviceId, zoneIds) => {
20524
+ const zones = await api.zones.listZones.query({ deviceId });
20525
+ const wanted = new Set(zoneIds);
20526
+ return zones.filter((z) => wanted.has(z.id) && Array.isArray(z.polygon)).map((z) => (z.polygon ?? []).map((pt) => ({
20527
+ x: pt.x,
20528
+ y: pt.y
20529
+ })));
20530
+ },
20531
+ renderFootage: async (req) => {
20532
+ const res = await api.streamBroker.renderPreBufferClip.mutate({
20533
+ deviceId: req.deviceId,
20534
+ aroundMs: req.aroundMs,
20535
+ format: req.format,
20536
+ preRollSec: req.preRollSec ?? NC_FOOTAGE_PRE_ROLL_SEC,
20537
+ postRollSec: req.postRollSec ?? NC_FOOTAGE_POST_ROLL_SEC,
20538
+ maxWidth: NC_FOOTAGE_MAX_WIDTH,
20539
+ fps: NC_FOOTAGE_FPS,
20540
+ ...req.profile === "high" || req.profile === "mid" || req.profile === "low" ? { profile: req.profile } : {}
20541
+ });
20542
+ const buf = Buffer.from(res.base64, "base64");
20543
+ if (buf.byteLength === 0) return null;
20544
+ const bytes = new Uint8Array(buf.byteLength);
20545
+ bytes.set(buf);
20546
+ return bytes;
20547
+ },
20548
+ publishArtifact: async (bytes, mime) => await this.ncArtifactPlane?.publish(bytes, mime) ?? null,
20549
+ listTargets: async () => {
20550
+ return (await api.notificationOutput.listTargets.query({})).map((t) => {
20551
+ const owner = t.config["ownerUserId"];
20552
+ return {
20553
+ id: t.id,
20554
+ addonId: t.addonId,
20555
+ name: t.name,
20556
+ kind: t.kind,
20557
+ enabled: t.enabled,
20558
+ ...typeof owner === "string" && owner.length > 0 ? { ownerUserId: owner } : {}
20559
+ };
20560
+ });
20561
+ },
19036
20562
  send: async (input) => {
19037
20563
  const { attachments, ...notification } = input.notification;
19038
20564
  return api.notificationOutput.send.mutate({
@@ -19085,6 +20611,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
19085
20611
  handler
19086
20612
  }) ?? null;
19087
20613
  this.eventMediaBaseUrl = this.eventMediaDataPlane !== null ? `/addon/${this.ctx.id}/event-media` : null;
20614
+ await this.serveNcArtifactPlane();
19088
20615
  this.ctx.logger.info("event-media data-plane served", { meta: { baseUrl: this.eventMediaBaseUrl ?? "(no dataPlane facility)" } });
19089
20616
  } catch (err) {
19090
20617
  this.ctx.logger.warn("event-media data-plane failed to serve", { meta: { error: require_dist.errMsg(err) } });
@@ -19370,6 +20897,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
19370
20897
  const liveRules = proxy?.state.zoneRules.value?.detection ?? [];
19371
20898
  processor.setZones(liveZones);
19372
20899
  processor.setDetectionRules(liveRules);
20900
+ processor.setZoneMembershipMinOverlap(trk.zoneMembershipMinOverlap);
19373
20901
  const result = processor.process({
19374
20902
  timestamp: frame.timestamp,
19375
20903
  frame
@@ -19600,7 +21128,29 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
19600
21128
  } });
19601
21129
  }
19602
21130
  await Promise.all([...result.objectEvents, ...result.appearanceEvents].map((e) => this.eventStore.insertObject(e)));
19603
- if (this.notificationCenter !== null) for (const e of result.objectEvents) this.notificationCenter.onObjectEventPersisted(e);
21131
+ if (this.notificationCenter !== null) {
21132
+ const overlaps = processor.getLastZoneOverlaps();
21133
+ for (const e of result.objectEvents) {
21134
+ if (e.zones && e.zones.length > 0) {
21135
+ const m = e.trackId ? overlaps.get(e.trackId) : void 0;
21136
+ this.ctx.logger.info("zone membership stamped on event", {
21137
+ tags: { deviceId },
21138
+ meta: {
21139
+ eventId: e.id,
21140
+ trackId: e.trackId,
21141
+ className: e.className,
21142
+ minOverlap: trk.zoneMembershipMinOverlap,
21143
+ zones: (m ?? []).map((z) => ({
21144
+ id: z.zoneId,
21145
+ name: z.zoneName,
21146
+ overlapPct: Math.round(z.overlap * 1e3) / 10
21147
+ }))
21148
+ }
21149
+ });
21150
+ }
21151
+ this.notificationCenter.onObjectEventPersisted(e);
21152
+ }
21153
+ }
19604
21154
  const objectEmbeddingBests = [];
19605
21155
  if (this.objectEmbeddingStore) for (const t of result.tracked) {
19606
21156
  if (!isClipObjectEmbedding(t)) continue;
@@ -19952,19 +21502,28 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
19952
21502
  }),
19953
21503
  redetect: (cropJpeg) => this.redetectCropForConfirmation(nodeId, deviceId, cropJpeg),
19954
21504
  onDecision: (decision) => {
21505
+ const meta = {
21506
+ trackId: decision.trackId,
21507
+ reason: decision.reason,
21508
+ className: decision.className,
21509
+ ...decision.bestScore !== void 0 ? { bestScore: decision.bestScore } : {},
21510
+ ...decision.bestIncompatibleClass !== void 0 ? {
21511
+ bestIncompatibleClass: decision.bestIncompatibleClass,
21512
+ bestIncompatibleScore: decision.bestIncompatibleScore
21513
+ } : {},
21514
+ minConfidence: config.minConfidence
21515
+ };
19955
21516
  if (!decision.confirmed) this.ctx.logger.info("confirmation gate: birth suppressed (false positive)", {
19956
21517
  tags: { deviceId },
19957
- meta: {
19958
- trackId: decision.trackId,
19959
- reason: decision.reason
19960
- }
21518
+ meta
19961
21519
  });
19962
21520
  else if (decision.reason !== "confirmed") this.ctx.logger.debug("confirmation gate: birth allowed (fail-open)", {
19963
21521
  tags: { deviceId },
19964
- meta: {
19965
- trackId: decision.trackId,
19966
- reason: decision.reason
19967
- }
21522
+ meta
21523
+ });
21524
+ else this.ctx.logger.info("confirmation gate: birth confirmed", {
21525
+ tags: { deviceId },
21526
+ meta
19968
21527
  });
19969
21528
  }
19970
21529
  });
@@ -19981,6 +21540,48 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
19981
21540
  async resolveDeviceStationarySettings(deviceId) {
19982
21541
  return this.stationarySettingsCache.get(deviceId, (id) => this.readDeviceSettings(id, resolveStationarySettings));
19983
21542
  }
21543
+ /**
21544
+ * Count SHORT + MOTIONLESS track closes per frame cell.
21545
+ *
21546
+ * The stationary registry cannot see this class of phantom. Promotion needs a
21547
+ * track that has EXISTED for `PROMOTION_WINDOW_MS` (30 s), and these die long
21548
+ * before: the dead ornamental grass on device 615 produced tracks of 3.8 s,
21549
+ * 4.1 s, 6.5 s and 19.0 s with 0-2.5 px of total displacement, roughly
21550
+ * fifteen of them in a day, all at the same spot. Each one is individually
21551
+ * innocent; the RECURRENCE is the signal, and nothing survives a track death
21552
+ * to notice it.
21553
+ *
21554
+ * Lowering the promotion window is not the fix — those 30 s exist so someone
21555
+ * standing still at a door is not declared scenery.
21556
+ *
21557
+ * This is the measurement half: it establishes how often a cell repeats
21558
+ * before any suppression is built, so "N closes in what window" comes from
21559
+ * data rather than intuition. It suppresses NOTHING.
21560
+ */
21561
+ noteShortMotionlessTrack(track) {
21562
+ const lifeMs = track.lastSeen - track.firstSeen;
21563
+ const moved = track.totalDistance ?? 0;
21564
+ if (lifeMs > SHORT_TRACK_MAX_MS || moved > MOTIONLESS_MAX_PX) return;
21565
+ const first = track.positions?.[0];
21566
+ if (!first) return;
21567
+ const cell = `${Math.round(first.x / PHANTOM_CELL_PX)},${Math.round(first.y / PHANTOM_CELL_PX)}`;
21568
+ const key = `${track.deviceId}:${track.className}:${cell}`;
21569
+ const now = Date.now();
21570
+ const seen = this.shortMotionlessCells.get(key)?.filter((t) => now - t < PHANTOM_CELL_WINDOW_MS) ?? [];
21571
+ seen.push(now);
21572
+ this.shortMotionlessCells.set(key, seen);
21573
+ this.ctx.logger.info("short motionless track closed", {
21574
+ tags: { deviceId: track.deviceId },
21575
+ meta: {
21576
+ trackId: track.trackId,
21577
+ className: track.className,
21578
+ lifeMs,
21579
+ movedPx: Math.round(moved * 10) / 10,
21580
+ cell,
21581
+ repeatsInWindow: seen.length
21582
+ }
21583
+ });
21584
+ }
19984
21585
  stationarySettingsFromCache(deviceId) {
19985
21586
  return this.stationarySettingsCache.peek(deviceId) ?? STATIONARY_DEFAULTS;
19986
21587
  }
@@ -19990,9 +21591,14 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
19990
21591
  /**
19991
21592
  * Resolve a device's ENABLED `package`-stage zone rules independent of the
19992
21593
  * live frame path (mirrors `resolveDeviceZones`). Warms the proxy and,
19993
- * when the cached slice is empty, forces one refresh. The `package` slice
19994
- * is written by the orchestrator's package-stage provider (a later slice);
19995
- * until then this returns `[]` and no package events fire.
21594
+ * when the cached slice is empty, forces one refresh.
21595
+ *
21596
+ * The `package` slice is written through the `zone-rules` capability
21597
+ * (`zoneRules.setRules({stage:'package'})`), which is live. An earlier
21598
+ * version of this comment claimed the provider did not exist yet and that
21599
+ * "no package events fire" — that was stale, and believing it produced a
21600
+ * confidently wrong diagnosis on 2026-07-30. An empty list here means the
21601
+ * operator has drawn no package zone rule, nothing more.
19996
21602
  */
19997
21603
  async resolveDevicePackageRules(deviceId) {
19998
21604
  const proxy = await this.ensureProxy(deviceId);
@@ -20729,42 +22335,112 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
20729
22335
  if (this.shuttingDown || !this.trackStore) return;
20730
22336
  await this.trackCloser.sweep();
20731
22337
  }
22338
+ /** Unified retention (Phase 1): earliest retained footage per device — the
22339
+ * follow-recordings cutoff source. Cached 10 min: availability answers in
22340
+ * ~70 ms but the sweep asks once per device per pass. `null` = no footage
22341
+ * (or recorder unreachable) → the policy falls back to the custom windows. */
22342
+ earliestFootageCache = /* @__PURE__ */ new Map();
22343
+ async earliestFootageMs(deviceId) {
22344
+ const cached = this.earliestFootageCache.get(deviceId);
22345
+ if (cached && Date.now() - cached.at < 10 * 6e4) return cached.value;
22346
+ let value = null;
22347
+ try {
22348
+ const res = await this.ctx.api.recording.getAvailability.query({
22349
+ deviceId,
22350
+ fromMs: 0,
22351
+ toMs: Date.now()
22352
+ });
22353
+ let min = Number.POSITIVE_INFINITY;
22354
+ for (const r of res.ranges) if (r.startMs < min) min = r.startMs;
22355
+ value = Number.isFinite(min) ? min : null;
22356
+ } catch {
22357
+ value = null;
22358
+ }
22359
+ this.earliestFootageCache.set(deviceId, {
22360
+ at: Date.now(),
22361
+ value
22362
+ });
22363
+ return value;
22364
+ }
22365
+ /** The per-device effective cutoffs (mode + footage → numbers). */
22366
+ async deviceRetentionCutoffs(deviceId, nowMs) {
22367
+ const settings = resolveRetentionSettings(await this.ctx?.settings?.readDeviceStore(deviceId) ?? {});
22368
+ return resolveRetentionCutoffs(settings, nowMs, settings.retentionMode === "follow-recordings" ? await this.earliestFootageMs(deviceId) : null);
22369
+ }
22370
+ /** The devices one retention pass covers: every camera the hub knows plus
22371
+ * any device that still has persisted tracks (covers deleted cameras whose
22372
+ * history must keep aging out). */
22373
+ async retentionDeviceIds() {
22374
+ const ids = /* @__PURE__ */ new Set();
22375
+ try {
22376
+ const all = await this.ctx.api.deviceManager.listAll.query({});
22377
+ for (const d of all) ids.add(d.id);
22378
+ } catch {}
22379
+ try {
22380
+ for (const id of await this.trackStore?.listDeviceIds() ?? []) ids.add(id);
22381
+ } catch {}
22382
+ return [...ids];
22383
+ }
20732
22384
  async sweepRetention() {
20733
22385
  if (this.shuttingDown || !this.eventStore || !this.mediaStore) return;
20734
22386
  const now = Date.now();
20735
22387
  const day = 1440 * 60 * 1e3;
20736
- const OBJECT_RETENTION_DAYS = 30;
20737
- const motionCutoffMs = now - 14 * day;
20738
- const objectCutoffMs = now - OBJECT_RETENTION_DAYS * day;
20739
- const audioCutoffMs = now - 7 * day;
20740
22388
  try {
20741
- const evicted = await this.eventStore.evictBefore({
20742
- motionCutoffMs,
20743
- objectCutoffMs,
20744
- audioCutoffMs
20745
- });
20746
- const evictedIds = [
20747
- ...evicted.motion,
20748
- ...evicted.object,
20749
- ...evicted.audio
20750
- ];
20751
- if (evictedIds.length > 0) {
20752
- await this.mediaStore.deleteForEvents(evictedIds);
20753
- this.ctx.logger.info("analytics event eviction (age sweep)", { meta: {
20754
- motion: evicted.motion.length,
20755
- object: evicted.object.length,
20756
- audio: evicted.audio.length,
20757
- motionCutoffMs,
20758
- objectCutoffMs,
20759
- audioCutoffMs
20760
- } });
22389
+ const deviceIds = await this.retentionDeviceIds();
22390
+ const evictedIds = [];
22391
+ let minObjectCutoffMs = Number.POSITIVE_INFINITY;
22392
+ for (const deviceId of deviceIds) {
22393
+ if (this.shuttingDown) return;
22394
+ try {
22395
+ const cutoffs = await this.deviceRetentionCutoffs(deviceId, now);
22396
+ if (cutoffs.objectCutoffMs !== null && cutoffs.objectCutoffMs < minObjectCutoffMs) minObjectCutoffMs = cutoffs.objectCutoffMs;
22397
+ if (cutoffs.motionCutoffMs === null && cutoffs.objectCutoffMs === null && cutoffs.audioCutoffMs === null) continue;
22398
+ const evicted = await this.eventStore.evictBefore({
22399
+ deviceId,
22400
+ motionCutoffMs: cutoffs.motionCutoffMs ?? 0,
22401
+ objectCutoffMs: cutoffs.objectCutoffMs ?? 0,
22402
+ audioCutoffMs: cutoffs.audioCutoffMs ?? 0
22403
+ });
22404
+ const ids = [
22405
+ ...evicted.motion,
22406
+ ...evicted.object,
22407
+ ...evicted.audio
22408
+ ];
22409
+ if (ids.length > 0) {
22410
+ evictedIds.push(...ids);
22411
+ this.eventsOpsLog?.append({
22412
+ op: "prune",
22413
+ reason: "retention",
22414
+ deviceId,
22415
+ itemsAffected: ids.length,
22416
+ bytesReclaimed: 0,
22417
+ detail: `age sweep (${cutoffs.effectiveMode}): ${evicted.motion.length} motion, ${evicted.object.length} object, ${evicted.audio.length} audio`
22418
+ });
22419
+ this.ctx.logger.info("analytics event eviction (age sweep)", {
22420
+ tags: { deviceId },
22421
+ meta: {
22422
+ motion: evicted.motion.length,
22423
+ object: evicted.object.length,
22424
+ audio: evicted.audio.length,
22425
+ mode: cutoffs.effectiveMode,
22426
+ objectCutoffMs: cutoffs.objectCutoffMs
22427
+ }
22428
+ });
22429
+ }
22430
+ } catch (err) {
22431
+ this.ctx.logger.debug("event retention sweep (device) failed", {
22432
+ tags: { deviceId },
22433
+ meta: { error: String(err) }
22434
+ });
22435
+ }
20761
22436
  }
20762
- await this.mediaStore.evictBefore(now - 31 * day);
20763
- if (this.sensorEventStore) try {
20764
- const sensorDeleted = await this.sensorEventStore.evictBefore(objectCutoffMs);
22437
+ if (evictedIds.length > 0) await this.mediaStore.deleteForEvents(evictedIds);
22438
+ if (Number.isFinite(minObjectCutoffMs)) await this.mediaStore.evictBefore(minObjectCutoffMs - 1 * day);
22439
+ if (this.sensorEventStore && Number.isFinite(minObjectCutoffMs)) try {
22440
+ const sensorDeleted = await this.sensorEventStore.evictBefore(minObjectCutoffMs);
20765
22441
  if (sensorDeleted > 0) this.ctx.logger.info("sensor-event retention prune", { meta: {
20766
22442
  deleted: sensorDeleted,
20767
- cutoffMs: objectCutoffMs
22443
+ cutoffMs: minObjectCutoffMs
20768
22444
  } });
20769
22445
  } catch (err) {
20770
22446
  this.ctx.logger.debug("sensor-event prune failed", { meta: { error: String(err) } });
@@ -20795,8 +22471,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
20795
22471
  } catch (err) {
20796
22472
  this.ctx.logger.debug("plate buffer prune failed", { meta: { error: String(err) } });
20797
22473
  }
20798
- if (this.objectEmbeddingStore) try {
20799
- const deletedEmbIds = await this.objectEmbeddingStore.pruneAll(objectCutoffMs);
22474
+ if (this.objectEmbeddingStore && Number.isFinite(minObjectCutoffMs)) try {
22475
+ const deletedEmbIds = await this.objectEmbeddingStore.pruneAll(minObjectCutoffMs);
20800
22476
  if (deletedEmbIds.length > 0) this.ctx.logger.info("object embedding retention prune", { meta: { deleted: deletedEmbIds.length } });
20801
22477
  } catch (err) {
20802
22478
  this.ctx.logger.debug("object embedding prune failed", { meta: { error: String(err) } });
@@ -21462,6 +23138,16 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
21462
23138
  return this.queryFacade.deleteDeviceEvents(input);
21463
23139
  }
21464
23140
  /** The events ops-log rows (newest-first), optionally scoped to one camera. */
23141
+ async relocateMedia(input) {
23142
+ if (!this.mediaRelocate) throw new Error("media relocation unavailable");
23143
+ return { jobId: this.mediaRelocate.start(input) };
23144
+ }
23145
+ async getMediaRelocateStatus() {
23146
+ return this.mediaRelocate?.list() ?? [];
23147
+ }
23148
+ async cancelMediaRelocate(input) {
23149
+ return { cancelled: this.mediaRelocate?.cancel(input.jobId) ?? false };
23150
+ }
21465
23151
  async listOpsLog(input) {
21466
23152
  return this.queryFacade.listOpsLog(input);
21467
23153
  }
@@ -21541,13 +23227,22 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
21541
23227
  try {
21542
23228
  const total = await sweepTrackRetention({
21543
23229
  listDeviceIds: () => trackStore.listDeviceIds(),
21544
- resolveRetentionDays: async (deviceId) => {
21545
- return resolveTrackRetentionDays(await this.ctx?.settings?.readDeviceStore(deviceId) ?? {});
23230
+ resolveCutoffMs: async (deviceId, nowMs) => (await this.deviceRetentionCutoffs(deviceId, nowMs)).trackCutoffMs,
23231
+ pruneTracksBefore: async (deviceId, cutoffMs) => {
23232
+ const counts = await this.pruneTracksBefore({
23233
+ deviceId,
23234
+ cutoffMs
23235
+ });
23236
+ if (counts.tracks > 0) this.eventsOpsLog?.append({
23237
+ op: "prune",
23238
+ reason: "retention",
23239
+ deviceId,
23240
+ itemsAffected: counts.tracks,
23241
+ bytesReclaimed: 0,
23242
+ detail: `track retention cascade: ${counts.tracks} tracks, ${counts.events} events, ${counts.media} media`
23243
+ });
23244
+ return counts;
21546
23245
  },
21547
- pruneTracksBefore: (deviceId, cutoffMs) => this.pruneTracksBefore({
21548
- deviceId,
21549
- cutoffMs
21550
- }),
21551
23246
  now: () => Date.now(),
21552
23247
  onError: (deviceId, err) => {
21553
23248
  this.ctx.logger.debug("track retention sweep (device) failed", { meta: {
@@ -21641,7 +23336,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
21641
23336
  const raw = await this.ctx?.settings?.readDeviceStore(input.deviceId) ?? {};
21642
23337
  const baseSections = schema ? require_dist.hydrateSchema({
21643
23338
  ...schema,
21644
- sections: retagDetectionSections(stripGlobalOnlyFields(toAnalyticsDeviceSections(schema.sections)))
23339
+ sections: retagRetentionSection(retagDetectionSections(stripGlobalOnlyFields(toAnalyticsDeviceSections(schema.sections))))
21645
23340
  }, raw).sections : [];
21646
23341
  const liveStatsSection = {
21647
23342
  id: "live-stats",
@@ -21697,5 +23392,6 @@ exports.default = PipelineAnalyticsAddon;
21697
23392
  exports.ncActions = ncActions;
21698
23393
  exports.pickCleanMedia = pickCleanMedia;
21699
23394
  exports.retagDetectionSections = retagDetectionSections;
23395
+ exports.retagRetentionSection = retagRetentionSection;
21700
23396
  exports.stripGlobalOnlyFields = stripGlobalOnlyFields;
21701
23397
  exports.toAnalyticsDeviceSections = toAnalyticsDeviceSections;