@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.
@@ -1,6 +1,346 @@
1
- import { A as DeviceType, B as EventCategory, C as pipelineAnalyticsCapability, D as zoneAnalyticsCapability, E as videoclipsCapability, F as boolean, I as literal, L as number, M as hydrateSchema, N as nodePin, O as errMsg, P as array, R as object, S as notificationRulesCapability, T as subKindsOf, _ as customAction, a as NC_CONDITION_CATALOG, b as faceGalleryCapability, c as NcRuleInputSchema, d as NcTaxonomySchema, f as OpsLogEntrySchema, g as cosineSimilarity, h as buildEventKindDescriptor, i as MACRO_LABELS, j as createEvent, k as BaseAddon, l as NcRulePatchSchema, m as audioMetricsCapability, n as EVENT_KIND_BY_CAP, o as NC_TAXONOMY, p as addonWidgetsSourceCapability, r as EVENT_PAD_MS, s as NcConditionDescriptorSchema, t as DEFAULT_EVENT_COLOR, u as NcRuleSchema, v as defineCustomActions, w as plateGalleryCapability, z as string } from "../dist-ggFRVg94.mjs";
2
- import { randomUUID } from "node:crypto";
1
+ import { A as errMsg, B as number, D as subKindsOf, E as plateGalleryCapability, F as nodePin, H as string, I as _enum, L as array, M as DeviceType, N as createEvent, O as videoclipsCapability, P as hydrateSchema, R as boolean, S as faceGalleryCapability, T as pipelineAnalyticsCapability, U as EventCategory, V as object, _ as buildEventKindDescriptor, a as NC_CONDITION_CATALOG, b as defineCustomActions, c as NcRuleInputSchema, d as NcTaxonomySchema, f as OpsLogEntrySchema, g as audioMetricsCapability, h as addonWidgetsSourceCapability, i as MACRO_LABELS, j as BaseAddon, k as zoneAnalyticsCapability, l as NcRulePatchSchema, m as TimelapseRuleSchema, n as EVENT_KIND_BY_CAP, o as NC_TAXONOMY, p as TimelapseRuleInputSchema, r as EVENT_PAD_MS, s as NcConditionDescriptorSchema, t as DEFAULT_EVENT_COLOR, u as NcRuleSchema, v as cosineSimilarity, w as notificationRulesCapability, y as customAction, z as literal } from "../dist-C41w6Xvl.mjs";
2
+ import { promises } from "node:fs";
3
+ import path from "node:path";
4
+ import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
3
5
  import sharp from "sharp";
6
+ //#region src/notification-center/artifact-url.ts
7
+ /**
8
+ * Signed, externally-reachable URLs for notification artifacts.
9
+ *
10
+ * WHY this exists: the dispatcher only ever produced attachment BYTES, and the
11
+ * degrade engine drops a bytes-only attachment for a `mode:'url'` target
12
+ * (`attachment:noUrl`) — so WhatsApp and gotify received no media at all,
13
+ * silently. A URL also lets an oversized attachment degrade to a link instead
14
+ * of being dropped on `maxBytes`.
15
+ *
16
+ * Two decisions the shape depends on:
17
+ *
18
+ * - **Signed, not authenticated.** The fetcher is a notifier BACKEND (or the
19
+ * recipient's phone) — it holds no session and no admin token. The route is
20
+ * therefore `access:'public'` and the authority is the signature: an
21
+ * unguessable HMAC over `(id, exp)` with a per-install secret, expiring on
22
+ * its own. Nothing else about the install is reachable through it.
23
+ * - **The base URL is CHOSEN, never assumed.** A notification carrying
24
+ * `http://127.0.0.1:...` is useless (the Alexa `hubUrl` bug, again). The
25
+ * ranked endpoint list the `local-network` cap already computes — public
26
+ * tunnel > mesh > LAN, loopback last — is the authority, and loopback is
27
+ * refused outright rather than shipped as a broken link.
28
+ */
29
+ /**
30
+ * Pick the base URL an artifact link should use: the LOWEST-priority-number
31
+ * endpoint that is not loopback (the list is already ranked public > mesh >
32
+ * LAN > loopback). `null` when only loopback is available — a link nobody
33
+ * outside the host could open is worse than no link, because the notification
34
+ * would arrive advertising media that 404s.
35
+ */
36
+ function pickArtifactBaseUrl(endpoints) {
37
+ return endpoints.filter((e) => e.kind !== "loopback").toSorted((a, b) => a.priority - b.priority)[0]?.baseUrl ?? null;
38
+ }
39
+ /** Sign `(id, exp)` — the exact string the verifier recomputes. */
40
+ function signArtifact(secret, id, expMs) {
41
+ return createHmac("sha256", secret).update(`${id}:${expMs}`).digest("hex");
42
+ }
43
+ /**
44
+ * Build the fully-qualified, signed URL for an artifact.
45
+ * `baseUrl` comes from {@link pickArtifactBaseUrl}, `routePrefix` from the
46
+ * data-plane handle (`/addon/<addonId>/nc-artifact`).
47
+ */
48
+ function buildArtifactUrl(input) {
49
+ const sig = signArtifact(input.secret, input.id, input.expMs);
50
+ 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}`;
51
+ }
52
+ /**
53
+ * Verify a request's `(id, exp, sig)`. Constant-time on the signature so a
54
+ * public route cannot be probed for it byte by byte, and expiry-checked before
55
+ * the compare so an expired link is rejected even with a valid signature.
56
+ */
57
+ function verifyArtifactSignature(input) {
58
+ if (!Number.isFinite(input.exp) || input.exp <= input.nowMs) return false;
59
+ const expected = signArtifact(input.secret, input.id, input.exp);
60
+ const a = Buffer.from(expected, "utf8");
61
+ const b = Buffer.from(input.sig, "utf8");
62
+ if (a.length !== b.length) return false;
63
+ return timingSafeEqual(a, b);
64
+ }
65
+ //#endregion
66
+ //#region src/notification-center/artifact-plane.ts
67
+ /**
68
+ * The hub's default HTTPS API port — the same constant `derive-hub-url.ts`
69
+ * documents. Only used for the LAN candidates; an operator who runs the hub on
70
+ * another port sets `CAMSTACK_HUB_PUBLIC_URL`, which wins outright.
71
+ */
72
+ var HUB_API_PORT = 4443;
73
+ /**
74
+ * Ranking of the public sources. All NEGATIVE: `local-network` gives its
75
+ * preferred LAN interface priority `0`, so a public candidate has to sit below
76
+ * zero to outrank it.
77
+ */
78
+ var PRIORITY_MARKED = -400;
79
+ var PRIORITY_CONFIGURED = -300;
80
+ var PRIORITY_CONNECTED = -200;
81
+ var PRIORITY_EXTERNAL_LIST = -100;
82
+ /**
83
+ * Build the ranked candidate list, in the operator's own order: an explicitly
84
+ * configured public origin, then the connected external ingress (tunnels),
85
+ * then the LAN addresses. Every source is best-effort — one that throws
86
+ * contributes nothing rather than costing the whole list.
87
+ */
88
+ async function collectArtifactEndpoints(sources) {
89
+ const out = [];
90
+ const marked = sources.markedBaseUrl;
91
+ if (marked !== void 0 && marked !== "") out.push({
92
+ baseUrl: marked,
93
+ kind: "public",
94
+ priority: PRIORITY_MARKED
95
+ });
96
+ const configured = sources.configuredPublicUrl;
97
+ if (configured !== void 0 && configured !== "" && !/127\.0\.0\.1|localhost/.test(configured)) out.push({
98
+ baseUrl: configured,
99
+ kind: "public",
100
+ priority: PRIORITY_CONFIGURED
101
+ });
102
+ try {
103
+ const connected = await sources.getConnected();
104
+ if (connected !== null && connected.protocol === "https") out.push({
105
+ baseUrl: connected.url,
106
+ kind: "public",
107
+ priority: PRIORITY_CONNECTED
108
+ });
109
+ } catch (err) {
110
+ sources.logger.debug("connected endpoint lookup failed", { meta: { error: String(err) } });
111
+ }
112
+ try {
113
+ const external = await sources.listExternal();
114
+ for (const [i, e] of external.entries()) {
115
+ if (e.protocol !== "https") continue;
116
+ out.push({
117
+ baseUrl: e.url,
118
+ kind: "public",
119
+ priority: PRIORITY_EXTERNAL_LIST + i
120
+ });
121
+ }
122
+ } catch (err) {
123
+ sources.logger.debug("external endpoint lookup failed", { meta: { error: String(err) } });
124
+ }
125
+ try {
126
+ out.push(...await sources.listLan(HUB_API_PORT));
127
+ } catch (err) {
128
+ sources.logger.debug("LAN endpoint lookup failed", { meta: { error: String(err) } });
129
+ }
130
+ return out;
131
+ }
132
+ var DEFAULT_TTL_MS = 24 * 36e5;
133
+ var DEFAULT_ENDPOINT_CACHE_MS = 6e4;
134
+ var NcArtifactPlane = class {
135
+ deps;
136
+ now;
137
+ cachedBaseUrl = null;
138
+ /** Last base URL announced in the log — so the line appears on CHANGE only. */
139
+ lastLoggedBaseUrl = null;
140
+ cachedAt = 0;
141
+ constructor(deps) {
142
+ this.deps = deps;
143
+ this.now = deps.now ?? (() => Date.now());
144
+ }
145
+ /**
146
+ * Store bytes and mint a signed, externally-reachable URL for them.
147
+ * Returns `null` when no usable base URL exists (loopback only) — the caller
148
+ * then ships bytes alone, exactly as before. Never throws: an artifact URL is
149
+ * an ENHANCEMENT, and failing to mint one must not cost the notification.
150
+ */
151
+ async publish(bytes, mime) {
152
+ try {
153
+ const baseUrl = await this.resolveBaseUrl();
154
+ if (baseUrl === null) return null;
155
+ const artifact = await this.deps.store.put(bytes, mime);
156
+ return buildArtifactUrl({
157
+ baseUrl,
158
+ routePrefix: this.deps.routePrefix,
159
+ id: artifact.id,
160
+ secret: this.deps.secret,
161
+ expMs: this.now() + (this.deps.ttlMs ?? DEFAULT_TTL_MS)
162
+ });
163
+ } catch (err) {
164
+ this.deps.logger.debug("artifact publish failed — shipping bytes only", { meta: { error: String(err) } });
165
+ return null;
166
+ }
167
+ }
168
+ /** The data-plane handler for `<prefix>/<id>?exp=&sig=`. */
169
+ handler = async (req, res) => {
170
+ const url = new URL(req.url ?? "/", "http://placeholder");
171
+ const id = decodeURIComponent(url.pathname.split("/").filter(Boolean).pop() ?? "");
172
+ if (!verifyArtifactSignature({
173
+ secret: this.deps.secret,
174
+ id,
175
+ exp: Number(url.searchParams.get("exp")),
176
+ sig: url.searchParams.get("sig") ?? "",
177
+ nowMs: this.now()
178
+ })) {
179
+ res.writeHead(404, { "content-type": "text/plain" });
180
+ res.end("not found");
181
+ return;
182
+ }
183
+ const found = await this.deps.store.read(id);
184
+ if (found === null) {
185
+ res.writeHead(404, { "content-type": "text/plain" });
186
+ res.end("not found");
187
+ return;
188
+ }
189
+ res.writeHead(200, {
190
+ "content-type": found.mime,
191
+ "content-length": String(found.bytes.byteLength),
192
+ "cache-control": "private, max-age=3600"
193
+ });
194
+ res.end(found.bytes);
195
+ };
196
+ /** The chosen base URL, cached briefly (the interface list barely moves). */
197
+ async resolveBaseUrl() {
198
+ const ttl = this.deps.endpointCacheMs ?? DEFAULT_ENDPOINT_CACHE_MS;
199
+ if (this.cachedBaseUrl !== null && this.now() - this.cachedAt < ttl) return this.cachedBaseUrl;
200
+ const picked = pickArtifactBaseUrl(await this.deps.listEndpoints());
201
+ if (picked === null) {
202
+ this.deps.logger.debug("no externally-reachable endpoint — artifacts ship as bytes only");
203
+ return null;
204
+ }
205
+ if (picked !== this.lastLoggedBaseUrl) {
206
+ this.deps.logger.info("artifact links will use", { meta: { baseUrl: picked } });
207
+ this.lastLoggedBaseUrl = picked;
208
+ }
209
+ this.cachedBaseUrl = picked;
210
+ this.cachedAt = this.now();
211
+ return picked;
212
+ }
213
+ };
214
+ //#endregion
215
+ //#region src/notification-center/artifact-store.ts
216
+ /**
217
+ * NcArtifactStore — the bytes behind a signed notification-artifact URL.
218
+ *
219
+ * A notification attachment is transient by nature: it exists to be fetched
220
+ * once, by a notifier backend or the recipient's phone, within minutes of the
221
+ * event. So this is a BOUNDED SCRATCH, not a media library — the durable copy
222
+ * of an event's media already lives in the media store, and duplicating it here
223
+ * permanently would grow without limit for no benefit.
224
+ *
225
+ * Two bounds, both enforced on every write (age first, then count): whichever
226
+ * bites first, the oldest artifacts go. A sweep on boot clears whatever a crash
227
+ * left behind — the directory is disposable by construction, so a lost file is
228
+ * a 404 on one link, never a corrupt state.
229
+ */
230
+ var DEFAULT_MAX_AGE_MS = 6 * 36e5;
231
+ var DEFAULT_MAX_ENTRIES = 500;
232
+ /** `image/jpeg` → `jpg`, so a fetched link has a name a client will accept. */
233
+ function extensionFor(mime) {
234
+ if (mime === "image/jpeg") return "jpg";
235
+ if (mime === "image/gif") return "gif";
236
+ if (mime === "video/mp4") return "mp4";
237
+ if (mime === "image/png") return "png";
238
+ return "bin";
239
+ }
240
+ var NcArtifactStore = class {
241
+ options;
242
+ now;
243
+ maxAgeMs;
244
+ maxEntries;
245
+ /** id → mime, so the route can answer with the right content-type without
246
+ * re-deriving it from the extension on every request. */
247
+ mimeById = /* @__PURE__ */ new Map();
248
+ constructor(options) {
249
+ this.options = options;
250
+ this.now = options.now ?? (() => Date.now());
251
+ this.maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
252
+ this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
253
+ }
254
+ /** Create the directory and sweep whatever a previous run left behind. */
255
+ async start() {
256
+ await promises.mkdir(this.options.dir, { recursive: true });
257
+ await this.sweep();
258
+ }
259
+ /** Persist bytes and return the artifact handle (never throws on sweep). */
260
+ async put(bytes, mime) {
261
+ const id = `${this.now()}-${randomUUID()}`;
262
+ const file = path.join(this.options.dir, `${id}.${extensionFor(mime)}`);
263
+ await promises.mkdir(this.options.dir, { recursive: true });
264
+ await promises.writeFile(file, bytes);
265
+ this.mimeById.set(id, mime);
266
+ await this.sweep();
267
+ return {
268
+ id,
269
+ mime,
270
+ bytes: bytes.byteLength
271
+ };
272
+ }
273
+ /** Read an artifact's bytes + mime, or null when it is gone (expired/swept). */
274
+ async read(id) {
275
+ if (!/^[0-9]+-[0-9a-f-]{36}$/i.test(id)) return null;
276
+ const found = (await this.list()).find((e) => e.id === id);
277
+ if (!found) return null;
278
+ try {
279
+ return {
280
+ bytes: await promises.readFile(found.file),
281
+ mime: this.mimeById.get(id) ?? mimeFromExtension(found.file)
282
+ };
283
+ } catch {
284
+ return null;
285
+ }
286
+ }
287
+ /** Delete every artifact (operator cleanup / shutdown). */
288
+ async clear() {
289
+ await promises.rm(this.options.dir, {
290
+ recursive: true,
291
+ force: true
292
+ }).catch(() => {});
293
+ this.mimeById.clear();
294
+ }
295
+ /** Age- then count-bounded prune. Best-effort: a failed unlink is logged once. */
296
+ async sweep() {
297
+ const entries = await this.list();
298
+ const cutoff = this.now() - this.maxAgeMs;
299
+ const doomed = entries.filter((e) => e.mtimeMs < cutoff);
300
+ const survivors = entries.filter((e) => e.mtimeMs >= cutoff).toSorted((a, b) => b.mtimeMs - a.mtimeMs);
301
+ doomed.push(...survivors.slice(this.maxEntries));
302
+ for (const e of doomed) try {
303
+ await promises.rm(e.file, { force: true });
304
+ this.mimeById.delete(e.id);
305
+ } catch (err) {
306
+ this.options.logger.debug("artifact sweep failed to unlink", { meta: {
307
+ file: e.file,
308
+ error: String(err)
309
+ } });
310
+ }
311
+ }
312
+ async list() {
313
+ let names;
314
+ try {
315
+ names = await promises.readdir(this.options.dir);
316
+ } catch {
317
+ return [];
318
+ }
319
+ const out = [];
320
+ for (const name of names) {
321
+ const file = path.join(this.options.dir, name);
322
+ try {
323
+ const st = await promises.stat(file);
324
+ if (!st.isFile()) continue;
325
+ out.push({
326
+ id: name.slice(0, name.lastIndexOf(".")),
327
+ file,
328
+ mtimeMs: st.mtimeMs
329
+ });
330
+ } catch {}
331
+ }
332
+ return out;
333
+ }
334
+ };
335
+ function mimeFromExtension(file) {
336
+ const ext = file.slice(file.lastIndexOf(".") + 1).toLowerCase();
337
+ if (ext === "jpg") return "image/jpeg";
338
+ if (ext === "gif") return "image/gif";
339
+ if (ext === "mp4") return "video/mp4";
340
+ if (ext === "png") return "image/png";
341
+ return "application/octet-stream";
342
+ }
343
+ //#endregion
4
344
  //#region src/pipeline-analytics/videoclips-provider.ts
5
345
  var SOURCE = "analytics";
6
346
  function clipIdFor(eventId, startMs, endMs) {
@@ -1956,16 +2296,28 @@ function resolveRuleThreshold(rule) {
1956
2296
  */
1957
2297
  var ZoneEngine = class {
1958
2298
  /**
1959
- * Annotate a single detection with its zone memberships.
1960
- * Returns zones where the detection overlaps above any active
1961
- * rule's threshold (or the engine default if no rule sets one).
2299
+ * Annotate a single detection with its zone memberships — every zone whose
2300
+ * polygon the detection overlaps by MORE than `minOverlap` (0–1 fraction of
2301
+ * the detection's own area).
2302
+ *
2303
+ * The previous version of this comment claimed memberships were returned
2304
+ * "above any active rule's threshold"; they were not — the threshold was
2305
+ * hardcoded to {@link MEMBERSHIP_MIN_OVERLAP} (zero) and no rule was ever
2306
+ * consulted. Read the parameter, not this paragraph.
2307
+ *
2308
+ * `minOverlap` matters because membership is what lands on an event as
2309
+ * `zones`, and a notification rule's `zones` condition is a plain set test
2310
+ * over that field — so this, not the zone-RULE threshold, is what decides
2311
+ * whether a zone-scoped notification fires. At the default of 0 a subject
2312
+ * clipping a zone by one pixel counts as inside it (measured 2026-07-30: a
2313
+ * dog overlapping `Aiuola` by 4.8% would have been stamped as in it).
1962
2314
  */
1963
- annotateDetection(bbox, zones, frameWidth, frameHeight, mask, maskWidth, maskHeight) {
2315
+ annotateDetection(bbox, zones, frameWidth, frameHeight, mask, maskWidth, maskHeight, minOverlap = MEMBERSHIP_MIN_OVERLAP) {
1964
2316
  const memberships = [];
1965
2317
  for (const zone of zones) {
1966
2318
  const pixelPolygon = zone.polygon.map((p) => normalizeToPixel(p, frameWidth, frameHeight));
1967
2319
  const overlap = mask && maskWidth && maskHeight ? maskPolygonOverlap(mask, maskWidth, maskHeight, bbox, pixelPolygon, frameWidth, frameHeight) : bboxPolygonOverlap(bbox, pixelPolygon);
1968
- if (overlap > MEMBERSHIP_MIN_OVERLAP) memberships.push({
2320
+ if (overlap > minOverlap) memberships.push({
1969
2321
  zoneId: zone.id,
1970
2322
  zoneName: zone.name,
1971
2323
  overlap
@@ -2065,6 +2417,10 @@ var FrameProcessor = class {
2065
2417
  * runners that haven't picked up the new gating yet.
2066
2418
  */
2067
2419
  detectionRules;
2420
+ /** See {@link setZoneMembershipMinOverlap}. 0 = any positive overlap. */
2421
+ zoneMembershipMinOverlap;
2422
+ /** See {@link getLastZoneOverlaps}. */
2423
+ lastZoneOverlaps;
2068
2424
  zoneEngine = new ZoneEngine();
2069
2425
  /** Optional stationary-object gate (parked-object suppression). Null until
2070
2426
  * the addon wires it via {@link setStationaryGate}. */
@@ -2077,10 +2433,32 @@ var FrameProcessor = class {
2077
2433
  this.eventEmitter = new DetectionEventEmitter(emitterConfig);
2078
2434
  this.zones = [];
2079
2435
  this.detectionRules = [];
2436
+ this.zoneMembershipMinOverlap = 0;
2437
+ this.lastZoneOverlaps = /* @__PURE__ */ new Map();
2080
2438
  }
2081
2439
  setZones(zones) {
2082
2440
  this.zones = zones;
2083
2441
  }
2442
+ /**
2443
+ * How much of a detection's box must lie inside a zone for the zone to be
2444
+ * stamped onto it (0–1 fraction of the box's own area).
2445
+ *
2446
+ * DEFAULT 0 — byte-identical to the behaviour before 2026-07-31, where any
2447
+ * positive overlap counted. Raising it is an operator decision and needs
2448
+ * evidence: a bar set blind removes notifications silently, which is the
2449
+ * failure mode this whole area keeps producing. {@link lastZoneOverlaps}
2450
+ * exists so the distribution can be read before a number is picked.
2451
+ */
2452
+ setZoneMembershipMinOverlap(minOverlap) {
2453
+ this.zoneMembershipMinOverlap = Number.isFinite(minOverlap) && minOverlap >= 0 && minOverlap <= 1 ? minOverlap : 0;
2454
+ }
2455
+ /** Per-track zone memberships WITH their overlap fractions, from the most
2456
+ * recent frame. The engine computes these and the pipeline previously
2457
+ * discarded everything but the ids — which is why no amount of production
2458
+ * data could say how far inside the zone a notifying subject actually was. */
2459
+ getLastZoneOverlaps() {
2460
+ return this.lastZoneOverlaps;
2461
+ }
2084
2462
  setDetectionRules(rules) {
2085
2463
  this.detectionRules = rules;
2086
2464
  }
@@ -2191,11 +2569,14 @@ var FrameProcessor = class {
2191
2569
  const objectStates = this.stateAnalyzer.analyze(trackedDetections, timestamp);
2192
2570
  const rawEvents = this.eventEmitter.emit(trackedDetections, objectStates, [], [], String(this.deviceId));
2193
2571
  const zonesByTrack = /* @__PURE__ */ new Map();
2572
+ const overlapsByTrack = /* @__PURE__ */ new Map();
2194
2573
  for (const td of trackedDetections) {
2195
2574
  const m = maskByBbox.get(td.bbox);
2196
- const memberships = this.zoneEngine.annotateDetection(td.bbox, this.zones, frameWidth, frameHeight, m?.mask, m?.width, m?.height);
2575
+ const memberships = this.zoneEngine.annotateDetection(td.bbox, this.zones, frameWidth, frameHeight, m?.mask, m?.width, m?.height, this.zoneMembershipMinOverlap);
2197
2576
  zonesByTrack.set(td.trackId, memberships.map((m2) => m2.zoneId));
2577
+ if (memberships.length > 0) overlapsByTrack.set(td.trackId, memberships);
2198
2578
  }
2579
+ this.lastZoneOverlaps = overlapsByTrack;
2199
2580
  const tracked = trackedDetections.map((td) => {
2200
2581
  const state = mapObjectStateToTrackState(objectStates.find((o) => o.trackId === td.trackId)?.state);
2201
2582
  const label = resolveDetectionLabel({
@@ -4157,6 +4538,42 @@ function attachmentKindPreference(policy, ownerKind) {
4157
4538
  ];
4158
4539
  }
4159
4540
  /**
4541
+ * The kind ladder for an EXPLICIT frame choice. Strict by design — each option
4542
+ * stays inside its own family, because a rule that asked for the clean scene
4543
+ * and received the boxed one (or vice versa) is not "degrading gracefully", it
4544
+ * is answering a different question.
4545
+ *
4546
+ * `boxed` is the one exception: with no annotated frame stored, the CLEAN
4547
+ * scene is the honest fallback (same picture, no annotation) — never a subject
4548
+ * crop, which shows something else entirely.
4549
+ */
4550
+ function framePreference(frame, ownerKind) {
4551
+ if (frame === "cropped") return ownerKind === "track" ? [
4552
+ "thumbnail",
4553
+ "thumbnailSmall",
4554
+ "crop"
4555
+ ] : [
4556
+ "crop",
4557
+ "thumbnail",
4558
+ "thumbnailSmall"
4559
+ ];
4560
+ if (frame === "boxed") return [
4561
+ "fullFrameBoxed",
4562
+ "keyFrame",
4563
+ "keyFrameSmall",
4564
+ "fullFrame"
4565
+ ];
4566
+ return ownerKind === "track" ? [
4567
+ "keyFrame",
4568
+ "keyFrameSmall",
4569
+ "firstFrame"
4570
+ ] : [
4571
+ "fullFrame",
4572
+ "keyFrame",
4573
+ "keyFrameSmall"
4574
+ ];
4575
+ }
4576
+ /**
4160
4577
  * Derive the `best-matching` media signal from a matched rule's condition
4161
4578
  * summary ({@link NcEvaluation.matchedOn}). Identity takes priority over plate
4162
4579
  * (D-3 ordering: a face rule that ALSO plate-matched attaches the face crop).
@@ -4185,6 +4602,20 @@ function bestMatchingKindPreference(signal, ownerKind) {
4185
4602
  //#endregion
4186
4603
  //#region src/notification-center/dispatcher.ts
4187
4604
  var DEFAULT_TARGET_CACHE_TTL_MS = 6e4;
4605
+ /** GIF and MP4 are the same cut in two containers — one render request each. */
4606
+ var FOOTAGE_FORMATS = [{
4607
+ flag: "mediaGif",
4608
+ format: "gif",
4609
+ mediaType: "gif",
4610
+ mime: "image/gif",
4611
+ name: "event.gif"
4612
+ }, {
4613
+ flag: "mediaClip",
4614
+ format: "mp4",
4615
+ mediaType: "video",
4616
+ mime: "video/mp4",
4617
+ name: "event.mp4"
4618
+ }];
4188
4619
  var NcDispatcher = class {
4189
4620
  deps;
4190
4621
  targetCache = null;
@@ -4239,7 +4670,9 @@ var NcDispatcher = class {
4239
4670
  ruleId: entry.ruleId,
4240
4671
  target: target.name,
4241
4672
  kind: target.kind,
4242
- recordKind: entry.recordKind
4673
+ recordKind: entry.recordKind,
4674
+ eventId: entry.recordId,
4675
+ ...entry.trackId !== void 0 ? { trackId: entry.trackId } : {}
4243
4676
  }
4244
4677
  });
4245
4678
  return { ok: true };
@@ -4254,14 +4687,19 @@ var NcDispatcher = class {
4254
4687
  async resolveTarget(targetId) {
4255
4688
  const cached = this.cachedTarget(targetId);
4256
4689
  if (cached !== null) return cached;
4690
+ let targets;
4257
4691
  try {
4258
- const targets = await this.deps.listTargets();
4259
- this.targetCache = new Map(targets.map((t) => [t.id, t]));
4260
- this.targetCacheAt = this.now();
4692
+ targets = await this.deps.listTargets();
4261
4693
  } catch (err) {
4262
4694
  this.deps.logger.warn("target catalog refresh failed", { meta: { error: String(err) } });
4263
4695
  throw err instanceof Error ? err : new Error(String(err));
4264
4696
  }
4697
+ if (targets.length === 0) {
4698
+ this.deps.logger.warn("target catalog came back EMPTY — treating as transient", { meta: { targetId } });
4699
+ throw new Error(`target catalog empty (transient) while resolving ${targetId}`);
4700
+ }
4701
+ this.targetCache = new Map(targets.map((t) => [t.id, t]));
4702
+ this.targetCacheAt = this.now();
4265
4703
  return this.targetCache.get(targetId) ?? null;
4266
4704
  }
4267
4705
  cachedTarget(targetId) {
@@ -4272,10 +4710,11 @@ var NcDispatcher = class {
4272
4710
  async buildNotification(entry) {
4273
4711
  const subject = entry.payload.subject;
4274
4712
  const deviceName = await this.deps.getDeviceName(subject.deviceId).catch(() => null) ?? `camera ${subject.deviceId}`;
4275
- const vars = buildTemplateVars(entry, deviceName);
4713
+ const zoneLabels = await resolveZoneLabels(this.deps.getZoneNames, subject.deviceId, subject.zones);
4714
+ const vars = buildTemplateVars(entry, deviceName, zoneLabels);
4276
4715
  const title = renderTemplate(entry.payload.template?.title, vars) ?? entry.payload.ruleName;
4277
- const body = renderTemplate(entry.payload.template?.body, vars) ?? defaultBody(entry, deviceName);
4278
- const attachment = await this.resolveAttachment(entry);
4716
+ const body = renderTemplate(entry.payload.template?.body, vars) ?? defaultBody(entry, deviceName, zoneLabels);
4717
+ const attachments = await this.withArtifactUrls(await this.resolveAttachments(entry));
4279
4718
  const params = pickParams(entry.payload.params);
4280
4719
  return {
4281
4720
  body,
@@ -4285,7 +4724,7 @@ var NcDispatcher = class {
4285
4724
  tag: entry.ruleId,
4286
4725
  deviceId: subject.deviceId,
4287
4726
  ...subject.eventId !== void 0 ? { eventId: subject.eventId } : {},
4288
- ...attachment !== null ? { attachments: [attachment] } : {}
4727
+ ...attachments.length > 0 ? { attachments } : {}
4289
4728
  };
4290
4729
  }
4291
4730
  /**
@@ -4297,9 +4736,142 @@ var NcDispatcher = class {
4297
4736
  * explains the fired condition (`faceCrop`/`plateCrop`, both event-owned)
4298
4737
  * then degrades to the plain `best` → `keyFrame` ladders.
4299
4738
  */
4300
- async resolveAttachment(entry) {
4301
- const policy = entry.payload.media;
4739
+ /**
4740
+ * Mint a signed URL for each attachment, alongside the bytes it already
4741
+ * carries. Best-effort per attachment: a failed mint leaves that one
4742
+ * bytes-only rather than costing the notification. Without this a url-mode
4743
+ * target (WhatsApp, gotify) silently received NO media — the degrade engine
4744
+ * drops a bytes-only attachment for them (`attachment:noUrl`).
4745
+ */
4746
+ /**
4747
+ * A failed footage render is best-effort — ship the still, skip the video.
4748
+ *
4749
+ * There is nothing to wait for: the clip ring only grows FORWARD, so a window
4750
+ * it does not already cover will never be covered by retrying. (An earlier
4751
+ * revision deferred delivery here, when clips were cut from finalized
4752
+ * recording segments; retrying under the pre-buffer would just age the
4753
+ * pre-roll out of the ring.)
4754
+ */
4755
+ notePendingFootage(entry, err, what) {
4756
+ this.deps.logger.debug(`${what} attachment render failed`, {
4757
+ tags: { deviceId: entry.payload.subject.deviceId },
4758
+ meta: {
4759
+ error: String(err),
4760
+ action: "send-without-video"
4761
+ }
4762
+ });
4763
+ }
4764
+ async withArtifactUrls(attachments) {
4765
+ const publish = this.deps.publishArtifact;
4766
+ if (publish === void 0) return [...attachments];
4767
+ const out = [];
4768
+ for (const att of attachments) {
4769
+ const url = await publish(att.bytes, att.mime).catch(() => null);
4770
+ out.push(url === null ? { ...att } : {
4771
+ ...att,
4772
+ url
4773
+ });
4774
+ }
4775
+ return out;
4776
+ }
4777
+ /** The full attachment list: the policy still (zone-cropped when the rule
4778
+ * froze zone ids) plus the footage cut from the broker's clip ring when
4779
+ * requested. Each part is best-effort — a failed crop falls back to the
4780
+ * uncropped still, a failed render just omits the video. */
4781
+ async resolveAttachments(entry) {
4782
+ const out = [];
4783
+ const zoneIdsWanted = entry.payload.mediaZoneIds;
4784
+ const still = await this.resolveAttachment(entry, zoneIdsWanted !== void 0 && zoneIdsWanted.length > 0 ? "keyFrame" : void 0);
4785
+ if (still !== null) {
4786
+ const zoneIds = zoneIdsWanted;
4787
+ if (zoneIds !== void 0 && zoneIds.length > 0) {
4788
+ const cropped = await this.zoneCrop(entry.payload.subject.deviceId, zoneIds, still.bytes);
4789
+ out.push(cropped !== null ? {
4790
+ ...still,
4791
+ bytes: cropped,
4792
+ name: "zone.jpg"
4793
+ } : still);
4794
+ } else out.push(still);
4795
+ }
4796
+ for (const want of FOOTAGE_FORMATS) {
4797
+ if (entry.payload[want.flag] !== true || !this.deps.renderFootage) continue;
4798
+ try {
4799
+ const rendered = await this.deps.renderFootage({
4800
+ deviceId: entry.payload.subject.deviceId,
4801
+ aroundMs: entry.payload.subject.timestamp,
4802
+ format: want.format,
4803
+ ...entry.payload.mediaClipPreRollSec !== void 0 ? { preRollSec: entry.payload.mediaClipPreRollSec } : {},
4804
+ ...entry.payload.mediaClipPostRollSec !== void 0 ? { postRollSec: entry.payload.mediaClipPostRollSec } : {},
4805
+ ...entry.payload.mediaProfile !== void 0 ? { profile: entry.payload.mediaProfile } : {}
4806
+ });
4807
+ if (rendered !== null && rendered.byteLength > 0) {
4808
+ const bytes = new Uint8Array(rendered.byteLength);
4809
+ bytes.set(rendered);
4810
+ out.push({
4811
+ mediaType: want.mediaType,
4812
+ bytes,
4813
+ mime: want.mime,
4814
+ name: want.name
4815
+ });
4816
+ }
4817
+ } catch (err) {
4818
+ this.notePendingFootage(entry, err, want.format === "gif" ? "gif" : "clip");
4819
+ }
4820
+ }
4821
+ return out;
4822
+ }
4823
+ /** Crop a JPEG to the padded bbox of the given zones (normalized polygons →
4824
+ * pixel rect via sharp metadata). Null on any failure — caller falls back
4825
+ * to the uncropped still. */
4826
+ async zoneCrop(deviceId, zoneIds, jpeg) {
4827
+ try {
4828
+ const points = (await this.deps.getZonePolygons?.(deviceId, zoneIds) ?? []).flat();
4829
+ if (points.length === 0) return null;
4830
+ const { default: sharp } = await import("sharp");
4831
+ const img = sharp(Buffer.from(jpeg));
4832
+ const meta = await img.metadata();
4833
+ const W = meta.width ?? 0;
4834
+ const H = meta.height ?? 0;
4835
+ if (W === 0 || H === 0) return null;
4836
+ let minX = 1;
4837
+ let minY = 1;
4838
+ let maxX = 0;
4839
+ let maxY = 0;
4840
+ for (const p of points) {
4841
+ if (p.x < minX) minX = p.x;
4842
+ if (p.y < minY) minY = p.y;
4843
+ if (p.x > maxX) maxX = p.x;
4844
+ if (p.y > maxY) maxY = p.y;
4845
+ }
4846
+ if (maxX <= minX || maxY <= minY) return null;
4847
+ const padX = (maxX - minX) * .1;
4848
+ const padY = (maxY - minY) * .1;
4849
+ const left = Math.max(0, Math.floor((minX - padX) * W));
4850
+ const top = Math.max(0, Math.floor((minY - padY) * H));
4851
+ const width = Math.min(W - left, Math.ceil((maxX - minX + 2 * padX) * W));
4852
+ const height = Math.min(H - top, Math.ceil((maxY - minY + 2 * padY) * H));
4853
+ if (width < 16 || height < 16) return null;
4854
+ const outBuf = await img.extract({
4855
+ left,
4856
+ top,
4857
+ width,
4858
+ height
4859
+ }).jpeg({ quality: 82 }).toBuffer();
4860
+ const bytes = new Uint8Array(outBuf.byteLength);
4861
+ bytes.set(outBuf);
4862
+ return bytes;
4863
+ } catch (err) {
4864
+ this.deps.logger.debug("zone crop failed — attaching uncropped still", { meta: {
4865
+ deviceId,
4866
+ error: String(err)
4867
+ } });
4868
+ return null;
4869
+ }
4870
+ }
4871
+ async resolveAttachment(entry, policyOverride) {
4872
+ const policy = policyOverride ?? entry.payload.media;
4302
4873
  if (policy === "none") return null;
4874
+ const frame = policyOverride === void 0 ? entry.payload.mediaFrame : void 0;
4303
4875
  const subject = entry.payload.subject;
4304
4876
  const signal = policy === "best-matching" ? matchSignal(entry.payload.matchedOn) : null;
4305
4877
  const owners = [];
@@ -4311,11 +4883,11 @@ var NcDispatcher = class {
4311
4883
  kind: "track",
4312
4884
  id: subject.trackId
4313
4885
  });
4314
- if (policy === "keyFrame") owners.reverse();
4886
+ if (policy === "keyFrame" || frame === "full" || frame === "boxed") owners.reverse();
4315
4887
  for (const owner of owners) try {
4316
4888
  const files = await this.deps.getMediaForOwner(owner.kind, owner.id);
4317
4889
  if (files.length === 0) continue;
4318
- const preference = policy === "best-matching" ? bestMatchingKindPreference(signal, owner.kind) : attachmentKindPreference(policy, owner.kind);
4890
+ const preference = frame !== void 0 ? framePreference(frame, owner.kind) : policy === "best-matching" ? bestMatchingKindPreference(signal, owner.kind) : attachmentKindPreference(policy, owner.kind);
4319
4891
  for (const kind of preference) {
4320
4892
  const file = files.find((f) => f.kind === kind);
4321
4893
  if (file === void 0) continue;
@@ -4340,15 +4912,37 @@ var NcDispatcher = class {
4340
4912
  return null;
4341
4913
  }
4342
4914
  };
4343
- function buildTemplateVars(entry, deviceName) {
4915
+ /**
4916
+ * Map admin zone IDs to their display names for rendering only.
4917
+ *
4918
+ * Order follows `zoneIds` (the order the track visited them), not the zone
4919
+ * catalog. Every failure mode degrades to the ID rather than dropping the
4920
+ * zone: an unknown id, a blank name, a throwing lookup, or no lookup wired at
4921
+ * all. A body that silently loses a zone is worse than one that shows a UUID.
4922
+ */
4923
+ async function resolveZoneLabels(getZoneNames, deviceId, zoneIds) {
4924
+ if (zoneIds.length === 0) return [];
4925
+ if (getZoneNames === void 0) return [...zoneIds];
4926
+ try {
4927
+ const zones = await getZoneNames(deviceId);
4928
+ const byId = new Map(zones.map((z) => [z.id, z.name]));
4929
+ return zoneIds.map((id) => {
4930
+ const name = byId.get(id);
4931
+ return name !== void 0 && name.trim().length > 0 ? name : id;
4932
+ });
4933
+ } catch {
4934
+ return [...zoneIds];
4935
+ }
4936
+ }
4937
+ function buildTemplateVars(entry, deviceName, zoneLabels) {
4344
4938
  const subject = entry.payload.subject;
4345
4939
  const occupancy = subject.occupancy;
4346
4940
  return {
4347
4941
  camera: deviceName,
4348
4942
  class: subject.className,
4349
4943
  label: subject.label ?? "",
4350
- zones: subject.zones.join(", "),
4351
- zone: occupancy?.zone ?? subject.zones[0] ?? "",
4944
+ zones: zoneLabels.join(", "),
4945
+ zone: occupancy?.zone ?? zoneLabels[0] ?? "",
4352
4946
  confidence: subject.confidence !== void 0 ? `${Math.round(subject.confidence * 100)}%` : "",
4353
4947
  time: new Date(subject.timestamp).toLocaleTimeString(),
4354
4948
  rule: entry.payload.ruleName,
@@ -4366,12 +4960,12 @@ function renderTemplate(template, vars) {
4366
4960
  if (template === void 0 || template.trim().length === 0) return null;
4367
4961
  return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (_m, name) => vars[name] ?? "");
4368
4962
  }
4369
- function defaultBody(entry, deviceName) {
4963
+ function defaultBody(entry, deviceName, zoneLabels) {
4370
4964
  const subject = entry.payload.subject;
4371
4965
  const occupancy = subject.occupancy;
4372
4966
  if (occupancy !== void 0) return `${occupancy.zone ?? deviceName} ${occupancyOpWord(occupancy.occupied)} (${occupancy.count}/${occupancy.capacity})`;
4373
4967
  const label = subject.label !== void 0 ? ` (${subject.label})` : "";
4374
- const zones = subject.zones.length > 0 ? ` in ${subject.zones.join(", ")}` : "";
4968
+ const zones = zoneLabels.length > 0 ? ` in ${zoneLabels.join(", ")}` : "";
4375
4969
  const suffix = entry.recordKind === "track-end" ? " — visit ended" : "";
4376
4970
  return `${subject.className}${label} on ${deviceName}${zones}${suffix}`;
4377
4971
  }
@@ -4637,7 +5231,7 @@ var NC_OCCUPANCY_INDEXES = [{
4637
5231
  }];
4638
5232
  /** Query cap — a per-(device, zone, class) key set is small; this is a
4639
5233
  * generous ceiling that still bounds a pathological read. */
4640
- var LOAD_LIMIT = 1e5;
5234
+ var LOAD_LIMIT$1 = 1e5;
4641
5235
  var OccupancyStore = class {
4642
5236
  cache = /* @__PURE__ */ new Map();
4643
5237
  store;
@@ -4664,7 +5258,7 @@ var OccupancyStore = class {
4664
5258
  try {
4665
5259
  const records = await this.store.query.query({
4666
5260
  collection: NC_OCCUPANCY_COLLECTION,
4667
- filter: { limit: LOAD_LIMIT }
5261
+ filter: { limit: LOAD_LIMIT$1 }
4668
5262
  });
4669
5263
  this.cache.clear();
4670
5264
  let skipped = 0;
@@ -5449,6 +6043,254 @@ var NcRuleStore = class {
5449
6043
  }
5450
6044
  };
5451
6045
  //#endregion
6046
+ //#region src/notification-center/timelapse/timelapse-store.ts
6047
+ var NC_TIMELAPSE_RULES_COLLECTION = "notification-center:timelapse-rules";
6048
+ var NC_TIMELAPSE_RULES_COLUMNS = [
6049
+ {
6050
+ name: "id",
6051
+ type: "TEXT",
6052
+ primaryKey: true,
6053
+ notNull: true
6054
+ },
6055
+ {
6056
+ name: "name",
6057
+ type: "TEXT",
6058
+ notNull: true
6059
+ },
6060
+ {
6061
+ name: "enabled",
6062
+ type: "BOOLEAN",
6063
+ notNull: true
6064
+ },
6065
+ {
6066
+ name: "updatedAt",
6067
+ type: "INTEGER",
6068
+ notNull: true
6069
+ },
6070
+ (
6071
+ /** The FULL rule object (Zod-validated on read) — scalars above are
6072
+ * indexed projections only. */
6073
+ {
6074
+ name: "rule",
6075
+ type: "JSON",
6076
+ notNull: true
6077
+ })
6078
+ ];
6079
+ var NC_TIMELAPSE_RULES_INDEXES = [{
6080
+ name: "idx_nc_timelapse_rules_enabled",
6081
+ columns: ["enabled"]
6082
+ }];
6083
+ /** Query cap — the rule set is operator-authored and tiny; a generous ceiling. */
6084
+ var LOAD_LIMIT = 1e4;
6085
+ /**
6086
+ * Resolve the three-way `template` patch signal onto a merged rule, immutably:
6087
+ * `undefined` (key absent) leaves it as-is, `null` DROPS the key, an object
6088
+ * replaces it. Keeping `null` out of the persisted rule is what lets
6089
+ * `TimelapseRuleSchema` stay a plain `.optional()`.
6090
+ */
6091
+ function applyTemplatePatch(merged, template) {
6092
+ if (template === void 0) return merged;
6093
+ if (template !== null) return {
6094
+ ...merged,
6095
+ template
6096
+ };
6097
+ const { template: _cleared, ...withoutTemplate } = merged;
6098
+ return withoutTemplate;
6099
+ }
6100
+ var TimelapseStore = class {
6101
+ byId = /* @__PURE__ */ new Map();
6102
+ store;
6103
+ logger;
6104
+ now;
6105
+ newId;
6106
+ constructor(deps) {
6107
+ this.store = deps.store;
6108
+ this.logger = deps.logger;
6109
+ this.now = deps.now ?? (() => Date.now());
6110
+ this.newId = deps.newId ?? (() => randomUUID());
6111
+ }
6112
+ static async declare(store) {
6113
+ await store.declareCollection.mutate({
6114
+ collection: NC_TIMELAPSE_RULES_COLLECTION,
6115
+ columns: [...NC_TIMELAPSE_RULES_COLUMNS],
6116
+ indexes: [...NC_TIMELAPSE_RULES_INDEXES]
6117
+ });
6118
+ }
6119
+ /**
6120
+ * (Re)hydrate the FULL rule set from the store — called at boot and on the
6121
+ * periodic refresh tick. Replaces the cache wholesale; a row whose JSON no
6122
+ * longer validates is skipped with a warning (a degraded rule must never
6123
+ * crash the scheduler).
6124
+ */
6125
+ async load() {
6126
+ try {
6127
+ const rows = await this.store.query.query({
6128
+ collection: NC_TIMELAPSE_RULES_COLLECTION,
6129
+ filter: { limit: LOAD_LIMIT }
6130
+ });
6131
+ this.byId.clear();
6132
+ let skipped = 0;
6133
+ for (const row of rows) {
6134
+ const parsed = TimelapseRuleSchema.safeParse(row.data["rule"]);
6135
+ if (!parsed.success) {
6136
+ skipped += 1;
6137
+ continue;
6138
+ }
6139
+ this.byId.set(parsed.data.id, parsed.data);
6140
+ }
6141
+ this.logger.debug("timelapse rules loaded", { meta: {
6142
+ rules: this.byId.size,
6143
+ ...skipped > 0 ? { skippedInvalid: skipped } : {}
6144
+ } });
6145
+ } catch (err) {
6146
+ this.logger.warn("timelapse rules load failed", { meta: { error: String(err) } });
6147
+ }
6148
+ }
6149
+ /** Every rule, newest-first (admin path). */
6150
+ list() {
6151
+ return [...this.byId.values()].sort((a, b) => b.updatedAt - a.updatedAt);
6152
+ }
6153
+ /** The scheduler's read: only rules that should be ticked. */
6154
+ listEnabled() {
6155
+ return this.list().filter((r) => r.enabled);
6156
+ }
6157
+ get(ruleId) {
6158
+ return this.byId.get(ruleId) ?? null;
6159
+ }
6160
+ /**
6161
+ * Rules visible to `userId`: their OWN personal rules (`ownerUserId ===
6162
+ * userId`) plus every admin/global rule (`ownerUserId` absent). Never another
6163
+ * user's personal rows. Newest-first (inherits {@link list}).
6164
+ *
6165
+ * The caller identity is server-derived; an absent/undefined caller must be
6166
+ * resolved to a fail-closed value by the bridge action BEFORE calling here —
6167
+ * this store never treats a missing caller as admin/global.
6168
+ */
6169
+ listForOwner(userId) {
6170
+ return this.list().filter((r) => r.ownerUserId === void 0 || r.ownerUserId === userId);
6171
+ }
6172
+ /**
6173
+ * Mutation gate for a NON-admin caller: true only for a PERSONAL rule this
6174
+ * user owns. A global rule (no `ownerUserId`) returns false — global rules
6175
+ * are admin-only, and the bridge action grants admins the mutation without
6176
+ * consulting this check. An unknown rule id is false (fail-closed).
6177
+ */
6178
+ isOwnedBy(ruleId, userId) {
6179
+ const rule = this.byId.get(ruleId);
6180
+ return rule?.ownerUserId !== void 0 && rule.ownerUserId === userId;
6181
+ }
6182
+ /**
6183
+ * Create a rule. `createdBy` is the SERVER-injected caller userId;
6184
+ * `ownerUserId` is the server-derived owner (omit for an admin/global rule).
6185
+ * Neither is ever read from `input`.
6186
+ *
6187
+ * The input is re-parsed through {@link TimelapseRuleInputSchema} BEFORE the
6188
+ * spread — that schema carries no ownership/provenance keys, so it strips any
6189
+ * that rode in on the blob. Without it, an `ownerUserId` on `input` would
6190
+ * survive whenever the `ownerUserId` ARGUMENT is omitted (the admin/global
6191
+ * path): a `TimelapseRule` is structurally assignable to `TimelapseRuleInput`,
6192
+ * so a future "duplicate rule" action (`create(existingRule, caller)`) would
6193
+ * compile cleanly and silently clone the ORIGINAL owner.
6194
+ */
6195
+ async create(input, createdBy, ownerUserId) {
6196
+ const now = this.now();
6197
+ const rule = TimelapseRuleSchema.parse({
6198
+ ...TimelapseRuleInputSchema.parse(input),
6199
+ id: this.newId(),
6200
+ ...ownerUserId !== void 0 ? { ownerUserId } : {},
6201
+ createdBy,
6202
+ createdAt: now,
6203
+ updatedAt: now
6204
+ });
6205
+ await this.persist(rule);
6206
+ this.byId.set(rule.id, rule);
6207
+ return rule;
6208
+ }
6209
+ /**
6210
+ * Apply a partial patch. Immutable: returns the NEW rule object. Identity,
6211
+ * ownership and generation state are re-pinned from the existing rule AFTER
6212
+ * the spread — the patch schema carries none of them, and this makes a
6213
+ * hand-built (unparsed) patch object equally unable to re-own a rule.
6214
+ *
6215
+ * `template` is the one clearable field: an absent key leaves it unchanged,
6216
+ * an explicit `null` CLEARS it (the persisted rule loses the key — `null`
6217
+ * never reaches {@link TimelapseRuleSchema}). See the patch schema's wire
6218
+ * note.
6219
+ */
6220
+ async update(ruleId, patch) {
6221
+ const existing = this.byId.get(ruleId);
6222
+ if (!existing) throw new Error(`timelapse rule not found: ${ruleId}`);
6223
+ const { template, ...rest } = patch;
6224
+ const merged = {
6225
+ ...existing,
6226
+ ...rest,
6227
+ id: existing.id,
6228
+ ownerUserId: existing.ownerUserId,
6229
+ lastGeneratedAt: existing.lastGeneratedAt,
6230
+ createdBy: existing.createdBy,
6231
+ createdAt: existing.createdAt,
6232
+ updatedAt: this.now()
6233
+ };
6234
+ return this.write(applyTemplatePatch(merged, template));
6235
+ }
6236
+ async setEnabled(ruleId, enabled) {
6237
+ return this.update(ruleId, { enabled });
6238
+ }
6239
+ /**
6240
+ * Record a successful generation. `at` is the generation epoch-ms — the
6241
+ * durable state behind the 1-hour re-generation guard. Does NOT bump
6242
+ * `updatedAt` (generation is not an edit of the rule definition).
6243
+ */
6244
+ async markGenerated(ruleId, at) {
6245
+ const existing = this.byId.get(ruleId);
6246
+ if (!existing) throw new Error(`timelapse rule not found: ${ruleId}`);
6247
+ return this.write({
6248
+ ...existing,
6249
+ lastGeneratedAt: at
6250
+ });
6251
+ }
6252
+ /** Idempotent delete — unknown ids are a no-op. */
6253
+ async delete(ruleId) {
6254
+ this.byId.delete(ruleId);
6255
+ try {
6256
+ await this.store.delete.mutate({
6257
+ collection: NC_TIMELAPSE_RULES_COLLECTION,
6258
+ key: ruleId
6259
+ });
6260
+ } catch (err) {
6261
+ this.logger.warn("timelapse rule delete failed", { meta: {
6262
+ ruleId,
6263
+ error: String(err)
6264
+ } });
6265
+ throw err instanceof Error ? err : new Error(String(err));
6266
+ }
6267
+ }
6268
+ /**
6269
+ * Re-validate a merged candidate, persist it, then cache it. Re-validating
6270
+ * means a patch can never persist a rule that would be skipped at the next
6271
+ * `load()`; provenance/ownership survive because they are spread from the
6272
+ * existing rule and never present on a patch.
6273
+ */
6274
+ async write(candidate) {
6275
+ const rule = TimelapseRuleSchema.parse(candidate);
6276
+ await this.persist(rule);
6277
+ this.byId.set(rule.id, rule);
6278
+ return rule;
6279
+ }
6280
+ async persist(rule) {
6281
+ await this.store.set.mutate({
6282
+ collection: NC_TIMELAPSE_RULES_COLLECTION,
6283
+ key: rule.id,
6284
+ value: {
6285
+ name: rule.name,
6286
+ enabled: rule.enabled,
6287
+ updatedAt: rule.updatedAt,
6288
+ rule
6289
+ }
6290
+ });
6291
+ }
6292
+ };
6293
+ //#endregion
5452
6294
  //#region src/notification-center/index.ts
5453
6295
  var DEFAULT_DRAIN_INTERVAL_MS = 2e3;
5454
6296
  var DEFAULT_RULE_RELOAD_INTERVAL_MS = 3e4;
@@ -5543,7 +6385,7 @@ function outboxEntryToHistory(entry) {
5543
6385
  }
5544
6386
  };
5545
6387
  }
5546
- var NotificationCenter = class {
6388
+ var NotificationCenter = class NotificationCenter {
5547
6389
  logger;
5548
6390
  rules;
5549
6391
  outbox;
@@ -5605,11 +6447,19 @@ var NotificationCenter = class {
5605
6447
  get ruleStore() {
5606
6448
  return this.rules;
5607
6449
  }
5608
- /** Declare every Notification Center collection (idempotent, boot-time). */
6450
+ /**
6451
+ * Declare every Notification Center collection (idempotent, boot-time).
6452
+ *
6453
+ * EVERY store the module can touch belongs here, including ones whose
6454
+ * feature is not wired yet: an UNDECLARED collection answers 412 on first
6455
+ * use and takes the whole runner down with it. The timelapse store shipped
6456
+ * without this line and was one enable away from doing exactly that.
6457
+ */
5609
6458
  static async declare(store) {
5610
6459
  await NcRuleStore.declare(store);
5611
6460
  await NcOutbox.declare(store);
5612
6461
  await OccupancyStore.declare(store);
6462
+ await TimelapseStore.declare(store);
5613
6463
  }
5614
6464
  /**
5615
6465
  * Load rules (every node — the cap provider serves CRUD from any node).
@@ -5794,6 +6644,7 @@ var NotificationCenter = class {
5794
6644
  listRules: async () => ({ rules: [...this.rules.list()] }),
5795
6645
  getRule: async ({ ruleId }) => ({ rule: this.rules.get(ruleId) }),
5796
6646
  createRule: async ({ rule, caller }) => {
6647
+ NotificationCenter.assertHasAddressee(rule.targets, rule.targetUsers);
5797
6648
  await this.validateTargetRefs(rule.targets.map((t) => t.targetId));
5798
6649
  const created = await this.rules.create(rule, caller.userId);
5799
6650
  this.logger.info("notification rule created", { meta: {
@@ -5805,6 +6656,10 @@ var NotificationCenter = class {
5805
6656
  },
5806
6657
  updateRule: async ({ ruleId, patch, caller }) => {
5807
6658
  if (patch.targets !== void 0) await this.validateTargetRefs(patch.targets.map((t) => t.targetId));
6659
+ if (patch.targets !== void 0 || patch.targetUsers !== void 0) {
6660
+ const existing = this.rules.get(ruleId);
6661
+ NotificationCenter.assertHasAddressee(patch.targets ?? existing?.targets ?? [], patch.targetUsers ?? existing?.targetUsers);
6662
+ }
5808
6663
  const { disabledTargetIds: _optOut, ...safePatch } = patch;
5809
6664
  const updated = await this.rules.update(ruleId, safePatch);
5810
6665
  this.logger.info("notification rule updated", { meta: {
@@ -5822,7 +6677,10 @@ var NotificationCenter = class {
5822
6677
  return { success: true };
5823
6678
  },
5824
6679
  testRule: async ({ rule, lookbackMinutes }) => ({ results: [...await this.dryRun(rule, lookbackMinutes)] }),
5825
- getConditionCatalog: async () => ({ catalog: [...NC_CONDITION_CATALOG] }),
6680
+ getConditionCatalog: async () => ({
6681
+ catalog: [...NC_CONDITION_CATALOG],
6682
+ taxonomy: NC_TAXONOMY
6683
+ }),
5826
6684
  getHistory: async ({ filter }) => {
5827
6685
  const entries = await this.outbox.queryHistory({
5828
6686
  ...filter.ruleId !== void 0 ? { ruleId: filter.ruleId } : {},
@@ -5863,26 +6721,84 @@ var NotificationCenter = class {
5863
6721
  async evaluateAndEnqueue(subject, kind) {
5864
6722
  const delivery = kind === "object-event" || kind === "audio-event" ? "immediate" : kind === "occupancy-event" ? "device-event" : kind;
5865
6723
  const candidates = this.rules.listEnabled(delivery);
5866
- if (candidates.length === 0) return;
6724
+ if (candidates.length === 0) {
6725
+ this.logger.debug("no enabled rule for this trigger", {
6726
+ tags: { deviceId: subject.deviceId },
6727
+ meta: {
6728
+ delivery,
6729
+ kind,
6730
+ rulesLoaded: this.rules.list().length
6731
+ }
6732
+ });
6733
+ return;
6734
+ }
5867
6735
  const now = this.now();
5868
6736
  for (const rule of candidates) {
5869
6737
  const evaluation = evaluateRule(rule, subject);
5870
- if (!evaluation.matched) continue;
6738
+ if (!evaluation.matched) {
6739
+ this.logger.debug("rule did not match", {
6740
+ tags: { deviceId: subject.deviceId },
6741
+ meta: {
6742
+ ruleId: rule.id,
6743
+ rule: rule.name,
6744
+ kind,
6745
+ failed: evaluation.failedCondition,
6746
+ classes: subject.classNames,
6747
+ eventId: subject.recordId,
6748
+ ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
6749
+ }
6750
+ });
6751
+ continue;
6752
+ }
5871
6753
  const key = cooldownKey(rule, subject);
5872
- if (isCoolingDown(rule, this.lastFiredAt.get(key), now)) continue;
5873
- if (await this.outbox.enqueue(this.buildEntries(rule, subject, kind, evaluation.matchedOn)) > 0) this.lastFiredAt.set(key, now);
6754
+ if (isCoolingDown(rule, this.lastFiredAt.get(key), now)) {
6755
+ this.logger.debug("rule matched but is cooling down", {
6756
+ tags: { deviceId: subject.deviceId },
6757
+ meta: {
6758
+ ruleId: rule.id,
6759
+ rule: rule.name,
6760
+ key,
6761
+ eventId: subject.recordId,
6762
+ ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
6763
+ }
6764
+ });
6765
+ continue;
6766
+ }
6767
+ this.logger.info("rule matched — enqueueing", {
6768
+ tags: { deviceId: subject.deviceId },
6769
+ meta: {
6770
+ ruleId: rule.id,
6771
+ rule: rule.name,
6772
+ kind,
6773
+ targets: rule.targets.length,
6774
+ eventId: subject.recordId,
6775
+ ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
6776
+ ...subject.confidence !== void 0 ? { confidence: subject.confidence } : {},
6777
+ ...subject.zones.length > 0 ? { zones: subject.zones } : {}
6778
+ }
6779
+ });
6780
+ const userTargets = await this.resolveUserTargets(rule, subject.deviceId);
6781
+ if (await this.outbox.enqueue(this.buildEntries(rule, subject, kind, evaluation.matchedOn, userTargets)) > 0) this.lastFiredAt.set(key, now);
5874
6782
  }
5875
6783
  }
5876
- buildEntries(rule, subject, kind, matchedOn) {
6784
+ buildEntries(rule, subject, kind, matchedOn, userTargets = []) {
5877
6785
  const hasEventMedia = kind === "object-event" || kind === "package-event";
5878
6786
  const isTrackScoped = kind === "object-event" || kind === "track-end";
5879
- return rule.targets.filter((target) => !rule.disabledTargetIds.includes(target.targetId)).map((target) => {
6787
+ const direct = new Set(rule.targets.map((t) => t.targetId));
6788
+ return [...rule.targets, ...userTargets.filter((t) => !direct.has(t.targetId))].filter((target) => !rule.disabledTargetIds.includes(target.targetId)).map((target) => {
5880
6789
  const payload = {
5881
6790
  ruleName: rule.name,
5882
6791
  delivery: rule.delivery,
5883
6792
  priority: rule.priority,
5884
6793
  ...rule.template !== void 0 ? { template: rule.template } : {},
5885
6794
  media: rule.media.attach,
6795
+ ...rule.media.zoneCrop === true && rule.conditions.zones !== void 0 ? { mediaZoneIds: [...rule.conditions.zones.ids] } : {},
6796
+ ...rule.media.frame !== void 0 ? { mediaFrame: rule.media.frame } : {},
6797
+ ...rule.media.profile !== void 0 ? { mediaProfile: rule.media.profile } : {},
6798
+ ...rule.media.gif === true ? { mediaGif: true } : {},
6799
+ ...rule.media.clip === true ? { mediaClip: true } : {},
6800
+ ...rule.media.clipPreRollSec !== void 0 ? { mediaClipPreRollSec: rule.media.clipPreRollSec } : {},
6801
+ ...rule.media.clipPostRollSec !== void 0 ? { mediaClipPostRollSec: rule.media.clipPostRollSec } : {},
5886
6802
  ...matchedOn !== void 0 && matchedOn.length > 0 ? { matchedOn } : {},
5887
6803
  ...target.params !== void 0 ? { params: target.params } : {},
5888
6804
  subject: {
@@ -5908,6 +6824,63 @@ var NotificationCenter = class {
5908
6824
  };
5909
6825
  });
5910
6826
  }
6827
+ /** rule→user fan-out caches (60 s users/targets; device routes immutable). */
6828
+ userGrantsCache = null;
6829
+ userGrantsAt = 0;
6830
+ ownedTargetsCache = null;
6831
+ ownedTargetsAt = 0;
6832
+ deviceRouteCache = /* @__PURE__ */ new Map();
6833
+ userFanoutUnavailableLogged = false;
6834
+ /**
6835
+ * The personal targets a rule's `targetUsers` resolve to for THIS device:
6836
+ * each addressed user contributes the enabled targets they own — but only
6837
+ * when their `allowedDevices` grant covers the firing camera (admin users
6838
+ * always pass). A user must never be notified about a device they cannot
6839
+ * open.
6840
+ */
6841
+ async resolveUserTargets(rule, deviceId) {
6842
+ const users = rule.targetUsers;
6843
+ if (users === void 0 || users.length === 0) return [];
6844
+ const { listUsers, getDeviceRoute } = this.deps;
6845
+ if (listUsers === void 0 || getDeviceRoute === void 0) {
6846
+ if (!this.userFanoutUnavailableLogged) {
6847
+ this.userFanoutUnavailableLogged = true;
6848
+ this.logger.warn("rule addresses users but the user fan-out deps are not wired", { meta: { ruleId: rule.id } });
6849
+ }
6850
+ return [];
6851
+ }
6852
+ const now = this.now();
6853
+ if (this.userGrantsCache === null || now - this.userGrantsAt > 6e4) {
6854
+ this.userGrantsCache = await listUsers();
6855
+ this.userGrantsAt = now;
6856
+ }
6857
+ if (this.ownedTargetsCache === null || now - this.ownedTargetsAt > 6e4) {
6858
+ const targets = await this.deps.dispatcher.listTargets();
6859
+ const owned = /* @__PURE__ */ new Map();
6860
+ for (const t of targets) {
6861
+ if (t.ownerUserId === void 0 || !t.enabled) continue;
6862
+ const list = owned.get(t.ownerUserId) ?? [];
6863
+ list.push(t.id);
6864
+ owned.set(t.ownerUserId, list);
6865
+ }
6866
+ this.ownedTargetsCache = owned;
6867
+ this.ownedTargetsAt = now;
6868
+ }
6869
+ if (!this.deviceRouteCache.has(deviceId)) this.deviceRouteCache.set(deviceId, await getDeviceRoute(deviceId));
6870
+ const route = this.deviceRouteCache.get(deviceId) ?? null;
6871
+ const out = [];
6872
+ for (const userId of users) {
6873
+ const user = this.userGrantsCache.find((u) => u.id === userId);
6874
+ if (user === void 0) continue;
6875
+ if (!user.isAdmin) {
6876
+ if (route === null) continue;
6877
+ const grant = user.allowedDevices[route.addonId];
6878
+ if (!(grant === "*" || Array.isArray(grant) && grant.includes(route.stableId))) continue;
6879
+ }
6880
+ for (const targetId of this.ownedTargetsCache.get(userId) ?? []) out.push({ targetId });
6881
+ }
6882
+ return out;
6883
+ }
5911
6884
  /** Rebuild the cooldown map from persisted outbox rows (restart-proof). */
5912
6885
  async seedCooldowns() {
5913
6886
  const entries = await this.outbox.queryRecentPersisted(this.now() - 864e5);
@@ -6021,10 +6994,21 @@ var NotificationCenter = class {
6021
6994
  this.drainTicks += 1;
6022
6995
  if (this.drainTicks % WATERMARK_EVERY_TICKS === 0) await this.outbox.setWatermark(this.now());
6023
6996
  }
6997
+ /** The "at least one addressee" invariant lives here, not in Zod: the
6998
+ * schema allows empty `targets` (a users-only rule) and a cross-field
6999
+ * refine would break `NcRulePatchSchema.partial()`. */
7000
+ static assertHasAddressee(targets, targetUsers) {
7001
+ if (targets.length === 0 && (targetUsers?.length ?? 0) === 0) throw new Error("a rule needs at least one delivery target or user");
7002
+ }
6024
7003
  /** Rule-save referential check: every targetId must resolve in the
6025
7004
  * live notification-output catalog (spec §2.3 save-time validation). */
6026
7005
  async validateTargetRefs(targetIds) {
7006
+ if (targetIds.length === 0) return;
6027
7007
  const targets = await this.deps.dispatcher.listTargets();
7008
+ if (targets.length === 0) {
7009
+ 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] } });
7010
+ return;
7011
+ }
6028
7012
  const known = new Set(targets.map((t) => t.id));
6029
7013
  for (const id of targetIds) if (!known.has(id)) throw new Error(`unknown notification target: ${id}`);
6030
7014
  }
@@ -6139,15 +7123,28 @@ function makeNcActionHandlers(deps) {
6139
7123
  if (rule === null) throw new Error(`forbidden: rule not found: ${ruleId}`);
6140
7124
  return rule;
6141
7125
  };
6142
- const assertRuleOwned = (ruleId, userId) => {
7126
+ /**
7127
+ * A rule the caller may EDIT. Ownership binds non-admins; an ADMIN
7128
+ * administers every rule — global ones (`ownerUserId: undefined`, what the
7129
+ * admin UI creates) and, for support, a user's personal rule.
7130
+ *
7131
+ * Without the admin bypass the comparison `rule.ownerUserId === userId` is
7132
+ * false for EVERY caller on a global rule, so the viewer refused to let an
7133
+ * admin edit an admin rule (operator report, 2026-07-30). Same bypass shape
7134
+ * as `assertTargetsOwned`.
7135
+ */
7136
+ const assertRuleEditable = (ruleId, caller) => {
6143
7137
  const rule = assertOwnsRule(ruleId);
6144
- if (rule.ownerUserId !== userId) throw new Error(`forbidden: rule not owned: ${ruleId}`);
7138
+ if (caller.isAdmin) return rule;
7139
+ if (rule.ownerUserId !== caller.userId) throw new Error(`forbidden: rule not owned: ${ruleId}`);
6145
7140
  return rule;
6146
7141
  };
6147
- /** A rule the caller may SEE — his own personal rule OR a global/admin rule. */
6148
- const assertRuleVisible = (ruleId, userId) => {
7142
+ /** A rule the caller may SEE — his own personal rule, a global/admin rule, or
7143
+ * (for an admin) any rule at all. */
7144
+ const assertRuleVisible = (ruleId, caller) => {
6149
7145
  const rule = assertOwnsRule(ruleId);
6150
- if (rule.ownerUserId !== void 0 && rule.ownerUserId !== userId) throw new Error(`forbidden: rule not owned: ${ruleId}`);
7146
+ if (caller.isAdmin) return rule;
7147
+ if (rule.ownerUserId !== void 0 && rule.ownerUserId !== caller.userId) throw new Error(`forbidden: rule not owned: ${ruleId}`);
6151
7148
  return rule;
6152
7149
  };
6153
7150
  const assertTargetsOwned = async (targetIds, caller) => {
@@ -6158,9 +7155,9 @@ function makeNcActionHandlers(deps) {
6158
7155
  return {
6159
7156
  "nc.listRules": async (_input, caller) => {
6160
7157
  const c = requireCaller(caller);
6161
- return { rules: deps.ruleStore.listForOwner(c.userId).map((r) => ({
7158
+ return { rules: (c.isAdmin ? deps.ruleStore.list() : deps.ruleStore.listForOwner(c.userId)).map((r) => ({
6162
7159
  ...r,
6163
- readOnly: r.ownerUserId !== c.userId
7160
+ readOnly: !c.isAdmin && r.ownerUserId !== c.userId
6164
7161
  })) };
6165
7162
  },
6166
7163
  "nc.getConditionCatalog": async () => ({
@@ -6183,7 +7180,7 @@ function makeNcActionHandlers(deps) {
6183
7180
  },
6184
7181
  "nc.updateRule": async (input, caller) => {
6185
7182
  const c = requireCaller(caller);
6186
- assertRuleOwned(input.ruleId, c.userId);
7183
+ assertRuleEditable(input.ruleId, c);
6187
7184
  const patch = NcRulePatchSchema.parse(input.patch);
6188
7185
  if (patch.targets !== void 0) await assertTargetsOwned(patch.targets.map((t) => t.targetId), c);
6189
7186
  const { ownerUserId: _owner, disabledTargetIds: _optOut, ...safe } = patch;
@@ -6196,7 +7193,7 @@ function makeNcActionHandlers(deps) {
6196
7193
  },
6197
7194
  "nc.deleteRule": async (input, caller) => {
6198
7195
  const c = requireCaller(caller);
6199
- assertRuleOwned(input.ruleId, c.userId);
7196
+ assertRuleEditable(input.ruleId, c);
6200
7197
  await deps.ruleStore.delete(input.ruleId);
6201
7198
  deps.logger.info("nc rule deleted", { meta: {
6202
7199
  ruleId: input.ruleId,
@@ -6206,7 +7203,7 @@ function makeNcActionHandlers(deps) {
6206
7203
  },
6207
7204
  "nc.setRuleTargetEnabled": async (input, caller) => {
6208
7205
  const c = requireCaller(caller);
6209
- assertRuleVisible(input.ruleId, c.userId);
7206
+ assertRuleVisible(input.ruleId, c);
6210
7207
  await assertTargetsOwned([input.targetId], c);
6211
7208
  await deps.ruleStore.setRuleTargetEnabled(input.ruleId, input.targetId, input.enabled);
6212
7209
  return { success: true };
@@ -8858,7 +9855,15 @@ var MEDIA_COLUMNS = [
8858
9855
  name: "sizeBytes",
8859
9856
  type: "INTEGER",
8860
9857
  notNull: true
8861
- }
9858
+ },
9859
+ (
9860
+ /** Storage location holding the blob. NULL = the default `eventMedia`
9861
+ * location (every pre-Phase-3 row, and every write until multi-location
9862
+ * events exist) — the relocate mover stamps real ids as it moves blobs. */
9863
+ {
9864
+ name: "locationId",
9865
+ type: "TEXT"
9866
+ })
8862
9867
  ];
8863
9868
  var MEDIA_INDEXES = [{
8864
9869
  name: "idx_media_owner",
@@ -8867,11 +9872,18 @@ var MEDIA_INDEXES = [{
8867
9872
  name: "idx_media_device_ts",
8868
9873
  columns: ["deviceId", "timestamp"]
8869
9874
  }];
9875
+ /** The storage location of a media row/record: its stamped `locationId`, or
9876
+ * the default `eventMedia` location for NULL (pre-multi-location) rows. */
9877
+ var DEFAULT_MEDIA_LOCATION = "eventMedia";
9878
+ function mediaRowLocation(data) {
9879
+ const id = data?.["locationId"];
9880
+ return typeof id === "string" && id.length > 0 ? id : DEFAULT_MEDIA_LOCATION;
9881
+ }
8870
9882
  function buildKey(params) {
8871
9883
  return isSingleInstanceKind(params.kind) ? `${params.ownerKind}:${params.ownerId}:${params.kind}` : `${params.ownerKind}:${params.ownerId}:${params.kind}:${params.timestamp}`;
8872
9884
  }
8873
9885
  function buildPath(params) {
8874
- const base = `pipeline-analytics/${params.deviceId}/${params.ownerKind}/${params.ownerId}`;
9886
+ const base = `${params.deviceId}/events/${params.ownerKind}/${params.ownerId}`;
8875
9887
  return isSingleInstanceKind(params.kind) ? `${base}/${params.kind}.jpg` : `${base}/${params.kind}-${params.timestamp}.jpg`;
8876
9888
  }
8877
9889
  var MediaStore = class {
@@ -8902,7 +9914,8 @@ var MediaStore = class {
8902
9914
  kind: params.kind,
8903
9915
  timestamp: params.timestamp,
8904
9916
  path,
8905
- sizeBytes: params.data.length
9917
+ sizeBytes: params.data.length,
9918
+ locationId: null
8906
9919
  };
8907
9920
  try {
8908
9921
  await this.storage.write({
@@ -8959,10 +9972,11 @@ var MediaStore = class {
8959
9972
  const newKey = await this.put(params);
8960
9973
  for (const row of existing) {
8961
9974
  if (row.id === newKey) continue;
8962
- const path = String(row.data["path"] ?? "");
9975
+ const rowData = row.data;
9976
+ const path = String(rowData["path"] ?? "");
8963
9977
  if (path) try {
8964
9978
  await this.storage.delete({
8965
- location: "eventMedia",
9979
+ location: mediaRowLocation(rowData),
8966
9980
  relativePath: path
8967
9981
  });
8968
9982
  } catch {}
@@ -9030,7 +10044,7 @@ var MediaStore = class {
9030
10044
  key,
9031
10045
  kind,
9032
10046
  base64: (await this.storage.read({
9033
- location: "eventMedia",
10047
+ location: mediaRowLocation(data),
9034
10048
  relativePath: path
9035
10049
  })).toString("base64"),
9036
10050
  sizeBytes,
@@ -9050,10 +10064,11 @@ var MediaStore = class {
9050
10064
  key
9051
10065
  });
9052
10066
  if (!row) return;
9053
- const path = String(row["path"] ?? "");
10067
+ const data = row;
10068
+ const path = String(data["path"] ?? "");
9054
10069
  if (path) try {
9055
10070
  await this.storage.delete({
9056
- location: "eventMedia",
10071
+ location: mediaRowLocation(data),
9057
10072
  relativePath: path
9058
10073
  });
9059
10074
  } catch {}
@@ -9092,7 +10107,7 @@ var MediaStore = class {
9092
10107
  const kind = String(data["kind"]);
9093
10108
  try {
9094
10109
  const buf = await this.storage.read({
9095
- location: "eventMedia",
10110
+ location: mediaRowLocation(data),
9096
10111
  relativePath: path
9097
10112
  });
9098
10113
  files.push({
@@ -9127,10 +10142,11 @@ var MediaStore = class {
9127
10142
  } }
9128
10143
  });
9129
10144
  for (const row of rows) {
9130
- const path = String(row.data["path"] ?? "");
10145
+ const rowData = row.data;
10146
+ const path = String(rowData["path"] ?? "");
9131
10147
  try {
9132
10148
  if (path) await this.storage.delete({
9133
- location: "eventMedia",
10149
+ location: mediaRowLocation(rowData),
9134
10150
  relativePath: path
9135
10151
  });
9136
10152
  } catch {}
@@ -9226,14 +10242,14 @@ var MediaStore = class {
9226
10242
  kind,
9227
10243
  timestamp,
9228
10244
  data: await this.storage.read({
9229
- location: "eventMedia",
10245
+ location: mediaRowLocation(meta),
9230
10246
  relativePath: oldPath
9231
10247
  })
9232
10248
  };
9233
10249
  const newKey = await this.put(newParams);
9234
10250
  try {
9235
10251
  await this.storage.delete({
9236
- location: "eventMedia",
10252
+ location: mediaRowLocation(meta),
9237
10253
  relativePath: oldPath
9238
10254
  });
9239
10255
  } catch (err) {
@@ -9303,10 +10319,11 @@ var MediaStore = class {
9303
10319
  if (rows.length === 0) break;
9304
10320
  let deletedInPage = 0;
9305
10321
  for (const row of rows) {
9306
- const path = String(row.data["path"] ?? "");
10322
+ const rowData = row.data;
10323
+ const path = String(rowData["path"] ?? "");
9307
10324
  try {
9308
10325
  if (path) await this.storage.delete({
9309
- location: "eventMedia",
10326
+ location: mediaRowLocation(rowData),
9310
10327
  relativePath: path
9311
10328
  });
9312
10329
  } catch {}
@@ -9862,6 +10879,7 @@ var EventStore = class {
9862
10879
  const rows = await this.store.query.query({
9863
10880
  collection,
9864
10881
  filter: {
10882
+ ...params.deviceId !== void 0 ? { where: { deviceId: params.deviceId } } : {},
9865
10883
  whereBetween: { timestamp: [0, cutoffMs] },
9866
10884
  limit: 500
9867
10885
  }
@@ -10262,6 +11280,183 @@ function stripNulls(data) {
10262
11280
  return out;
10263
11281
  }
10264
11282
  //#endregion
11283
+ //#region src/pipeline-analytics/location-aware-media-storage.ts
11284
+ /**
11285
+ * Location-aware blob storage for event media (entity-routing spec, Phase 3).
11286
+ *
11287
+ * Media rows may carry a `locationId` (stamped by the relocate mover when a
11288
+ * blob is moved off the default location). The write-rate bypass provider
11289
+ * only knows the default media root — this wrapper routes any OTHER location
11290
+ * id through a resolver (the storage cap's `resolve`, cached forever: a
11291
+ * location's root only changes via operator reconfig, which restarts us) and
11292
+ * does direct fs I/O against that root, keeping the bypass's
11293
+ * no-RPC-per-blob property for every location.
11294
+ */
11295
+ function createLocationAwareMediaStorage(deps) {
11296
+ const roots = /* @__PURE__ */ new Map();
11297
+ const rootOf = async (locationId) => {
11298
+ const cached = roots.get(locationId);
11299
+ if (cached !== void 0) return cached;
11300
+ const root = await deps.resolveRoot(locationId);
11301
+ roots.set(locationId, root);
11302
+ return root;
11303
+ };
11304
+ const absOf = async (locationId, relativePath) => path.join(await rootOf(locationId), relativePath);
11305
+ return {
11306
+ write: async (input) => {
11307
+ if (input.location === deps.defaultLocation) return deps.base.write(input);
11308
+ const abs = await absOf(input.location, input.relativePath);
11309
+ await promises.mkdir(path.dirname(abs), { recursive: true });
11310
+ await promises.writeFile(abs, input.data);
11311
+ },
11312
+ read: async (input) => {
11313
+ if (input.location === deps.defaultLocation) return deps.base.read(input);
11314
+ return promises.readFile(await absOf(input.location, input.relativePath));
11315
+ },
11316
+ delete: async (input) => {
11317
+ if (input.location === deps.defaultLocation) return deps.base.delete(input);
11318
+ await promises.rm(await absOf(input.location, input.relativePath), { force: true });
11319
+ }
11320
+ };
11321
+ }
11322
+ //#endregion
11323
+ //#region src/pipeline-analytics/media-relocate-engine.ts
11324
+ var PAGE_SIZE = 200;
11325
+ var DEFAULT_THROTTLE_MBPS = 40;
11326
+ function snapshot(j) {
11327
+ return {
11328
+ jobId: j.jobId,
11329
+ state: j.state,
11330
+ fromLocationId: j.fromLocationId,
11331
+ toLocationId: j.toLocationId,
11332
+ deviceId: j.deviceId,
11333
+ entities: ["media"],
11334
+ filesMoved: j.filesMoved,
11335
+ bytesMoved: j.bytesMoved,
11336
+ filesTotal: null,
11337
+ startedAt: j.startedAt,
11338
+ finishedAt: j.finishedAt,
11339
+ error: j.error
11340
+ };
11341
+ }
11342
+ var MediaRelocateEngine = class {
11343
+ deps;
11344
+ jobs = /* @__PURE__ */ new Map();
11345
+ constructor(deps) {
11346
+ this.deps = deps;
11347
+ }
11348
+ list() {
11349
+ return [...this.jobs.values()].sort((a, b) => b.startedAt - a.startedAt).map(snapshot);
11350
+ }
11351
+ cancel(jobId) {
11352
+ const job = this.jobs.get(jobId);
11353
+ if (!job || job.state !== "running") return false;
11354
+ job.cancelRequested = true;
11355
+ return true;
11356
+ }
11357
+ start(input) {
11358
+ for (const j of this.jobs.values()) if (j.state === "running") throw new Error(`a media relocation is already running (${j.jobId})`);
11359
+ const job = {
11360
+ jobId: this.deps.newId(),
11361
+ state: "running",
11362
+ fromLocationId: "*",
11363
+ toLocationId: input.toLocationId,
11364
+ deviceId: input.deviceId ?? null,
11365
+ filesMoved: 0,
11366
+ bytesMoved: 0,
11367
+ startedAt: this.deps.now(),
11368
+ finishedAt: null,
11369
+ error: null,
11370
+ cancelRequested: false
11371
+ };
11372
+ this.jobs.set(job.jobId, job);
11373
+ this.run(job, input.throttleMbps ?? DEFAULT_THROTTLE_MBPS);
11374
+ return job.jobId;
11375
+ }
11376
+ async run(job, throttleMbps) {
11377
+ const sleep = this.deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
11378
+ const bytesPerMs = throttleMbps * 1024 * 1024 / 1e3;
11379
+ try {
11380
+ await this.deps.resolveTargetRoot(job.toLocationId);
11381
+ let cursor = 0;
11382
+ let seenAtCursor = /* @__PURE__ */ new Set();
11383
+ for (;;) {
11384
+ if (job.cancelRequested) break;
11385
+ const fresh = (await this.deps.store.query.query({
11386
+ collection: MEDIA_COLLECTION,
11387
+ filter: {
11388
+ ...job.deviceId !== null ? { where: { deviceId: job.deviceId } } : {},
11389
+ whereBetween: { timestamp: [cursor, Number.MAX_SAFE_INTEGER] },
11390
+ orderBy: {
11391
+ field: "timestamp",
11392
+ direction: "asc"
11393
+ },
11394
+ limit: PAGE_SIZE
11395
+ }
11396
+ })).filter((r) => !seenAtCursor.has(r.id));
11397
+ if (fresh.length === 0) break;
11398
+ for (const row of fresh) {
11399
+ if (job.cancelRequested) break;
11400
+ const data = row.data;
11401
+ const from = mediaRowLocation(data);
11402
+ if (from === job.toLocationId) continue;
11403
+ const relativePath = String(data["path"] ?? "");
11404
+ if (relativePath.length === 0) continue;
11405
+ try {
11406
+ const bytes = await this.deps.storage.read({
11407
+ location: from,
11408
+ relativePath
11409
+ });
11410
+ await this.deps.storage.write({
11411
+ location: job.toLocationId,
11412
+ relativePath,
11413
+ data: bytes
11414
+ });
11415
+ await this.deps.store.set.mutate({
11416
+ collection: MEDIA_COLLECTION,
11417
+ key: row.id,
11418
+ value: {
11419
+ ...data,
11420
+ locationId: job.toLocationId
11421
+ }
11422
+ });
11423
+ await this.deps.storage.delete({
11424
+ location: from,
11425
+ relativePath
11426
+ });
11427
+ job.filesMoved++;
11428
+ job.bytesMoved += bytes.length;
11429
+ await sleep(bytes.length / bytesPerMs);
11430
+ } catch (err) {
11431
+ this.deps.logger.debug("media relocate row failed", { meta: {
11432
+ key: row.id,
11433
+ error: String(err)
11434
+ } });
11435
+ }
11436
+ }
11437
+ const last = fresh[fresh.length - 1];
11438
+ const lastTs = Number(last.data["timestamp"] ?? cursor);
11439
+ if (lastTs === cursor) for (const r of fresh) seenAtCursor.add(r.id);
11440
+ else {
11441
+ cursor = lastTs;
11442
+ seenAtCursor = new Set(fresh.filter((r) => Number(r.data["timestamp"]) === lastTs).map((r) => r.id));
11443
+ }
11444
+ }
11445
+ job.state = job.cancelRequested ? "cancelled" : "done";
11446
+ } catch (err) {
11447
+ job.state = "failed";
11448
+ job.error = err instanceof Error ? err.message : String(err);
11449
+ this.deps.logger.warn("media relocate job failed", { meta: {
11450
+ jobId: job.jobId,
11451
+ error: job.error
11452
+ } });
11453
+ } finally {
11454
+ job.finishedAt = this.deps.now();
11455
+ this.deps.onFinished?.(snapshot(job));
11456
+ }
11457
+ }
11458
+ };
11459
+ //#endregion
10265
11460
  //#region src/pipeline-analytics/store/sensor-event-store.ts
10266
11461
  var SENSOR_EVENTS_COLLECTION = "pipeline-analytics:sensor-events";
10267
11462
  var SENSOR_EVENT_COLUMNS = [
@@ -11388,7 +12583,7 @@ var EventMediaDispatcher = class {
11388
12583
  if (sn.rollingLastFrame && boxed) lastFrameWritten = await this.replaceKind(deviceId, sn.trackId, "lastFrame", sn.timestamp, boxed);
11389
12584
  let thumbnailWritten = false;
11390
12585
  if (sn.bestThumbnail) {
11391
- const variants = await this.cropSubjectVariants(frameHandle, fw, fh, sn.bbox);
12586
+ const variants = await this.cropSubjectVariants(deviceId, frameHandle, fw, fh, sn.bbox);
11392
12587
  if (variants) {
11393
12588
  thumbnailWritten = await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, variants.thumbnail);
11394
12589
  await this.replaceKind(deviceId, sn.trackId, "thumbnailSmall", sn.timestamp, variants.thumbnailSmall);
@@ -11490,12 +12685,15 @@ var EventMediaDispatcher = class {
11490
12685
  * per-frame retry lands a real native crop later. Never a local resize
11491
12686
  * upscale of a ≤640 tile (a blurred lie).
11492
12687
  */
11493
- async cropSubjectVariants(frameHandle, fw, fh, bbox) {
12688
+ async cropSubjectVariants(deviceId, frameHandle, fw, fh, bbox) {
11494
12689
  if (!this.deps.getNativeCropJpeg) {
11495
- this.deps.logger.warn("native subject crop miss — will retry, no upscale", { meta: {
11496
- shmId: frameHandle.shmId,
11497
- reason: "no-native-cap"
11498
- } });
12690
+ this.deps.logger.warn("native subject crop miss — will retry, no upscale", {
12691
+ tags: { deviceId },
12692
+ meta: {
12693
+ shmId: frameHandle.shmId,
12694
+ reason: "no-native-cap"
12695
+ }
12696
+ });
11499
12697
  return null;
11500
12698
  }
11501
12699
  try {
@@ -11503,12 +12701,19 @@ var EventMediaDispatcher = class {
11503
12701
  W: fw,
11504
12702
  H: fh
11505
12703
  });
12704
+ const askedAt = Date.now();
11506
12705
  const slab = await this.deps.getNativeCropJpeg(frameHandle, layout.fetch);
11507
12706
  if (!slab) {
11508
- this.deps.logger.warn("native subject crop miss — will retry, no upscale", { meta: {
11509
- shmId: frameHandle.shmId,
11510
- reason: "native-miss"
11511
- } });
12707
+ this.deps.logger.warn("native subject crop miss — will retry, no upscale", {
12708
+ tags: { deviceId },
12709
+ meta: {
12710
+ shmId: frameHandle.shmId,
12711
+ handle: `${frameHandle.shmId}#${frameHandle.slot}#${frameHandle.seq}`,
12712
+ handleNodeId: frameHandle.nodeId,
12713
+ roundTripMs: Date.now() - askedAt,
12714
+ reason: "native-miss"
12715
+ }
12716
+ });
11512
12717
  return null;
11513
12718
  }
11514
12719
  const thumbnail = await composeWideCentralSquareThumbnail(slab, layout);
@@ -11517,10 +12722,14 @@ var EventMediaDispatcher = class {
11517
12722
  thumbnailSmall: await deriveThumbnailSmall(thumbnail)
11518
12723
  };
11519
12724
  } catch (err) {
11520
- this.deps.logger.warn("native subject crop miss — will retry, no upscale", { meta: {
11521
- shmId: frameHandle.shmId,
11522
- error: err instanceof Error ? err.message : String(err)
11523
- } });
12725
+ this.deps.logger.warn("native subject crop miss — will retry, no upscale", {
12726
+ tags: { deviceId },
12727
+ meta: {
12728
+ shmId: frameHandle.shmId,
12729
+ reason: "threw",
12730
+ error: err instanceof Error ? err.message : String(err)
12731
+ }
12732
+ });
11524
12733
  return null;
11525
12734
  }
11526
12735
  }
@@ -12712,6 +13921,23 @@ function resolveDetectionSensitivitySettings(raw) {
12712
13921
  };
12713
13922
  }
12714
13923
  var TrackingSettingsSchema = object({
13924
+ /**
13925
+ * How much of a detection's box must lie inside a zone (0-1 fraction of the
13926
+ * box's own area) for that zone to be STAMPED onto the detection.
13927
+ *
13928
+ * This is the field a zone-scoped notification rule ultimately depends on: a
13929
+ * rule's `zones` condition is a plain set test over the stamped zone ids, so
13930
+ * a subject that merely clips a zone edge satisfies it. Measured on
13931
+ * 2026-07-30: a dog overlapping `Aiuola` by 4.8% would have counted as
13932
+ * inside it.
13933
+ *
13934
+ * DEFAULT 0 — byte-identical to the behaviour before 2026-07-31. Raising it
13935
+ * is deliberately an operator decision: the overlap fractions are now logged
13936
+ * (`zone membership` lines), so the bar can be chosen from the distribution
13937
+ * instead of guessed. Distinct from a zone RULE's `bboxInclusionPct`, which
13938
+ * gates the DETECTION stage, not what gets stamped.
13939
+ */
13940
+ zoneMembershipMinOverlap: number().min(0).max(1).default(0),
12715
13941
  /** IoU required to match a (predicted) track to a detection. */
12716
13942
  iouThreshold: number().min(0).max(1).default(.3),
12717
13943
  /** Wall-clock coasting budget (ms) before a missed track is dropped. Time-
@@ -12848,6 +14074,7 @@ function resolveTrackingSettings(raw) {
12848
14074
  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;
12849
14075
  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;
12850
14076
  return {
14077
+ zoneMembershipMinOverlap: s.zoneMembershipMinOverlap.catch(TRACKING_DEFAULTS.zoneMembershipMinOverlap).parse(raw.zoneMembershipMinOverlap),
12851
14078
  iouThreshold: s.iouThreshold.catch(TRACKING_DEFAULTS.iouThreshold).parse(raw.iouThreshold),
12852
14079
  maxMissedMs,
12853
14080
  minTrackAgeMs: s.minTrackAgeMs.catch(TRACKING_DEFAULTS.minTrackAgeMs).parse(raw.minTrackAgeMs),
@@ -12902,7 +14129,11 @@ function resolveTrackingSettings(raw) {
12902
14129
  * low-res detection frame (a static "person" phantom, a parked-truck ghost, a
12903
14130
  * misclassified static object).
12904
14131
  *
12905
- * Ships DORMANT: `enabled` defaults to `false`, so behaviour is byte-identical
14132
+ * ON by default (`enabled` defaults to TRUE). An earlier version of this line
14133
+ * said "Ships DORMANT: `enabled` defaults to `false`" — that was stale, and on
14134
+ * 2026-07-30 it nearly produced the conclusion that the gate was not running at
14135
+ * all. It is: it suppressed several phantom births on device 615 that same day.
14136
+ * Read the schema, not this paragraph. Historically the intent was byte-identical
12906
14137
  * to today until an operator opts in per camera. The gate is fail-OPEN — any
12907
14138
  * missing frame handle, unavailable inference cap, crop-fetch miss, re-detection
12908
14139
  * error, or timeout ALLOWS the birth (a real track is never suppressed because
@@ -12998,10 +14229,11 @@ function isConfirmationCompatible(trackClassName, detectionMacroClass) {
12998
14229
  if (track === "other" || det === "other") return true;
12999
14230
  return track === det;
13000
14231
  }
13001
- var failOpen = (trackId, reason) => ({
13002
- trackId,
14232
+ var failOpen = (candidate, reason) => ({
14233
+ trackId: candidate.trackId,
13003
14234
  confirmed: true,
13004
- reason
14235
+ reason,
14236
+ className: candidate.className
13005
14237
  });
13006
14238
  function withTimeout(promise, timeoutMs) {
13007
14239
  return new Promise((resolve, reject) => {
@@ -13017,23 +14249,34 @@ function withTimeout(promise, timeoutMs) {
13017
14249
  }
13018
14250
  async function runConfirmation(candidate, config, deps) {
13019
14251
  const crop = await deps.fetchCrop(candidate);
13020
- if (!crop) return failOpen(candidate.trackId, "no-crop");
14252
+ if (!crop) return failOpen(candidate, "no-crop");
13021
14253
  const detections = await deps.redetect(crop);
13022
- if (detections === null) return failOpen(candidate.trackId, "redetect-error");
13023
- const confirmed = detections.some((d) => d.score >= config.minConfidence && isConfirmationCompatible(candidate.className, d.macroClass));
14254
+ if (detections === null) return failOpen(candidate, "redetect-error");
14255
+ let best;
14256
+ let bestIncompatible;
14257
+ for (const d of detections) if (isConfirmationCompatible(candidate.className, d.macroClass)) {
14258
+ if (!best || d.score > best.score) best = d;
14259
+ } else if (!bestIncompatible || d.score > bestIncompatible.score) bestIncompatible = d;
14260
+ const confirmed = best !== void 0 && best.score >= config.minConfidence;
13024
14261
  return {
13025
14262
  trackId: candidate.trackId,
13026
14263
  confirmed,
13027
- reason: confirmed ? "confirmed" : "suppressed"
14264
+ reason: confirmed ? "confirmed" : "suppressed",
14265
+ className: candidate.className,
14266
+ ...best ? { bestScore: best.score } : {},
14267
+ ...bestIncompatible ? {
14268
+ bestIncompatibleClass: bestIncompatible.macroClass,
14269
+ bestIncompatibleScore: bestIncompatible.score
14270
+ } : {}
13028
14271
  };
13029
14272
  }
13030
14273
  async function confirmOne(candidate, config, deps) {
13031
14274
  const cropPx = Math.max(candidate.bbox.w, candidate.bbox.h);
13032
- if (config.minCropPx > 0 && cropPx < config.minCropPx) return failOpen(candidate.trackId, "below-min-crop");
14275
+ if (config.minCropPx > 0 && cropPx < config.minCropPx) return failOpen(candidate, "below-min-crop");
13033
14276
  try {
13034
14277
  return await withTimeout(runConfirmation(candidate, config, deps), config.timeoutMs);
13035
14278
  } catch {
13036
- return failOpen(candidate.trackId, "timeout");
14279
+ return failOpen(candidate, "timeout");
13037
14280
  }
13038
14281
  }
13039
14282
  /**
@@ -13216,8 +14459,23 @@ function resolveMediaSettings(raw) {
13216
14459
  * §3.3 draft said 45s).
13217
14460
  */
13218
14461
  var PackageDropSettingsSchema = object({
13219
- /** Master switch — off by default; opt-in per camera (porch/door cams). */
13220
- packageDropEnabled: boolean().default(false),
14462
+ /**
14463
+ * Explicit per-camera OFF. **On by default: the ZONE RULE is what enables
14464
+ * package detection**, not a second switch.
14465
+ *
14466
+ * As an opt-in default-false this was a parallel source of truth. Device 615
14467
+ * on 2026-07-30 had the zone (`Uscio`), an enabled `package`-stage zone rule
14468
+ * (`Pacchetti`, classFilter `['package']`), and a notification rule on
14469
+ * `delivery: 'package-event'` — three layers of operator intent, all defeated
14470
+ * silently by a boolean none of them mentions.
14471
+ *
14472
+ * Defaulting to true costs nothing on cameras nobody configured:
14473
+ * `PackageDropDetector.onAppeared` still returns early when the device has no
14474
+ * enabled `package`-stage rule, so the work is a class check plus one cached
14475
+ * lookup. Set this false to force the feature off on a camera that HAS a zone
14476
+ * rule.
14477
+ */
14478
+ packageDropEnabled: boolean().default(true),
13221
14479
  /**
13222
14480
  * Minimum OBSERVED dwell (seconds, since first-seen) before a newly
13223
14481
  * promoted stationary package counts as a delivery. Kills a bag briefly
@@ -13581,37 +14839,7 @@ var AnalyticsQueryFacade = class {
13581
14839
  }));
13582
14840
  }
13583
14841
  };
13584
- //#endregion
13585
- //#region src/pipeline-analytics/track-retention-sweep.ts
13586
- /**
13587
- * Periodic track retention sweep (design §6) — the piece that makes retention
13588
- * actually BOUNDED. Without it, `pruneTracksBefore` only runs when something
13589
- * calls it; this sweep ages persisted tracks out on the provider's existing
13590
- * retention interval.
13591
- *
13592
- * `retentionMs` is a PER-DEVICE setting (`trackRetentionDays`, default 7 days;
13593
- * `0` = keep forever → the device is skipped). Enrolled gallery is exempt by the
13594
- * cascade store layer (design §4), not here.
13595
- *
13596
- * Kept as a pure, dependency-injected function so the loop is unit-testable
13597
- * without booting the addon.
13598
- */
13599
- var DAY_MS = 1440 * 60 * 1e3;
13600
- /** Per-device track retention setting. Default 7 days; 0 = keep forever. */
13601
- var TrackRetentionSettingsSchema = object({ trackRetentionDays: number().min(0).default(7) });
13602
- var TRACK_RETENTION_DEFAULT_DAYS = TrackRetentionSettingsSchema.parse({}).trackRetentionDays;
13603
- /** Resolve `trackRetentionDays` from a raw per-device store blob — an invalid or
13604
- * missing value falls back to the default (parse never throws). */
13605
- function resolveTrackRetentionDays(raw) {
13606
- const parsed = TrackRetentionSettingsSchema.shape.trackRetentionDays.safeParse(raw["trackRetentionDays"]);
13607
- return parsed.success ? parsed.data : TRACK_RETENTION_DEFAULT_DAYS;
13608
- }
13609
- /** Cutoff timestamp for a retention window. `null` when retention is disabled
13610
- * (`retentionDays <= 0` → keep forever, the sweep skips the device). */
13611
- function trackRetentionCutoff(nowMs, retentionDays) {
13612
- if (retentionDays <= 0) return null;
13613
- return nowMs - retentionDays * DAY_MS;
13614
- }
14842
+ var TRACK_RETENTION_DEFAULT_DAYS = object({ trackRetentionDays: number().min(0).default(7) }).parse({}).trackRetentionDays;
13615
14843
  /**
13616
14844
  * Sweep every device with persisted tracks: skip retention-disabled devices,
13617
14845
  * prune the rest at `now − retentionMs`. Per-device ISOLATED — one bad device
@@ -13622,7 +14850,7 @@ async function sweepTrackRetention(deps) {
13622
14850
  const nowMs = deps.now();
13623
14851
  let totalTracks = 0;
13624
14852
  for (const deviceId of devices) try {
13625
- const cutoffMs = trackRetentionCutoff(nowMs, await deps.resolveRetentionDays(deviceId));
14853
+ const cutoffMs = await deps.resolveCutoffMs(deviceId, nowMs);
13626
14854
  if (cutoffMs === null) continue;
13627
14855
  const counts = await deps.pruneTracksBefore(deviceId, cutoffMs);
13628
14856
  totalTracks += counts.tracks;
@@ -14187,6 +15415,19 @@ function retagDetectionSections(sections) {
14187
15415
  } : s);
14188
15416
  }
14189
15417
  /**
15418
+ * Re-home the analytics `retention` section onto the recorder's `recording`
15419
+ * top-tab (operator ask, 2026-07-29): footage and analytics retention are ONE
15420
+ * unified policy since the follow-recordings default, so their controls
15421
+ * belong on ONE tab. Same pure-retag mechanism as the detection sections —
15422
+ * `DeviceDetail` folds matching-tab sections together, no admin-ui change.
15423
+ */
15424
+ function retagRetentionSection(sections) {
15425
+ return sections.map((s) => s.id === "retention" ? {
15426
+ ...s,
15427
+ tab: "recording"
15428
+ } : s);
15429
+ }
15430
+ /**
14190
15431
  * Fields that live ONLY on the global settings page and must never surface in a
14191
15432
  * per-device contribution. The face-recognition `enabled` switch is the GLOBAL
14192
15433
  * master kill for the whole subsystem — per-camera face production is governed
@@ -14365,9 +15606,23 @@ function buildGlobalSettingsSchema() {
14365
15606
  {
14366
15607
  id: "retention",
14367
15608
  title: "Retention",
14368
- description: "How long analytics history is kept in the SQL store. Media files follow the minimum of these.",
15609
+ 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.",
14369
15610
  columns: 3,
14370
15611
  fields: [
15612
+ {
15613
+ type: "select",
15614
+ key: "retentionMode",
15615
+ label: "Mode",
15616
+ 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.",
15617
+ default: "follow-recordings",
15618
+ options: [{
15619
+ value: "follow-recordings",
15620
+ label: "Follow recordings (default)"
15621
+ }, {
15622
+ value: "custom",
15623
+ label: "Custom windows"
15624
+ }]
15625
+ },
14371
15626
  {
14372
15627
  type: "number",
14373
15628
  key: "trackRetentionDays",
@@ -14902,6 +16157,86 @@ async function encodeKeyFrameVariants(native) {
14902
16157
  };
14903
16158
  }
14904
16159
  //#endregion
16160
+ //#region src/pipeline-analytics/retention-policy.ts
16161
+ /**
16162
+ * Unified analytics retention policy (storage entity-routing spec, Phase 1).
16163
+ *
16164
+ * Two modes, per device:
16165
+ *
16166
+ * - `follow-recordings` (the DEFAULT): tracks and events live exactly as long
16167
+ * as the camera's retained footage — ONE cutoff, the instant of the oldest
16168
+ * segment still on disk. That follows the recorder's age retention AND its
16169
+ * disk-pressure eviction automatically, with no second policy to keep in
16170
+ * sync. A camera with no footage at all (recording off / brand new) falls
16171
+ * back to the custom day windows below, so analytics stay bounded either
16172
+ * way.
16173
+ *
16174
+ * - `custom`: the per-kind day windows (tracks / motion / object / audio),
16175
+ * exactly the pre-unification behaviour — except the knobs are now actually
16176
+ * READ (they were exposed in settings and silently ignored by the sweep,
16177
+ * which used hardcoded 14/30/7).
16178
+ *
16179
+ * Follow-mode floor: events younger than {@link FOLLOW_FLOOR_DAYS} are never
16180
+ * pruned, even when footage is younger (a camera that STARTED recording
16181
+ * yesterday must not nuke its whole event history back to the first segment).
16182
+ */
16183
+ var DAY_MS = 1440 * 60 * 1e3;
16184
+ /** Per-device analytics retention settings as stored in the device blob.
16185
+ * `retentionMode` absent = follow-recordings (the new default). The day
16186
+ * windows keep their historical defaults and double as the no-footage
16187
+ * fallback in follow mode. */
16188
+ var RetentionSettingsSchema = object({
16189
+ retentionMode: _enum(["follow-recordings", "custom"]).default("follow-recordings"),
16190
+ trackRetentionDays: number().min(0).default(7),
16191
+ retentionMotionDays: number().min(1).default(14),
16192
+ retentionObjectDays: number().min(1).default(30),
16193
+ retentionAudioDays: number().min(1).default(7)
16194
+ });
16195
+ /** Resolve the settings from a raw device-store blob — invalid or missing
16196
+ * fields fall back to defaults, never throw. */
16197
+ function resolveRetentionSettings(raw) {
16198
+ const parsed = RetentionSettingsSchema.safeParse(raw);
16199
+ if (parsed.success) return parsed.data;
16200
+ const shape = RetentionSettingsSchema.shape;
16201
+ const field = (k) => shape[k].safeParse(raw[k]).success ? shape[k].parse(raw[k]) : RetentionSettingsSchema.parse({})[k];
16202
+ return {
16203
+ retentionMode: field("retentionMode"),
16204
+ trackRetentionDays: field("trackRetentionDays"),
16205
+ retentionMotionDays: field("retentionMotionDays"),
16206
+ retentionObjectDays: field("retentionObjectDays"),
16207
+ retentionAudioDays: field("retentionAudioDays")
16208
+ };
16209
+ }
16210
+ /**
16211
+ * Follow-mode cutoff: the oldest retained footage instant, floored so
16212
+ * anything younger than {@link FOLLOW_FLOOR_DAYS} survives. `null` footage
16213
+ * (none on disk) → `null`, the caller falls back to custom windows.
16214
+ */
16215
+ function followCutoffMs(nowMs, earliestFootageMs) {
16216
+ if (earliestFootageMs === null) return null;
16217
+ return Math.min(earliestFootageMs, nowMs - 7 * DAY_MS);
16218
+ }
16219
+ /** Compute the effective cutoffs for one device at `nowMs`. */
16220
+ function resolveRetentionCutoffs(settings, nowMs, earliestFootageMs) {
16221
+ if (settings.retentionMode === "follow-recordings") {
16222
+ const cutoff = followCutoffMs(nowMs, earliestFootageMs);
16223
+ if (cutoff !== null) return {
16224
+ trackCutoffMs: cutoff,
16225
+ motionCutoffMs: cutoff,
16226
+ objectCutoffMs: cutoff,
16227
+ audioCutoffMs: cutoff,
16228
+ effectiveMode: "follow-recordings"
16229
+ };
16230
+ }
16231
+ return {
16232
+ trackCutoffMs: settings.trackRetentionDays <= 0 ? null : nowMs - settings.trackRetentionDays * DAY_MS,
16233
+ motionCutoffMs: nowMs - settings.retentionMotionDays * DAY_MS,
16234
+ objectCutoffMs: nowMs - settings.retentionObjectDays * DAY_MS,
16235
+ audioCutoffMs: nowMs - settings.retentionAudioDays * DAY_MS,
16236
+ effectiveMode: "custom"
16237
+ };
16238
+ }
16239
+ //#endregion
14905
16240
  //#region src/pipeline-analytics/store/identity-store.ts
14906
16241
  /**
14907
16242
  * IdentityStore — per-person identity registry for face recognition.
@@ -18288,6 +19623,17 @@ var KEY_EVENT_DEFAULT_LIMIT = 50;
18288
19623
  * Absent / empty / non-string all fall back to the hub default — the exact
18289
19624
  * narrowing the old raw read applied inline. */
18290
19625
  var PostProcessingNodeIdSchema = string().min(1);
19626
+ /**
19627
+ * Footage-attachment window + geometry, used when the rule states none. The
19628
+ * window is CENTRED on the event, so the recipient sees the approach and what
19629
+ * followed rather than one side of it.
19630
+ */
19631
+ var NC_FOOTAGE_PRE_ROLL_SEC = 3;
19632
+ var NC_FOOTAGE_POST_ROLL_SEC = 5;
19633
+ var NC_FOOTAGE_MAX_WIDTH = 480;
19634
+ var NC_FOOTAGE_FPS = 5;
19635
+ /** Per-install HMAC secret behind the signed artifact links (minted once). */
19636
+ var NcArtifactSecretSchema = string();
18291
19637
  var EmbeddingEnabledSchema = boolean();
18292
19638
  var SILENCE_FLOOR_DBFS = -55;
18293
19639
  var RETENTION_SWEEP_INTERVAL_MS = 5 * 6e4;
@@ -18348,7 +19694,21 @@ function decodeEmbeddingBase64(base64) {
18348
19694
  const view = new Float32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));
18349
19695
  return Array.from(view);
18350
19696
  }
19697
+ /** A track shorter than this is a candidate phantom, not a subject that came
19698
+ * and went. Brackets the observed plant tracks (3.8-19.0 s). */
19699
+ var SHORT_TRACK_MAX_MS = 25e3;
19700
+ /** Total displacement under this is "did not move at all" (observed: 0-2.5 px). */
19701
+ var MOTIONLESS_MAX_PX = 8;
19702
+ /** Grid the spawn point is quantised to, so respawns whose boxes never repeat
19703
+ * to the pixel still land in one cell. */
19704
+ var PHANTOM_CELL_PX = 32;
19705
+ /** How long a cell remembers its closes. */
19706
+ var PHANTOM_CELL_WINDOW_MS = 360 * 6e4;
18351
19707
  var PipelineAnalyticsAddon = class extends BaseAddon {
19708
+ /** Recent SHORT+MOTIONLESS track closes per `<device>:<class>:<cell>` —
19709
+ * see {@link noteShortMotionlessTrack}. Measurement only; each entry is
19710
+ * filtered against the 6-hour window on write, so it stays bounded. */
19711
+ shortMotionlessCells = /* @__PURE__ */ new Map();
18352
19712
  processors = /* @__PURE__ */ new Map();
18353
19713
  trackStore = null;
18354
19714
  /** Parked-object registry: promotes a track that stopped moving into a
@@ -18359,6 +19719,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
18359
19719
  eventStore = null;
18360
19720
  /** Events-domain ops-log (footprint/prune/manual-delete audit). Null until onInitialize. */
18361
19721
  eventsOpsLog = null;
19722
+ /** Event-media relocation engine (entity-routing Phase 4). */
19723
+ mediaRelocate = null;
18362
19724
  /** Per-camera history of LINKED-device sensor state changes (Part B). */
18363
19725
  sensorEventStore = null;
18364
19726
  /** Ingest-side reverse index (sensor device → linked camera ids), TTL-cached
@@ -18417,6 +19779,88 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
18417
19779
  retentionSweepTimer = null;
18418
19780
  /** Handle for the event-media data-plane listener (dispose on shutdown). */
18419
19781
  eventMediaDataPlane = null;
19782
+ /** The NC artifact plane (signed public links for notification media) and its
19783
+ * data-plane handle. Null until served / when the facility is absent. */
19784
+ ncArtifactPlane = null;
19785
+ ncArtifactDataPlane = null;
19786
+ /** The operator's marked notification endpoint, or null (AUTO / unavailable). */
19787
+ async markedNotificationEndpoint() {
19788
+ try {
19789
+ return (await this.ctx.api.localNetwork.getNotificationEndpoint.query()).baseUrl ?? void 0;
19790
+ } catch {
19791
+ return;
19792
+ }
19793
+ }
19794
+ /**
19795
+ * Serve the NC artifact plane: a PUBLIC route whose authority is the HMAC on
19796
+ * each link (see `notification-center/artifact-url.ts`). It exists because
19797
+ * attachments used to carry BYTES only, and the degrade engine drops a
19798
+ * bytes-only attachment for a url-mode backend — WhatsApp and gotify were
19799
+ * receiving no media at all.
19800
+ *
19801
+ * Best-effort at every step: no facility, no secret or no reachable base URL
19802
+ * ⇒ no plane, and the dispatcher keeps shipping bytes exactly as before.
19803
+ */
19804
+ async serveNcArtifactPlane() {
19805
+ try {
19806
+ const secretState = this.state("ncArtifactSecret", NcArtifactSecretSchema, "");
19807
+ let secret = await secretState.get();
19808
+ if (secret === "") {
19809
+ secret = randomUUID().replace(/-/g, "");
19810
+ await secretState.set(secret);
19811
+ }
19812
+ const store = new NcArtifactStore({
19813
+ dir: path.join(this.ctx.dataDir, "nc-artifacts"),
19814
+ logger: this.ctx.logger.child("nc-artifacts")
19815
+ });
19816
+ await store.start();
19817
+ const plane = new NcArtifactPlane({
19818
+ store,
19819
+ secret,
19820
+ logger: this.ctx.logger.child("nc-artifacts"),
19821
+ routePrefix: `/addon/${this.ctx.id}/nc-artifact`,
19822
+ listEndpoints: async () => collectArtifactEndpoints({
19823
+ markedBaseUrl: await this.markedNotificationEndpoint(),
19824
+ configuredPublicUrl: process.env["CAMSTACK_HUB_PUBLIC_URL"],
19825
+ getConnected: async () => {
19826
+ const status = await this.ctx.api.networkAccess.getStatus.query();
19827
+ this.ctx.logger.debug("artifact base-url: connected ingress", { meta: {
19828
+ connected: status.connected,
19829
+ url: status.endpoint?.url ?? null,
19830
+ protocol: status.endpoint?.protocol ?? null
19831
+ } });
19832
+ return status.connected && status.endpoint !== null ? {
19833
+ url: status.endpoint.url,
19834
+ protocol: status.endpoint.protocol
19835
+ } : null;
19836
+ },
19837
+ listExternal: async () => {
19838
+ return (await this.ctx.api.networkAccess.listEndpoints.query()).map((e) => ({
19839
+ url: e.url,
19840
+ protocol: e.protocol
19841
+ }));
19842
+ },
19843
+ listLan: async (port) => {
19844
+ return (await this.ctx.api.localNetwork.getConnectionEndpoints.query({ port })).endpoints.map((e) => ({
19845
+ baseUrl: e.baseUrl,
19846
+ kind: e.kind,
19847
+ priority: e.priority
19848
+ }));
19849
+ },
19850
+ logger: this.ctx.logger
19851
+ })
19852
+ });
19853
+ this.ncArtifactDataPlane = await this.ctx.dataPlane?.serve({
19854
+ prefix: "nc-artifact",
19855
+ access: "public",
19856
+ handler: plane.handler
19857
+ }) ?? null;
19858
+ this.ncArtifactPlane = this.ncArtifactDataPlane !== null ? plane : null;
19859
+ this.ctx.logger.info("nc-artifact data-plane served", { meta: { served: this.ncArtifactPlane !== null } });
19860
+ } catch (err) {
19861
+ this.ctx.logger.warn("nc-artifact data-plane failed to serve", { meta: { error: errMsg(err) } });
19862
+ }
19863
+ }
18420
19864
  /** Public base URL for event thumbnails: `/addon/<addonId>/event-media`.
18421
19865
  * Set once the data-plane is registered; null until then (e.g. no
18422
19866
  * dataPlane facility in the current environment). */
@@ -18574,7 +20018,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
18574
20018
  });
18575
20019
  },
18576
20020
  emitTrackLifecycle: (payload, timestampMs) => this.emitTrackLifecycle(payload, timestampMs),
18577
- onTrackClosed: (track, ownedMedia, info) => this.notificationCenter?.onTrackClosed(track, ownedMedia, info),
20021
+ onTrackClosed: (track, ownedMedia, info) => {
20022
+ this.noteShortMotionlessTrack(track);
20023
+ return this.notificationCenter?.onTrackClosed(track, ownedMedia, info);
20024
+ },
18578
20025
  deriveThumbnailFromKeyFrame: async (input) => {
18579
20026
  const derived = await deriveKeyFrameThumbnailJpeg({
18580
20027
  ...input,
@@ -18720,12 +20167,19 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
18720
20167
  let storage = this.ctx.kernel.storage;
18721
20168
  const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
18722
20169
  if (mediaRoot) {
18723
- const { FilesystemStorageProvider } = await import("../node-BuPMb-ti.mjs");
18724
- storage = new FilesystemStorageProvider(mediaRoot);
20170
+ const { FilesystemStorageProvider } = await import("../node-BmVBZiOw.mjs");
20171
+ storage = new FilesystemStorageProvider(mediaRoot, { eventMedia: mediaRoot });
18725
20172
  logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
18726
20173
  }
18727
20174
  if (!storage) throw new Error("pipeline-analytics requires ctx.kernel.storage");
18728
- return storage;
20175
+ return createLocationAwareMediaStorage({
20176
+ base: storage,
20177
+ defaultLocation: "eventMedia",
20178
+ resolveRoot: (locationId) => this.ctx.api.storage.resolve.query({
20179
+ location: locationId,
20180
+ relativePath: ""
20181
+ })
20182
+ });
18729
20183
  }
18730
20184
  /** Constructs every SQLite-backed store plus the stationary/package-drop/
18731
20185
  * sensor plumbing, in the exact pre-S7 order. Returns the non-null bundle
@@ -18773,6 +20227,27 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
18773
20227
  logger: logger.child("MediaStore")
18774
20228
  });
18775
20229
  this.mediaStore = mediaStore;
20230
+ this.mediaRelocate = new MediaRelocateEngine({
20231
+ store: api.settingsStore,
20232
+ storage,
20233
+ logger: logger.child("MediaRelocate"),
20234
+ now: () => Date.now(),
20235
+ newId: () => randomUUID(),
20236
+ resolveTargetRoot: (locationId) => api.storage.resolve.query({
20237
+ location: locationId,
20238
+ relativePath: ""
20239
+ }),
20240
+ onFinished: (job) => {
20241
+ this.eventsOpsLog?.append({
20242
+ op: "relocate",
20243
+ reason: "operator",
20244
+ deviceId: job.deviceId,
20245
+ itemsAffected: job.filesMoved,
20246
+ bytesReclaimed: 0,
20247
+ detail: `${job.state}: media → ${job.toLocationId} (${job.bytesMoved} bytes${job.error ? `; ${job.error}` : ""})`
20248
+ });
20249
+ }
20250
+ });
18776
20251
  const eventStore = new EventStore({
18777
20252
  store: api.settingsStore,
18778
20253
  logger: logger.child("EventStore"),
@@ -19018,16 +20493,66 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
19018
20493
  this.notificationCenter = new NotificationCenter({
19019
20494
  store: api.settingsStore,
19020
20495
  logger: logger.child("NotificationCenter"),
20496
+ listUsers: async () => {
20497
+ return (await api.userManagement.listUsers.query()).map((u) => ({
20498
+ id: u.id,
20499
+ isAdmin: u.isAdmin,
20500
+ allowedDevices: u.allowedDevices
20501
+ }));
20502
+ },
20503
+ getDeviceRoute: async (deviceId) => {
20504
+ const device = await api.deviceManager.getDevice.query({ deviceId });
20505
+ return device === null ? null : {
20506
+ stableId: device.stableId,
20507
+ addonId: device.addonId
20508
+ };
20509
+ },
19021
20510
  dispatcher: {
19022
- listTargets: async () => {
19023
- return (await api.notificationOutput.listTargets.query({})).map((t) => ({
19024
- id: t.id,
19025
- addonId: t.addonId,
19026
- name: t.name,
19027
- kind: t.kind,
19028
- enabled: t.enabled
20511
+ getZoneNames: async (deviceId) => {
20512
+ return (await api.zones.listZones.query({ deviceId })).map((z) => ({
20513
+ id: z.id,
20514
+ name: z.name
19029
20515
  }));
19030
20516
  },
20517
+ getZonePolygons: async (deviceId, zoneIds) => {
20518
+ const zones = await api.zones.listZones.query({ deviceId });
20519
+ const wanted = new Set(zoneIds);
20520
+ return zones.filter((z) => wanted.has(z.id) && Array.isArray(z.polygon)).map((z) => (z.polygon ?? []).map((pt) => ({
20521
+ x: pt.x,
20522
+ y: pt.y
20523
+ })));
20524
+ },
20525
+ renderFootage: async (req) => {
20526
+ const res = await api.streamBroker.renderPreBufferClip.mutate({
20527
+ deviceId: req.deviceId,
20528
+ aroundMs: req.aroundMs,
20529
+ format: req.format,
20530
+ preRollSec: req.preRollSec ?? NC_FOOTAGE_PRE_ROLL_SEC,
20531
+ postRollSec: req.postRollSec ?? NC_FOOTAGE_POST_ROLL_SEC,
20532
+ maxWidth: NC_FOOTAGE_MAX_WIDTH,
20533
+ fps: NC_FOOTAGE_FPS,
20534
+ ...req.profile === "high" || req.profile === "mid" || req.profile === "low" ? { profile: req.profile } : {}
20535
+ });
20536
+ const buf = Buffer.from(res.base64, "base64");
20537
+ if (buf.byteLength === 0) return null;
20538
+ const bytes = new Uint8Array(buf.byteLength);
20539
+ bytes.set(buf);
20540
+ return bytes;
20541
+ },
20542
+ publishArtifact: async (bytes, mime) => await this.ncArtifactPlane?.publish(bytes, mime) ?? null,
20543
+ listTargets: async () => {
20544
+ return (await api.notificationOutput.listTargets.query({})).map((t) => {
20545
+ const owner = t.config["ownerUserId"];
20546
+ return {
20547
+ id: t.id,
20548
+ addonId: t.addonId,
20549
+ name: t.name,
20550
+ kind: t.kind,
20551
+ enabled: t.enabled,
20552
+ ...typeof owner === "string" && owner.length > 0 ? { ownerUserId: owner } : {}
20553
+ };
20554
+ });
20555
+ },
19031
20556
  send: async (input) => {
19032
20557
  const { attachments, ...notification } = input.notification;
19033
20558
  return api.notificationOutput.send.mutate({
@@ -19080,6 +20605,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
19080
20605
  handler
19081
20606
  }) ?? null;
19082
20607
  this.eventMediaBaseUrl = this.eventMediaDataPlane !== null ? `/addon/${this.ctx.id}/event-media` : null;
20608
+ await this.serveNcArtifactPlane();
19083
20609
  this.ctx.logger.info("event-media data-plane served", { meta: { baseUrl: this.eventMediaBaseUrl ?? "(no dataPlane facility)" } });
19084
20610
  } catch (err) {
19085
20611
  this.ctx.logger.warn("event-media data-plane failed to serve", { meta: { error: errMsg(err) } });
@@ -19365,6 +20891,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
19365
20891
  const liveRules = proxy?.state.zoneRules.value?.detection ?? [];
19366
20892
  processor.setZones(liveZones);
19367
20893
  processor.setDetectionRules(liveRules);
20894
+ processor.setZoneMembershipMinOverlap(trk.zoneMembershipMinOverlap);
19368
20895
  const result = processor.process({
19369
20896
  timestamp: frame.timestamp,
19370
20897
  frame
@@ -19595,7 +21122,29 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
19595
21122
  } });
19596
21123
  }
19597
21124
  await Promise.all([...result.objectEvents, ...result.appearanceEvents].map((e) => this.eventStore.insertObject(e)));
19598
- if (this.notificationCenter !== null) for (const e of result.objectEvents) this.notificationCenter.onObjectEventPersisted(e);
21125
+ if (this.notificationCenter !== null) {
21126
+ const overlaps = processor.getLastZoneOverlaps();
21127
+ for (const e of result.objectEvents) {
21128
+ if (e.zones && e.zones.length > 0) {
21129
+ const m = e.trackId ? overlaps.get(e.trackId) : void 0;
21130
+ this.ctx.logger.info("zone membership stamped on event", {
21131
+ tags: { deviceId },
21132
+ meta: {
21133
+ eventId: e.id,
21134
+ trackId: e.trackId,
21135
+ className: e.className,
21136
+ minOverlap: trk.zoneMembershipMinOverlap,
21137
+ zones: (m ?? []).map((z) => ({
21138
+ id: z.zoneId,
21139
+ name: z.zoneName,
21140
+ overlapPct: Math.round(z.overlap * 1e3) / 10
21141
+ }))
21142
+ }
21143
+ });
21144
+ }
21145
+ this.notificationCenter.onObjectEventPersisted(e);
21146
+ }
21147
+ }
19599
21148
  const objectEmbeddingBests = [];
19600
21149
  if (this.objectEmbeddingStore) for (const t of result.tracked) {
19601
21150
  if (!isClipObjectEmbedding(t)) continue;
@@ -19947,19 +21496,28 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
19947
21496
  }),
19948
21497
  redetect: (cropJpeg) => this.redetectCropForConfirmation(nodeId, deviceId, cropJpeg),
19949
21498
  onDecision: (decision) => {
21499
+ const meta = {
21500
+ trackId: decision.trackId,
21501
+ reason: decision.reason,
21502
+ className: decision.className,
21503
+ ...decision.bestScore !== void 0 ? { bestScore: decision.bestScore } : {},
21504
+ ...decision.bestIncompatibleClass !== void 0 ? {
21505
+ bestIncompatibleClass: decision.bestIncompatibleClass,
21506
+ bestIncompatibleScore: decision.bestIncompatibleScore
21507
+ } : {},
21508
+ minConfidence: config.minConfidence
21509
+ };
19950
21510
  if (!decision.confirmed) this.ctx.logger.info("confirmation gate: birth suppressed (false positive)", {
19951
21511
  tags: { deviceId },
19952
- meta: {
19953
- trackId: decision.trackId,
19954
- reason: decision.reason
19955
- }
21512
+ meta
19956
21513
  });
19957
21514
  else if (decision.reason !== "confirmed") this.ctx.logger.debug("confirmation gate: birth allowed (fail-open)", {
19958
21515
  tags: { deviceId },
19959
- meta: {
19960
- trackId: decision.trackId,
19961
- reason: decision.reason
19962
- }
21516
+ meta
21517
+ });
21518
+ else this.ctx.logger.info("confirmation gate: birth confirmed", {
21519
+ tags: { deviceId },
21520
+ meta
19963
21521
  });
19964
21522
  }
19965
21523
  });
@@ -19976,6 +21534,48 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
19976
21534
  async resolveDeviceStationarySettings(deviceId) {
19977
21535
  return this.stationarySettingsCache.get(deviceId, (id) => this.readDeviceSettings(id, resolveStationarySettings));
19978
21536
  }
21537
+ /**
21538
+ * Count SHORT + MOTIONLESS track closes per frame cell.
21539
+ *
21540
+ * The stationary registry cannot see this class of phantom. Promotion needs a
21541
+ * track that has EXISTED for `PROMOTION_WINDOW_MS` (30 s), and these die long
21542
+ * before: the dead ornamental grass on device 615 produced tracks of 3.8 s,
21543
+ * 4.1 s, 6.5 s and 19.0 s with 0-2.5 px of total displacement, roughly
21544
+ * fifteen of them in a day, all at the same spot. Each one is individually
21545
+ * innocent; the RECURRENCE is the signal, and nothing survives a track death
21546
+ * to notice it.
21547
+ *
21548
+ * Lowering the promotion window is not the fix — those 30 s exist so someone
21549
+ * standing still at a door is not declared scenery.
21550
+ *
21551
+ * This is the measurement half: it establishes how often a cell repeats
21552
+ * before any suppression is built, so "N closes in what window" comes from
21553
+ * data rather than intuition. It suppresses NOTHING.
21554
+ */
21555
+ noteShortMotionlessTrack(track) {
21556
+ const lifeMs = track.lastSeen - track.firstSeen;
21557
+ const moved = track.totalDistance ?? 0;
21558
+ if (lifeMs > SHORT_TRACK_MAX_MS || moved > MOTIONLESS_MAX_PX) return;
21559
+ const first = track.positions?.[0];
21560
+ if (!first) return;
21561
+ const cell = `${Math.round(first.x / PHANTOM_CELL_PX)},${Math.round(first.y / PHANTOM_CELL_PX)}`;
21562
+ const key = `${track.deviceId}:${track.className}:${cell}`;
21563
+ const now = Date.now();
21564
+ const seen = this.shortMotionlessCells.get(key)?.filter((t) => now - t < PHANTOM_CELL_WINDOW_MS) ?? [];
21565
+ seen.push(now);
21566
+ this.shortMotionlessCells.set(key, seen);
21567
+ this.ctx.logger.info("short motionless track closed", {
21568
+ tags: { deviceId: track.deviceId },
21569
+ meta: {
21570
+ trackId: track.trackId,
21571
+ className: track.className,
21572
+ lifeMs,
21573
+ movedPx: Math.round(moved * 10) / 10,
21574
+ cell,
21575
+ repeatsInWindow: seen.length
21576
+ }
21577
+ });
21578
+ }
19979
21579
  stationarySettingsFromCache(deviceId) {
19980
21580
  return this.stationarySettingsCache.peek(deviceId) ?? STATIONARY_DEFAULTS;
19981
21581
  }
@@ -19985,9 +21585,14 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
19985
21585
  /**
19986
21586
  * Resolve a device's ENABLED `package`-stage zone rules independent of the
19987
21587
  * live frame path (mirrors `resolveDeviceZones`). Warms the proxy and,
19988
- * when the cached slice is empty, forces one refresh. The `package` slice
19989
- * is written by the orchestrator's package-stage provider (a later slice);
19990
- * until then this returns `[]` and no package events fire.
21588
+ * when the cached slice is empty, forces one refresh.
21589
+ *
21590
+ * The `package` slice is written through the `zone-rules` capability
21591
+ * (`zoneRules.setRules({stage:'package'})`), which is live. An earlier
21592
+ * version of this comment claimed the provider did not exist yet and that
21593
+ * "no package events fire" — that was stale, and believing it produced a
21594
+ * confidently wrong diagnosis on 2026-07-30. An empty list here means the
21595
+ * operator has drawn no package zone rule, nothing more.
19991
21596
  */
19992
21597
  async resolveDevicePackageRules(deviceId) {
19993
21598
  const proxy = await this.ensureProxy(deviceId);
@@ -20724,42 +22329,112 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
20724
22329
  if (this.shuttingDown || !this.trackStore) return;
20725
22330
  await this.trackCloser.sweep();
20726
22331
  }
22332
+ /** Unified retention (Phase 1): earliest retained footage per device — the
22333
+ * follow-recordings cutoff source. Cached 10 min: availability answers in
22334
+ * ~70 ms but the sweep asks once per device per pass. `null` = no footage
22335
+ * (or recorder unreachable) → the policy falls back to the custom windows. */
22336
+ earliestFootageCache = /* @__PURE__ */ new Map();
22337
+ async earliestFootageMs(deviceId) {
22338
+ const cached = this.earliestFootageCache.get(deviceId);
22339
+ if (cached && Date.now() - cached.at < 10 * 6e4) return cached.value;
22340
+ let value = null;
22341
+ try {
22342
+ const res = await this.ctx.api.recording.getAvailability.query({
22343
+ deviceId,
22344
+ fromMs: 0,
22345
+ toMs: Date.now()
22346
+ });
22347
+ let min = Number.POSITIVE_INFINITY;
22348
+ for (const r of res.ranges) if (r.startMs < min) min = r.startMs;
22349
+ value = Number.isFinite(min) ? min : null;
22350
+ } catch {
22351
+ value = null;
22352
+ }
22353
+ this.earliestFootageCache.set(deviceId, {
22354
+ at: Date.now(),
22355
+ value
22356
+ });
22357
+ return value;
22358
+ }
22359
+ /** The per-device effective cutoffs (mode + footage → numbers). */
22360
+ async deviceRetentionCutoffs(deviceId, nowMs) {
22361
+ const settings = resolveRetentionSettings(await this.ctx?.settings?.readDeviceStore(deviceId) ?? {});
22362
+ return resolveRetentionCutoffs(settings, nowMs, settings.retentionMode === "follow-recordings" ? await this.earliestFootageMs(deviceId) : null);
22363
+ }
22364
+ /** The devices one retention pass covers: every camera the hub knows plus
22365
+ * any device that still has persisted tracks (covers deleted cameras whose
22366
+ * history must keep aging out). */
22367
+ async retentionDeviceIds() {
22368
+ const ids = /* @__PURE__ */ new Set();
22369
+ try {
22370
+ const all = await this.ctx.api.deviceManager.listAll.query({});
22371
+ for (const d of all) ids.add(d.id);
22372
+ } catch {}
22373
+ try {
22374
+ for (const id of await this.trackStore?.listDeviceIds() ?? []) ids.add(id);
22375
+ } catch {}
22376
+ return [...ids];
22377
+ }
20727
22378
  async sweepRetention() {
20728
22379
  if (this.shuttingDown || !this.eventStore || !this.mediaStore) return;
20729
22380
  const now = Date.now();
20730
22381
  const day = 1440 * 60 * 1e3;
20731
- const OBJECT_RETENTION_DAYS = 30;
20732
- const motionCutoffMs = now - 14 * day;
20733
- const objectCutoffMs = now - OBJECT_RETENTION_DAYS * day;
20734
- const audioCutoffMs = now - 7 * day;
20735
22382
  try {
20736
- const evicted = await this.eventStore.evictBefore({
20737
- motionCutoffMs,
20738
- objectCutoffMs,
20739
- audioCutoffMs
20740
- });
20741
- const evictedIds = [
20742
- ...evicted.motion,
20743
- ...evicted.object,
20744
- ...evicted.audio
20745
- ];
20746
- if (evictedIds.length > 0) {
20747
- await this.mediaStore.deleteForEvents(evictedIds);
20748
- this.ctx.logger.info("analytics event eviction (age sweep)", { meta: {
20749
- motion: evicted.motion.length,
20750
- object: evicted.object.length,
20751
- audio: evicted.audio.length,
20752
- motionCutoffMs,
20753
- objectCutoffMs,
20754
- audioCutoffMs
20755
- } });
22383
+ const deviceIds = await this.retentionDeviceIds();
22384
+ const evictedIds = [];
22385
+ let minObjectCutoffMs = Number.POSITIVE_INFINITY;
22386
+ for (const deviceId of deviceIds) {
22387
+ if (this.shuttingDown) return;
22388
+ try {
22389
+ const cutoffs = await this.deviceRetentionCutoffs(deviceId, now);
22390
+ if (cutoffs.objectCutoffMs !== null && cutoffs.objectCutoffMs < minObjectCutoffMs) minObjectCutoffMs = cutoffs.objectCutoffMs;
22391
+ if (cutoffs.motionCutoffMs === null && cutoffs.objectCutoffMs === null && cutoffs.audioCutoffMs === null) continue;
22392
+ const evicted = await this.eventStore.evictBefore({
22393
+ deviceId,
22394
+ motionCutoffMs: cutoffs.motionCutoffMs ?? 0,
22395
+ objectCutoffMs: cutoffs.objectCutoffMs ?? 0,
22396
+ audioCutoffMs: cutoffs.audioCutoffMs ?? 0
22397
+ });
22398
+ const ids = [
22399
+ ...evicted.motion,
22400
+ ...evicted.object,
22401
+ ...evicted.audio
22402
+ ];
22403
+ if (ids.length > 0) {
22404
+ evictedIds.push(...ids);
22405
+ this.eventsOpsLog?.append({
22406
+ op: "prune",
22407
+ reason: "retention",
22408
+ deviceId,
22409
+ itemsAffected: ids.length,
22410
+ bytesReclaimed: 0,
22411
+ detail: `age sweep (${cutoffs.effectiveMode}): ${evicted.motion.length} motion, ${evicted.object.length} object, ${evicted.audio.length} audio`
22412
+ });
22413
+ this.ctx.logger.info("analytics event eviction (age sweep)", {
22414
+ tags: { deviceId },
22415
+ meta: {
22416
+ motion: evicted.motion.length,
22417
+ object: evicted.object.length,
22418
+ audio: evicted.audio.length,
22419
+ mode: cutoffs.effectiveMode,
22420
+ objectCutoffMs: cutoffs.objectCutoffMs
22421
+ }
22422
+ });
22423
+ }
22424
+ } catch (err) {
22425
+ this.ctx.logger.debug("event retention sweep (device) failed", {
22426
+ tags: { deviceId },
22427
+ meta: { error: String(err) }
22428
+ });
22429
+ }
20756
22430
  }
20757
- await this.mediaStore.evictBefore(now - 31 * day);
20758
- if (this.sensorEventStore) try {
20759
- const sensorDeleted = await this.sensorEventStore.evictBefore(objectCutoffMs);
22431
+ if (evictedIds.length > 0) await this.mediaStore.deleteForEvents(evictedIds);
22432
+ if (Number.isFinite(minObjectCutoffMs)) await this.mediaStore.evictBefore(minObjectCutoffMs - 1 * day);
22433
+ if (this.sensorEventStore && Number.isFinite(minObjectCutoffMs)) try {
22434
+ const sensorDeleted = await this.sensorEventStore.evictBefore(minObjectCutoffMs);
20760
22435
  if (sensorDeleted > 0) this.ctx.logger.info("sensor-event retention prune", { meta: {
20761
22436
  deleted: sensorDeleted,
20762
- cutoffMs: objectCutoffMs
22437
+ cutoffMs: minObjectCutoffMs
20763
22438
  } });
20764
22439
  } catch (err) {
20765
22440
  this.ctx.logger.debug("sensor-event prune failed", { meta: { error: String(err) } });
@@ -20790,8 +22465,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
20790
22465
  } catch (err) {
20791
22466
  this.ctx.logger.debug("plate buffer prune failed", { meta: { error: String(err) } });
20792
22467
  }
20793
- if (this.objectEmbeddingStore) try {
20794
- const deletedEmbIds = await this.objectEmbeddingStore.pruneAll(objectCutoffMs);
22468
+ if (this.objectEmbeddingStore && Number.isFinite(minObjectCutoffMs)) try {
22469
+ const deletedEmbIds = await this.objectEmbeddingStore.pruneAll(minObjectCutoffMs);
20795
22470
  if (deletedEmbIds.length > 0) this.ctx.logger.info("object embedding retention prune", { meta: { deleted: deletedEmbIds.length } });
20796
22471
  } catch (err) {
20797
22472
  this.ctx.logger.debug("object embedding prune failed", { meta: { error: String(err) } });
@@ -21457,6 +23132,16 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
21457
23132
  return this.queryFacade.deleteDeviceEvents(input);
21458
23133
  }
21459
23134
  /** The events ops-log rows (newest-first), optionally scoped to one camera. */
23135
+ async relocateMedia(input) {
23136
+ if (!this.mediaRelocate) throw new Error("media relocation unavailable");
23137
+ return { jobId: this.mediaRelocate.start(input) };
23138
+ }
23139
+ async getMediaRelocateStatus() {
23140
+ return this.mediaRelocate?.list() ?? [];
23141
+ }
23142
+ async cancelMediaRelocate(input) {
23143
+ return { cancelled: this.mediaRelocate?.cancel(input.jobId) ?? false };
23144
+ }
21460
23145
  async listOpsLog(input) {
21461
23146
  return this.queryFacade.listOpsLog(input);
21462
23147
  }
@@ -21536,13 +23221,22 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
21536
23221
  try {
21537
23222
  const total = await sweepTrackRetention({
21538
23223
  listDeviceIds: () => trackStore.listDeviceIds(),
21539
- resolveRetentionDays: async (deviceId) => {
21540
- return resolveTrackRetentionDays(await this.ctx?.settings?.readDeviceStore(deviceId) ?? {});
23224
+ resolveCutoffMs: async (deviceId, nowMs) => (await this.deviceRetentionCutoffs(deviceId, nowMs)).trackCutoffMs,
23225
+ pruneTracksBefore: async (deviceId, cutoffMs) => {
23226
+ const counts = await this.pruneTracksBefore({
23227
+ deviceId,
23228
+ cutoffMs
23229
+ });
23230
+ if (counts.tracks > 0) this.eventsOpsLog?.append({
23231
+ op: "prune",
23232
+ reason: "retention",
23233
+ deviceId,
23234
+ itemsAffected: counts.tracks,
23235
+ bytesReclaimed: 0,
23236
+ detail: `track retention cascade: ${counts.tracks} tracks, ${counts.events} events, ${counts.media} media`
23237
+ });
23238
+ return counts;
21541
23239
  },
21542
- pruneTracksBefore: (deviceId, cutoffMs) => this.pruneTracksBefore({
21543
- deviceId,
21544
- cutoffMs
21545
- }),
21546
23240
  now: () => Date.now(),
21547
23241
  onError: (deviceId, err) => {
21548
23242
  this.ctx.logger.debug("track retention sweep (device) failed", { meta: {
@@ -21636,7 +23330,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
21636
23330
  const raw = await this.ctx?.settings?.readDeviceStore(input.deviceId) ?? {};
21637
23331
  const baseSections = schema ? hydrateSchema({
21638
23332
  ...schema,
21639
- sections: retagDetectionSections(stripGlobalOnlyFields(toAnalyticsDeviceSections(schema.sections)))
23333
+ sections: retagRetentionSection(retagDetectionSections(stripGlobalOnlyFields(toAnalyticsDeviceSections(schema.sections))))
21640
23334
  }, raw).sections : [];
21641
23335
  const liveStatsSection = {
21642
23336
  id: "live-stats",
@@ -21686,4 +23380,4 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
21686
23380
  }
21687
23381
  };
21688
23382
  //#endregion
21689
- export { DETECTION_PIPELINE_SECTION_IDS, ncActions as customActions, ncActions, PipelineAnalyticsAddon as default, pickCleanMedia, retagDetectionSections, stripGlobalOnlyFields, toAnalyticsDeviceSections };
23383
+ export { DETECTION_PIPELINE_SECTION_IDS, ncActions as customActions, ncActions, PipelineAnalyticsAddon as default, pickCleanMedia, retagDetectionSections, retagRetentionSection, stripGlobalOnlyFields, toAnalyticsDeviceSections };