@camstack/addon-post-analysis 1.1.28 → 1.1.29

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,7 +1,6 @@
1
- import { S as string, _ as createEvent, b as number, c as nodePin, d as videoclipsCapability, f as zoneAnalyticsCapability, g as EventCategory, h as DeviceType, i as cosineSimilarity, l as pipelineAnalyticsCapability, m as BaseAddon, n as addonWidgetsSourceCapability, o as faceGalleryCapability, p as errMsg, r as audioMetricsCapability, t as EVENT_PAD_MS, u as plateGalleryCapability, v as hydrateSchema, x as object, y as boolean } from "../dist-Blpsv-M0.mjs";
1
+ import { S as string, _ as createEvent, b as number, c as nodePin, d as videoclipsCapability, f as zoneAnalyticsCapability, g as EventCategory, h as DeviceType, i as cosineSimilarity, l as pipelineAnalyticsCapability, m as BaseAddon, n as addonWidgetsSourceCapability, o as faceGalleryCapability, p as errMsg, r as audioMetricsCapability, t as EVENT_PAD_MS, u as plateGalleryCapability, v as hydrateSchema, x as object, y as boolean } from "../dist-CFjLqX2m.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import sharp from "sharp";
4
- import { FrameRingReaderCache } from "@camstack/shm-ring";
5
4
  //#region src/pipeline-analytics/videoclips-provider.ts
6
5
  var SOURCE = "analytics";
7
6
  function clipIdFor(eventId, startMs, endMs) {
@@ -146,6 +145,17 @@ var WEIGHT_IDENTITY = .05;
146
145
  var DWELL_FULL_MS = 6e4;
147
146
  /** Peak bbox area (as a fraction of frame area) that saturates the size term. */
148
147
  var SIZE_FULL = .15;
148
+ /** A track whose net centroid displacement stays below this fraction of the
149
+ * frame diagonal over its whole life counts as stationary on the net signal. */
150
+ var STATIC_DISPLACEMENT_FRAC = .02;
151
+ /** ...and whose entire centroid path fits within this fraction of the frame
152
+ * diagonal counts as stationary on the span signal. Both must hold. */
153
+ var STATIC_SPAN_FRAC = .04;
154
+ /** Importance multiplier applied to a stationary track that has NO resolved
155
+ * identity — strongly demotes "ghost" detections on static objects (a pile of
156
+ * clothes read as a person, a kitchen object read as an animal) below the
157
+ * key-event threshold. Identified tracks (face/plate) are never suppressed. */
158
+ var STATIC_IMPORTANCE_MULTIPLIER = .1;
149
159
  var CLASS_RANK_VEHICLE = .8;
150
160
  var CLASS_RANK_ANIMAL = .5;
151
161
  var CLASS_RANK_DEFAULT = .25;
@@ -220,6 +230,11 @@ function computeImportance(input) {
220
230
  sum += term.value;
221
231
  if (term.value > best.value) best = term;
222
232
  }
233
+ const identified = input.label !== void 0 && input.label.length > 0;
234
+ if (input.netDisplacementFrac !== void 0 && input.pathSpanFrac !== void 0 && input.netDisplacementFrac < .02 && input.pathSpanFrac < .04 && !identified) return {
235
+ importance: clamp01(sum * STATIC_IMPORTANCE_MULTIPLIER),
236
+ reason: best.reason
237
+ };
223
238
  return {
224
239
  importance: clamp01(sum),
225
240
  reason: best.reason
@@ -613,6 +628,20 @@ function normalizePlate(text) {
613
628
  return text.toUpperCase().replace(/[^A-Z0-9]/g, "");
614
629
  }
615
630
  /**
631
+ * Quality gate for a raw OCR plate read BEFORE it becomes a track label or a
632
+ * gallery row. Distant/oblique parked plates produce junk reads ("N", "Idag",
633
+ * "@em") that otherwise flood track labels (live-observed on the parking
634
+ * camera, 2026-07-16). A plausible European plate is ≥{@link PLATE_MIN_LENGTH}
635
+ * alphanumerics and mixes letters AND digits; anything else — or a read below
636
+ * {@link PLATE_MIN_SCORE} — is discarded, not stored.
637
+ */
638
+ function isPlausiblePlateRead(text, score) {
639
+ if (score < .4) return false;
640
+ const norm = normalizePlate(text);
641
+ if (norm.length < 4) return false;
642
+ return /[0-9]/.test(norm) && /[A-Z]/.test(norm);
643
+ }
644
+ /**
616
645
  * Fold characters that OCR routinely confuses onto a single canonical symbol, so
617
646
  * "AB0" and "ABO" compare as equal. Applied to BOTH operands of a distance
618
647
  * comparison only — never stored. Conservative set covering the common Latin
@@ -1097,7 +1126,7 @@ var MAX_PATH_LENGTH = 300;
1097
1126
  function clamp(value, min, max) {
1098
1127
  return Math.max(min, Math.min(max, value));
1099
1128
  }
1100
- function iou$1(a, b) {
1129
+ function iou$2(a, b) {
1101
1130
  const ax1 = a.x, ay1 = a.y, ax2 = a.x + a.w, ay2 = a.y + a.h;
1102
1131
  const bx1 = b.x, by1 = b.y, bx2 = b.x + b.w, by2 = b.y + b.h;
1103
1132
  const ix1 = Math.max(ax1, bx1), iy1 = Math.max(ay1, by1);
@@ -1152,7 +1181,7 @@ var SortTracker = class {
1152
1181
  */
1153
1182
  looseMatch(track, det) {
1154
1183
  if (track.class !== det.class) return false;
1155
- if (iou$1(track.bbox, det.bbox) >= this.config.rescueIouThreshold) return true;
1184
+ if (iou$2(track.bbox, det.bbox) >= this.config.rescueIouThreshold) return true;
1156
1185
  const tc = bboxCentroid(track.bbox);
1157
1186
  const dc = bboxCentroid(det.bbox);
1158
1187
  const dist = Math.hypot(tc.x - dc.x, tc.y - dc.y);
@@ -1180,7 +1209,7 @@ var SortTracker = class {
1180
1209
  for (let di = 0; di < detections.length; di++) {
1181
1210
  const det = detections[di];
1182
1211
  if (this.config.classGating && track.class !== det.class) continue;
1183
- const score = iou$1(pbox, det.bbox);
1212
+ const score = iou$2(pbox, det.bbox);
1184
1213
  if (score >= this.config.iouThreshold) pairs.push({
1185
1214
  track,
1186
1215
  detIdx: di,
@@ -1205,7 +1234,7 @@ var SortTracker = class {
1205
1234
  rescuePairs.push({
1206
1235
  track,
1207
1236
  detIdx: di,
1208
- score: iou$1(track.bbox, det.bbox)
1237
+ score: iou$2(track.bbox, det.bbox)
1209
1238
  });
1210
1239
  }
1211
1240
  }
@@ -1270,7 +1299,7 @@ var SortTracker = class {
1270
1299
  if (!lost.resurrectable) continue;
1271
1300
  if (timestamp - lost.lostAt > this.config.resurrectionWindowMs) continue;
1272
1301
  if (!this.looseMatch(lost, det)) continue;
1273
- const score = iou$1(lost.bbox, det.bbox);
1302
+ const score = iou$2(lost.bbox, det.bbox);
1274
1303
  if (score > bestScore) {
1275
1304
  best = lost;
1276
1305
  bestScore = score;
@@ -1331,6 +1360,16 @@ var SortTracker = class {
1331
1360
  path: [...t.path]
1332
1361
  }));
1333
1362
  }
1363
+ /**
1364
+ * Remove a track from BOTH the live and graveyard sets so it neither emits
1365
+ * nor resurrects. Used when a track is PROMOTED to a stationary-object
1366
+ * registry entry: the registry now owns that parked object, and the tracker
1367
+ * must forget it so the object's detections don't re-spawn a duplicate track.
1368
+ */
1369
+ dropTrack(trackId) {
1370
+ this.tracks = this.tracks.filter((t) => t.id !== trackId);
1371
+ this.lostTracks = this.lostTracks.filter((t) => t.id !== trackId);
1372
+ }
1334
1373
  getActiveTracks() {
1335
1374
  return this.tracks;
1336
1375
  }
@@ -1744,6 +1783,9 @@ var FrameProcessor = class {
1744
1783
  */
1745
1784
  detectionRules;
1746
1785
  zoneEngine = new ZoneEngine();
1786
+ /** Optional stationary-object gate (parked-object suppression). Null until
1787
+ * the addon wires it via {@link setStationaryGate}. */
1788
+ stationaryGate = null;
1747
1789
  constructor(deviceId, trackerConfig = {}, stateConfig = {}, emitterConfig = {}, source = "pipeline") {
1748
1790
  this.deviceId = deviceId;
1749
1791
  this.source = source;
@@ -1759,6 +1801,16 @@ var FrameProcessor = class {
1759
1801
  setDetectionRules(rules) {
1760
1802
  this.detectionRules = rules;
1761
1803
  }
1804
+ /** Wire (or clear) the stationary-object gate. Called by the addon per
1805
+ * device so parked-object suppression applies from the next frame. */
1806
+ setStationaryGate(gate) {
1807
+ this.stationaryGate = gate;
1808
+ }
1809
+ /** Forget a track in the underlying tracker (used when the addon promotes it
1810
+ * to a stationary-object registry entry). */
1811
+ dropTrack(trackId) {
1812
+ this.tracker.dropTrack(trackId);
1813
+ }
1762
1814
  process(input) {
1763
1815
  const { timestamp, frame } = input;
1764
1816
  const frameWidth = frame.width;
@@ -1837,7 +1889,17 @@ var FrameProcessor = class {
1837
1889
  }
1838
1890
  const { passed } = this.zoneEngine.filterDetections(flatDetections, this.zones, this.detectionRules, frameWidth, frameHeight, (fd) => fd.detection.class);
1839
1891
  const filteredDetections = passed.map((fd) => fd.detection);
1840
- const trackedDetections = this.tracker.update(filteredDetections, timestamp);
1892
+ const gate = this.stationaryGate ? this.stationaryGate.filter({
1893
+ detections: filteredDetections,
1894
+ frameWidth,
1895
+ frameHeight
1896
+ }) : {
1897
+ suppressedIndices: /* @__PURE__ */ new Set(),
1898
+ confirmed: [],
1899
+ wokenEntryIds: []
1900
+ };
1901
+ const trackerInput = gate.suppressedIndices.size > 0 ? filteredDetections.filter((_, i) => !gate.suppressedIndices.has(i)) : filteredDetections;
1902
+ const trackedDetections = this.tracker.update(trackerInput, timestamp);
1841
1903
  const objectStates = this.stateAnalyzer.analyze(trackedDetections, timestamp);
1842
1904
  const rawEvents = this.eventEmitter.emit(trackedDetections, objectStates, [], [], String(this.deviceId));
1843
1905
  const zonesByTrack = /* @__PURE__ */ new Map();
@@ -1919,7 +1981,9 @@ var FrameProcessor = class {
1919
1981
  frameHeight,
1920
1982
  tracked,
1921
1983
  objectEvents,
1922
- rawTrackedDetections: trackedDetections
1984
+ rawTrackedDetections: trackedDetections,
1985
+ stationaryConfirmed: gate.confirmed,
1986
+ stationaryWoken: gate.wokenEntryIds
1923
1987
  };
1924
1988
  }
1925
1989
  };
@@ -1996,6 +2060,7 @@ function buildTrackLifecyclePayload(input) {
1996
2060
  ...input.positionsCount !== void 0 ? { positionsCount: input.positionsCount } : {},
1997
2061
  ...input.importance !== void 0 ? { importance: input.importance } : {},
1998
2062
  ...input.importanceReason !== void 0 ? { importanceReason: input.importanceReason } : {},
2063
+ ...input.audioLabels !== void 0 && input.audioLabels.length > 0 ? { audioLabels: input.audioLabels } : {},
1999
2064
  ...input.embeddingId !== void 0 ? { embeddingId: input.embeddingId } : {},
2000
2065
  ...input.embeddingModelId !== void 0 ? { embeddingModelId: input.embeddingModelId } : {},
2001
2066
  ...hasMedia ? { media } : {}
@@ -2112,6 +2177,555 @@ function resolveSearchThumbnailUrl(input) {
2112
2177
  return `${input.baseUrl}/${encodeURIComponent(id)}`;
2113
2178
  }
2114
2179
  //#endregion
2180
+ //#region src/pipeline-analytics/pipeline/static-track-gate.ts
2181
+ /**
2182
+ * Net displacement + path span for a track's centroid path, normalized to
2183
+ * `referenceDiagonalPx`. Returns undefined when there is nothing meaningful to
2184
+ * measure (fewer than two points, or a degenerate ≤0 reference) so the caller
2185
+ * leaves the importance score untouched.
2186
+ */
2187
+ function computeStaticTrackMetrics(centroids, referenceDiagonalPx) {
2188
+ if (!(referenceDiagonalPx > 0) || centroids.length < 2) return void 0;
2189
+ const first = centroids[0];
2190
+ const last = centroids[centroids.length - 1];
2191
+ const netDisplacementFrac = Math.hypot(last.x - first.x, last.y - first.y) / referenceDiagonalPx;
2192
+ let minX = Infinity;
2193
+ let minY = Infinity;
2194
+ let maxX = -Infinity;
2195
+ let maxY = -Infinity;
2196
+ for (const c of centroids) {
2197
+ if (c.x < minX) minX = c.x;
2198
+ if (c.x > maxX) maxX = c.x;
2199
+ if (c.y < minY) minY = c.y;
2200
+ if (c.y > maxY) maxY = c.y;
2201
+ }
2202
+ return {
2203
+ netDisplacementFrac,
2204
+ pathSpanFrac: Math.hypot(maxX - minX, maxY - minY) / referenceDiagonalPx
2205
+ };
2206
+ }
2207
+ /** Average bbox diagonal (px) across a track's positions — the scale reference
2208
+ * when frame dimensions aren't available. Returns 0 for an empty list. */
2209
+ function averageBboxDiagonal(boxes) {
2210
+ if (boxes.length === 0) return 0;
2211
+ let sum = 0;
2212
+ for (const b of boxes) sum += Math.hypot(b.w, b.h);
2213
+ return sum / boxes.length;
2214
+ }
2215
+ //#endregion
2216
+ //#region src/pipeline-analytics/pipeline/stationary/stationary-types.ts
2217
+ function entryToView(e) {
2218
+ return {
2219
+ id: e.id,
2220
+ className: e.className,
2221
+ bbox: { ...e.bbox },
2222
+ frameWidth: e.frameWidth,
2223
+ frameHeight: e.frameHeight,
2224
+ firstSeenAt: e.firstSeenAt,
2225
+ becameStationaryAt: e.becameStationaryAt,
2226
+ lastConfirmedAt: e.lastConfirmedAt,
2227
+ ...e.label !== void 0 ? { label: e.label } : {},
2228
+ ...e.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: e.keyFrameMediaKey } : {}
2229
+ };
2230
+ }
2231
+ /** IoU at/above which a detection is "the same parked object" → suppress its
2232
+ * spawn and refresh the entry's `lastConfirmedAt`. Matches decision #2 (0.6). */
2233
+ var SUPPRESS_IOU = .6;
2234
+ /** Centroid move (fraction of the frame diagonal) beyond which a near
2235
+ * same-class detection means the parked object actually MOVED → wake. */
2236
+ var WAKE_MOVE_FRAC = .08;
2237
+ /** How near (fraction of frame diagonal) a same-class detection's centroid must
2238
+ * be to an entry to be considered "this entry's object" when testing for a
2239
+ * wake. Keeps an unrelated object elsewhere in the frame from waking it. */
2240
+ var WAKE_SEARCH_FRAC = .5;
2241
+ /**
2242
+ * Look-back window over which a track must have stayed put to be PROMOTED. A
2243
+ * car that drives in then parks has a large whole-life displacement but a tiny
2244
+ * last-`windowMs` displacement — so promotion is judged on the recent window,
2245
+ * not the full path. Also the minimum age (the track must have EXISTED this
2246
+ * long) so a freshly-spawned static blob isn't promoted instantly.
2247
+ */
2248
+ var PROMOTION_WINDOW_MS = 3e4;
2249
+ var DEFAULT_MATCH_CONFIG = {
2250
+ suppressIou: SUPPRESS_IOU,
2251
+ wakeMoveFrac: WAKE_MOVE_FRAC,
2252
+ wakeSearchFrac: WAKE_SEARCH_FRAC
2253
+ };
2254
+ //#endregion
2255
+ //#region src/pipeline-analytics/pipeline/stationary/stationary-match.ts
2256
+ /**
2257
+ * stationary-match — PURE geometry for the stationary-object registry.
2258
+ *
2259
+ * Two decisions, both unit-testable in isolation:
2260
+ *
2261
+ * 1. `partitionDetectionsAgainstRegistry` — given the current registry entries
2262
+ * and this frame's (zone-filtered) detections, decides which detections are
2263
+ * suppressed (they keep confirming a known parked object → no track spawns),
2264
+ * which entries are confirmed present, and which entries WOKE (their object
2265
+ * moved → retire the entry and let the detection spawn a normal track).
2266
+ *
2267
+ * 2. `evaluateStationaryPromotion` — given a track's recent centroid path,
2268
+ * decides whether it has stayed put long enough to become a stationary
2269
+ * entry. Reuses the static-track-gate metrics (net displacement + path span
2270
+ * normalised to the frame diagonal) over the recent look-back window.
2271
+ */
2272
+ /** Default promotion tunables — static thresholds shared with the key-event
2273
+ * static gate so "stationary" means the same thing in both places. */
2274
+ var DEFAULT_PROMOTION_CONFIG = {
2275
+ windowMs: PROMOTION_WINDOW_MS,
2276
+ netFracMax: STATIC_DISPLACEMENT_FRAC,
2277
+ spanFracMax: STATIC_SPAN_FRAC,
2278
+ minPoints: 4
2279
+ };
2280
+ function iou$1(a, b) {
2281
+ const ax2 = a.x + a.w;
2282
+ const ay2 = a.y + a.h;
2283
+ const bx2 = b.x + b.w;
2284
+ const by2 = b.y + b.h;
2285
+ const ix1 = Math.max(a.x, b.x);
2286
+ const iy1 = Math.max(a.y, b.y);
2287
+ const ix2 = Math.min(ax2, bx2);
2288
+ const iy2 = Math.min(ay2, by2);
2289
+ const inter = Math.max(0, ix2 - ix1) * Math.max(0, iy2 - iy1);
2290
+ const union = a.w * a.h + b.w * b.h - inter;
2291
+ return union > 0 ? inter / union : 0;
2292
+ }
2293
+ function centroid(b) {
2294
+ return {
2295
+ x: b.x + b.w / 2,
2296
+ y: b.y + b.h / 2
2297
+ };
2298
+ }
2299
+ function diagonalOf(width, height) {
2300
+ return Math.hypot(width, height);
2301
+ }
2302
+ /**
2303
+ * Decide, per stationary entry, whether the current frame confirms it, wakes
2304
+ * it, or misses it (no matching detection — leave it for the TTL sweep).
2305
+ *
2306
+ * Per entry, over same-class detections:
2307
+ * - best IoU ≥ `suppressIou` → SUPPRESS the best-overlap detection (it is the
2308
+ * parked object, unmoved) and mark the entry confirmed.
2309
+ * - else if a same-class detection sits within `wakeSearchFrac × diag` of the
2310
+ * entry centroid but has moved > `wakeMoveFrac × diag` → WAKE the entry (the
2311
+ * object slid out of its parked box). The detection is NOT suppressed, so it
2312
+ * spawns a fresh moving track.
2313
+ * - else → MISS (occlusion / brief absence): neither suppress nor wake.
2314
+ *
2315
+ * A detection can suppress at most one spawn even if it overlaps two entries
2316
+ * (`suppressedIndices` is a set).
2317
+ */
2318
+ function partitionDetectionsAgainstRegistry(input) {
2319
+ const { entries, detections, referenceDiagonalPx, config } = input;
2320
+ const suppressed = /* @__PURE__ */ new Set();
2321
+ const confirmed = [];
2322
+ const woken = [];
2323
+ const diag = referenceDiagonalPx;
2324
+ for (const entry of entries) {
2325
+ const ec = centroid(entry.bbox);
2326
+ let bestIou = 0;
2327
+ let bestIdx = -1;
2328
+ let wakeCandidate = false;
2329
+ for (let di = 0; di < detections.length; di++) {
2330
+ const det = detections[di];
2331
+ if (det.className !== entry.className) continue;
2332
+ const o = iou$1(entry.bbox, det.bbox);
2333
+ if (o > bestIou) {
2334
+ bestIou = o;
2335
+ bestIdx = di;
2336
+ }
2337
+ if (diag > 0) {
2338
+ const dc = centroid(det.bbox);
2339
+ const dist = Math.hypot(dc.x - ec.x, dc.y - ec.y);
2340
+ if (dist <= config.wakeSearchFrac * diag && dist > config.wakeMoveFrac * diag) wakeCandidate = true;
2341
+ }
2342
+ }
2343
+ if (bestIou >= config.suppressIou && bestIdx >= 0) {
2344
+ suppressed.add(bestIdx);
2345
+ confirmed.push({
2346
+ entryId: entry.id,
2347
+ className: entry.className,
2348
+ bbox: { ...entry.bbox }
2349
+ });
2350
+ } else if (wakeCandidate) woken.push(entry.id);
2351
+ }
2352
+ return {
2353
+ suppressedIndices: suppressed,
2354
+ confirmed,
2355
+ wokenEntryIds: woken
2356
+ };
2357
+ }
2358
+ /**
2359
+ * A track is promoted when, over the recent `windowMs`, its centroid barely
2360
+ * moved (both net displacement and path span below the static thresholds) AND
2361
+ * the track has actually EXISTED for at least `windowMs` (so a car that just
2362
+ * arrived isn't parked yet). Judging on the recent window — not the whole life
2363
+ * — is what lets a car that drove in then parked be recognised as stationary.
2364
+ */
2365
+ function evaluateStationaryPromotion(input) {
2366
+ const { positions, referenceDiagonalPx, now, config } = input;
2367
+ if (!(referenceDiagonalPx > 0) || positions.length === 0) return { promote: false };
2368
+ if (now - positions[0].timestamp < config.windowMs) return { promote: false };
2369
+ const cutoff = now - config.windowMs;
2370
+ const window = positions.filter((p) => p.timestamp >= cutoff);
2371
+ if (window.length < config.minPoints) return { promote: false };
2372
+ const metrics = computeStaticTrackMetrics(window.map((p) => ({
2373
+ x: p.x,
2374
+ y: p.y
2375
+ })), referenceDiagonalPx);
2376
+ if (metrics === void 0) return { promote: false };
2377
+ return {
2378
+ promote: metrics.netDisplacementFrac < config.netFracMax && metrics.pathSpanFrac < config.spanFracMax,
2379
+ netFrac: metrics.netDisplacementFrac,
2380
+ spanFrac: metrics.pathSpanFrac
2381
+ };
2382
+ }
2383
+ //#endregion
2384
+ //#region src/pipeline-analytics/pipeline/stationary/stationary-registry.ts
2385
+ var STATIONARY_COLLECTION = "pipeline-analytics:stationary-objects";
2386
+ var STATIONARY_COLUMNS = [
2387
+ {
2388
+ name: "id",
2389
+ type: "TEXT",
2390
+ primaryKey: true,
2391
+ notNull: true
2392
+ },
2393
+ {
2394
+ name: "deviceId",
2395
+ type: "INTEGER",
2396
+ notNull: true
2397
+ },
2398
+ {
2399
+ name: "className",
2400
+ type: "TEXT",
2401
+ notNull: true
2402
+ },
2403
+ {
2404
+ name: "bbox",
2405
+ type: "JSON"
2406
+ },
2407
+ {
2408
+ name: "frameWidth",
2409
+ type: "INTEGER"
2410
+ },
2411
+ {
2412
+ name: "frameHeight",
2413
+ type: "INTEGER"
2414
+ },
2415
+ {
2416
+ name: "firstSeenAt",
2417
+ type: "INTEGER"
2418
+ },
2419
+ {
2420
+ name: "becameStationaryAt",
2421
+ type: "INTEGER"
2422
+ },
2423
+ {
2424
+ name: "lastConfirmedAt",
2425
+ type: "INTEGER"
2426
+ },
2427
+ {
2428
+ name: "sourceTrackId",
2429
+ type: "TEXT"
2430
+ },
2431
+ {
2432
+ name: "label",
2433
+ type: "TEXT"
2434
+ },
2435
+ {
2436
+ name: "keyFrameMediaKey",
2437
+ type: "TEXT"
2438
+ }
2439
+ ];
2440
+ var STATIONARY_INDEXES = [{
2441
+ name: "idx_stationary_device",
2442
+ columns: ["deviceId"]
2443
+ }];
2444
+ var StationaryObjectRegistry = class {
2445
+ byDevice = /* @__PURE__ */ new Map();
2446
+ dirty = /* @__PURE__ */ new Set();
2447
+ store;
2448
+ logger;
2449
+ matchConfig;
2450
+ entryTtlMs;
2451
+ onChange;
2452
+ /** Latest processed-frame timestamp per device — expiry counts OBSERVED
2453
+ * time, not wall-clock. A session-dispatch camera produces no frames
2454
+ * between motion sessions; that silence is not evidence the object left,
2455
+ * so quiet minutes must not age the entries (see {@link sweep}). */
2456
+ lastFrameAtByDevice = /* @__PURE__ */ new Map();
2457
+ constructor(deps) {
2458
+ this.store = deps.store;
2459
+ this.logger = deps.logger;
2460
+ this.matchConfig = deps.matchConfig ?? DEFAULT_MATCH_CONFIG;
2461
+ this.entryTtlMs = deps.entryTtlMs ?? 3e5;
2462
+ this.onChange = deps.onChange;
2463
+ }
2464
+ static async declare(store) {
2465
+ await store.declareCollection.mutate({
2466
+ collection: STATIONARY_COLLECTION,
2467
+ columns: [...STATIONARY_COLUMNS],
2468
+ indexes: [...STATIONARY_INDEXES]
2469
+ });
2470
+ }
2471
+ /** Hydrate all persisted entries into memory (call once at boot, after
2472
+ * `declare`). Best-effort — a query failure leaves the registry empty. */
2473
+ async load() {
2474
+ try {
2475
+ const rows = await this.store.query.query({
2476
+ collection: STATIONARY_COLLECTION,
2477
+ filter: { limit: 1e5 }
2478
+ });
2479
+ for (const row of rows) {
2480
+ const entry = rowToEntry(row.id, row.data);
2481
+ if (!entry) continue;
2482
+ this.deviceMap(entry.deviceId).set(entry.id, entry);
2483
+ }
2484
+ this.logger.info("stationary registry loaded", { meta: { entries: rows.length } });
2485
+ } catch (err) {
2486
+ this.logger.warn("stationary registry load failed", { meta: { error: String(err) } });
2487
+ }
2488
+ }
2489
+ list(deviceId) {
2490
+ const m = this.byDevice.get(deviceId);
2491
+ return m ? [...m.values()] : [];
2492
+ }
2493
+ listViews(deviceId) {
2494
+ return this.list(deviceId).map(entryToView);
2495
+ }
2496
+ count(deviceId) {
2497
+ return this.byDevice.get(deviceId)?.size ?? 0;
2498
+ }
2499
+ /** Record that a frame was processed for a device — advances the OBSERVED
2500
+ * clock that drives entry expiry in {@link sweep}. */
2501
+ noteFrame(deviceId, timestamp) {
2502
+ if (timestamp > (this.lastFrameAtByDevice.get(deviceId) ?? 0)) this.lastFrameAtByDevice.set(deviceId, timestamp);
2503
+ }
2504
+ /**
2505
+ * Per-frame gate: partition this frame's detections against the device's
2506
+ * entries. PURE with respect to registry state — apply the outcome with
2507
+ * {@link applyFrameOutcome} once the frame result is assembled.
2508
+ */
2509
+ filter(input) {
2510
+ const entries = this.list(input.deviceId);
2511
+ if (entries.length === 0) return {
2512
+ suppressedIndices: /* @__PURE__ */ new Set(),
2513
+ confirmed: [],
2514
+ wokenEntryIds: []
2515
+ };
2516
+ return partitionDetectionsAgainstRegistry({
2517
+ entries,
2518
+ detections: input.detections,
2519
+ referenceDiagonalPx: diagonalOf(input.frameWidth, input.frameHeight),
2520
+ config: this.matchConfig
2521
+ });
2522
+ }
2523
+ /** Fold a frame's gate result back into state: advance confirmed entries'
2524
+ * `lastConfirmedAt` and retire woken entries (their object departed). */
2525
+ applyFrameOutcome(input) {
2526
+ const m = this.byDevice.get(input.deviceId);
2527
+ if (!m) return;
2528
+ for (const c of input.confirmed) {
2529
+ const e = m.get(c.entryId);
2530
+ if (!e) continue;
2531
+ m.set(c.entryId, {
2532
+ ...e,
2533
+ lastConfirmedAt: input.timestamp
2534
+ });
2535
+ this.dirty.add(c.entryId);
2536
+ }
2537
+ for (const id of input.wokenEntryIds) {
2538
+ const e = m.get(id);
2539
+ if (!e) continue;
2540
+ m.delete(id);
2541
+ this.dirty.delete(id);
2542
+ this.deletePersisted(id);
2543
+ this.onChange?.({
2544
+ phase: "departed",
2545
+ entry: e,
2546
+ timestamp: input.timestamp
2547
+ });
2548
+ }
2549
+ }
2550
+ /** Promote a parked track into a persisted stationary entry. */
2551
+ async promote(entry) {
2552
+ this.deviceMap(entry.deviceId).set(entry.id, entry);
2553
+ this.dirty.delete(entry.id);
2554
+ try {
2555
+ await this.persist(entry);
2556
+ } catch (err) {
2557
+ this.logger.warn("stationary promote persist failed", {
2558
+ tags: { deviceId: entry.deviceId },
2559
+ meta: {
2560
+ entryId: entry.id,
2561
+ error: String(err)
2562
+ }
2563
+ });
2564
+ }
2565
+ this.onChange?.({
2566
+ phase: "appeared",
2567
+ entry,
2568
+ timestamp: entry.becameStationaryAt
2569
+ });
2570
+ }
2571
+ /**
2572
+ * Retire entries unconfirmed for longer than the TTL of OBSERVED time, and
2573
+ * flush any advanced `lastConfirmedAt`s to the store. Returns retired
2574
+ * entries (for logging).
2575
+ *
2576
+ * Expiry is measured against the device's latest processed-frame timestamp
2577
+ * ({@link noteFrame}), NOT the wall clock: a session-dispatch camera emits
2578
+ * no frames between motion sessions, and that silence says nothing about
2579
+ * the object. Only when the camera has actually been WATCHING for `ttl`
2580
+ * beyond the last confirmation (frames flowed, object never matched) is the
2581
+ * object considered removed. A device with no recorded frame yet never
2582
+ * expires its entries. `now` only stamps the departed telemetry.
2583
+ */
2584
+ async sweep(now) {
2585
+ const retired = [];
2586
+ for (const [deviceId, m] of this.byDevice) {
2587
+ const observedAt = this.lastFrameAtByDevice.get(deviceId);
2588
+ if (observedAt === void 0) continue;
2589
+ for (const [id, e] of m) if (observedAt - e.lastConfirmedAt > this.entryTtlMs) {
2590
+ m.delete(id);
2591
+ this.dirty.delete(id);
2592
+ retired.push(e);
2593
+ this.deletePersisted(id);
2594
+ this.onChange?.({
2595
+ phase: "departed",
2596
+ entry: e,
2597
+ timestamp: now
2598
+ });
2599
+ }
2600
+ if (m.size === 0) this.byDevice.delete(deviceId);
2601
+ }
2602
+ for (const id of [...this.dirty]) {
2603
+ this.dirty.delete(id);
2604
+ const entry = this.findById(id);
2605
+ if (!entry) continue;
2606
+ try {
2607
+ await this.store.update.mutate({
2608
+ collection: STATIONARY_COLLECTION,
2609
+ id,
2610
+ data: { lastConfirmedAt: entry.lastConfirmedAt }
2611
+ });
2612
+ } catch (err) {
2613
+ this.logger.debug("stationary lastConfirmedAt flush failed", { meta: {
2614
+ entryId: id,
2615
+ error: String(err)
2616
+ } });
2617
+ }
2618
+ }
2619
+ return retired;
2620
+ }
2621
+ /** Drop a device's entries from memory WITHOUT deleting persisted rows.
2622
+ * Used on device unbind; a rebind reloads from the store. */
2623
+ forgetDevice(deviceId) {
2624
+ this.lastFrameAtByDevice.delete(deviceId);
2625
+ const m = this.byDevice.get(deviceId);
2626
+ if (!m) return;
2627
+ for (const id of m.keys()) this.dirty.delete(id);
2628
+ this.byDevice.delete(deviceId);
2629
+ }
2630
+ /** Delete every persisted + in-memory entry for a device (operator wipe). */
2631
+ async clearDevice(deviceId) {
2632
+ this.lastFrameAtByDevice.delete(deviceId);
2633
+ const m = this.byDevice.get(deviceId);
2634
+ if (m) {
2635
+ for (const id of [...m.keys()]) {
2636
+ this.dirty.delete(id);
2637
+ this.deletePersisted(id);
2638
+ }
2639
+ this.byDevice.delete(deviceId);
2640
+ }
2641
+ }
2642
+ deviceMap(deviceId) {
2643
+ let m = this.byDevice.get(deviceId);
2644
+ if (!m) {
2645
+ m = /* @__PURE__ */ new Map();
2646
+ this.byDevice.set(deviceId, m);
2647
+ }
2648
+ return m;
2649
+ }
2650
+ findById(id) {
2651
+ for (const m of this.byDevice.values()) {
2652
+ const e = m.get(id);
2653
+ if (e) return e;
2654
+ }
2655
+ }
2656
+ async persist(e) {
2657
+ await this.store.set.mutate({
2658
+ collection: STATIONARY_COLLECTION,
2659
+ key: e.id,
2660
+ value: {
2661
+ deviceId: e.deviceId,
2662
+ className: e.className,
2663
+ bbox: { ...e.bbox },
2664
+ frameWidth: e.frameWidth,
2665
+ frameHeight: e.frameHeight,
2666
+ firstSeenAt: e.firstSeenAt,
2667
+ becameStationaryAt: e.becameStationaryAt,
2668
+ lastConfirmedAt: e.lastConfirmedAt,
2669
+ ...e.sourceTrackId !== void 0 ? { sourceTrackId: e.sourceTrackId } : {},
2670
+ ...e.label !== void 0 ? { label: e.label } : {},
2671
+ ...e.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: e.keyFrameMediaKey } : {}
2672
+ }
2673
+ });
2674
+ }
2675
+ async deletePersisted(id) {
2676
+ try {
2677
+ await this.store.delete.mutate({
2678
+ collection: STATIONARY_COLLECTION,
2679
+ key: id
2680
+ });
2681
+ } catch (err) {
2682
+ this.logger.debug("stationary delete failed", { meta: {
2683
+ entryId: id,
2684
+ error: String(err)
2685
+ } });
2686
+ }
2687
+ }
2688
+ };
2689
+ function rowToEntry(id, data) {
2690
+ const deviceId = Number(data["deviceId"]);
2691
+ const className = data["className"];
2692
+ const bbox = data["bbox"];
2693
+ if (!Number.isFinite(deviceId) || typeof className !== "string" || !bbox) return null;
2694
+ const sourceTrackId = data["sourceTrackId"];
2695
+ const label = data["label"];
2696
+ const keyFrameMediaKey = data["keyFrameMediaKey"];
2697
+ return {
2698
+ id,
2699
+ deviceId,
2700
+ className,
2701
+ bbox: {
2702
+ x: Number(bbox.x),
2703
+ y: Number(bbox.y),
2704
+ w: Number(bbox.w),
2705
+ h: Number(bbox.h)
2706
+ },
2707
+ frameWidth: Number(data["frameWidth"] ?? 0),
2708
+ frameHeight: Number(data["frameHeight"] ?? 0),
2709
+ firstSeenAt: Number(data["firstSeenAt"] ?? 0),
2710
+ becameStationaryAt: Number(data["becameStationaryAt"] ?? 0),
2711
+ lastConfirmedAt: Number(data["lastConfirmedAt"] ?? 0),
2712
+ ...typeof sourceTrackId === "string" ? { sourceTrackId } : {},
2713
+ ...typeof label === "string" ? { label } : {},
2714
+ ...typeof keyFrameMediaKey === "string" ? { keyFrameMediaKey } : {}
2715
+ };
2716
+ }
2717
+ //#endregion
2718
+ //#region src/pipeline-analytics/pipeline/track-appearance.ts
2719
+ /**
2720
+ * Pure: no side effects. `continuing` = still active from last frame;
2721
+ * `birth` = a brand-new track's first sighting; `resurrection` = a known track
2722
+ * re-entering the active set after being lost.
2723
+ */
2724
+ function classifyTrackAppearance(input) {
2725
+ if (input.inPrevActive) return "continuing";
2726
+ return input.positionsCount > 1 ? "resurrection" : "birth";
2727
+ }
2728
+ //#endregion
2115
2729
  //#region src/pipeline-analytics/pipeline/key-event-query.ts
2116
2730
  async function rankKeyEvents(candidates, options, peakLookup) {
2117
2731
  const scored = [];
@@ -2121,6 +2735,10 @@ async function rankKeyEvents(candidates, options, peakLookup) {
2121
2735
  let bestEventId = t.bestEventId;
2122
2736
  if (importance === void 0) {
2123
2737
  const peak = await peakLookup(t.trackId);
2738
+ const staticMetrics = computeStaticTrackMetrics(t.positions.map((p) => ({
2739
+ x: p.x,
2740
+ y: p.y
2741
+ })), averageBboxDiagonal(t.positions.map((p) => p.bbox)));
2124
2742
  importance = computeImportance({
2125
2743
  peakConfidence: peak.peakConfidence,
2126
2744
  className: t.className,
@@ -2128,7 +2746,11 @@ async function rankKeyEvents(candidates, options, peakLookup) {
2128
2746
  peakBboxAreaFrac: peak.peakBboxAreaFrac,
2129
2747
  totalDistance: t.totalDistance,
2130
2748
  zonesVisited: t.zonesVisited,
2131
- ...t.label !== void 0 ? { label: t.label } : {}
2749
+ ...t.label !== void 0 ? { label: t.label } : {},
2750
+ ...staticMetrics ? {
2751
+ netDisplacementFrac: staticMetrics.netDisplacementFrac,
2752
+ pathSpanFrac: staticMetrics.pathSpanFrac
2753
+ } : {}
2132
2754
  }).importance;
2133
2755
  bestEventId = bestEventId ?? peak.bestEventId;
2134
2756
  }
@@ -2140,7 +2762,7 @@ async function rankKeyEvents(candidates, options, peakLookup) {
2140
2762
  className: t.className,
2141
2763
  ...t.label !== void 0 ? { label: t.label } : {},
2142
2764
  importance,
2143
- bestEventId: bestEventId ?? "",
2765
+ bestEventId: bestEventId ?? t.trackId,
2144
2766
  windowMs: t.lastSeen - t.firstSeen
2145
2767
  });
2146
2768
  }
@@ -2377,6 +2999,10 @@ var TRACKS_COLUMNS = [
2377
2999
  {
2378
3000
  name: "importanceReason",
2379
3001
  type: "TEXT"
3002
+ },
3003
+ {
3004
+ name: "audioLabels",
3005
+ type: "JSON"
2380
3006
  }
2381
3007
  ];
2382
3008
  var TRACKS_INDEXES = [{
@@ -2386,6 +3012,17 @@ var TRACKS_INDEXES = [{
2386
3012
  name: "idx_tracks_device_firstSeen",
2387
3013
  columns: ["deviceId", "firstSeen"]
2388
3014
  }];
3015
+ /** Serialize the per-label aggregate map into the `Track.audioLabels`
3016
+ * array shape, most-frequent label first. */
3017
+ function audioLabelsToArray(agg) {
3018
+ return [...agg.entries()].map(([label, a]) => ({
3019
+ label,
3020
+ peakScore: a.peakScore,
3021
+ count: a.count,
3022
+ firstAt: a.firstAt,
3023
+ lastAt: a.lastAt
3024
+ })).sort((a, b) => b.count - a.count);
3025
+ }
2389
3026
  function cloneTrack(t) {
2390
3027
  return {
2391
3028
  trackId: t.trackId,
@@ -2412,7 +3049,8 @@ function cloneTrack(t) {
2412
3049
  active: t.active,
2413
3050
  ...t.importance !== void 0 ? { importance: t.importance } : {},
2414
3051
  ...t.bestEventId !== void 0 ? { bestEventId: t.bestEventId } : {},
2415
- ...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {}
3052
+ ...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {},
3053
+ ...t.audioLabels !== void 0 && t.audioLabels.size > 0 ? { audioLabels: audioLabelsToArray(t.audioLabels) } : {}
2416
3054
  };
2417
3055
  }
2418
3056
  var TrackStore = class {
@@ -2473,25 +3111,81 @@ var TrackStore = class {
2473
3111
  this.active.set(params.trackId, fresh);
2474
3112
  return fresh;
2475
3113
  }
3114
+ /**
3115
+ * Record one audio-classification EPISODE against every track currently
3116
+ * active on the device — "what was heard on this camera while the track
3117
+ * was alive". Called from the confident-classification audio-event insert
3118
+ * (score ≥ device `classificationMinScore`, class-change-or-heartbeat
3119
+ * coalesced), so counts stay episode-scaled rather than 30 Hz chunk-scaled.
3120
+ */
3121
+ addAudioLabelEpisode(deviceId, label, score, timestamp) {
3122
+ for (const t of this.active.values()) {
3123
+ if (t.deviceId !== deviceId || !t.active) continue;
3124
+ const agg = t.audioLabels ??= /* @__PURE__ */ new Map();
3125
+ const entry = agg.get(label);
3126
+ if (entry) {
3127
+ entry.peakScore = Math.max(entry.peakScore, score);
3128
+ entry.count += 1;
3129
+ entry.lastAt = timestamp;
3130
+ } else agg.set(label, {
3131
+ peakScore: score,
3132
+ count: 1,
3133
+ firstAt: timestamp,
3134
+ lastAt: timestamp
3135
+ });
3136
+ }
3137
+ }
2476
3138
  /** Attach a snapshot reference to an active track. */
2477
3139
  addSnapshot(trackId, snapshot) {
2478
3140
  const t = this.active.get(trackId);
2479
3141
  if (!t) return;
2480
3142
  t.snapshots.push(snapshot);
2481
3143
  t.lastSnapshotAt = snapshot.timestamp;
3144
+ t.lastSnapshotBbox = { ...snapshot.position.bbox };
3145
+ }
3146
+ /**
3147
+ * Synchronously advance the periodic-snapshot gate reference (clock + bbox) at
3148
+ * the MOMENT a snapshot write is DECIDED — before the async encode/dispatch
3149
+ * lands the real snapshot via {@link addSnapshot}. Without it, `lastSnapshotAt`
3150
+ * only advances when the dispatcher round-trip returns (~50-200ms of sharp
3151
+ * encode), so at 10-25fps several consecutive frames pass
3152
+ * `evaluatePeriodicSnapshot` before the clock moves → a burst of near-identical
3153
+ * snapshots. Mirrors the synchronous `lastFrameAtByTrack` advance for the
3154
+ * rolling `lastFrame`.
3155
+ *
3156
+ * No rollback: if the write later fails the slot is simply lost (a rare dropped
3157
+ * snapshot is preferable to a burst). `addSnapshot` re-stamps the same
3158
+ * clock/bbox when the real snapshot lands, so the two stay consistent. No-op for
3159
+ * an unknown/expired track (the active entry is dropped on expiry, so there is
3160
+ * no separate map to leak).
3161
+ */
3162
+ markSnapshotPending(trackId, timestamp, bbox) {
3163
+ const t = this.active.get(trackId);
3164
+ if (!t) return;
3165
+ t.lastSnapshotAt = timestamp;
3166
+ t.lastSnapshotBbox = { ...bbox };
2482
3167
  }
2483
3168
  lastSnapshotAt(trackId) {
2484
3169
  return this.active.get(trackId)?.lastSnapshotAt ?? 0;
2485
3170
  }
3171
+ /** Bbox reference of the last captured snapshot (or the seed bbox), for the
3172
+ * periodic-snapshot movement gate. Undefined until the clock is seeded. */
3173
+ lastSnapshotBbox(trackId) {
3174
+ const b = this.active.get(trackId)?.lastSnapshotBbox;
3175
+ return b ? { ...b } : void 0;
3176
+ }
2486
3177
  /**
2487
3178
  * Seed the snapshot cadence clock (once, at track start) WITHOUT appending a
2488
3179
  * snapshot, so the first periodic snapshot fires ~snapshotIntervalMs after the
2489
3180
  * track begins rather than immediately — the `firstFrame` already covers the
2490
3181
  * track's start. No-op if a snapshot was already taken (clock already set).
2491
3182
  */
2492
- seedSnapshotClock(trackId, timestamp) {
3183
+ seedSnapshotClock(trackId, timestamp, bbox) {
2493
3184
  const t = this.active.get(trackId);
2494
- if (t && t.lastSnapshotAt === 0) t.lastSnapshotAt = timestamp;
3185
+ if (t && t.lastSnapshotAt === 0) {
3186
+ t.lastSnapshotAt = timestamp;
3187
+ if (bbox) t.lastSnapshotBbox = { ...bbox };
3188
+ }
2495
3189
  }
2496
3190
  getActive(deviceId) {
2497
3191
  const out = [];
@@ -2502,6 +3196,21 @@ var TrackStore = class {
2502
3196
  const t = this.active.get(trackId);
2503
3197
  return t && t.active ? cloneTrack(t) : null;
2504
3198
  }
3199
+ /**
3200
+ * Cheap read of an active track's promotion-relevant fields WITHOUT the deep
3201
+ * clone `getActiveByTrack` does — the returned `positions` is the live
3202
+ * internal array (read-only; callers must not mutate). Feeds the per-frame
3203
+ * stationary-promotion check, called at inference fps. Null if unknown/expired.
3204
+ */
3205
+ peekActive(trackId) {
3206
+ const t = this.active.get(trackId);
3207
+ if (!t || !t.active) return null;
3208
+ return {
3209
+ firstSeen: t.firstSeen,
3210
+ positions: t.positions,
3211
+ ...t.label !== void 0 ? { label: t.label } : {}
3212
+ };
3213
+ }
2505
3214
  /** Expire tracks whose `lastSeen` is older than TTL. Persists each
2506
3215
  * expired track to the declared collection and returns them. */
2507
3216
  async expireStale(nowMs) {
@@ -2607,6 +3316,15 @@ var TrackStore = class {
2607
3316
  clearAll() {
2608
3317
  this.active.clear();
2609
3318
  }
3319
+ /**
3320
+ * Drop a single active track WITHOUT persisting it as a historical row. Used
3321
+ * when a track is PROMOTED to a stationary-object registry entry: the durable
3322
+ * record for a parked object is the registry entry, not a Track, so the track
3323
+ * must NOT land in the key-event feed. No-op for an unknown/expired track.
3324
+ */
3325
+ dropActive(trackId) {
3326
+ this.active.delete(trackId);
3327
+ }
2610
3328
  /** Delete the persisted track row (keyed by trackId) and drop the in-RAM
2611
3329
  * active entry if present. Used by the whole-track deletion cascade. */
2612
3330
  async deletePersisted(trackId) {
@@ -2739,7 +3457,8 @@ var TrackStore = class {
2739
3457
  state: t.state,
2740
3458
  ...t.importance !== void 0 ? { importance: t.importance } : {},
2741
3459
  ...t.bestEventId !== void 0 ? { bestEventId: t.bestEventId } : {},
2742
- ...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {}
3460
+ ...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {},
3461
+ ...t.audioLabels !== void 0 ? { audioLabels: [...t.audioLabels] } : {}
2743
3462
  }
2744
3463
  });
2745
3464
  }
@@ -2752,6 +3471,7 @@ var TrackStore = class {
2752
3471
  const importance = data["importance"];
2753
3472
  const bestEventId = data["bestEventId"];
2754
3473
  const importanceReason = data["importanceReason"];
3474
+ const audioLabels = data["audioLabels"];
2755
3475
  return {
2756
3476
  trackId: id,
2757
3477
  deviceId: Number(data["deviceId"]),
@@ -2768,7 +3488,8 @@ var TrackStore = class {
2768
3488
  active: false,
2769
3489
  ...typeof importance === "number" ? { importance } : {},
2770
3490
  ...typeof bestEventId === "string" ? { bestEventId } : {},
2771
- ...typeof importanceReason === "string" ? { importanceReason } : {}
3491
+ ...typeof importanceReason === "string" ? { importanceReason } : {},
3492
+ ...Array.isArray(audioLabels) && audioLabels.length > 0 ? { audioLabels } : {}
2772
3493
  };
2773
3494
  }
2774
3495
  };
@@ -3881,13 +4602,10 @@ function stripNulls(data) {
3881
4602
  //#endregion
3882
4603
  //#region src/shared/frame/resolve-frame.ts
3883
4604
  /**
3884
- * Resolve the pixels a `FrameHandle` refers to. Local shm read when the
3885
- * handle's `nodeId` matches `deps.ownNodeId`, else routed via
3886
- * `deps.getRemoteFrame`. Returns `null` when the frame is no longer
3887
- * available (slot recycled locally, or the remote node reports no frame).
4605
+ * Resolve the pixels a `FrameHandle` refers to via the node-routed fetch.
4606
+ * Returns `null` when the frame is no longer available.
3888
4607
  */
3889
4608
  async function resolveFrame(handle, deps) {
3890
- if (handle.nodeId === deps.ownNodeId) return deps.readers.read(handle);
3891
4609
  return deps.getRemoteFrame(handle);
3892
4610
  }
3893
4611
  //#endregion
@@ -4064,11 +4782,7 @@ var EventMediaDispatcher = class {
4064
4782
  if (events.length === 0 && trackFrames.length === 0 && snapshots.length === 0) return empty;
4065
4783
  let decoded;
4066
4784
  try {
4067
- decoded = await resolveFrame(frameHandle, {
4068
- ownNodeId: this.deps.ownNodeId,
4069
- readers: this.deps.readers,
4070
- getRemoteFrame: this.deps.getRemoteFrame
4071
- });
4785
+ decoded = await resolveFrame(frameHandle, { getRemoteFrame: this.deps.getRemoteFrame });
4072
4786
  } catch (err) {
4073
4787
  this.deps.logger.debug("event media: resolveFrame threw", {
4074
4788
  tags: { deviceId },
@@ -4379,8 +5093,6 @@ var EmbeddingDispatcher = class {
4379
5093
  encoder;
4380
5094
  eventBus;
4381
5095
  logger;
4382
- ownNodeId;
4383
- readers;
4384
5096
  getRemoteFrame;
4385
5097
  lastEmbedTime = /* @__PURE__ */ new Map();
4386
5098
  pendingCrops = /* @__PURE__ */ new Map();
@@ -4393,8 +5105,6 @@ var EmbeddingDispatcher = class {
4393
5105
  this.encoder = deps.encoder;
4394
5106
  this.eventBus = deps.eventBus;
4395
5107
  this.logger = deps.logger;
4396
- this.ownNodeId = deps.ownNodeId;
4397
- this.readers = deps.readers;
4398
5108
  this.getRemoteFrame = deps.getRemoteFrame;
4399
5109
  }
4400
5110
  async start() {
@@ -4446,11 +5156,7 @@ var EmbeddingDispatcher = class {
4446
5156
  }
4447
5157
  let decoded;
4448
5158
  try {
4449
- decoded = await resolveFrame(handle, {
4450
- ownNodeId: this.ownNodeId,
4451
- readers: this.readers,
4452
- getRemoteFrame: this.getRemoteFrame
4453
- });
5159
+ decoded = await resolveFrame(handle, { getRemoteFrame: this.getRemoteFrame });
4454
5160
  } catch (err) {
4455
5161
  this.logger.debug("skip: resolveFrame threw", {
4456
5162
  tags: { deviceId: Number(deviceId) },
@@ -4877,7 +5583,8 @@ function computeSnapshot(input) {
4877
5583
  unzoned: {
4878
5584
  totalObjects: unzonedTotal,
4879
5585
  byClass: unzonedByClass
4880
- }
5586
+ },
5587
+ ...input.stationaryObjects !== void 0 && input.stationaryObjects.length > 0 ? { stationaryObjects: input.stationaryObjects } : {}
4881
5588
  };
4882
5589
  }
4883
5590
  //#endregion
@@ -5453,7 +6160,18 @@ var MediaSettingsSchema = object({
5453
6160
  /** Cadence (ms) for the periodic per-track `snapshot` + rolling `lastFrame`.
5454
6161
  * A snapshot is captured for an active track only after this much wall-clock
5455
6162
  * has elapsed since its previous one. */
5456
- snapshotIntervalMs: number().int().min(500).max(6e4).default(5e3)
6163
+ snapshotIntervalMs: number().int().min(500).max(6e4).default(5e3),
6164
+ /** Movement gate for the periodic `snapshot`: once `snapshotIntervalMs` has
6165
+ * elapsed, a fresh snapshot is only captured when the track's centroid moved
6166
+ * at least this fraction of the frame DIAGONAL since the last captured
6167
+ * snapshot. Suppresses near-identical frames from a long-lived / stationary
6168
+ * track. 0 disables the gate (pure-time behaviour). Default 0.03 ≈ 3% of the
6169
+ * frame diagonal (~66px on 1080p). */
6170
+ snapshotMovementThreshold: number().min(0).max(1).default(.03),
6171
+ /** Loiterer fallback (ms): force a periodic `snapshot` for a stationary but
6172
+ * still-present track after this much wall-clock without one, so its
6173
+ * filmstrip is never empty. Effectively clamped to ≥ `snapshotIntervalMs`. */
6174
+ snapshotMaxIdleMs: number().int().min(1e3).max(6e5).default(3e4)
5457
6175
  });
5458
6176
  var MEDIA_DEFAULTS = MediaSettingsSchema.parse({});
5459
6177
  /**
@@ -5468,7 +6186,148 @@ function resolveMediaSettings(raw) {
5468
6186
  return {
5469
6187
  cropPadding: pick("cropPadding"),
5470
6188
  saveThumbnails: pick("saveThumbnails"),
5471
- snapshotIntervalMs: pick("snapshotIntervalMs")
6189
+ snapshotIntervalMs: pick("snapshotIntervalMs"),
6190
+ snapshotMovementThreshold: pick("snapshotMovementThreshold"),
6191
+ snapshotMaxIdleMs: pick("snapshotMaxIdleMs")
6192
+ };
6193
+ }
6194
+ function centroidOf(b) {
6195
+ return {
6196
+ x: b.x + b.w / 2,
6197
+ y: b.y + b.h / 2
6198
+ };
6199
+ }
6200
+ /** Centroid displacement between two boxes as a fraction of the frame diagonal.
6201
+ * Returns 0 for a degenerate (≤0) frame diagonal so the caller can fall back
6202
+ * to pure-time behaviour instead of dividing by zero. */
6203
+ function centroidMovedFraction(a, b, frameWidth, frameHeight) {
6204
+ const diag = Math.hypot(frameWidth, frameHeight);
6205
+ if (diag <= 0) return 0;
6206
+ const ca = centroidOf(a);
6207
+ const cb = centroidOf(b);
6208
+ return Math.hypot(cb.x - ca.x, cb.y - ca.y) / diag;
6209
+ }
6210
+ /**
6211
+ * Decide whether the periodic `snapshot` should be captured for a track THIS
6212
+ * frame. Pure: no side effects. The caller keeps the `saveThumbnails` master
6213
+ * switch and advances `lastSnapshotAt`/`lastSnapshotBbox` only when a capture
6214
+ * actually lands — so a skipped (stationary) frame leaves the clock untouched,
6215
+ * which naturally lets `maxIdleMs` fire and re-evaluates movement every frame
6216
+ * until the object moves.
6217
+ */
6218
+ function evaluatePeriodicSnapshot(input) {
6219
+ const { lastSnapshotAt, lastSnapshotBbox, currentBbox, now, frameWidth, frameHeight, intervalMs, movementThreshold, maxIdleMs } = input;
6220
+ if (lastSnapshotAt <= 0 || now - lastSnapshotAt < intervalMs) {
6221
+ if (lastSnapshotAt > 0 && lastSnapshotBbox !== void 0 && now - lastSnapshotAt >= 1500) {
6222
+ const fastMoved = centroidMovedFraction(lastSnapshotBbox, currentBbox, frameWidth, frameHeight);
6223
+ if (fastMoved >= .08) return {
6224
+ capture: true,
6225
+ reason: "fast-mover",
6226
+ movedFraction: fastMoved
6227
+ };
6228
+ }
6229
+ return {
6230
+ capture: false,
6231
+ reason: "interval-not-elapsed",
6232
+ movedFraction: 0
6233
+ };
6234
+ }
6235
+ if (lastSnapshotBbox === void 0) return {
6236
+ capture: true,
6237
+ reason: "no-reference",
6238
+ movedFraction: 0
6239
+ };
6240
+ const movedFraction = centroidMovedFraction(lastSnapshotBbox, currentBbox, frameWidth, frameHeight);
6241
+ if (movedFraction >= movementThreshold) return {
6242
+ capture: true,
6243
+ reason: "moved",
6244
+ movedFraction
6245
+ };
6246
+ const idleLimit = Math.max(maxIdleMs, intervalMs);
6247
+ if (now - lastSnapshotAt >= idleLimit) return {
6248
+ capture: true,
6249
+ reason: "idle-forced",
6250
+ movedFraction
6251
+ };
6252
+ return {
6253
+ capture: false,
6254
+ reason: "stationary-skip",
6255
+ movedFraction
6256
+ };
6257
+ }
6258
+ //#endregion
6259
+ //#region src/pipeline-analytics/periodic-media-plan.ts
6260
+ /**
6261
+ * Decide the periodic media writes for one track on one frame. Pure: no side
6262
+ * effects. The caller advances its own `lastFrameAt` clock only when the
6263
+ * returned `rollingLastFrame` is true.
6264
+ *
6265
+ * INVARIANT: `appendSnapshot` and `rollingLastFrame` are never both true — the
6266
+ * rolling `lastFrame` is never the same frame as an appended `snapshot`, so it
6267
+ * can never duplicate one.
6268
+ */
6269
+ function planPeriodicMedia(input) {
6270
+ const appendSnapshot = input.dueSnapshot;
6271
+ return {
6272
+ appendSnapshot,
6273
+ rollingLastFrame: input.saveThumbnails && input.now - input.lastFrameAt >= input.intervalMs && !appendSnapshot,
6274
+ bestThumbnail: input.isNewBest
6275
+ };
6276
+ }
6277
+ //#endregion
6278
+ //#region src/pipeline-analytics/pipeline/key-frame-capture.ts
6279
+ /**
6280
+ * Pure helpers for the per-track NATIVE-resolution `keyFrame` capture
6281
+ * (Design B — one native full-frame per track at its best-detection moment).
6282
+ *
6283
+ * ## Why this exists (the missing native keyFrame)
6284
+ *
6285
+ * `keyFrame` was historically captured ONLY inside the CLIP object-embedding
6286
+ * best path (`persistObjectEmbeddingBests`, gated by `isClipObjectEmbedding`).
6287
+ * Under the two-plane pipeline the root frame carries NO CLIP embedding (clip is
6288
+ * a per-track DETAIL served via `runDetailSubtree`, and is disabled cluster-
6289
+ * wide), so that gate was never satisfied and the native `keyFrame` was NEVER
6290
+ * produced — every stored frame stayed at the ≤640×360 detection resolution.
6291
+ *
6292
+ * The fix decouples the `keyFrame` from the clip path: it is captured on the
6293
+ * GENERAL best-frame signal (the same `bestThumbnail` decision that drives the
6294
+ * `thumbnail`), reusing the WORKING native crop path (`captureCrop` →
6295
+ * `pipelineRunner.getNativeCrop`, which cuts the ROI from the decode worker's
6296
+ * retained NATIVE surface and only falls back to the detection frame on a miss).
6297
+ * A full-frame ROI at {@link KEYFRAME_NATIVE_MAX_WIDTH} therefore yields a frame
6298
+ * LARGER than the detection raster (up to the cap), which is the whole point of
6299
+ * the `keyFrame` kind.
6300
+ */
6301
+ /** Cap (px) on the width of the native KEY FRAME (full-frame native capture).
6302
+ * Native resolution is the point, but a full 4K RGB surface over the transport
6303
+ * per new-best is wasteful for a web detail view — 1920px keeps a sharp native
6304
+ * frame while bounding the copy (a miss falls back to the detection-res frame,
6305
+ * which is already ≤640px). */
6306
+ var KEYFRAME_NATIVE_MAX_WIDTH = 1920;
6307
+ /**
6308
+ * The tracks whose native `keyFrame` should be (re)captured THIS frame: exactly
6309
+ * the tracks that hit a new best-frame moment (`bestThumbnail`). `putReplacing`
6310
+ * downstream keeps one `keyFrame` per track (the current peak).
6311
+ */
6312
+ function selectKeyFrameTrackIds(targets) {
6313
+ return targets.filter((t) => t.bestThumbnail).map((t) => t.trackId);
6314
+ }
6315
+ /**
6316
+ * Build the `captureCrop` request for a track's native `keyFrame`: the FULL
6317
+ * frame (no padding) at the native width cap. The full-frame box is what makes
6318
+ * the capture route through the native surface at native resolution instead of
6319
+ * a tight ≤640 detection crop.
6320
+ */
6321
+ function buildKeyFrameCaptureRequest(frameWidth, frameHeight) {
6322
+ return {
6323
+ bbox: {
6324
+ x: 0,
6325
+ y: 0,
6326
+ w: frameWidth,
6327
+ h: frameHeight
6328
+ },
6329
+ padding: 0,
6330
+ maxWidth: KEYFRAME_NATIVE_MAX_WIDTH
5472
6331
  };
5473
6332
  }
5474
6333
  //#endregion
@@ -6938,7 +7797,7 @@ var DetailScheduler = class {
6938
7797
  //#region src/pipeline-analytics/detail-dispatcher.ts
6939
7798
  /**
6940
7799
  * Compose the `steps` list sent to `runDetailSubtree` for one request —
6941
- * identity-aware for the face chain.
7800
+ * chain-aware for the multi-step detail subtrees.
6942
7801
  *
6943
7802
  * A `face-detection` request ALSO includes `'face-embedding'` (the full
6944
7803
  * detect→recognize chain) EXCEPT when it is a PERIODIC geometry refresh on a
@@ -6947,13 +7806,35 @@ var DetailScheduler = class {
6947
7806
  * that case runs the detector geometry ALONE. Every recognition-bearing reason
6948
7807
  * (new-track / improve / retry) keeps the embedding regardless of the label.
6949
7808
  *
6950
- * Non-face steps are unchanged (`[req.stepId]`). Pairs with the pipeline's
6951
- * strict-`steps` pruning (`pruneChildStepsToRequested`) including
6952
- * `'face-embedding'` here is what keeps the nested child in the executed chain.
7809
+ * A `plate-detection` request ALWAYS includes `'plate-ocr'` symmetric to the
7810
+ * face chain. Without `'plate-ocr'` in the array the pipeline's strict-`steps`
7811
+ * pruning (`pruneChildStepsToRequested`) drops the OCR child, so a detected
7812
+ * plate never gets read and no plate text is ever produced. (The dispatcher
7813
+ * cannot import the pipeline catalog to derive the child chain — this hardcode
7814
+ * mirrors it; keep the two in sync when the catalog's plate subtree changes.)
7815
+ *
7816
+ * Other steps are unchanged (`[req.stepId]`). Pairs with the pipeline's
7817
+ * strict-`steps` pruning — naming the nested child here is what keeps it in the
7818
+ * executed chain.
6953
7819
  */
6954
7820
  function composeDetailSteps(req, hasTrackLabel) {
6955
- if (req.stepId !== "face-detection") return [req.stepId];
6956
- return req.reason === "periodic" && hasTrackLabel(req.trackId) ? ["face-detection"] : ["face-detection", "face-embedding"];
7821
+ if (req.stepId === "face-detection") return req.reason === "periodic" && hasTrackLabel(req.trackId) ? ["face-detection"] : ["face-detection", "face-embedding"];
7822
+ if (req.stepId === "plate-detection") return ["plate-detection", "plate-ocr"];
7823
+ return [req.stepId];
7824
+ }
7825
+ /**
7826
+ * Does `steps` name a nested-enrichment chain (root detector + a child that
7827
+ * produces a `label`/`embedding`), i.e. more than the bare root step? Used to
7828
+ * surface a silent enrichment miss (BUG C): a plate detected but never read, a
7829
+ * face detected but never embedded — the root detail still routes so the miss
7830
+ * is otherwise invisible. `['plate-detection']` alone is NOT a chain.
7831
+ */
7832
+ function isEnrichmentChain(steps) {
7833
+ return steps.length > 1;
7834
+ }
7835
+ /** Does any returned detail carry the enrichment a chain request asked for? */
7836
+ function detailsCarryEnrichment(details) {
7837
+ return details.some((d) => d.label !== void 0 || d.embedding !== void 0);
6957
7838
  }
6958
7839
  /** Throttle for the per-device "detail call failed" warn — one line / minute. */
6959
7840
  var FAIL_WARN_THROTTLE_MS = 6e4;
@@ -7030,7 +7911,8 @@ var TrackDetailDispatcher = class {
7030
7911
  queue: [],
7031
7912
  inFlight: 0,
7032
7913
  timer: null,
7033
- lastFailWarnAt: 0
7914
+ lastFailWarnAt: 0,
7915
+ lastEnrichWarnAt: 0
7034
7916
  };
7035
7917
  this.devices.set(deviceId, dev);
7036
7918
  }
@@ -7076,6 +7958,8 @@ var TrackDetailDispatcher = class {
7076
7958
  let topScore = null;
7077
7959
  if (details !== null && details.length > 0) {
7078
7960
  topScore = details.reduce((max, d) => d.score > max ? d.score : max, Number.NEGATIVE_INFINITY);
7961
+ const steps = composeDetailSteps(req, (id) => this.deps.hasTrackLabel?.(id) ?? false);
7962
+ if (isEnrichmentChain(steps) && !detailsCarryEnrichment(details)) this.warnEnrichmentMissThrottled(deviceId, dev, req, steps);
7079
7963
  try {
7080
7964
  await this.deps.routeResults(deviceId, req.trackId, details, frame);
7081
7965
  } catch (err) {
@@ -7157,6 +8041,20 @@ var TrackDetailDispatcher = class {
7157
8041
  }
7158
8042
  });
7159
8043
  }
8044
+ warnEnrichmentMissThrottled(deviceId, dev, req, steps) {
8045
+ const now = Date.now();
8046
+ if (now - dev.lastEnrichWarnAt < FAIL_WARN_THROTTLE_MS) return;
8047
+ dev.lastEnrichWarnAt = now;
8048
+ this.deps.logger.warn("detail chain ran but produced no enrichment (root detected, child yielded no label/embedding)", {
8049
+ tags: { deviceId },
8050
+ meta: {
8051
+ trackId: req.trackId,
8052
+ stepId: req.stepId,
8053
+ reason: req.reason,
8054
+ steps
8055
+ }
8056
+ });
8057
+ }
7160
8058
  };
7161
8059
  //#endregion
7162
8060
  //#region src/pipeline-analytics/overlay-state.ts
@@ -7994,38 +8892,68 @@ var PlateRecognizer = class {
7994
8892
  name
7995
8893
  } : null;
7996
8894
  }
7997
- /** Live label for a plate read: the recognized vehicle NAME when matched, else
7998
- * the raw OCR text (today's behavior). Used by the ingest label path. */
8895
+ /** Live label for a plate read: the recognized vehicle NAME when matched,
8896
+ * else the raw OCR text. Returns `null` for an implausible read (junk OCR
8897
+ * off a distant/oblique plate) — the caller must NOT stamp a label then. */
7999
8898
  resolveLabel(text, score) {
8899
+ if (!isPlausiblePlateRead(text, score)) return null;
8000
8900
  return this.matchVehicle(text, score)?.name ?? text;
8001
8901
  }
8002
8902
  async processFrame(input) {
8003
8903
  const minConfidence = input.minConfidence ?? 0;
8004
8904
  const candidates = input.tracked.filter((t) => typeof t.plateText === "string" && t.plateText.length > 0 && typeof t.plateScore === "number" && t.plateScore >= minConfidence && t.plateBbox !== void 0);
8005
8905
  if (candidates.length === 0) return;
8006
- for (const c of candidates) {
8007
- const held = this.bestPlate.get(c.trackId);
8008
- if (held !== void 0 && c.plateScore <= held.score) continue;
8009
- let crop;
8010
- if (input.frameHandle !== void 0) try {
8011
- crop = await this.deps.captureCrop(input.frameHandle, c.plateBbox, input.frameWidth, input.frameHeight, input.cropPadding) ?? void 0;
8012
- } catch (err) {
8013
- this.deps.logger.debug("PlateRecognizer crop capture failed", {
8014
- tags: { deviceId: input.deviceId },
8015
- meta: {
8016
- trackId: c.trackId,
8017
- error: String(err)
8018
- }
8019
- });
8020
- }
8021
- this.bestPlate.set(c.trackId, {
8022
- text: c.plateText,
8023
- score: c.plateScore,
8024
- bbox: c.plateBbox,
8025
- timestamp: input.timestamp,
8026
- ...crop !== void 0 ? { crop } : {}
8906
+ for (const c of candidates) await this.holdBest({
8907
+ deviceId: input.deviceId,
8908
+ trackId: c.trackId,
8909
+ text: c.plateText,
8910
+ score: c.plateScore,
8911
+ bbox: c.plateBbox,
8912
+ timestamp: input.timestamp,
8913
+ frameWidth: input.frameWidth,
8914
+ frameHeight: input.frameHeight,
8915
+ cropPadding: input.cropPadding,
8916
+ ...input.frameHandle !== void 0 ? { frameHandle: input.frameHandle } : {}
8917
+ });
8918
+ }
8919
+ /**
8920
+ * Detail-plane entry point (two-plane design): plate-ocr runs on demand per
8921
+ * track via `pipelineRunner.runDetailSubtree`, NOT per frame, so the OCR read
8922
+ * never lands on the per-frame `tracked[]` that {@link processFrame} scans.
8923
+ * The dispatcher's result router calls this with each plate detail so the
8924
+ * gallery still collects the best read + tight crop (persisted on
8925
+ * {@link onTrackEnd}). Without it the plate label rides the event but the
8926
+ * gallery stays empty (0 plateCrop) the observed live gap.
8927
+ */
8928
+ async observePlateRead(input) {
8929
+ if (!isPlausiblePlateRead(input.text, input.score)) return;
8930
+ if (input.score < (input.minConfidence ?? 0)) return;
8931
+ await this.holdBest(input);
8932
+ }
8933
+ /** Hold the highest-scoring plate read per track, capturing a tight crop the
8934
+ * first time a new best is seen (shared by the per-frame + detail-plane paths). */
8935
+ async holdBest(input) {
8936
+ const held = this.bestPlate.get(input.trackId);
8937
+ if (held !== void 0 && input.score <= held.score) return;
8938
+ let crop;
8939
+ if (input.frameHandle !== void 0) try {
8940
+ crop = await this.deps.captureCrop(input.frameHandle, input.bbox, input.frameWidth, input.frameHeight, input.cropPadding) ?? void 0;
8941
+ } catch (err) {
8942
+ this.deps.logger.debug("PlateRecognizer crop capture failed", {
8943
+ tags: { deviceId: input.deviceId },
8944
+ meta: {
8945
+ trackId: input.trackId,
8946
+ error: String(err)
8947
+ }
8027
8948
  });
8028
8949
  }
8950
+ this.bestPlate.set(input.trackId, {
8951
+ text: input.text,
8952
+ score: input.score,
8953
+ bbox: input.bbox,
8954
+ timestamp: input.timestamp,
8955
+ ...crop !== void 0 ? { crop } : {}
8956
+ });
8029
8957
  }
8030
8958
  /** Persist the held best plate for a finished track as one PlateStore row
8031
8959
  * (crop → MediaStore under ownerKind 'plate'), then drop in-memory state. */
@@ -8436,18 +9364,17 @@ var DETAIL_FALLBACK_CROP_PADDING = .15;
8436
9364
  * before re-reading. */
8437
9365
  var CLIP_MODEL_ID_CACHE_TTL_MS = 6e4;
8438
9366
  var SETTINGS_CACHE_TTL_MS = 5e3;
9367
+ /** Reconcile cadence for the per-device `zones` / `zoneRules` slice handles.
9368
+ * The `device.state-changed` push doesn't reliably reach a forked child, so
9369
+ * each cached proxy re-pulls both slices on this timer (see ensureProxy) —
9370
+ * a zone drawn in the editor shows up in per-zone stats within one tick. */
9371
+ var ZONE_SLICE_RECONCILE_MS = 3e4;
8439
9372
  /** §5 best-frame: a track's `thumbnail` is overwritten only when the current
8440
9373
  * detection confidence beats the held best by at least this margin (hysteresis
8441
9374
  * so jitter around a plateau doesn't churn the write). */
8442
9375
  var BEST_FRAME_HYSTERESIS = .05;
8443
9376
  /** §5 best-frame: at most one best-thumbnail capture per this interval per track. */
8444
9377
  var BEST_FRAME_MIN_GAP_MS = 2e3;
8445
- /** Design B: cap (px) on the width of the native KEY FRAME (full-frame native
8446
- * capture). Native resolution is the point, but a full 4K RGB surface over the
8447
- * transport per new-best is wasteful for a web detail view — 1920px keeps a
8448
- * sharp native frame while bounding the copy (a miss falls back to the
8449
- * detection-res frame, which is already ≤640px). */
8450
- var KEYFRAME_NATIVE_MAX_WIDTH = 1920;
8451
9378
  /** getKeyEvents: max completed tracks pulled from a window before importance
8452
9379
  * ranking. Ordering is by importance (not firstSeen) and legacy rows score on
8453
9380
  * read, so we over-fetch candidates and trim to `limit` after sorting. */
@@ -8520,6 +9447,10 @@ function stripGlobalOnlyFields(sections) {
8520
9447
  var PipelineAnalyticsAddon = class extends BaseAddon {
8521
9448
  processors = /* @__PURE__ */ new Map();
8522
9449
  trackStore = null;
9450
+ /** Parked-object registry: promotes a track that stopped moving into a
9451
+ * lightweight entry, suppresses its detections from re-spawning tracks, and
9452
+ * wakes it when the object departs. Null until onInitialize. */
9453
+ stationaryRegistry = null;
8523
9454
  mediaStore = null;
8524
9455
  eventStore = null;
8525
9456
  identityStore = null;
@@ -8545,9 +9476,6 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
8545
9476
  * detection-pipeline DECODED frame — the ONLY image source (never the
8546
9477
  * snapshot cap). Null when shm frame access is unavailable. */
8547
9478
  eventMediaDispatcher = null;
8548
- /** Shared shm-ring reader cache for resolving `frameHandle`s to pixels.
8549
- * Owned here so segments stay open across frames; closed once on shutdown. */
8550
- frameReaders = null;
8551
9479
  /** Object/face embedding dispatcher — migrated from the retired
8552
9480
  * enrichment-engine addon. Runs ONLY on the post-processing node; on each
8553
9481
  * detection it resolves the frame, crops the ROI, and calls the
@@ -8584,6 +9512,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
8584
9512
  * dataPlane facility in the current environment). */
8585
9513
  eventMediaBaseUrl = null;
8586
9514
  lastActiveTrackIds = /* @__PURE__ */ new Map();
9515
+ lastFrameDimsByDevice = /* @__PURE__ */ new Map();
8587
9516
  lastAudioInsertByDevice = /* @__PURE__ */ new Map();
8588
9517
  lastMotionInsertByDevice = /* @__PURE__ */ new Map();
8589
9518
  levelStateByDevice = /* @__PURE__ */ new Map();
@@ -8615,10 +9544,15 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
8615
9544
  * cross-restart "best" gate stays in `ObjectEmbeddingStore.upsertIfBetter`. */
8616
9545
  objectEmbeddingBestSelector = new TrackBestSelector();
8617
9546
  /** Design B: the track's shared native key-frame media key, captured at the
8618
- * best-detection moment (object-embedding best path). Read by the face path
8619
- * at track end so a face row links to the SAME single key frame. Cleared on
8620
- * track end. */
9547
+ * best-detection moment (general best-frame path). Read by the face / plate /
9548
+ * object-embedding rows so they LINK the SAME single native key frame.
9549
+ * Cleared on track end. */
8621
9550
  keyFrameKeyByTrackId = /* @__PURE__ */ new Map();
9551
+ /** Wall-clock of each track's last ACTUALLY-written rolling `lastFrame`. The
9552
+ * rolling `lastFrame` runs on its OWN pure-time cadence and only on frames
9553
+ * where no `snapshot` is appended, so it is never byte-identical to a stored
9554
+ * `snapshot` (kills the end-of-track duplicate). Cleared on track end. */
9555
+ lastFrameAtByTrack = /* @__PURE__ */ new Map();
8622
9556
  /** Per-track update-gate memory for `PipelineAnalyticsTrackLifecycle`
8623
9557
  * `phase:'update'` — the last-emitted best (confidence / label / crop
8624
9558
  * area) + emit time, so a material improvement is measured against the
@@ -8669,6 +9603,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
8669
9603
  await PlateStore.declare(api.settingsStore);
8670
9604
  await VehicleStore.declare(api.settingsStore);
8671
9605
  await ObjectEmbeddingStore.declare(api.settingsStore);
9606
+ await StationaryObjectRegistry.declare(api.settingsStore);
8672
9607
  const logger = this.ctx.logger;
8673
9608
  let storage = this.ctx.kernel.storage;
8674
9609
  const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
@@ -8682,6 +9617,30 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
8682
9617
  store: api.settingsStore,
8683
9618
  logger: logger.child("TrackStore")
8684
9619
  });
9620
+ this.stationaryRegistry = new StationaryObjectRegistry({
9621
+ store: api.settingsStore,
9622
+ logger: logger.child("StationaryRegistry"),
9623
+ onChange: ({ phase, entry, timestamp }) => {
9624
+ this.ctx.eventBus.emit({
9625
+ id: `pa-stationary-${entry.id}-${phase}`,
9626
+ timestamp: new Date(timestamp),
9627
+ source: {
9628
+ type: "addon",
9629
+ id: "pipeline-analytics",
9630
+ addonId: "pipeline-analytics"
9631
+ },
9632
+ category: EventCategory.PipelineAnalyticsStationaryChanged,
9633
+ data: {
9634
+ deviceId: entry.deviceId,
9635
+ entryId: entry.id,
9636
+ className: entry.className,
9637
+ phase,
9638
+ timestamp
9639
+ }
9640
+ });
9641
+ }
9642
+ });
9643
+ await this.stationaryRegistry.load();
8685
9644
  this.mediaStore = new MediaStore({
8686
9645
  storage,
8687
9646
  store: api.settingsStore,
@@ -8718,33 +9677,33 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
8718
9677
  designatedNode: designated
8719
9678
  } });
8720
9679
  }
8721
- this.frameReaders = new FrameRingReaderCache(logger.child("shm-readers"));
8722
- const decoderApi = api.decoder;
9680
+ const pipelineRunnerApi = api.pipelineRunner;
8723
9681
  const getRemoteFrame = async (handle) => {
8724
- if (!decoderApi?.getFrame) return null;
8725
- const remote = await decoderApi.getFrame.query({
9682
+ if (!pipelineRunnerApi?.getNativeCrop) return null;
9683
+ const full = await pipelineRunnerApi.getNativeCrop.query({
8726
9684
  handle,
8727
- nodeId: handle.nodeId
8728
- });
8729
- if (!remote) return null;
9685
+ bbox: {
9686
+ x: 0,
9687
+ y: 0,
9688
+ w: 1,
9689
+ h: 1
9690
+ },
9691
+ maxWidth: handle.width
9692
+ }, nodePin(handle.nodeId));
9693
+ if (!full || full.width <= 0 || full.height <= 0) return null;
8730
9694
  return {
8731
- data: Buffer.from(remote.data),
8732
- width: remote.width,
8733
- height: remote.height,
8734
- format: remote.format,
8735
- timestamp: remote.timestamp
9695
+ data: Buffer.from(full.bytes),
9696
+ width: full.width,
9697
+ height: full.height,
9698
+ format: "rgb",
9699
+ timestamp: 0
8736
9700
  };
8737
9701
  };
8738
9702
  this.eventMediaDispatcher = new EventMediaDispatcher({
8739
- ownNodeId,
8740
- readers: this.frameReaders,
8741
9703
  getRemoteFrame,
8742
9704
  mediaStore: this.mediaStore,
8743
9705
  logger: logger.child("EventMediaDispatcher")
8744
9706
  });
8745
- const ownNodeIdForFaces = ownNodeId;
8746
- const frameReadersForFaces = this.frameReaders;
8747
- const pipelineRunnerApi = api.pipelineRunner;
8748
9707
  const cropMetricLogger = logger.child("NativeCrop");
8749
9708
  let nativeHits = 0;
8750
9709
  let nativeFallbacks = 0;
@@ -8776,11 +9735,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
8776
9735
  return null;
8777
9736
  }
8778
9737
  };
8779
- const resolveFrameShared = createSharedFrameResolver((frameHandle) => resolveFrame(frameHandle, {
8780
- ownNodeId: ownNodeIdForFaces,
8781
- readers: frameReadersForFaces,
8782
- getRemoteFrame
8783
- }));
9738
+ const resolveFrameShared = createSharedFrameResolver((frameHandle) => resolveFrame(frameHandle, { getRemoteFrame }));
8784
9739
  const captureCrop = async (frameHandle, bbox, frameWidth, frameHeight, padding, maxWidth) => {
8785
9740
  const paddedNorm = padBbox({
8786
9741
  x: bbox.x / frameWidth,
@@ -8936,8 +9891,6 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
8936
9891
  encoder: encoderClient,
8937
9892
  eventBus: this.ctx.eventBus,
8938
9893
  logger: logger.child("EmbeddingDispatcher"),
8939
- ownNodeId,
8940
- readers: frameReadersForFaces,
8941
9894
  getRemoteFrame
8942
9895
  });
8943
9896
  await this.embeddingDispatcher.start();
@@ -8948,6 +9901,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
8948
9901
  this.bindingCache?.onBindingsChanged(data);
8949
9902
  if (data.capName === "pipeline-analytics" && data.reason === "wrapper-deactivated") {
8950
9903
  this.trackStore?.clearDevice(data.deviceId);
9904
+ this.stationaryRegistry?.forgetDevice(data.deviceId);
8951
9905
  this.overlayState.clearDevice(data.deviceId);
8952
9906
  this.overlaySynthesisWarnAt.delete(data.deviceId);
8953
9907
  this.forgetDeviceProcessors(data.deviceId);
@@ -8962,6 +9916,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
8962
9916
  this.unsubDeviceUnreg = this.ctx.eventBus.subscribe({ category: EventCategory.DeviceUnregistered }, (ev) => {
8963
9917
  const { deviceId } = ev.data;
8964
9918
  this.trackStore?.clearDevice(deviceId);
9919
+ this.stationaryRegistry?.forgetDevice(deviceId);
8965
9920
  this.overlayState.clearDevice(deviceId);
8966
9921
  this.overlaySynthesisWarnAt.delete(deviceId);
8967
9922
  this.forgetDeviceProcessors(deviceId);
@@ -8982,6 +9937,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
8982
9937
  this.retentionSweepTimer = setInterval(() => {
8983
9938
  this.sweepRetention();
8984
9939
  this.runTrackRetentionSweep();
9940
+ this.stationaryRegistry?.sweep(Date.now());
8985
9941
  }, RETENTION_SWEEP_INTERVAL_MS);
8986
9942
  this.ctx.logger.info("pipeline-analytics subscribers installed");
8987
9943
  const widgetsProvider = { listWidgets: async () => [
@@ -9302,8 +10258,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
9302
10258
  this.overlaySynthesisWarnAt.clear();
9303
10259
  this.processors.clear();
9304
10260
  this.lastActiveTrackIds.clear();
10261
+ this.lastFrameDimsByDevice.clear();
9305
10262
  this.dropoutSkipsByKey.clear();
9306
10263
  this.bestFrameTracker.clear();
10264
+ this.lastFrameAtByTrack.clear();
9307
10265
  this.trackLifecycleUpdateMem.clear();
9308
10266
  this.objectEmbeddingBestSelector.clear();
9309
10267
  this.levelStateByDevice.clear();
@@ -9314,13 +10272,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
9314
10272
  this.faceGlobalEnabledCache = null;
9315
10273
  this.mediaCacheByDevice.clear();
9316
10274
  this.trackStore?.clearAll();
10275
+ this.stationaryRegistry = null;
9317
10276
  this.bindingCache?.clearAll();
9318
10277
  await this.eventMediaDataPlane?.dispose();
9319
10278
  this.eventMediaDataPlane = null;
9320
10279
  this.eventMediaBaseUrl = null;
9321
10280
  this.eventMediaDispatcher = null;
9322
- this.frameReaders?.close();
9323
- this.frameReaders = null;
9324
10281
  }
9325
10282
  async handleInferenceResult(data) {
9326
10283
  if (this.shuttingDown) return;
@@ -9368,22 +10325,41 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
9368
10325
  timestamp: frame.timestamp,
9369
10326
  frame
9370
10327
  });
10328
+ if (source === "pipeline") this.stationaryRegistry?.noteFrame(deviceId, result.timestamp);
10329
+ if (this.stationaryRegistry && (result.stationaryConfirmed.length > 0 || result.stationaryWoken.length > 0)) this.stationaryRegistry.applyFrameOutcome({
10330
+ deviceId,
10331
+ confirmed: result.stationaryConfirmed,
10332
+ wokenEntryIds: result.stationaryWoken,
10333
+ timestamp: result.timestamp
10334
+ });
10335
+ const stationaryViews = this.stationaryRegistry?.listViews(deviceId) ?? [];
10336
+ const stationaryAsTracked = stationaryViews.map((v) => ({
10337
+ trackId: `stationary:${v.id}`,
10338
+ className: v.className,
10339
+ zones: []
10340
+ }));
9371
10341
  this.zoneAnalytics?.recordFrame({
9372
10342
  deviceId,
9373
10343
  timestamp: result.timestamp,
9374
10344
  frameWidth: result.frameWidth,
9375
10345
  frameHeight: result.frameHeight,
9376
- tracked: result.tracked,
9377
- zones: liveZones
10346
+ tracked: stationaryAsTracked.length > 0 ? [...result.tracked, ...stationaryAsTracked] : result.tracked,
10347
+ zones: liveZones,
10348
+ ...stationaryViews.length > 0 ? { stationaryObjects: stationaryViews } : {}
10349
+ });
10350
+ if (result.frameWidth > 0 && result.frameHeight > 0) this.lastFrameDimsByDevice.set(deviceId, {
10351
+ w: result.frameWidth,
10352
+ h: result.frameHeight
9378
10353
  });
9379
10354
  const currentTrackIds = /* @__PURE__ */ new Set();
10355
+ const positionsCountById = /* @__PURE__ */ new Map();
9380
10356
  for (const t of result.tracked) {
9381
10357
  currentTrackIds.add(t.trackId);
9382
10358
  const center = {
9383
10359
  x: t.bbox.x + t.bbox.w / 2,
9384
10360
  y: t.bbox.y + t.bbox.h / 2
9385
10361
  };
9386
- this.trackStore.upsert({
10362
+ const upserted = this.trackStore.upsert({
9387
10363
  trackId: t.trackId,
9388
10364
  deviceId,
9389
10365
  className: t.className,
@@ -9398,6 +10374,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
9398
10374
  zones: t.zones,
9399
10375
  state: t.state
9400
10376
  });
10377
+ positionsCountById.set(t.trackId, upserted.positions.length);
9401
10378
  }
9402
10379
  const log = this.ctx.logger.withTags({ deviceId });
9403
10380
  const prevIds = this.lastActiveTrackIds.get(key) ?? /* @__PURE__ */ new Set();
@@ -9406,6 +10383,18 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
9406
10383
  for (const id of currentTrackIds) if (!prevIds.has(id)) {
9407
10384
  const t = result.tracked.find((x) => x.trackId === id);
9408
10385
  if (t) {
10386
+ if (classifyTrackAppearance({
10387
+ inPrevActive: false,
10388
+ positionsCount: positionsCountById.get(id) ?? 1
10389
+ }) === "resurrection") {
10390
+ log.info("track resumed", { meta: {
10391
+ trackId: id,
10392
+ className: t.className,
10393
+ source,
10394
+ resurrected: true
10395
+ } });
10396
+ continue;
10397
+ }
9409
10398
  newTrackCount += 1;
9410
10399
  log.info("track started", { meta: {
9411
10400
  trackId: id,
@@ -9419,7 +10408,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
9419
10408
  bbox: { ...t.bbox },
9420
10409
  ...t.label ? { label: t.label } : {}
9421
10410
  });
9422
- this.trackStore.seedSnapshotClock(id, result.timestamp);
10411
+ this.trackStore.seedSnapshotClock(id, result.timestamp, t.bbox);
9423
10412
  }
9424
10413
  this.ctx.eventBus.emit({
9425
10414
  id: `pa-${randomUUID()}`,
@@ -9465,6 +10454,34 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
9465
10454
  } });
9466
10455
  }
9467
10456
  this.lastActiveTrackIds.set(key, currentTrackIds);
10457
+ if (source === "pipeline" && this.stationaryRegistry) {
10458
+ const dims = this.lastFrameDimsByDevice.get(deviceId);
10459
+ if (dims && dims.w > 0 && dims.h > 0) {
10460
+ const refDiag = Math.hypot(dims.w, dims.h);
10461
+ for (const t of result.tracked) {
10462
+ const active = this.trackStore.peekActive(t.trackId);
10463
+ if (!active) continue;
10464
+ const { promote } = evaluateStationaryPromotion({
10465
+ positions: active.positions,
10466
+ referenceDiagonalPx: refDiag,
10467
+ now: result.timestamp,
10468
+ config: DEFAULT_PROMOTION_CONFIG
10469
+ });
10470
+ if (!promote) continue;
10471
+ this.promoteToStationary({
10472
+ deviceId,
10473
+ key,
10474
+ processor,
10475
+ track: t,
10476
+ firstSeen: active.firstSeen,
10477
+ label: active.label,
10478
+ frameWidth: result.frameWidth,
10479
+ frameHeight: result.frameHeight,
10480
+ timestamp: result.timestamp
10481
+ });
10482
+ }
10483
+ }
10484
+ }
9468
10485
  if (this.detailDispatcher && detailSteps && detailSteps.length > 0) {
9469
10486
  const dispatcher = this.detailDispatcher;
9470
10487
  const steps = detailSteps;
@@ -9533,7 +10550,9 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
9533
10550
  let plateCrops = 0;
9534
10551
  for (const crops of childCropsByEvent.values()) for (const c of crops) if (c.kind === "faceCrop") faceCrops += 1;
9535
10552
  else plateCrops += 1;
9536
- const snapshotTargets = this.buildSnapshotTargets(deviceId, result.tracked, result.timestamp, mediaSettings);
10553
+ const snapshotTargets = this.buildSnapshotTargets(deviceId, result.tracked, result.timestamp, mediaSettings, result.frameWidth, result.frameHeight);
10554
+ const keyFrameTrackIds = selectKeyFrameTrackIds(snapshotTargets);
10555
+ if (keyFrameTrackIds.length > 0) this.persistKeyFrames(deviceId, result.timestamp, keyFrameTrackIds, frameHandle, result.frameWidth, result.frameHeight);
9537
10556
  if (eventTargets.length > 0 || firstFrameTargets.length > 0 || snapshotTargets.length > 0) {
9538
10557
  const captureCounts = {
9539
10558
  events: eventTargets.length,
@@ -9748,14 +10767,36 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
9748
10767
  if (isFaceDetail && d.embedding !== void 0) await this.routeFaceDetail(deviceId, trackId, d, frame);
9749
10768
  else if (d.embedding !== void 0) await this.routeClipDetail(deviceId, trackId, d, frame.timestamp);
9750
10769
  else if (d.label !== void 0 && d.label.length > 0) {
9751
- if (d.className === "plate" && d.bbox !== void 0) this.overlayState.notePlateDetail(deviceId, trackId, {
9752
- x: d.bbox.x,
9753
- y: d.bbox.y,
9754
- w: d.bbox.w,
9755
- h: d.bbox.h
9756
- }, d.score, d.label, frame.timestamp);
9757
- const label = d.className === "plate" ? this.plateRecognizer?.resolveLabel(d.label, d.score) ?? d.label : d.label;
9758
- await this.applyTrackEnrichmentLabel(deviceId, trackId, label);
10770
+ if (d.className === "plate" && d.bbox !== void 0) {
10771
+ this.overlayState.notePlateDetail(deviceId, trackId, {
10772
+ x: d.bbox.x,
10773
+ y: d.bbox.y,
10774
+ w: d.bbox.w,
10775
+ h: d.bbox.h
10776
+ }, d.score, d.label, frame.timestamp);
10777
+ if (this.plateRecognizer) {
10778
+ const mediaSettings = await this.resolveDeviceMediaSettings(deviceId);
10779
+ await this.plateRecognizer.observePlateRead({
10780
+ deviceId,
10781
+ trackId,
10782
+ text: d.label,
10783
+ score: d.score,
10784
+ bbox: {
10785
+ x: d.bbox.x,
10786
+ y: d.bbox.y,
10787
+ w: d.bbox.w,
10788
+ h: d.bbox.h
10789
+ },
10790
+ timestamp: frame.timestamp,
10791
+ frameWidth: frame.frameWidth,
10792
+ frameHeight: frame.frameHeight,
10793
+ cropPadding: mediaSettings.cropPadding,
10794
+ ...frame.frameHandle !== void 0 ? { frameHandle: frame.frameHandle } : {}
10795
+ });
10796
+ }
10797
+ }
10798
+ const label = d.className === "plate" ? this.plateRecognizer?.resolveLabel(d.label, d.score) ?? null : d.label;
10799
+ if (label !== null && label !== void 0) await this.applyTrackEnrichmentLabel(deviceId, trackId, label);
9759
10800
  }
9760
10801
  } catch (err) {
9761
10802
  this.ctx.logger.warn("detail result route failed", {
@@ -9902,55 +10943,26 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
9902
10943
  await Promise.all(bests.map(async (t) => {
9903
10944
  if (!isClipObjectEmbedding(t)) return;
9904
10945
  let mediaKey;
9905
- let keyFrameMediaKey;
9906
- if (frameHandle !== void 0 && this.captureCrop && this.mediaStore) {
9907
- try {
9908
- const crop = await this.captureCrop(frameHandle, t.bbox, frameWidth, frameHeight, cropPadding);
9909
- if (crop) mediaKey = await this.mediaStore.putReplacing({
9910
- deviceId,
9911
- ownerKind: "track",
9912
- ownerId: t.trackId,
9913
- kind: "crop",
9914
- timestamp,
9915
- data: crop
9916
- });
9917
- } catch (err) {
9918
- this.ctx.logger.debug("object-embedding crop capture failed", {
9919
- tags: { deviceId },
9920
- meta: {
9921
- trackId: t.trackId,
9922
- error: errMsg(err)
9923
- }
9924
- });
9925
- }
9926
- try {
9927
- const keyFrame = await this.captureCrop(frameHandle, {
9928
- x: 0,
9929
- y: 0,
9930
- w: frameWidth,
9931
- h: frameHeight
9932
- }, frameWidth, frameHeight, 0, KEYFRAME_NATIVE_MAX_WIDTH);
9933
- if (keyFrame) {
9934
- keyFrameMediaKey = await this.mediaStore.putReplacing({
9935
- deviceId,
9936
- ownerKind: "track",
9937
- ownerId: t.trackId,
9938
- kind: "keyFrame",
9939
- timestamp,
9940
- data: keyFrame
9941
- });
9942
- this.keyFrameKeyByTrackId.set(t.trackId, keyFrameMediaKey);
10946
+ if (frameHandle !== void 0 && this.captureCrop && this.mediaStore) try {
10947
+ const crop = await this.captureCrop(frameHandle, t.bbox, frameWidth, frameHeight, cropPadding);
10948
+ if (crop) mediaKey = await this.mediaStore.putReplacing({
10949
+ deviceId,
10950
+ ownerKind: "track",
10951
+ ownerId: t.trackId,
10952
+ kind: "crop",
10953
+ timestamp,
10954
+ data: crop
10955
+ });
10956
+ } catch (err) {
10957
+ this.ctx.logger.debug("object-embedding crop capture failed", {
10958
+ tags: { deviceId },
10959
+ meta: {
10960
+ trackId: t.trackId,
10961
+ error: errMsg(err)
9943
10962
  }
9944
- } catch (err) {
9945
- this.ctx.logger.debug("key-frame capture failed", {
9946
- tags: { deviceId },
9947
- meta: {
9948
- trackId: t.trackId,
9949
- error: errMsg(err)
9950
- }
9951
- });
9952
- }
10963
+ });
9953
10964
  }
10965
+ const keyFrameMediaKey = this.keyFrameKeyByTrackId.get(t.trackId);
9954
10966
  await store.upsertIfBetter({
9955
10967
  trackId: t.trackId,
9956
10968
  deviceId,
@@ -9964,6 +10976,48 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
9964
10976
  });
9965
10977
  }));
9966
10978
  }
10979
+ /**
10980
+ * Capture ONE native-resolution KEY FRAME per given track at this best-
10981
+ * detection frame and store it (`putReplacing` → one keyFrame per track).
10982
+ *
10983
+ * The full frame is cropped NATIVE-FIRST via `captureCrop`: the request is the
10984
+ * FULL frame (no padding) at `KEYFRAME_NATIVE_MAX_WIDTH`, which routes through
10985
+ * `pipelineRunner.getNativeCrop` (the decode worker's retained native surface)
10986
+ * and only falls back to the ≤640 detection frame when the native lease is
10987
+ * gone. The stored key is recorded in `keyFrameKeyByTrackId` so the face /
10988
+ * plate / object-embedding rows LINK the SAME native key frame (Design B).
10989
+ * Issued in the live-frame window so the native lease is still held. Best-
10990
+ * effort (D8) — a per-track failure is logged and never thrown.
10991
+ */
10992
+ async persistKeyFrames(deviceId, timestamp, trackIds, frameHandle, frameWidth, frameHeight) {
10993
+ const capture = this.captureCrop;
10994
+ const mediaStore = this.mediaStore;
10995
+ if (!capture || !mediaStore) return;
10996
+ const req = buildKeyFrameCaptureRequest(frameWidth, frameHeight);
10997
+ await Promise.all(trackIds.map(async (trackId) => {
10998
+ try {
10999
+ const keyFrame = await capture(frameHandle, req.bbox, frameWidth, frameHeight, req.padding, req.maxWidth);
11000
+ if (!keyFrame) return;
11001
+ const key = await mediaStore.putReplacing({
11002
+ deviceId,
11003
+ ownerKind: "track",
11004
+ ownerId: trackId,
11005
+ kind: "keyFrame",
11006
+ timestamp,
11007
+ data: keyFrame
11008
+ });
11009
+ this.keyFrameKeyByTrackId.set(trackId, key);
11010
+ } catch (err) {
11011
+ this.ctx.logger.debug("key-frame capture failed", {
11012
+ tags: { deviceId },
11013
+ meta: {
11014
+ trackId,
11015
+ error: errMsg(err)
11016
+ }
11017
+ });
11018
+ }
11019
+ }));
11020
+ }
9967
11021
  /** Emit a `PipelineAnalyticsTrackLifecycle` event (start / update / end). */
9968
11022
  emitTrackLifecycle(payload, timestamp) {
9969
11023
  this.ctx.eventBus.emit({
@@ -10013,27 +11067,48 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
10013
11067
  ...track?.zonesVisited !== void 0 ? { zonesVisited: track.zonesVisited } : {},
10014
11068
  ...track?.totalDistance !== void 0 ? { totalDistance: track.totalDistance } : {},
10015
11069
  ...track?.positions !== void 0 ? { positionsCount: track.positions.length } : {},
11070
+ ...track?.audioLabels !== void 0 ? { audioLabels: track.audioLabels } : {},
10016
11071
  ...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {},
10017
11072
  ...t.embeddingModelId !== void 0 ? { embeddingModelId: t.embeddingModelId } : {}
10018
11073
  });
10019
11074
  this.emitTrackLifecycle(payload, timestamp);
10020
11075
  }
10021
- buildSnapshotTargets(deviceId, tracked, timestamp, media) {
11076
+ buildSnapshotTargets(deviceId, tracked, timestamp, media, frameWidth, frameHeight) {
10022
11077
  const targets = [];
10023
11078
  for (const t of tracked) {
10024
11079
  const lastSnap = this.trackStore.lastSnapshotAt(t.trackId);
10025
- const dueSnapshot = media.saveThumbnails && lastSnap > 0 && timestamp - lastSnap >= media.snapshotIntervalMs;
11080
+ const dueSnapshot = media.saveThumbnails && evaluatePeriodicSnapshot({
11081
+ lastSnapshotAt: lastSnap,
11082
+ lastSnapshotBbox: this.trackStore.lastSnapshotBbox(t.trackId),
11083
+ currentBbox: t.bbox,
11084
+ now: timestamp,
11085
+ frameWidth,
11086
+ frameHeight,
11087
+ intervalMs: media.snapshotIntervalMs,
11088
+ movementThreshold: media.snapshotMovementThreshold,
11089
+ maxIdleMs: media.snapshotMaxIdleMs
11090
+ }).capture;
10026
11091
  const isNewBest = this.bestFrameTracker.observe(t.trackId, t.confidence, timestamp);
10027
11092
  this.maybeEmitTrackLifecycleUpdate(deviceId, t, timestamp, isNewBest);
10028
- if (!dueSnapshot && !isNewBest) continue;
11093
+ const plan = planPeriodicMedia({
11094
+ saveThumbnails: media.saveThumbnails,
11095
+ dueSnapshot,
11096
+ isNewBest,
11097
+ lastFrameAt: this.lastFrameAtByTrack.get(t.trackId) ?? 0,
11098
+ now: timestamp,
11099
+ intervalMs: media.snapshotIntervalMs
11100
+ });
11101
+ if (plan.rollingLastFrame) this.lastFrameAtByTrack.set(t.trackId, timestamp);
11102
+ if (plan.appendSnapshot) this.trackStore.markSnapshotPending(t.trackId, timestamp, t.bbox);
11103
+ if (!plan.appendSnapshot && !plan.rollingLastFrame && !plan.bestThumbnail) continue;
10029
11104
  targets.push({
10030
11105
  trackId: t.trackId,
10031
11106
  timestamp,
10032
11107
  bbox: { ...t.bbox },
10033
11108
  ...t.label ? { label: t.label } : {},
10034
- appendSnapshot: dueSnapshot,
10035
- rollingLastFrame: dueSnapshot,
10036
- bestThumbnail: isNewBest
11109
+ appendSnapshot: plan.appendSnapshot,
11110
+ rollingLastFrame: plan.rollingLastFrame,
11111
+ bestThumbnail: plan.bestThumbnail
10037
11112
  });
10038
11113
  }
10039
11114
  return targets;
@@ -10089,6 +11164,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
10089
11164
  atMs: timestamp
10090
11165
  });
10091
11166
  await this.eventStore.insertAudio(ev);
11167
+ this.trackStore?.addAudioLabelEpisode(deviceId, route.className, topClassification.score, timestamp);
10092
11168
  this.ctx.eventBus.emit({
10093
11169
  id: `pa-${ev.id}`,
10094
11170
  timestamp: new Date(ev.timestamp),
@@ -10306,6 +11382,11 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
10306
11382
  const peak = await this.eventStore?.peakForTrack(t.trackId);
10307
11383
  if (peak) {
10308
11384
  endBestEventId = peak.bestEventId;
11385
+ const dims = this.lastFrameDimsByDevice.get(t.deviceId);
11386
+ const staticMetrics = dims ? computeStaticTrackMetrics(t.positions.map((p) => ({
11387
+ x: p.x,
11388
+ y: p.y
11389
+ })), Math.hypot(dims.w, dims.h)) : void 0;
10309
11390
  const { importance, reason } = computeImportance({
10310
11391
  peakConfidence: trackerPeak?.confidence ?? peak.peakConfidence,
10311
11392
  className: t.className,
@@ -10313,7 +11394,11 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
10313
11394
  peakBboxAreaFrac: peak.peakBboxAreaFrac,
10314
11395
  totalDistance: t.totalDistance,
10315
11396
  zonesVisited: t.zonesVisited,
10316
- ...t.label !== void 0 ? { label: t.label } : {}
11397
+ ...t.label !== void 0 ? { label: t.label } : {},
11398
+ ...staticMetrics ? {
11399
+ netDisplacementFrac: staticMetrics.netDisplacementFrac,
11400
+ pathSpanFrac: staticMetrics.pathSpanFrac
11401
+ } : {}
10317
11402
  });
10318
11403
  endImportance = importance;
10319
11404
  endImportanceReason = reason;
@@ -10328,6 +11413,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
10328
11413
  }
10329
11414
  this.bestFrameTracker.delete(t.trackId);
10330
11415
  this.objectEmbeddingBestSelector.delete(t.trackId);
11416
+ this.lastFrameAtByTrack.delete(t.trackId);
10331
11417
  this.detailDispatcher?.onTrackEnded(t.deviceId, t.trackId);
10332
11418
  this.overlayState.onTrackEnded(t.deviceId, t.trackId);
10333
11419
  if ((this.trackStore?.getActive(t.deviceId).length ?? 0) === 0) {
@@ -10372,6 +11458,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
10372
11458
  positionsCount: t.positions.length,
10373
11459
  ...endImportance !== void 0 ? { importance: endImportance } : {},
10374
11460
  ...endImportanceReason !== void 0 ? { importanceReason: endImportanceReason } : {},
11461
+ ...t.audioLabels !== void 0 ? { audioLabels: t.audioLabels } : {},
10375
11462
  ...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {},
10376
11463
  ...endBestEventId !== void 0 ? { bestEventId: endBestEventId } : {}
10377
11464
  });
@@ -10534,6 +11621,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
10534
11621
  for (const k of this.processors.keys()) if (k.startsWith(prefix)) this.processors.delete(k);
10535
11622
  for (const k of this.lastActiveTrackIds.keys()) if (k.startsWith(prefix)) this.lastActiveTrackIds.delete(k);
10536
11623
  for (const k of this.dropoutSkipsByKey.keys()) if (k.startsWith(prefix)) this.dropoutSkipsByKey.delete(k);
11624
+ this.lastFrameDimsByDevice.delete(deviceId);
10537
11625
  }
10538
11626
  /** Apply a mutation to every live source-processor of a device (zones/rules
10539
11627
  * are device-level and must reach all sources). */
@@ -10541,6 +11629,57 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
10541
11629
  const prefix = `${deviceId}:`;
10542
11630
  for (const [k, p] of this.processors) if (k.startsWith(prefix)) fn(p);
10543
11631
  }
11632
+ /**
11633
+ * Turn a parked track into a stationary-registry entry and tear the track
11634
+ * down WITHOUT firing an 'end' key-event / importance scoring / media flush
11635
+ * (a parked object is not a highlight; the durable record is the entry). The
11636
+ * tracker + store forget the track so its detections stop re-spawning tracks,
11637
+ * and the registry suppresses them from the next frame on.
11638
+ */
11639
+ promoteToStationary(input) {
11640
+ const { deviceId, key, processor, track, firstSeen, label, frameWidth, frameHeight, timestamp } = input;
11641
+ const keyFrameMediaKey = this.keyFrameKeyByTrackId.get(track.trackId);
11642
+ const entry = {
11643
+ id: randomUUID(),
11644
+ deviceId,
11645
+ className: track.className,
11646
+ bbox: { ...track.bbox },
11647
+ frameWidth,
11648
+ frameHeight,
11649
+ firstSeenAt: firstSeen,
11650
+ becameStationaryAt: timestamp,
11651
+ lastConfirmedAt: timestamp,
11652
+ sourceTrackId: track.trackId,
11653
+ ...label !== void 0 ? { label } : {},
11654
+ ...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {}
11655
+ };
11656
+ this.stationaryRegistry?.promote(entry);
11657
+ processor.dropTrack(track.trackId);
11658
+ this.trackStore?.dropActive(track.trackId);
11659
+ const faceEnd = this.faceRecognizer?.onTrackEnd(deviceId, track.trackId);
11660
+ const dropKeyFrame = () => {
11661
+ this.keyFrameKeyByTrackId.delete(track.trackId);
11662
+ };
11663
+ if (faceEnd) faceEnd.finally(dropKeyFrame);
11664
+ else dropKeyFrame();
11665
+ this.plateRecognizer?.onTrackEnd(deviceId, track.trackId);
11666
+ this.bestFrameTracker.delete(track.trackId);
11667
+ this.objectEmbeddingBestSelector.delete(track.trackId);
11668
+ this.lastFrameAtByTrack.delete(track.trackId);
11669
+ this.trackLifecycleUpdateMem.delete(track.trackId);
11670
+ this.detailDispatcher?.onTrackEnded(deviceId, track.trackId);
11671
+ this.overlayState.onTrackEnded(deviceId, track.trackId);
11672
+ this.lastActiveTrackIds.get(key)?.delete(track.trackId);
11673
+ this.ctx.logger.info("track promoted to stationary", {
11674
+ tags: { deviceId },
11675
+ meta: {
11676
+ trackId: track.trackId,
11677
+ className: track.className,
11678
+ entryId: entry.id,
11679
+ ...label ? { label } : {}
11680
+ }
11681
+ });
11682
+ }
10544
11683
  async getOrCreateProcessor(deviceId, source) {
10545
11684
  const key = this.procKey(deviceId, source);
10546
11685
  let p = this.processors.get(key);
@@ -10566,6 +11705,18 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
10566
11705
  cooldownSec,
10567
11706
  minTrackAgeMs: trk.minTrackAgeMs
10568
11707
  }, source);
11708
+ if (source === "pipeline" && this.stationaryRegistry) {
11709
+ const registry = this.stationaryRegistry;
11710
+ p.setStationaryGate({ filter: (input) => registry.filter({
11711
+ deviceId,
11712
+ detections: input.detections.map((d) => ({
11713
+ bbox: d.bbox,
11714
+ className: d.class
11715
+ })),
11716
+ frameWidth: input.frameWidth,
11717
+ frameHeight: input.frameHeight
11718
+ }) });
11719
+ }
10569
11720
  this.processors.set(key, p);
10570
11721
  }
10571
11722
  return p;
@@ -10577,6 +11728,16 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
10577
11728
  * forward updates to the per-device FrameProcessor so a rule change
10578
11729
  * applies to the very next frame even when frames stop briefly
10579
11730
  * (e.g. during binding flips).
11731
+ *
11732
+ * RECONCILE: the push channel behind `subscribe` (`device.state-changed`
11733
+ * via `live.onEvent`) does not reliably reach a forked addon child — a
11734
+ * zone created AFTER the proxy's cold read stayed invisible until the
11735
+ * addon respawned (live-diagnosed on device 617, 2026-07-16: zone slice
11736
+ * populated hub-side, `zones: []` in every snapshot). Events are lossy
11737
+ * telemetry (D8); the durable channel is RPC + reconcile — so each
11738
+ * proxy also refreshes its two slices on a slow timer. `refresh()`
11739
+ * round-trips `deviceState.getCapSlice` and fans out through the SAME
11740
+ * subscribe callbacks above, so a zone edit lands within one interval.
10580
11741
  */
10581
11742
  async ensureProxy(deviceId) {
10582
11743
  const cached = this.proxies.get(deviceId);
@@ -10585,13 +11746,22 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
10585
11746
  const proxy = await this.ctx.api.deviceManager ? await this.ctx.fetchDevice(deviceId) : null;
10586
11747
  if (!proxy) return null;
10587
11748
  this.proxies.set(deviceId, proxy);
10588
- const unsubs = [proxy.state.zones.subscribe((slice) => {
10589
- const zones = slice?.zones ?? [];
10590
- this.forEachDeviceProcessor(deviceId, (p) => p.setZones(zones));
10591
- }), proxy.state.zoneRules.subscribe((slice) => {
10592
- const rules = slice?.detection ?? [];
10593
- this.forEachDeviceProcessor(deviceId, (p) => p.setDetectionRules(rules));
10594
- })];
11749
+ const reconcile = setInterval(() => {
11750
+ proxy.state.zones.refresh().catch(() => void 0);
11751
+ proxy.state.zoneRules.refresh().catch(() => void 0);
11752
+ }, ZONE_SLICE_RECONCILE_MS);
11753
+ reconcile.unref?.();
11754
+ const unsubs = [
11755
+ proxy.state.zones.subscribe((slice) => {
11756
+ const zones = slice?.zones ?? [];
11757
+ this.forEachDeviceProcessor(deviceId, (p) => p.setZones(zones));
11758
+ }),
11759
+ proxy.state.zoneRules.subscribe((slice) => {
11760
+ const rules = slice?.detection ?? [];
11761
+ this.forEachDeviceProcessor(deviceId, (p) => p.setDetectionRules(rules));
11762
+ }),
11763
+ () => clearInterval(reconcile)
11764
+ ];
10595
11765
  this.proxyUnsubs.set(deviceId, unsubs);
10596
11766
  return proxy;
10597
11767
  } catch (err) {
@@ -10623,6 +11793,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
10623
11793
  }
10624
11794
  async clearTracks(input) {
10625
11795
  this.trackStore?.clearDevice(input.deviceId);
11796
+ this.stationaryRegistry?.clearDevice(input.deviceId);
10626
11797
  this.overlayState.clearDevice(input.deviceId);
10627
11798
  this.overlaySynthesisWarnAt.delete(input.deviceId);
10628
11799
  const prefix = `${input.deviceId}:`;
@@ -10924,6 +12095,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
10924
12095
  * Enrolled gallery + identity media are exempt.
10925
12096
  */
10926
12097
  async wipeAllAnalytics(input) {
12098
+ await this.stationaryRegistry?.clearDevice(input.deviceId);
10927
12099
  return this.pruneTracksBefore({
10928
12100
  deviceId: input.deviceId,
10929
12101
  cutoffMs: Date.now()
@@ -10986,13 +12158,19 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
10986
12158
  }
10987
12159
  return this.readEventThumbnail(id);
10988
12160
  }
10989
- async readEventThumbnail(eventId) {
10990
- const files = await (this.mediaStore?.listByOwner("event", eventId) ?? Promise.resolve([]));
10991
- const chosen = files.find((f) => f.kind === "crop") ?? files.find((f) => f.kind === "fullFrameBoxed") ?? files[0];
10992
- if (!chosen) return null;
12161
+ async readEventThumbnail(id) {
12162
+ const eventFiles = await (this.mediaStore?.listByOwner("event", id) ?? Promise.resolve([]));
12163
+ const chosenEvent = eventFiles.find((f) => f.kind === "crop") ?? eventFiles.find((f) => f.kind === "fullFrameBoxed") ?? eventFiles[0];
12164
+ if (chosenEvent) return {
12165
+ bytes: Buffer.from(chosenEvent.base64, "base64"),
12166
+ key: chosenEvent.key
12167
+ };
12168
+ const trackFiles = await (this.mediaStore?.listByOwner("track", id) ?? Promise.resolve([]));
12169
+ const chosenTrack = trackFiles.find((f) => f.kind === "thumbnail") ?? trackFiles.find((f) => f.kind === "lastFrame") ?? trackFiles.find((f) => f.kind === "firstFrame") ?? [...trackFiles].reverse().find((f) => f.kind === "snapshot") ?? trackFiles[trackFiles.length - 1];
12170
+ if (!chosenTrack) return null;
10993
12171
  return {
10994
- bytes: Buffer.from(chosen.base64, "base64"),
10995
- key: chosen.key
12172
+ bytes: Buffer.from(chosenTrack.base64, "base64"),
12173
+ key: chosenTrack.key
10996
12174
  };
10997
12175
  }
10998
12176
  /**
@@ -11085,6 +12263,32 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
11085
12263
  unit: "s",
11086
12264
  displayScale: 1e3
11087
12265
  },
12266
+ {
12267
+ type: "slider",
12268
+ key: "snapshotMovementThreshold",
12269
+ label: "Snapshot movement gate",
12270
+ description: "After the interval elapses, only capture a snapshot if the object moved this fraction of the frame since the last one. Higher = fewer near-identical frames. 0 disables the gate.",
12271
+ min: 0,
12272
+ max: .15,
12273
+ step: .005,
12274
+ default: .03,
12275
+ showValue: true,
12276
+ unit: "%",
12277
+ displayScale: .01
12278
+ },
12279
+ {
12280
+ type: "slider",
12281
+ key: "snapshotMaxIdleMs",
12282
+ label: "Snapshot max idle",
12283
+ description: "Force a snapshot for a stationary but still-present track after this long without one, so its filmstrip is never empty.",
12284
+ min: 5e3,
12285
+ max: 12e4,
12286
+ step: 5e3,
12287
+ default: 3e4,
12288
+ showValue: true,
12289
+ unit: "s",
12290
+ displayScale: 1e3
12291
+ },
11088
12292
  {
11089
12293
  type: "select",
11090
12294
  key: "mediaAttachPolicy",