@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.
@@ -2,11 +2,10 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-Bnb58pyL.js");
5
+ const require_dist = require("../dist-AFLbpmAs.js");
6
6
  let node_crypto = require("node:crypto");
7
7
  let sharp = require("sharp");
8
8
  sharp = require_dist.__toESM(sharp);
9
- let _camstack_shm_ring = require("@camstack/shm-ring");
10
9
  //#region src/pipeline-analytics/videoclips-provider.ts
11
10
  var SOURCE = "analytics";
12
11
  function clipIdFor(eventId, startMs, endMs) {
@@ -151,6 +150,17 @@ var WEIGHT_IDENTITY = .05;
151
150
  var DWELL_FULL_MS = 6e4;
152
151
  /** Peak bbox area (as a fraction of frame area) that saturates the size term. */
153
152
  var SIZE_FULL = .15;
153
+ /** A track whose net centroid displacement stays below this fraction of the
154
+ * frame diagonal over its whole life counts as stationary on the net signal. */
155
+ var STATIC_DISPLACEMENT_FRAC = .02;
156
+ /** ...and whose entire centroid path fits within this fraction of the frame
157
+ * diagonal counts as stationary on the span signal. Both must hold. */
158
+ var STATIC_SPAN_FRAC = .04;
159
+ /** Importance multiplier applied to a stationary track that has NO resolved
160
+ * identity — strongly demotes "ghost" detections on static objects (a pile of
161
+ * clothes read as a person, a kitchen object read as an animal) below the
162
+ * key-event threshold. Identified tracks (face/plate) are never suppressed. */
163
+ var STATIC_IMPORTANCE_MULTIPLIER = .1;
154
164
  var CLASS_RANK_VEHICLE = .8;
155
165
  var CLASS_RANK_ANIMAL = .5;
156
166
  var CLASS_RANK_DEFAULT = .25;
@@ -225,6 +235,11 @@ function computeImportance(input) {
225
235
  sum += term.value;
226
236
  if (term.value > best.value) best = term;
227
237
  }
238
+ const identified = input.label !== void 0 && input.label.length > 0;
239
+ if (input.netDisplacementFrac !== void 0 && input.pathSpanFrac !== void 0 && input.netDisplacementFrac < .02 && input.pathSpanFrac < .04 && !identified) return {
240
+ importance: clamp01(sum * STATIC_IMPORTANCE_MULTIPLIER),
241
+ reason: best.reason
242
+ };
228
243
  return {
229
244
  importance: clamp01(sum),
230
245
  reason: best.reason
@@ -618,6 +633,20 @@ function normalizePlate(text) {
618
633
  return text.toUpperCase().replace(/[^A-Z0-9]/g, "");
619
634
  }
620
635
  /**
636
+ * Quality gate for a raw OCR plate read BEFORE it becomes a track label or a
637
+ * gallery row. Distant/oblique parked plates produce junk reads ("N", "Idag",
638
+ * "@em") that otherwise flood track labels (live-observed on the parking
639
+ * camera, 2026-07-16). A plausible European plate is ≥{@link PLATE_MIN_LENGTH}
640
+ * alphanumerics and mixes letters AND digits; anything else — or a read below
641
+ * {@link PLATE_MIN_SCORE} — is discarded, not stored.
642
+ */
643
+ function isPlausiblePlateRead(text, score) {
644
+ if (score < .4) return false;
645
+ const norm = normalizePlate(text);
646
+ if (norm.length < 4) return false;
647
+ return /[0-9]/.test(norm) && /[A-Z]/.test(norm);
648
+ }
649
+ /**
621
650
  * Fold characters that OCR routinely confuses onto a single canonical symbol, so
622
651
  * "AB0" and "ABO" compare as equal. Applied to BOTH operands of a distance
623
652
  * comparison only — never stored. Conservative set covering the common Latin
@@ -1102,7 +1131,7 @@ var MAX_PATH_LENGTH = 300;
1102
1131
  function clamp(value, min, max) {
1103
1132
  return Math.max(min, Math.min(max, value));
1104
1133
  }
1105
- function iou$1(a, b) {
1134
+ function iou$2(a, b) {
1106
1135
  const ax1 = a.x, ay1 = a.y, ax2 = a.x + a.w, ay2 = a.y + a.h;
1107
1136
  const bx1 = b.x, by1 = b.y, bx2 = b.x + b.w, by2 = b.y + b.h;
1108
1137
  const ix1 = Math.max(ax1, bx1), iy1 = Math.max(ay1, by1);
@@ -1157,7 +1186,7 @@ var SortTracker = class {
1157
1186
  */
1158
1187
  looseMatch(track, det) {
1159
1188
  if (track.class !== det.class) return false;
1160
- if (iou$1(track.bbox, det.bbox) >= this.config.rescueIouThreshold) return true;
1189
+ if (iou$2(track.bbox, det.bbox) >= this.config.rescueIouThreshold) return true;
1161
1190
  const tc = bboxCentroid(track.bbox);
1162
1191
  const dc = bboxCentroid(det.bbox);
1163
1192
  const dist = Math.hypot(tc.x - dc.x, tc.y - dc.y);
@@ -1185,7 +1214,7 @@ var SortTracker = class {
1185
1214
  for (let di = 0; di < detections.length; di++) {
1186
1215
  const det = detections[di];
1187
1216
  if (this.config.classGating && track.class !== det.class) continue;
1188
- const score = iou$1(pbox, det.bbox);
1217
+ const score = iou$2(pbox, det.bbox);
1189
1218
  if (score >= this.config.iouThreshold) pairs.push({
1190
1219
  track,
1191
1220
  detIdx: di,
@@ -1210,7 +1239,7 @@ var SortTracker = class {
1210
1239
  rescuePairs.push({
1211
1240
  track,
1212
1241
  detIdx: di,
1213
- score: iou$1(track.bbox, det.bbox)
1242
+ score: iou$2(track.bbox, det.bbox)
1214
1243
  });
1215
1244
  }
1216
1245
  }
@@ -1275,7 +1304,7 @@ var SortTracker = class {
1275
1304
  if (!lost.resurrectable) continue;
1276
1305
  if (timestamp - lost.lostAt > this.config.resurrectionWindowMs) continue;
1277
1306
  if (!this.looseMatch(lost, det)) continue;
1278
- const score = iou$1(lost.bbox, det.bbox);
1307
+ const score = iou$2(lost.bbox, det.bbox);
1279
1308
  if (score > bestScore) {
1280
1309
  best = lost;
1281
1310
  bestScore = score;
@@ -1336,6 +1365,16 @@ var SortTracker = class {
1336
1365
  path: [...t.path]
1337
1366
  }));
1338
1367
  }
1368
+ /**
1369
+ * Remove a track from BOTH the live and graveyard sets so it neither emits
1370
+ * nor resurrects. Used when a track is PROMOTED to a stationary-object
1371
+ * registry entry: the registry now owns that parked object, and the tracker
1372
+ * must forget it so the object's detections don't re-spawn a duplicate track.
1373
+ */
1374
+ dropTrack(trackId) {
1375
+ this.tracks = this.tracks.filter((t) => t.id !== trackId);
1376
+ this.lostTracks = this.lostTracks.filter((t) => t.id !== trackId);
1377
+ }
1339
1378
  getActiveTracks() {
1340
1379
  return this.tracks;
1341
1380
  }
@@ -1749,6 +1788,9 @@ var FrameProcessor = class {
1749
1788
  */
1750
1789
  detectionRules;
1751
1790
  zoneEngine = new ZoneEngine();
1791
+ /** Optional stationary-object gate (parked-object suppression). Null until
1792
+ * the addon wires it via {@link setStationaryGate}. */
1793
+ stationaryGate = null;
1752
1794
  constructor(deviceId, trackerConfig = {}, stateConfig = {}, emitterConfig = {}, source = "pipeline") {
1753
1795
  this.deviceId = deviceId;
1754
1796
  this.source = source;
@@ -1764,6 +1806,16 @@ var FrameProcessor = class {
1764
1806
  setDetectionRules(rules) {
1765
1807
  this.detectionRules = rules;
1766
1808
  }
1809
+ /** Wire (or clear) the stationary-object gate. Called by the addon per
1810
+ * device so parked-object suppression applies from the next frame. */
1811
+ setStationaryGate(gate) {
1812
+ this.stationaryGate = gate;
1813
+ }
1814
+ /** Forget a track in the underlying tracker (used when the addon promotes it
1815
+ * to a stationary-object registry entry). */
1816
+ dropTrack(trackId) {
1817
+ this.tracker.dropTrack(trackId);
1818
+ }
1767
1819
  process(input) {
1768
1820
  const { timestamp, frame } = input;
1769
1821
  const frameWidth = frame.width;
@@ -1842,7 +1894,17 @@ var FrameProcessor = class {
1842
1894
  }
1843
1895
  const { passed } = this.zoneEngine.filterDetections(flatDetections, this.zones, this.detectionRules, frameWidth, frameHeight, (fd) => fd.detection.class);
1844
1896
  const filteredDetections = passed.map((fd) => fd.detection);
1845
- const trackedDetections = this.tracker.update(filteredDetections, timestamp);
1897
+ const gate = this.stationaryGate ? this.stationaryGate.filter({
1898
+ detections: filteredDetections,
1899
+ frameWidth,
1900
+ frameHeight
1901
+ }) : {
1902
+ suppressedIndices: /* @__PURE__ */ new Set(),
1903
+ confirmed: [],
1904
+ wokenEntryIds: []
1905
+ };
1906
+ const trackerInput = gate.suppressedIndices.size > 0 ? filteredDetections.filter((_, i) => !gate.suppressedIndices.has(i)) : filteredDetections;
1907
+ const trackedDetections = this.tracker.update(trackerInput, timestamp);
1846
1908
  const objectStates = this.stateAnalyzer.analyze(trackedDetections, timestamp);
1847
1909
  const rawEvents = this.eventEmitter.emit(trackedDetections, objectStates, [], [], String(this.deviceId));
1848
1910
  const zonesByTrack = /* @__PURE__ */ new Map();
@@ -1924,7 +1986,9 @@ var FrameProcessor = class {
1924
1986
  frameHeight,
1925
1987
  tracked,
1926
1988
  objectEvents,
1927
- rawTrackedDetections: trackedDetections
1989
+ rawTrackedDetections: trackedDetections,
1990
+ stationaryConfirmed: gate.confirmed,
1991
+ stationaryWoken: gate.wokenEntryIds
1928
1992
  };
1929
1993
  }
1930
1994
  };
@@ -2001,6 +2065,7 @@ function buildTrackLifecyclePayload(input) {
2001
2065
  ...input.positionsCount !== void 0 ? { positionsCount: input.positionsCount } : {},
2002
2066
  ...input.importance !== void 0 ? { importance: input.importance } : {},
2003
2067
  ...input.importanceReason !== void 0 ? { importanceReason: input.importanceReason } : {},
2068
+ ...input.audioLabels !== void 0 && input.audioLabels.length > 0 ? { audioLabels: input.audioLabels } : {},
2004
2069
  ...input.embeddingId !== void 0 ? { embeddingId: input.embeddingId } : {},
2005
2070
  ...input.embeddingModelId !== void 0 ? { embeddingModelId: input.embeddingModelId } : {},
2006
2071
  ...hasMedia ? { media } : {}
@@ -2117,6 +2182,555 @@ function resolveSearchThumbnailUrl(input) {
2117
2182
  return `${input.baseUrl}/${encodeURIComponent(id)}`;
2118
2183
  }
2119
2184
  //#endregion
2185
+ //#region src/pipeline-analytics/pipeline/static-track-gate.ts
2186
+ /**
2187
+ * Net displacement + path span for a track's centroid path, normalized to
2188
+ * `referenceDiagonalPx`. Returns undefined when there is nothing meaningful to
2189
+ * measure (fewer than two points, or a degenerate ≤0 reference) so the caller
2190
+ * leaves the importance score untouched.
2191
+ */
2192
+ function computeStaticTrackMetrics(centroids, referenceDiagonalPx) {
2193
+ if (!(referenceDiagonalPx > 0) || centroids.length < 2) return void 0;
2194
+ const first = centroids[0];
2195
+ const last = centroids[centroids.length - 1];
2196
+ const netDisplacementFrac = Math.hypot(last.x - first.x, last.y - first.y) / referenceDiagonalPx;
2197
+ let minX = Infinity;
2198
+ let minY = Infinity;
2199
+ let maxX = -Infinity;
2200
+ let maxY = -Infinity;
2201
+ for (const c of centroids) {
2202
+ if (c.x < minX) minX = c.x;
2203
+ if (c.x > maxX) maxX = c.x;
2204
+ if (c.y < minY) minY = c.y;
2205
+ if (c.y > maxY) maxY = c.y;
2206
+ }
2207
+ return {
2208
+ netDisplacementFrac,
2209
+ pathSpanFrac: Math.hypot(maxX - minX, maxY - minY) / referenceDiagonalPx
2210
+ };
2211
+ }
2212
+ /** Average bbox diagonal (px) across a track's positions — the scale reference
2213
+ * when frame dimensions aren't available. Returns 0 for an empty list. */
2214
+ function averageBboxDiagonal(boxes) {
2215
+ if (boxes.length === 0) return 0;
2216
+ let sum = 0;
2217
+ for (const b of boxes) sum += Math.hypot(b.w, b.h);
2218
+ return sum / boxes.length;
2219
+ }
2220
+ //#endregion
2221
+ //#region src/pipeline-analytics/pipeline/stationary/stationary-types.ts
2222
+ function entryToView(e) {
2223
+ return {
2224
+ id: e.id,
2225
+ className: e.className,
2226
+ bbox: { ...e.bbox },
2227
+ frameWidth: e.frameWidth,
2228
+ frameHeight: e.frameHeight,
2229
+ firstSeenAt: e.firstSeenAt,
2230
+ becameStationaryAt: e.becameStationaryAt,
2231
+ lastConfirmedAt: e.lastConfirmedAt,
2232
+ ...e.label !== void 0 ? { label: e.label } : {},
2233
+ ...e.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: e.keyFrameMediaKey } : {}
2234
+ };
2235
+ }
2236
+ /** IoU at/above which a detection is "the same parked object" → suppress its
2237
+ * spawn and refresh the entry's `lastConfirmedAt`. Matches decision #2 (0.6). */
2238
+ var SUPPRESS_IOU = .6;
2239
+ /** Centroid move (fraction of the frame diagonal) beyond which a near
2240
+ * same-class detection means the parked object actually MOVED → wake. */
2241
+ var WAKE_MOVE_FRAC = .08;
2242
+ /** How near (fraction of frame diagonal) a same-class detection's centroid must
2243
+ * be to an entry to be considered "this entry's object" when testing for a
2244
+ * wake. Keeps an unrelated object elsewhere in the frame from waking it. */
2245
+ var WAKE_SEARCH_FRAC = .5;
2246
+ /**
2247
+ * Look-back window over which a track must have stayed put to be PROMOTED. A
2248
+ * car that drives in then parks has a large whole-life displacement but a tiny
2249
+ * last-`windowMs` displacement — so promotion is judged on the recent window,
2250
+ * not the full path. Also the minimum age (the track must have EXISTED this
2251
+ * long) so a freshly-spawned static blob isn't promoted instantly.
2252
+ */
2253
+ var PROMOTION_WINDOW_MS = 3e4;
2254
+ var DEFAULT_MATCH_CONFIG = {
2255
+ suppressIou: SUPPRESS_IOU,
2256
+ wakeMoveFrac: WAKE_MOVE_FRAC,
2257
+ wakeSearchFrac: WAKE_SEARCH_FRAC
2258
+ };
2259
+ //#endregion
2260
+ //#region src/pipeline-analytics/pipeline/stationary/stationary-match.ts
2261
+ /**
2262
+ * stationary-match — PURE geometry for the stationary-object registry.
2263
+ *
2264
+ * Two decisions, both unit-testable in isolation:
2265
+ *
2266
+ * 1. `partitionDetectionsAgainstRegistry` — given the current registry entries
2267
+ * and this frame's (zone-filtered) detections, decides which detections are
2268
+ * suppressed (they keep confirming a known parked object → no track spawns),
2269
+ * which entries are confirmed present, and which entries WOKE (their object
2270
+ * moved → retire the entry and let the detection spawn a normal track).
2271
+ *
2272
+ * 2. `evaluateStationaryPromotion` — given a track's recent centroid path,
2273
+ * decides whether it has stayed put long enough to become a stationary
2274
+ * entry. Reuses the static-track-gate metrics (net displacement + path span
2275
+ * normalised to the frame diagonal) over the recent look-back window.
2276
+ */
2277
+ /** Default promotion tunables — static thresholds shared with the key-event
2278
+ * static gate so "stationary" means the same thing in both places. */
2279
+ var DEFAULT_PROMOTION_CONFIG = {
2280
+ windowMs: PROMOTION_WINDOW_MS,
2281
+ netFracMax: STATIC_DISPLACEMENT_FRAC,
2282
+ spanFracMax: STATIC_SPAN_FRAC,
2283
+ minPoints: 4
2284
+ };
2285
+ function iou$1(a, b) {
2286
+ const ax2 = a.x + a.w;
2287
+ const ay2 = a.y + a.h;
2288
+ const bx2 = b.x + b.w;
2289
+ const by2 = b.y + b.h;
2290
+ const ix1 = Math.max(a.x, b.x);
2291
+ const iy1 = Math.max(a.y, b.y);
2292
+ const ix2 = Math.min(ax2, bx2);
2293
+ const iy2 = Math.min(ay2, by2);
2294
+ const inter = Math.max(0, ix2 - ix1) * Math.max(0, iy2 - iy1);
2295
+ const union = a.w * a.h + b.w * b.h - inter;
2296
+ return union > 0 ? inter / union : 0;
2297
+ }
2298
+ function centroid(b) {
2299
+ return {
2300
+ x: b.x + b.w / 2,
2301
+ y: b.y + b.h / 2
2302
+ };
2303
+ }
2304
+ function diagonalOf(width, height) {
2305
+ return Math.hypot(width, height);
2306
+ }
2307
+ /**
2308
+ * Decide, per stationary entry, whether the current frame confirms it, wakes
2309
+ * it, or misses it (no matching detection — leave it for the TTL sweep).
2310
+ *
2311
+ * Per entry, over same-class detections:
2312
+ * - best IoU ≥ `suppressIou` → SUPPRESS the best-overlap detection (it is the
2313
+ * parked object, unmoved) and mark the entry confirmed.
2314
+ * - else if a same-class detection sits within `wakeSearchFrac × diag` of the
2315
+ * entry centroid but has moved > `wakeMoveFrac × diag` → WAKE the entry (the
2316
+ * object slid out of its parked box). The detection is NOT suppressed, so it
2317
+ * spawns a fresh moving track.
2318
+ * - else → MISS (occlusion / brief absence): neither suppress nor wake.
2319
+ *
2320
+ * A detection can suppress at most one spawn even if it overlaps two entries
2321
+ * (`suppressedIndices` is a set).
2322
+ */
2323
+ function partitionDetectionsAgainstRegistry(input) {
2324
+ const { entries, detections, referenceDiagonalPx, config } = input;
2325
+ const suppressed = /* @__PURE__ */ new Set();
2326
+ const confirmed = [];
2327
+ const woken = [];
2328
+ const diag = referenceDiagonalPx;
2329
+ for (const entry of entries) {
2330
+ const ec = centroid(entry.bbox);
2331
+ let bestIou = 0;
2332
+ let bestIdx = -1;
2333
+ let wakeCandidate = false;
2334
+ for (let di = 0; di < detections.length; di++) {
2335
+ const det = detections[di];
2336
+ if (det.className !== entry.className) continue;
2337
+ const o = iou$1(entry.bbox, det.bbox);
2338
+ if (o > bestIou) {
2339
+ bestIou = o;
2340
+ bestIdx = di;
2341
+ }
2342
+ if (diag > 0) {
2343
+ const dc = centroid(det.bbox);
2344
+ const dist = Math.hypot(dc.x - ec.x, dc.y - ec.y);
2345
+ if (dist <= config.wakeSearchFrac * diag && dist > config.wakeMoveFrac * diag) wakeCandidate = true;
2346
+ }
2347
+ }
2348
+ if (bestIou >= config.suppressIou && bestIdx >= 0) {
2349
+ suppressed.add(bestIdx);
2350
+ confirmed.push({
2351
+ entryId: entry.id,
2352
+ className: entry.className,
2353
+ bbox: { ...entry.bbox }
2354
+ });
2355
+ } else if (wakeCandidate) woken.push(entry.id);
2356
+ }
2357
+ return {
2358
+ suppressedIndices: suppressed,
2359
+ confirmed,
2360
+ wokenEntryIds: woken
2361
+ };
2362
+ }
2363
+ /**
2364
+ * A track is promoted when, over the recent `windowMs`, its centroid barely
2365
+ * moved (both net displacement and path span below the static thresholds) AND
2366
+ * the track has actually EXISTED for at least `windowMs` (so a car that just
2367
+ * arrived isn't parked yet). Judging on the recent window — not the whole life
2368
+ * — is what lets a car that drove in then parked be recognised as stationary.
2369
+ */
2370
+ function evaluateStationaryPromotion(input) {
2371
+ const { positions, referenceDiagonalPx, now, config } = input;
2372
+ if (!(referenceDiagonalPx > 0) || positions.length === 0) return { promote: false };
2373
+ if (now - positions[0].timestamp < config.windowMs) return { promote: false };
2374
+ const cutoff = now - config.windowMs;
2375
+ const window = positions.filter((p) => p.timestamp >= cutoff);
2376
+ if (window.length < config.minPoints) return { promote: false };
2377
+ const metrics = computeStaticTrackMetrics(window.map((p) => ({
2378
+ x: p.x,
2379
+ y: p.y
2380
+ })), referenceDiagonalPx);
2381
+ if (metrics === void 0) return { promote: false };
2382
+ return {
2383
+ promote: metrics.netDisplacementFrac < config.netFracMax && metrics.pathSpanFrac < config.spanFracMax,
2384
+ netFrac: metrics.netDisplacementFrac,
2385
+ spanFrac: metrics.pathSpanFrac
2386
+ };
2387
+ }
2388
+ //#endregion
2389
+ //#region src/pipeline-analytics/pipeline/stationary/stationary-registry.ts
2390
+ var STATIONARY_COLLECTION = "pipeline-analytics:stationary-objects";
2391
+ var STATIONARY_COLUMNS = [
2392
+ {
2393
+ name: "id",
2394
+ type: "TEXT",
2395
+ primaryKey: true,
2396
+ notNull: true
2397
+ },
2398
+ {
2399
+ name: "deviceId",
2400
+ type: "INTEGER",
2401
+ notNull: true
2402
+ },
2403
+ {
2404
+ name: "className",
2405
+ type: "TEXT",
2406
+ notNull: true
2407
+ },
2408
+ {
2409
+ name: "bbox",
2410
+ type: "JSON"
2411
+ },
2412
+ {
2413
+ name: "frameWidth",
2414
+ type: "INTEGER"
2415
+ },
2416
+ {
2417
+ name: "frameHeight",
2418
+ type: "INTEGER"
2419
+ },
2420
+ {
2421
+ name: "firstSeenAt",
2422
+ type: "INTEGER"
2423
+ },
2424
+ {
2425
+ name: "becameStationaryAt",
2426
+ type: "INTEGER"
2427
+ },
2428
+ {
2429
+ name: "lastConfirmedAt",
2430
+ type: "INTEGER"
2431
+ },
2432
+ {
2433
+ name: "sourceTrackId",
2434
+ type: "TEXT"
2435
+ },
2436
+ {
2437
+ name: "label",
2438
+ type: "TEXT"
2439
+ },
2440
+ {
2441
+ name: "keyFrameMediaKey",
2442
+ type: "TEXT"
2443
+ }
2444
+ ];
2445
+ var STATIONARY_INDEXES = [{
2446
+ name: "idx_stationary_device",
2447
+ columns: ["deviceId"]
2448
+ }];
2449
+ var StationaryObjectRegistry = class {
2450
+ byDevice = /* @__PURE__ */ new Map();
2451
+ dirty = /* @__PURE__ */ new Set();
2452
+ store;
2453
+ logger;
2454
+ matchConfig;
2455
+ entryTtlMs;
2456
+ onChange;
2457
+ /** Latest processed-frame timestamp per device — expiry counts OBSERVED
2458
+ * time, not wall-clock. A session-dispatch camera produces no frames
2459
+ * between motion sessions; that silence is not evidence the object left,
2460
+ * so quiet minutes must not age the entries (see {@link sweep}). */
2461
+ lastFrameAtByDevice = /* @__PURE__ */ new Map();
2462
+ constructor(deps) {
2463
+ this.store = deps.store;
2464
+ this.logger = deps.logger;
2465
+ this.matchConfig = deps.matchConfig ?? DEFAULT_MATCH_CONFIG;
2466
+ this.entryTtlMs = deps.entryTtlMs ?? 3e5;
2467
+ this.onChange = deps.onChange;
2468
+ }
2469
+ static async declare(store) {
2470
+ await store.declareCollection.mutate({
2471
+ collection: STATIONARY_COLLECTION,
2472
+ columns: [...STATIONARY_COLUMNS],
2473
+ indexes: [...STATIONARY_INDEXES]
2474
+ });
2475
+ }
2476
+ /** Hydrate all persisted entries into memory (call once at boot, after
2477
+ * `declare`). Best-effort — a query failure leaves the registry empty. */
2478
+ async load() {
2479
+ try {
2480
+ const rows = await this.store.query.query({
2481
+ collection: STATIONARY_COLLECTION,
2482
+ filter: { limit: 1e5 }
2483
+ });
2484
+ for (const row of rows) {
2485
+ const entry = rowToEntry(row.id, row.data);
2486
+ if (!entry) continue;
2487
+ this.deviceMap(entry.deviceId).set(entry.id, entry);
2488
+ }
2489
+ this.logger.info("stationary registry loaded", { meta: { entries: rows.length } });
2490
+ } catch (err) {
2491
+ this.logger.warn("stationary registry load failed", { meta: { error: String(err) } });
2492
+ }
2493
+ }
2494
+ list(deviceId) {
2495
+ const m = this.byDevice.get(deviceId);
2496
+ return m ? [...m.values()] : [];
2497
+ }
2498
+ listViews(deviceId) {
2499
+ return this.list(deviceId).map(entryToView);
2500
+ }
2501
+ count(deviceId) {
2502
+ return this.byDevice.get(deviceId)?.size ?? 0;
2503
+ }
2504
+ /** Record that a frame was processed for a device — advances the OBSERVED
2505
+ * clock that drives entry expiry in {@link sweep}. */
2506
+ noteFrame(deviceId, timestamp) {
2507
+ if (timestamp > (this.lastFrameAtByDevice.get(deviceId) ?? 0)) this.lastFrameAtByDevice.set(deviceId, timestamp);
2508
+ }
2509
+ /**
2510
+ * Per-frame gate: partition this frame's detections against the device's
2511
+ * entries. PURE with respect to registry state — apply the outcome with
2512
+ * {@link applyFrameOutcome} once the frame result is assembled.
2513
+ */
2514
+ filter(input) {
2515
+ const entries = this.list(input.deviceId);
2516
+ if (entries.length === 0) return {
2517
+ suppressedIndices: /* @__PURE__ */ new Set(),
2518
+ confirmed: [],
2519
+ wokenEntryIds: []
2520
+ };
2521
+ return partitionDetectionsAgainstRegistry({
2522
+ entries,
2523
+ detections: input.detections,
2524
+ referenceDiagonalPx: diagonalOf(input.frameWidth, input.frameHeight),
2525
+ config: this.matchConfig
2526
+ });
2527
+ }
2528
+ /** Fold a frame's gate result back into state: advance confirmed entries'
2529
+ * `lastConfirmedAt` and retire woken entries (their object departed). */
2530
+ applyFrameOutcome(input) {
2531
+ const m = this.byDevice.get(input.deviceId);
2532
+ if (!m) return;
2533
+ for (const c of input.confirmed) {
2534
+ const e = m.get(c.entryId);
2535
+ if (!e) continue;
2536
+ m.set(c.entryId, {
2537
+ ...e,
2538
+ lastConfirmedAt: input.timestamp
2539
+ });
2540
+ this.dirty.add(c.entryId);
2541
+ }
2542
+ for (const id of input.wokenEntryIds) {
2543
+ const e = m.get(id);
2544
+ if (!e) continue;
2545
+ m.delete(id);
2546
+ this.dirty.delete(id);
2547
+ this.deletePersisted(id);
2548
+ this.onChange?.({
2549
+ phase: "departed",
2550
+ entry: e,
2551
+ timestamp: input.timestamp
2552
+ });
2553
+ }
2554
+ }
2555
+ /** Promote a parked track into a persisted stationary entry. */
2556
+ async promote(entry) {
2557
+ this.deviceMap(entry.deviceId).set(entry.id, entry);
2558
+ this.dirty.delete(entry.id);
2559
+ try {
2560
+ await this.persist(entry);
2561
+ } catch (err) {
2562
+ this.logger.warn("stationary promote persist failed", {
2563
+ tags: { deviceId: entry.deviceId },
2564
+ meta: {
2565
+ entryId: entry.id,
2566
+ error: String(err)
2567
+ }
2568
+ });
2569
+ }
2570
+ this.onChange?.({
2571
+ phase: "appeared",
2572
+ entry,
2573
+ timestamp: entry.becameStationaryAt
2574
+ });
2575
+ }
2576
+ /**
2577
+ * Retire entries unconfirmed for longer than the TTL of OBSERVED time, and
2578
+ * flush any advanced `lastConfirmedAt`s to the store. Returns retired
2579
+ * entries (for logging).
2580
+ *
2581
+ * Expiry is measured against the device's latest processed-frame timestamp
2582
+ * ({@link noteFrame}), NOT the wall clock: a session-dispatch camera emits
2583
+ * no frames between motion sessions, and that silence says nothing about
2584
+ * the object. Only when the camera has actually been WATCHING for `ttl`
2585
+ * beyond the last confirmation (frames flowed, object never matched) is the
2586
+ * object considered removed. A device with no recorded frame yet never
2587
+ * expires its entries. `now` only stamps the departed telemetry.
2588
+ */
2589
+ async sweep(now) {
2590
+ const retired = [];
2591
+ for (const [deviceId, m] of this.byDevice) {
2592
+ const observedAt = this.lastFrameAtByDevice.get(deviceId);
2593
+ if (observedAt === void 0) continue;
2594
+ for (const [id, e] of m) if (observedAt - e.lastConfirmedAt > this.entryTtlMs) {
2595
+ m.delete(id);
2596
+ this.dirty.delete(id);
2597
+ retired.push(e);
2598
+ this.deletePersisted(id);
2599
+ this.onChange?.({
2600
+ phase: "departed",
2601
+ entry: e,
2602
+ timestamp: now
2603
+ });
2604
+ }
2605
+ if (m.size === 0) this.byDevice.delete(deviceId);
2606
+ }
2607
+ for (const id of [...this.dirty]) {
2608
+ this.dirty.delete(id);
2609
+ const entry = this.findById(id);
2610
+ if (!entry) continue;
2611
+ try {
2612
+ await this.store.update.mutate({
2613
+ collection: STATIONARY_COLLECTION,
2614
+ id,
2615
+ data: { lastConfirmedAt: entry.lastConfirmedAt }
2616
+ });
2617
+ } catch (err) {
2618
+ this.logger.debug("stationary lastConfirmedAt flush failed", { meta: {
2619
+ entryId: id,
2620
+ error: String(err)
2621
+ } });
2622
+ }
2623
+ }
2624
+ return retired;
2625
+ }
2626
+ /** Drop a device's entries from memory WITHOUT deleting persisted rows.
2627
+ * Used on device unbind; a rebind reloads from the store. */
2628
+ forgetDevice(deviceId) {
2629
+ this.lastFrameAtByDevice.delete(deviceId);
2630
+ const m = this.byDevice.get(deviceId);
2631
+ if (!m) return;
2632
+ for (const id of m.keys()) this.dirty.delete(id);
2633
+ this.byDevice.delete(deviceId);
2634
+ }
2635
+ /** Delete every persisted + in-memory entry for a device (operator wipe). */
2636
+ async clearDevice(deviceId) {
2637
+ this.lastFrameAtByDevice.delete(deviceId);
2638
+ const m = this.byDevice.get(deviceId);
2639
+ if (m) {
2640
+ for (const id of [...m.keys()]) {
2641
+ this.dirty.delete(id);
2642
+ this.deletePersisted(id);
2643
+ }
2644
+ this.byDevice.delete(deviceId);
2645
+ }
2646
+ }
2647
+ deviceMap(deviceId) {
2648
+ let m = this.byDevice.get(deviceId);
2649
+ if (!m) {
2650
+ m = /* @__PURE__ */ new Map();
2651
+ this.byDevice.set(deviceId, m);
2652
+ }
2653
+ return m;
2654
+ }
2655
+ findById(id) {
2656
+ for (const m of this.byDevice.values()) {
2657
+ const e = m.get(id);
2658
+ if (e) return e;
2659
+ }
2660
+ }
2661
+ async persist(e) {
2662
+ await this.store.set.mutate({
2663
+ collection: STATIONARY_COLLECTION,
2664
+ key: e.id,
2665
+ value: {
2666
+ deviceId: e.deviceId,
2667
+ className: e.className,
2668
+ bbox: { ...e.bbox },
2669
+ frameWidth: e.frameWidth,
2670
+ frameHeight: e.frameHeight,
2671
+ firstSeenAt: e.firstSeenAt,
2672
+ becameStationaryAt: e.becameStationaryAt,
2673
+ lastConfirmedAt: e.lastConfirmedAt,
2674
+ ...e.sourceTrackId !== void 0 ? { sourceTrackId: e.sourceTrackId } : {},
2675
+ ...e.label !== void 0 ? { label: e.label } : {},
2676
+ ...e.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: e.keyFrameMediaKey } : {}
2677
+ }
2678
+ });
2679
+ }
2680
+ async deletePersisted(id) {
2681
+ try {
2682
+ await this.store.delete.mutate({
2683
+ collection: STATIONARY_COLLECTION,
2684
+ key: id
2685
+ });
2686
+ } catch (err) {
2687
+ this.logger.debug("stationary delete failed", { meta: {
2688
+ entryId: id,
2689
+ error: String(err)
2690
+ } });
2691
+ }
2692
+ }
2693
+ };
2694
+ function rowToEntry(id, data) {
2695
+ const deviceId = Number(data["deviceId"]);
2696
+ const className = data["className"];
2697
+ const bbox = data["bbox"];
2698
+ if (!Number.isFinite(deviceId) || typeof className !== "string" || !bbox) return null;
2699
+ const sourceTrackId = data["sourceTrackId"];
2700
+ const label = data["label"];
2701
+ const keyFrameMediaKey = data["keyFrameMediaKey"];
2702
+ return {
2703
+ id,
2704
+ deviceId,
2705
+ className,
2706
+ bbox: {
2707
+ x: Number(bbox.x),
2708
+ y: Number(bbox.y),
2709
+ w: Number(bbox.w),
2710
+ h: Number(bbox.h)
2711
+ },
2712
+ frameWidth: Number(data["frameWidth"] ?? 0),
2713
+ frameHeight: Number(data["frameHeight"] ?? 0),
2714
+ firstSeenAt: Number(data["firstSeenAt"] ?? 0),
2715
+ becameStationaryAt: Number(data["becameStationaryAt"] ?? 0),
2716
+ lastConfirmedAt: Number(data["lastConfirmedAt"] ?? 0),
2717
+ ...typeof sourceTrackId === "string" ? { sourceTrackId } : {},
2718
+ ...typeof label === "string" ? { label } : {},
2719
+ ...typeof keyFrameMediaKey === "string" ? { keyFrameMediaKey } : {}
2720
+ };
2721
+ }
2722
+ //#endregion
2723
+ //#region src/pipeline-analytics/pipeline/track-appearance.ts
2724
+ /**
2725
+ * Pure: no side effects. `continuing` = still active from last frame;
2726
+ * `birth` = a brand-new track's first sighting; `resurrection` = a known track
2727
+ * re-entering the active set after being lost.
2728
+ */
2729
+ function classifyTrackAppearance(input) {
2730
+ if (input.inPrevActive) return "continuing";
2731
+ return input.positionsCount > 1 ? "resurrection" : "birth";
2732
+ }
2733
+ //#endregion
2120
2734
  //#region src/pipeline-analytics/pipeline/key-event-query.ts
2121
2735
  async function rankKeyEvents(candidates, options, peakLookup) {
2122
2736
  const scored = [];
@@ -2126,6 +2740,10 @@ async function rankKeyEvents(candidates, options, peakLookup) {
2126
2740
  let bestEventId = t.bestEventId;
2127
2741
  if (importance === void 0) {
2128
2742
  const peak = await peakLookup(t.trackId);
2743
+ const staticMetrics = computeStaticTrackMetrics(t.positions.map((p) => ({
2744
+ x: p.x,
2745
+ y: p.y
2746
+ })), averageBboxDiagonal(t.positions.map((p) => p.bbox)));
2129
2747
  importance = computeImportance({
2130
2748
  peakConfidence: peak.peakConfidence,
2131
2749
  className: t.className,
@@ -2133,7 +2751,11 @@ async function rankKeyEvents(candidates, options, peakLookup) {
2133
2751
  peakBboxAreaFrac: peak.peakBboxAreaFrac,
2134
2752
  totalDistance: t.totalDistance,
2135
2753
  zonesVisited: t.zonesVisited,
2136
- ...t.label !== void 0 ? { label: t.label } : {}
2754
+ ...t.label !== void 0 ? { label: t.label } : {},
2755
+ ...staticMetrics ? {
2756
+ netDisplacementFrac: staticMetrics.netDisplacementFrac,
2757
+ pathSpanFrac: staticMetrics.pathSpanFrac
2758
+ } : {}
2137
2759
  }).importance;
2138
2760
  bestEventId = bestEventId ?? peak.bestEventId;
2139
2761
  }
@@ -2145,7 +2767,7 @@ async function rankKeyEvents(candidates, options, peakLookup) {
2145
2767
  className: t.className,
2146
2768
  ...t.label !== void 0 ? { label: t.label } : {},
2147
2769
  importance,
2148
- bestEventId: bestEventId ?? "",
2770
+ bestEventId: bestEventId ?? t.trackId,
2149
2771
  windowMs: t.lastSeen - t.firstSeen
2150
2772
  });
2151
2773
  }
@@ -2382,6 +3004,10 @@ var TRACKS_COLUMNS = [
2382
3004
  {
2383
3005
  name: "importanceReason",
2384
3006
  type: "TEXT"
3007
+ },
3008
+ {
3009
+ name: "audioLabels",
3010
+ type: "JSON"
2385
3011
  }
2386
3012
  ];
2387
3013
  var TRACKS_INDEXES = [{
@@ -2391,6 +3017,17 @@ var TRACKS_INDEXES = [{
2391
3017
  name: "idx_tracks_device_firstSeen",
2392
3018
  columns: ["deviceId", "firstSeen"]
2393
3019
  }];
3020
+ /** Serialize the per-label aggregate map into the `Track.audioLabels`
3021
+ * array shape, most-frequent label first. */
3022
+ function audioLabelsToArray(agg) {
3023
+ return [...agg.entries()].map(([label, a]) => ({
3024
+ label,
3025
+ peakScore: a.peakScore,
3026
+ count: a.count,
3027
+ firstAt: a.firstAt,
3028
+ lastAt: a.lastAt
3029
+ })).sort((a, b) => b.count - a.count);
3030
+ }
2394
3031
  function cloneTrack(t) {
2395
3032
  return {
2396
3033
  trackId: t.trackId,
@@ -2417,7 +3054,8 @@ function cloneTrack(t) {
2417
3054
  active: t.active,
2418
3055
  ...t.importance !== void 0 ? { importance: t.importance } : {},
2419
3056
  ...t.bestEventId !== void 0 ? { bestEventId: t.bestEventId } : {},
2420
- ...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {}
3057
+ ...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {},
3058
+ ...t.audioLabels !== void 0 && t.audioLabels.size > 0 ? { audioLabels: audioLabelsToArray(t.audioLabels) } : {}
2421
3059
  };
2422
3060
  }
2423
3061
  var TrackStore = class {
@@ -2478,25 +3116,81 @@ var TrackStore = class {
2478
3116
  this.active.set(params.trackId, fresh);
2479
3117
  return fresh;
2480
3118
  }
3119
+ /**
3120
+ * Record one audio-classification EPISODE against every track currently
3121
+ * active on the device — "what was heard on this camera while the track
3122
+ * was alive". Called from the confident-classification audio-event insert
3123
+ * (score ≥ device `classificationMinScore`, class-change-or-heartbeat
3124
+ * coalesced), so counts stay episode-scaled rather than 30 Hz chunk-scaled.
3125
+ */
3126
+ addAudioLabelEpisode(deviceId, label, score, timestamp) {
3127
+ for (const t of this.active.values()) {
3128
+ if (t.deviceId !== deviceId || !t.active) continue;
3129
+ const agg = t.audioLabels ??= /* @__PURE__ */ new Map();
3130
+ const entry = agg.get(label);
3131
+ if (entry) {
3132
+ entry.peakScore = Math.max(entry.peakScore, score);
3133
+ entry.count += 1;
3134
+ entry.lastAt = timestamp;
3135
+ } else agg.set(label, {
3136
+ peakScore: score,
3137
+ count: 1,
3138
+ firstAt: timestamp,
3139
+ lastAt: timestamp
3140
+ });
3141
+ }
3142
+ }
2481
3143
  /** Attach a snapshot reference to an active track. */
2482
3144
  addSnapshot(trackId, snapshot) {
2483
3145
  const t = this.active.get(trackId);
2484
3146
  if (!t) return;
2485
3147
  t.snapshots.push(snapshot);
2486
3148
  t.lastSnapshotAt = snapshot.timestamp;
3149
+ t.lastSnapshotBbox = { ...snapshot.position.bbox };
3150
+ }
3151
+ /**
3152
+ * Synchronously advance the periodic-snapshot gate reference (clock + bbox) at
3153
+ * the MOMENT a snapshot write is DECIDED — before the async encode/dispatch
3154
+ * lands the real snapshot via {@link addSnapshot}. Without it, `lastSnapshotAt`
3155
+ * only advances when the dispatcher round-trip returns (~50-200ms of sharp
3156
+ * encode), so at 10-25fps several consecutive frames pass
3157
+ * `evaluatePeriodicSnapshot` before the clock moves → a burst of near-identical
3158
+ * snapshots. Mirrors the synchronous `lastFrameAtByTrack` advance for the
3159
+ * rolling `lastFrame`.
3160
+ *
3161
+ * No rollback: if the write later fails the slot is simply lost (a rare dropped
3162
+ * snapshot is preferable to a burst). `addSnapshot` re-stamps the same
3163
+ * clock/bbox when the real snapshot lands, so the two stay consistent. No-op for
3164
+ * an unknown/expired track (the active entry is dropped on expiry, so there is
3165
+ * no separate map to leak).
3166
+ */
3167
+ markSnapshotPending(trackId, timestamp, bbox) {
3168
+ const t = this.active.get(trackId);
3169
+ if (!t) return;
3170
+ t.lastSnapshotAt = timestamp;
3171
+ t.lastSnapshotBbox = { ...bbox };
2487
3172
  }
2488
3173
  lastSnapshotAt(trackId) {
2489
3174
  return this.active.get(trackId)?.lastSnapshotAt ?? 0;
2490
3175
  }
3176
+ /** Bbox reference of the last captured snapshot (or the seed bbox), for the
3177
+ * periodic-snapshot movement gate. Undefined until the clock is seeded. */
3178
+ lastSnapshotBbox(trackId) {
3179
+ const b = this.active.get(trackId)?.lastSnapshotBbox;
3180
+ return b ? { ...b } : void 0;
3181
+ }
2491
3182
  /**
2492
3183
  * Seed the snapshot cadence clock (once, at track start) WITHOUT appending a
2493
3184
  * snapshot, so the first periodic snapshot fires ~snapshotIntervalMs after the
2494
3185
  * track begins rather than immediately — the `firstFrame` already covers the
2495
3186
  * track's start. No-op if a snapshot was already taken (clock already set).
2496
3187
  */
2497
- seedSnapshotClock(trackId, timestamp) {
3188
+ seedSnapshotClock(trackId, timestamp, bbox) {
2498
3189
  const t = this.active.get(trackId);
2499
- if (t && t.lastSnapshotAt === 0) t.lastSnapshotAt = timestamp;
3190
+ if (t && t.lastSnapshotAt === 0) {
3191
+ t.lastSnapshotAt = timestamp;
3192
+ if (bbox) t.lastSnapshotBbox = { ...bbox };
3193
+ }
2500
3194
  }
2501
3195
  getActive(deviceId) {
2502
3196
  const out = [];
@@ -2507,6 +3201,21 @@ var TrackStore = class {
2507
3201
  const t = this.active.get(trackId);
2508
3202
  return t && t.active ? cloneTrack(t) : null;
2509
3203
  }
3204
+ /**
3205
+ * Cheap read of an active track's promotion-relevant fields WITHOUT the deep
3206
+ * clone `getActiveByTrack` does — the returned `positions` is the live
3207
+ * internal array (read-only; callers must not mutate). Feeds the per-frame
3208
+ * stationary-promotion check, called at inference fps. Null if unknown/expired.
3209
+ */
3210
+ peekActive(trackId) {
3211
+ const t = this.active.get(trackId);
3212
+ if (!t || !t.active) return null;
3213
+ return {
3214
+ firstSeen: t.firstSeen,
3215
+ positions: t.positions,
3216
+ ...t.label !== void 0 ? { label: t.label } : {}
3217
+ };
3218
+ }
2510
3219
  /** Expire tracks whose `lastSeen` is older than TTL. Persists each
2511
3220
  * expired track to the declared collection and returns them. */
2512
3221
  async expireStale(nowMs) {
@@ -2612,6 +3321,15 @@ var TrackStore = class {
2612
3321
  clearAll() {
2613
3322
  this.active.clear();
2614
3323
  }
3324
+ /**
3325
+ * Drop a single active track WITHOUT persisting it as a historical row. Used
3326
+ * when a track is PROMOTED to a stationary-object registry entry: the durable
3327
+ * record for a parked object is the registry entry, not a Track, so the track
3328
+ * must NOT land in the key-event feed. No-op for an unknown/expired track.
3329
+ */
3330
+ dropActive(trackId) {
3331
+ this.active.delete(trackId);
3332
+ }
2615
3333
  /** Delete the persisted track row (keyed by trackId) and drop the in-RAM
2616
3334
  * active entry if present. Used by the whole-track deletion cascade. */
2617
3335
  async deletePersisted(trackId) {
@@ -2744,7 +3462,8 @@ var TrackStore = class {
2744
3462
  state: t.state,
2745
3463
  ...t.importance !== void 0 ? { importance: t.importance } : {},
2746
3464
  ...t.bestEventId !== void 0 ? { bestEventId: t.bestEventId } : {},
2747
- ...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {}
3465
+ ...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {},
3466
+ ...t.audioLabels !== void 0 ? { audioLabels: [...t.audioLabels] } : {}
2748
3467
  }
2749
3468
  });
2750
3469
  }
@@ -2757,6 +3476,7 @@ var TrackStore = class {
2757
3476
  const importance = data["importance"];
2758
3477
  const bestEventId = data["bestEventId"];
2759
3478
  const importanceReason = data["importanceReason"];
3479
+ const audioLabels = data["audioLabels"];
2760
3480
  return {
2761
3481
  trackId: id,
2762
3482
  deviceId: Number(data["deviceId"]),
@@ -2773,7 +3493,8 @@ var TrackStore = class {
2773
3493
  active: false,
2774
3494
  ...typeof importance === "number" ? { importance } : {},
2775
3495
  ...typeof bestEventId === "string" ? { bestEventId } : {},
2776
- ...typeof importanceReason === "string" ? { importanceReason } : {}
3496
+ ...typeof importanceReason === "string" ? { importanceReason } : {},
3497
+ ...Array.isArray(audioLabels) && audioLabels.length > 0 ? { audioLabels } : {}
2777
3498
  };
2778
3499
  }
2779
3500
  };
@@ -3886,13 +4607,10 @@ function stripNulls(data) {
3886
4607
  //#endregion
3887
4608
  //#region src/shared/frame/resolve-frame.ts
3888
4609
  /**
3889
- * Resolve the pixels a `FrameHandle` refers to. Local shm read when the
3890
- * handle's `nodeId` matches `deps.ownNodeId`, else routed via
3891
- * `deps.getRemoteFrame`. Returns `null` when the frame is no longer
3892
- * available (slot recycled locally, or the remote node reports no frame).
4610
+ * Resolve the pixels a `FrameHandle` refers to via the node-routed fetch.
4611
+ * Returns `null` when the frame is no longer available.
3893
4612
  */
3894
4613
  async function resolveFrame(handle, deps) {
3895
- if (handle.nodeId === deps.ownNodeId) return deps.readers.read(handle);
3896
4614
  return deps.getRemoteFrame(handle);
3897
4615
  }
3898
4616
  //#endregion
@@ -4069,11 +4787,7 @@ var EventMediaDispatcher = class {
4069
4787
  if (events.length === 0 && trackFrames.length === 0 && snapshots.length === 0) return empty;
4070
4788
  let decoded;
4071
4789
  try {
4072
- decoded = await resolveFrame(frameHandle, {
4073
- ownNodeId: this.deps.ownNodeId,
4074
- readers: this.deps.readers,
4075
- getRemoteFrame: this.deps.getRemoteFrame
4076
- });
4790
+ decoded = await resolveFrame(frameHandle, { getRemoteFrame: this.deps.getRemoteFrame });
4077
4791
  } catch (err) {
4078
4792
  this.deps.logger.debug("event media: resolveFrame threw", {
4079
4793
  tags: { deviceId },
@@ -4384,8 +5098,6 @@ var EmbeddingDispatcher = class {
4384
5098
  encoder;
4385
5099
  eventBus;
4386
5100
  logger;
4387
- ownNodeId;
4388
- readers;
4389
5101
  getRemoteFrame;
4390
5102
  lastEmbedTime = /* @__PURE__ */ new Map();
4391
5103
  pendingCrops = /* @__PURE__ */ new Map();
@@ -4398,8 +5110,6 @@ var EmbeddingDispatcher = class {
4398
5110
  this.encoder = deps.encoder;
4399
5111
  this.eventBus = deps.eventBus;
4400
5112
  this.logger = deps.logger;
4401
- this.ownNodeId = deps.ownNodeId;
4402
- this.readers = deps.readers;
4403
5113
  this.getRemoteFrame = deps.getRemoteFrame;
4404
5114
  }
4405
5115
  async start() {
@@ -4451,11 +5161,7 @@ var EmbeddingDispatcher = class {
4451
5161
  }
4452
5162
  let decoded;
4453
5163
  try {
4454
- decoded = await resolveFrame(handle, {
4455
- ownNodeId: this.ownNodeId,
4456
- readers: this.readers,
4457
- getRemoteFrame: this.getRemoteFrame
4458
- });
5164
+ decoded = await resolveFrame(handle, { getRemoteFrame: this.getRemoteFrame });
4459
5165
  } catch (err) {
4460
5166
  this.logger.debug("skip: resolveFrame threw", {
4461
5167
  tags: { deviceId: Number(deviceId) },
@@ -4882,7 +5588,8 @@ function computeSnapshot(input) {
4882
5588
  unzoned: {
4883
5589
  totalObjects: unzonedTotal,
4884
5590
  byClass: unzonedByClass
4885
- }
5591
+ },
5592
+ ...input.stationaryObjects !== void 0 && input.stationaryObjects.length > 0 ? { stationaryObjects: input.stationaryObjects } : {}
4886
5593
  };
4887
5594
  }
4888
5595
  //#endregion
@@ -5458,7 +6165,18 @@ var MediaSettingsSchema = require_dist.object({
5458
6165
  /** Cadence (ms) for the periodic per-track `snapshot` + rolling `lastFrame`.
5459
6166
  * A snapshot is captured for an active track only after this much wall-clock
5460
6167
  * has elapsed since its previous one. */
5461
- snapshotIntervalMs: require_dist.number().int().min(500).max(6e4).default(5e3)
6168
+ snapshotIntervalMs: require_dist.number().int().min(500).max(6e4).default(5e3),
6169
+ /** Movement gate for the periodic `snapshot`: once `snapshotIntervalMs` has
6170
+ * elapsed, a fresh snapshot is only captured when the track's centroid moved
6171
+ * at least this fraction of the frame DIAGONAL since the last captured
6172
+ * snapshot. Suppresses near-identical frames from a long-lived / stationary
6173
+ * track. 0 disables the gate (pure-time behaviour). Default 0.03 ≈ 3% of the
6174
+ * frame diagonal (~66px on 1080p). */
6175
+ snapshotMovementThreshold: require_dist.number().min(0).max(1).default(.03),
6176
+ /** Loiterer fallback (ms): force a periodic `snapshot` for a stationary but
6177
+ * still-present track after this much wall-clock without one, so its
6178
+ * filmstrip is never empty. Effectively clamped to ≥ `snapshotIntervalMs`. */
6179
+ snapshotMaxIdleMs: require_dist.number().int().min(1e3).max(6e5).default(3e4)
5462
6180
  });
5463
6181
  var MEDIA_DEFAULTS = MediaSettingsSchema.parse({});
5464
6182
  /**
@@ -5473,7 +6191,148 @@ function resolveMediaSettings(raw) {
5473
6191
  return {
5474
6192
  cropPadding: pick("cropPadding"),
5475
6193
  saveThumbnails: pick("saveThumbnails"),
5476
- snapshotIntervalMs: pick("snapshotIntervalMs")
6194
+ snapshotIntervalMs: pick("snapshotIntervalMs"),
6195
+ snapshotMovementThreshold: pick("snapshotMovementThreshold"),
6196
+ snapshotMaxIdleMs: pick("snapshotMaxIdleMs")
6197
+ };
6198
+ }
6199
+ function centroidOf(b) {
6200
+ return {
6201
+ x: b.x + b.w / 2,
6202
+ y: b.y + b.h / 2
6203
+ };
6204
+ }
6205
+ /** Centroid displacement between two boxes as a fraction of the frame diagonal.
6206
+ * Returns 0 for a degenerate (≤0) frame diagonal so the caller can fall back
6207
+ * to pure-time behaviour instead of dividing by zero. */
6208
+ function centroidMovedFraction(a, b, frameWidth, frameHeight) {
6209
+ const diag = Math.hypot(frameWidth, frameHeight);
6210
+ if (diag <= 0) return 0;
6211
+ const ca = centroidOf(a);
6212
+ const cb = centroidOf(b);
6213
+ return Math.hypot(cb.x - ca.x, cb.y - ca.y) / diag;
6214
+ }
6215
+ /**
6216
+ * Decide whether the periodic `snapshot` should be captured for a track THIS
6217
+ * frame. Pure: no side effects. The caller keeps the `saveThumbnails` master
6218
+ * switch and advances `lastSnapshotAt`/`lastSnapshotBbox` only when a capture
6219
+ * actually lands — so a skipped (stationary) frame leaves the clock untouched,
6220
+ * which naturally lets `maxIdleMs` fire and re-evaluates movement every frame
6221
+ * until the object moves.
6222
+ */
6223
+ function evaluatePeriodicSnapshot(input) {
6224
+ const { lastSnapshotAt, lastSnapshotBbox, currentBbox, now, frameWidth, frameHeight, intervalMs, movementThreshold, maxIdleMs } = input;
6225
+ if (lastSnapshotAt <= 0 || now - lastSnapshotAt < intervalMs) {
6226
+ if (lastSnapshotAt > 0 && lastSnapshotBbox !== void 0 && now - lastSnapshotAt >= 1500) {
6227
+ const fastMoved = centroidMovedFraction(lastSnapshotBbox, currentBbox, frameWidth, frameHeight);
6228
+ if (fastMoved >= .08) return {
6229
+ capture: true,
6230
+ reason: "fast-mover",
6231
+ movedFraction: fastMoved
6232
+ };
6233
+ }
6234
+ return {
6235
+ capture: false,
6236
+ reason: "interval-not-elapsed",
6237
+ movedFraction: 0
6238
+ };
6239
+ }
6240
+ if (lastSnapshotBbox === void 0) return {
6241
+ capture: true,
6242
+ reason: "no-reference",
6243
+ movedFraction: 0
6244
+ };
6245
+ const movedFraction = centroidMovedFraction(lastSnapshotBbox, currentBbox, frameWidth, frameHeight);
6246
+ if (movedFraction >= movementThreshold) return {
6247
+ capture: true,
6248
+ reason: "moved",
6249
+ movedFraction
6250
+ };
6251
+ const idleLimit = Math.max(maxIdleMs, intervalMs);
6252
+ if (now - lastSnapshotAt >= idleLimit) return {
6253
+ capture: true,
6254
+ reason: "idle-forced",
6255
+ movedFraction
6256
+ };
6257
+ return {
6258
+ capture: false,
6259
+ reason: "stationary-skip",
6260
+ movedFraction
6261
+ };
6262
+ }
6263
+ //#endregion
6264
+ //#region src/pipeline-analytics/periodic-media-plan.ts
6265
+ /**
6266
+ * Decide the periodic media writes for one track on one frame. Pure: no side
6267
+ * effects. The caller advances its own `lastFrameAt` clock only when the
6268
+ * returned `rollingLastFrame` is true.
6269
+ *
6270
+ * INVARIANT: `appendSnapshot` and `rollingLastFrame` are never both true — the
6271
+ * rolling `lastFrame` is never the same frame as an appended `snapshot`, so it
6272
+ * can never duplicate one.
6273
+ */
6274
+ function planPeriodicMedia(input) {
6275
+ const appendSnapshot = input.dueSnapshot;
6276
+ return {
6277
+ appendSnapshot,
6278
+ rollingLastFrame: input.saveThumbnails && input.now - input.lastFrameAt >= input.intervalMs && !appendSnapshot,
6279
+ bestThumbnail: input.isNewBest
6280
+ };
6281
+ }
6282
+ //#endregion
6283
+ //#region src/pipeline-analytics/pipeline/key-frame-capture.ts
6284
+ /**
6285
+ * Pure helpers for the per-track NATIVE-resolution `keyFrame` capture
6286
+ * (Design B — one native full-frame per track at its best-detection moment).
6287
+ *
6288
+ * ## Why this exists (the missing native keyFrame)
6289
+ *
6290
+ * `keyFrame` was historically captured ONLY inside the CLIP object-embedding
6291
+ * best path (`persistObjectEmbeddingBests`, gated by `isClipObjectEmbedding`).
6292
+ * Under the two-plane pipeline the root frame carries NO CLIP embedding (clip is
6293
+ * a per-track DETAIL served via `runDetailSubtree`, and is disabled cluster-
6294
+ * wide), so that gate was never satisfied and the native `keyFrame` was NEVER
6295
+ * produced — every stored frame stayed at the ≤640×360 detection resolution.
6296
+ *
6297
+ * The fix decouples the `keyFrame` from the clip path: it is captured on the
6298
+ * GENERAL best-frame signal (the same `bestThumbnail` decision that drives the
6299
+ * `thumbnail`), reusing the WORKING native crop path (`captureCrop` →
6300
+ * `pipelineRunner.getNativeCrop`, which cuts the ROI from the decode worker's
6301
+ * retained NATIVE surface and only falls back to the detection frame on a miss).
6302
+ * A full-frame ROI at {@link KEYFRAME_NATIVE_MAX_WIDTH} therefore yields a frame
6303
+ * LARGER than the detection raster (up to the cap), which is the whole point of
6304
+ * the `keyFrame` kind.
6305
+ */
6306
+ /** Cap (px) on the width of the native KEY FRAME (full-frame native capture).
6307
+ * Native resolution is the point, but a full 4K RGB surface over the transport
6308
+ * per new-best is wasteful for a web detail view — 1920px keeps a sharp native
6309
+ * frame while bounding the copy (a miss falls back to the detection-res frame,
6310
+ * which is already ≤640px). */
6311
+ var KEYFRAME_NATIVE_MAX_WIDTH = 1920;
6312
+ /**
6313
+ * The tracks whose native `keyFrame` should be (re)captured THIS frame: exactly
6314
+ * the tracks that hit a new best-frame moment (`bestThumbnail`). `putReplacing`
6315
+ * downstream keeps one `keyFrame` per track (the current peak).
6316
+ */
6317
+ function selectKeyFrameTrackIds(targets) {
6318
+ return targets.filter((t) => t.bestThumbnail).map((t) => t.trackId);
6319
+ }
6320
+ /**
6321
+ * Build the `captureCrop` request for a track's native `keyFrame`: the FULL
6322
+ * frame (no padding) at the native width cap. The full-frame box is what makes
6323
+ * the capture route through the native surface at native resolution instead of
6324
+ * a tight ≤640 detection crop.
6325
+ */
6326
+ function buildKeyFrameCaptureRequest(frameWidth, frameHeight) {
6327
+ return {
6328
+ bbox: {
6329
+ x: 0,
6330
+ y: 0,
6331
+ w: frameWidth,
6332
+ h: frameHeight
6333
+ },
6334
+ padding: 0,
6335
+ maxWidth: KEYFRAME_NATIVE_MAX_WIDTH
5477
6336
  };
5478
6337
  }
5479
6338
  //#endregion
@@ -6943,7 +7802,7 @@ var DetailScheduler = class {
6943
7802
  //#region src/pipeline-analytics/detail-dispatcher.ts
6944
7803
  /**
6945
7804
  * Compose the `steps` list sent to `runDetailSubtree` for one request —
6946
- * identity-aware for the face chain.
7805
+ * chain-aware for the multi-step detail subtrees.
6947
7806
  *
6948
7807
  * A `face-detection` request ALSO includes `'face-embedding'` (the full
6949
7808
  * detect→recognize chain) EXCEPT when it is a PERIODIC geometry refresh on a
@@ -6952,13 +7811,35 @@ var DetailScheduler = class {
6952
7811
  * that case runs the detector geometry ALONE. Every recognition-bearing reason
6953
7812
  * (new-track / improve / retry) keeps the embedding regardless of the label.
6954
7813
  *
6955
- * Non-face steps are unchanged (`[req.stepId]`). Pairs with the pipeline's
6956
- * strict-`steps` pruning (`pruneChildStepsToRequested`) including
6957
- * `'face-embedding'` here is what keeps the nested child in the executed chain.
7814
+ * A `plate-detection` request ALWAYS includes `'plate-ocr'` symmetric to the
7815
+ * face chain. Without `'plate-ocr'` in the array the pipeline's strict-`steps`
7816
+ * pruning (`pruneChildStepsToRequested`) drops the OCR child, so a detected
7817
+ * plate never gets read and no plate text is ever produced. (The dispatcher
7818
+ * cannot import the pipeline catalog to derive the child chain — this hardcode
7819
+ * mirrors it; keep the two in sync when the catalog's plate subtree changes.)
7820
+ *
7821
+ * Other steps are unchanged (`[req.stepId]`). Pairs with the pipeline's
7822
+ * strict-`steps` pruning — naming the nested child here is what keeps it in the
7823
+ * executed chain.
6958
7824
  */
6959
7825
  function composeDetailSteps(req, hasTrackLabel) {
6960
- if (req.stepId !== "face-detection") return [req.stepId];
6961
- return req.reason === "periodic" && hasTrackLabel(req.trackId) ? ["face-detection"] : ["face-detection", "face-embedding"];
7826
+ if (req.stepId === "face-detection") return req.reason === "periodic" && hasTrackLabel(req.trackId) ? ["face-detection"] : ["face-detection", "face-embedding"];
7827
+ if (req.stepId === "plate-detection") return ["plate-detection", "plate-ocr"];
7828
+ return [req.stepId];
7829
+ }
7830
+ /**
7831
+ * Does `steps` name a nested-enrichment chain (root detector + a child that
7832
+ * produces a `label`/`embedding`), i.e. more than the bare root step? Used to
7833
+ * surface a silent enrichment miss (BUG C): a plate detected but never read, a
7834
+ * face detected but never embedded — the root detail still routes so the miss
7835
+ * is otherwise invisible. `['plate-detection']` alone is NOT a chain.
7836
+ */
7837
+ function isEnrichmentChain(steps) {
7838
+ return steps.length > 1;
7839
+ }
7840
+ /** Does any returned detail carry the enrichment a chain request asked for? */
7841
+ function detailsCarryEnrichment(details) {
7842
+ return details.some((d) => d.label !== void 0 || d.embedding !== void 0);
6962
7843
  }
6963
7844
  /** Throttle for the per-device "detail call failed" warn — one line / minute. */
6964
7845
  var FAIL_WARN_THROTTLE_MS = 6e4;
@@ -7035,7 +7916,8 @@ var TrackDetailDispatcher = class {
7035
7916
  queue: [],
7036
7917
  inFlight: 0,
7037
7918
  timer: null,
7038
- lastFailWarnAt: 0
7919
+ lastFailWarnAt: 0,
7920
+ lastEnrichWarnAt: 0
7039
7921
  };
7040
7922
  this.devices.set(deviceId, dev);
7041
7923
  }
@@ -7081,6 +7963,8 @@ var TrackDetailDispatcher = class {
7081
7963
  let topScore = null;
7082
7964
  if (details !== null && details.length > 0) {
7083
7965
  topScore = details.reduce((max, d) => d.score > max ? d.score : max, Number.NEGATIVE_INFINITY);
7966
+ const steps = composeDetailSteps(req, (id) => this.deps.hasTrackLabel?.(id) ?? false);
7967
+ if (isEnrichmentChain(steps) && !detailsCarryEnrichment(details)) this.warnEnrichmentMissThrottled(deviceId, dev, req, steps);
7084
7968
  try {
7085
7969
  await this.deps.routeResults(deviceId, req.trackId, details, frame);
7086
7970
  } catch (err) {
@@ -7162,6 +8046,20 @@ var TrackDetailDispatcher = class {
7162
8046
  }
7163
8047
  });
7164
8048
  }
8049
+ warnEnrichmentMissThrottled(deviceId, dev, req, steps) {
8050
+ const now = Date.now();
8051
+ if (now - dev.lastEnrichWarnAt < FAIL_WARN_THROTTLE_MS) return;
8052
+ dev.lastEnrichWarnAt = now;
8053
+ this.deps.logger.warn("detail chain ran but produced no enrichment (root detected, child yielded no label/embedding)", {
8054
+ tags: { deviceId },
8055
+ meta: {
8056
+ trackId: req.trackId,
8057
+ stepId: req.stepId,
8058
+ reason: req.reason,
8059
+ steps
8060
+ }
8061
+ });
8062
+ }
7165
8063
  };
7166
8064
  //#endregion
7167
8065
  //#region src/pipeline-analytics/overlay-state.ts
@@ -7999,38 +8897,68 @@ var PlateRecognizer = class {
7999
8897
  name
8000
8898
  } : null;
8001
8899
  }
8002
- /** Live label for a plate read: the recognized vehicle NAME when matched, else
8003
- * the raw OCR text (today's behavior). Used by the ingest label path. */
8900
+ /** Live label for a plate read: the recognized vehicle NAME when matched,
8901
+ * else the raw OCR text. Returns `null` for an implausible read (junk OCR
8902
+ * off a distant/oblique plate) — the caller must NOT stamp a label then. */
8004
8903
  resolveLabel(text, score) {
8904
+ if (!isPlausiblePlateRead(text, score)) return null;
8005
8905
  return this.matchVehicle(text, score)?.name ?? text;
8006
8906
  }
8007
8907
  async processFrame(input) {
8008
8908
  const minConfidence = input.minConfidence ?? 0;
8009
8909
  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);
8010
8910
  if (candidates.length === 0) return;
8011
- for (const c of candidates) {
8012
- const held = this.bestPlate.get(c.trackId);
8013
- if (held !== void 0 && c.plateScore <= held.score) continue;
8014
- let crop;
8015
- if (input.frameHandle !== void 0) try {
8016
- crop = await this.deps.captureCrop(input.frameHandle, c.plateBbox, input.frameWidth, input.frameHeight, input.cropPadding) ?? void 0;
8017
- } catch (err) {
8018
- this.deps.logger.debug("PlateRecognizer crop capture failed", {
8019
- tags: { deviceId: input.deviceId },
8020
- meta: {
8021
- trackId: c.trackId,
8022
- error: String(err)
8023
- }
8024
- });
8025
- }
8026
- this.bestPlate.set(c.trackId, {
8027
- text: c.plateText,
8028
- score: c.plateScore,
8029
- bbox: c.plateBbox,
8030
- timestamp: input.timestamp,
8031
- ...crop !== void 0 ? { crop } : {}
8911
+ for (const c of candidates) await this.holdBest({
8912
+ deviceId: input.deviceId,
8913
+ trackId: c.trackId,
8914
+ text: c.plateText,
8915
+ score: c.plateScore,
8916
+ bbox: c.plateBbox,
8917
+ timestamp: input.timestamp,
8918
+ frameWidth: input.frameWidth,
8919
+ frameHeight: input.frameHeight,
8920
+ cropPadding: input.cropPadding,
8921
+ ...input.frameHandle !== void 0 ? { frameHandle: input.frameHandle } : {}
8922
+ });
8923
+ }
8924
+ /**
8925
+ * Detail-plane entry point (two-plane design): plate-ocr runs on demand per
8926
+ * track via `pipelineRunner.runDetailSubtree`, NOT per frame, so the OCR read
8927
+ * never lands on the per-frame `tracked[]` that {@link processFrame} scans.
8928
+ * The dispatcher's result router calls this with each plate detail so the
8929
+ * gallery still collects the best read + tight crop (persisted on
8930
+ * {@link onTrackEnd}). Without it the plate label rides the event but the
8931
+ * gallery stays empty (0 plateCrop) the observed live gap.
8932
+ */
8933
+ async observePlateRead(input) {
8934
+ if (!isPlausiblePlateRead(input.text, input.score)) return;
8935
+ if (input.score < (input.minConfidence ?? 0)) return;
8936
+ await this.holdBest(input);
8937
+ }
8938
+ /** Hold the highest-scoring plate read per track, capturing a tight crop the
8939
+ * first time a new best is seen (shared by the per-frame + detail-plane paths). */
8940
+ async holdBest(input) {
8941
+ const held = this.bestPlate.get(input.trackId);
8942
+ if (held !== void 0 && input.score <= held.score) return;
8943
+ let crop;
8944
+ if (input.frameHandle !== void 0) try {
8945
+ crop = await this.deps.captureCrop(input.frameHandle, input.bbox, input.frameWidth, input.frameHeight, input.cropPadding) ?? void 0;
8946
+ } catch (err) {
8947
+ this.deps.logger.debug("PlateRecognizer crop capture failed", {
8948
+ tags: { deviceId: input.deviceId },
8949
+ meta: {
8950
+ trackId: input.trackId,
8951
+ error: String(err)
8952
+ }
8032
8953
  });
8033
8954
  }
8955
+ this.bestPlate.set(input.trackId, {
8956
+ text: input.text,
8957
+ score: input.score,
8958
+ bbox: input.bbox,
8959
+ timestamp: input.timestamp,
8960
+ ...crop !== void 0 ? { crop } : {}
8961
+ });
8034
8962
  }
8035
8963
  /** Persist the held best plate for a finished track as one PlateStore row
8036
8964
  * (crop → MediaStore under ownerKind 'plate'), then drop in-memory state. */
@@ -8441,18 +9369,17 @@ var DETAIL_FALLBACK_CROP_PADDING = .15;
8441
9369
  * before re-reading. */
8442
9370
  var CLIP_MODEL_ID_CACHE_TTL_MS = 6e4;
8443
9371
  var SETTINGS_CACHE_TTL_MS = 5e3;
9372
+ /** Reconcile cadence for the per-device `zones` / `zoneRules` slice handles.
9373
+ * The `device.state-changed` push doesn't reliably reach a forked child, so
9374
+ * each cached proxy re-pulls both slices on this timer (see ensureProxy) —
9375
+ * a zone drawn in the editor shows up in per-zone stats within one tick. */
9376
+ var ZONE_SLICE_RECONCILE_MS = 3e4;
8444
9377
  /** §5 best-frame: a track's `thumbnail` is overwritten only when the current
8445
9378
  * detection confidence beats the held best by at least this margin (hysteresis
8446
9379
  * so jitter around a plateau doesn't churn the write). */
8447
9380
  var BEST_FRAME_HYSTERESIS = .05;
8448
9381
  /** §5 best-frame: at most one best-thumbnail capture per this interval per track. */
8449
9382
  var BEST_FRAME_MIN_GAP_MS = 2e3;
8450
- /** Design B: cap (px) on the width of the native KEY FRAME (full-frame native
8451
- * capture). Native resolution is the point, but a full 4K RGB surface over the
8452
- * transport per new-best is wasteful for a web detail view — 1920px keeps a
8453
- * sharp native frame while bounding the copy (a miss falls back to the
8454
- * detection-res frame, which is already ≤640px). */
8455
- var KEYFRAME_NATIVE_MAX_WIDTH = 1920;
8456
9383
  /** getKeyEvents: max completed tracks pulled from a window before importance
8457
9384
  * ranking. Ordering is by importance (not firstSeen) and legacy rows score on
8458
9385
  * read, so we over-fetch candidates and trim to `limit` after sorting. */
@@ -8525,6 +9452,10 @@ function stripGlobalOnlyFields(sections) {
8525
9452
  var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
8526
9453
  processors = /* @__PURE__ */ new Map();
8527
9454
  trackStore = null;
9455
+ /** Parked-object registry: promotes a track that stopped moving into a
9456
+ * lightweight entry, suppresses its detections from re-spawning tracks, and
9457
+ * wakes it when the object departs. Null until onInitialize. */
9458
+ stationaryRegistry = null;
8528
9459
  mediaStore = null;
8529
9460
  eventStore = null;
8530
9461
  identityStore = null;
@@ -8550,9 +9481,6 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
8550
9481
  * detection-pipeline DECODED frame — the ONLY image source (never the
8551
9482
  * snapshot cap). Null when shm frame access is unavailable. */
8552
9483
  eventMediaDispatcher = null;
8553
- /** Shared shm-ring reader cache for resolving `frameHandle`s to pixels.
8554
- * Owned here so segments stay open across frames; closed once on shutdown. */
8555
- frameReaders = null;
8556
9484
  /** Object/face embedding dispatcher — migrated from the retired
8557
9485
  * enrichment-engine addon. Runs ONLY on the post-processing node; on each
8558
9486
  * detection it resolves the frame, crops the ROI, and calls the
@@ -8589,6 +9517,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
8589
9517
  * dataPlane facility in the current environment). */
8590
9518
  eventMediaBaseUrl = null;
8591
9519
  lastActiveTrackIds = /* @__PURE__ */ new Map();
9520
+ lastFrameDimsByDevice = /* @__PURE__ */ new Map();
8592
9521
  lastAudioInsertByDevice = /* @__PURE__ */ new Map();
8593
9522
  lastMotionInsertByDevice = /* @__PURE__ */ new Map();
8594
9523
  levelStateByDevice = /* @__PURE__ */ new Map();
@@ -8620,10 +9549,15 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
8620
9549
  * cross-restart "best" gate stays in `ObjectEmbeddingStore.upsertIfBetter`. */
8621
9550
  objectEmbeddingBestSelector = new TrackBestSelector();
8622
9551
  /** Design B: the track's shared native key-frame media key, captured at the
8623
- * best-detection moment (object-embedding best path). Read by the face path
8624
- * at track end so a face row links to the SAME single key frame. Cleared on
8625
- * track end. */
9552
+ * best-detection moment (general best-frame path). Read by the face / plate /
9553
+ * object-embedding rows so they LINK the SAME single native key frame.
9554
+ * Cleared on track end. */
8626
9555
  keyFrameKeyByTrackId = /* @__PURE__ */ new Map();
9556
+ /** Wall-clock of each track's last ACTUALLY-written rolling `lastFrame`. The
9557
+ * rolling `lastFrame` runs on its OWN pure-time cadence and only on frames
9558
+ * where no `snapshot` is appended, so it is never byte-identical to a stored
9559
+ * `snapshot` (kills the end-of-track duplicate). Cleared on track end. */
9560
+ lastFrameAtByTrack = /* @__PURE__ */ new Map();
8627
9561
  /** Per-track update-gate memory for `PipelineAnalyticsTrackLifecycle`
8628
9562
  * `phase:'update'` — the last-emitted best (confidence / label / crop
8629
9563
  * area) + emit time, so a material improvement is measured against the
@@ -8674,11 +9608,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
8674
9608
  await PlateStore.declare(api.settingsStore);
8675
9609
  await VehicleStore.declare(api.settingsStore);
8676
9610
  await ObjectEmbeddingStore.declare(api.settingsStore);
9611
+ await StationaryObjectRegistry.declare(api.settingsStore);
8677
9612
  const logger = this.ctx.logger;
8678
9613
  let storage = this.ctx.kernel.storage;
8679
9614
  const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
8680
9615
  if (mediaRoot) {
8681
- const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-Cvhwrf43.js"));
9616
+ const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-DtltlqrH.js"));
8682
9617
  storage = new FilesystemStorageProvider(mediaRoot);
8683
9618
  logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
8684
9619
  }
@@ -8687,6 +9622,30 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
8687
9622
  store: api.settingsStore,
8688
9623
  logger: logger.child("TrackStore")
8689
9624
  });
9625
+ this.stationaryRegistry = new StationaryObjectRegistry({
9626
+ store: api.settingsStore,
9627
+ logger: logger.child("StationaryRegistry"),
9628
+ onChange: ({ phase, entry, timestamp }) => {
9629
+ this.ctx.eventBus.emit({
9630
+ id: `pa-stationary-${entry.id}-${phase}`,
9631
+ timestamp: new Date(timestamp),
9632
+ source: {
9633
+ type: "addon",
9634
+ id: "pipeline-analytics",
9635
+ addonId: "pipeline-analytics"
9636
+ },
9637
+ category: require_dist.EventCategory.PipelineAnalyticsStationaryChanged,
9638
+ data: {
9639
+ deviceId: entry.deviceId,
9640
+ entryId: entry.id,
9641
+ className: entry.className,
9642
+ phase,
9643
+ timestamp
9644
+ }
9645
+ });
9646
+ }
9647
+ });
9648
+ await this.stationaryRegistry.load();
8690
9649
  this.mediaStore = new MediaStore({
8691
9650
  storage,
8692
9651
  store: api.settingsStore,
@@ -8723,33 +9682,33 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
8723
9682
  designatedNode: designated
8724
9683
  } });
8725
9684
  }
8726
- this.frameReaders = new _camstack_shm_ring.FrameRingReaderCache(logger.child("shm-readers"));
8727
- const decoderApi = api.decoder;
9685
+ const pipelineRunnerApi = api.pipelineRunner;
8728
9686
  const getRemoteFrame = async (handle) => {
8729
- if (!decoderApi?.getFrame) return null;
8730
- const remote = await decoderApi.getFrame.query({
9687
+ if (!pipelineRunnerApi?.getNativeCrop) return null;
9688
+ const full = await pipelineRunnerApi.getNativeCrop.query({
8731
9689
  handle,
8732
- nodeId: handle.nodeId
8733
- });
8734
- if (!remote) return null;
9690
+ bbox: {
9691
+ x: 0,
9692
+ y: 0,
9693
+ w: 1,
9694
+ h: 1
9695
+ },
9696
+ maxWidth: handle.width
9697
+ }, require_dist.nodePin(handle.nodeId));
9698
+ if (!full || full.width <= 0 || full.height <= 0) return null;
8735
9699
  return {
8736
- data: Buffer.from(remote.data),
8737
- width: remote.width,
8738
- height: remote.height,
8739
- format: remote.format,
8740
- timestamp: remote.timestamp
9700
+ data: Buffer.from(full.bytes),
9701
+ width: full.width,
9702
+ height: full.height,
9703
+ format: "rgb",
9704
+ timestamp: 0
8741
9705
  };
8742
9706
  };
8743
9707
  this.eventMediaDispatcher = new EventMediaDispatcher({
8744
- ownNodeId,
8745
- readers: this.frameReaders,
8746
9708
  getRemoteFrame,
8747
9709
  mediaStore: this.mediaStore,
8748
9710
  logger: logger.child("EventMediaDispatcher")
8749
9711
  });
8750
- const ownNodeIdForFaces = ownNodeId;
8751
- const frameReadersForFaces = this.frameReaders;
8752
- const pipelineRunnerApi = api.pipelineRunner;
8753
9712
  const cropMetricLogger = logger.child("NativeCrop");
8754
9713
  let nativeHits = 0;
8755
9714
  let nativeFallbacks = 0;
@@ -8781,11 +9740,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
8781
9740
  return null;
8782
9741
  }
8783
9742
  };
8784
- const resolveFrameShared = createSharedFrameResolver((frameHandle) => resolveFrame(frameHandle, {
8785
- ownNodeId: ownNodeIdForFaces,
8786
- readers: frameReadersForFaces,
8787
- getRemoteFrame
8788
- }));
9743
+ const resolveFrameShared = createSharedFrameResolver((frameHandle) => resolveFrame(frameHandle, { getRemoteFrame }));
8789
9744
  const captureCrop = async (frameHandle, bbox, frameWidth, frameHeight, padding, maxWidth) => {
8790
9745
  const paddedNorm = padBbox({
8791
9746
  x: bbox.x / frameWidth,
@@ -8941,8 +9896,6 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
8941
9896
  encoder: encoderClient,
8942
9897
  eventBus: this.ctx.eventBus,
8943
9898
  logger: logger.child("EmbeddingDispatcher"),
8944
- ownNodeId,
8945
- readers: frameReadersForFaces,
8946
9899
  getRemoteFrame
8947
9900
  });
8948
9901
  await this.embeddingDispatcher.start();
@@ -8953,6 +9906,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
8953
9906
  this.bindingCache?.onBindingsChanged(data);
8954
9907
  if (data.capName === "pipeline-analytics" && data.reason === "wrapper-deactivated") {
8955
9908
  this.trackStore?.clearDevice(data.deviceId);
9909
+ this.stationaryRegistry?.forgetDevice(data.deviceId);
8956
9910
  this.overlayState.clearDevice(data.deviceId);
8957
9911
  this.overlaySynthesisWarnAt.delete(data.deviceId);
8958
9912
  this.forgetDeviceProcessors(data.deviceId);
@@ -8967,6 +9921,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
8967
9921
  this.unsubDeviceUnreg = this.ctx.eventBus.subscribe({ category: require_dist.EventCategory.DeviceUnregistered }, (ev) => {
8968
9922
  const { deviceId } = ev.data;
8969
9923
  this.trackStore?.clearDevice(deviceId);
9924
+ this.stationaryRegistry?.forgetDevice(deviceId);
8970
9925
  this.overlayState.clearDevice(deviceId);
8971
9926
  this.overlaySynthesisWarnAt.delete(deviceId);
8972
9927
  this.forgetDeviceProcessors(deviceId);
@@ -8987,6 +9942,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
8987
9942
  this.retentionSweepTimer = setInterval(() => {
8988
9943
  this.sweepRetention();
8989
9944
  this.runTrackRetentionSweep();
9945
+ this.stationaryRegistry?.sweep(Date.now());
8990
9946
  }, RETENTION_SWEEP_INTERVAL_MS);
8991
9947
  this.ctx.logger.info("pipeline-analytics subscribers installed");
8992
9948
  const widgetsProvider = { listWidgets: async () => [
@@ -9307,8 +10263,10 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
9307
10263
  this.overlaySynthesisWarnAt.clear();
9308
10264
  this.processors.clear();
9309
10265
  this.lastActiveTrackIds.clear();
10266
+ this.lastFrameDimsByDevice.clear();
9310
10267
  this.dropoutSkipsByKey.clear();
9311
10268
  this.bestFrameTracker.clear();
10269
+ this.lastFrameAtByTrack.clear();
9312
10270
  this.trackLifecycleUpdateMem.clear();
9313
10271
  this.objectEmbeddingBestSelector.clear();
9314
10272
  this.levelStateByDevice.clear();
@@ -9319,13 +10277,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
9319
10277
  this.faceGlobalEnabledCache = null;
9320
10278
  this.mediaCacheByDevice.clear();
9321
10279
  this.trackStore?.clearAll();
10280
+ this.stationaryRegistry = null;
9322
10281
  this.bindingCache?.clearAll();
9323
10282
  await this.eventMediaDataPlane?.dispose();
9324
10283
  this.eventMediaDataPlane = null;
9325
10284
  this.eventMediaBaseUrl = null;
9326
10285
  this.eventMediaDispatcher = null;
9327
- this.frameReaders?.close();
9328
- this.frameReaders = null;
9329
10286
  }
9330
10287
  async handleInferenceResult(data) {
9331
10288
  if (this.shuttingDown) return;
@@ -9373,22 +10330,41 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
9373
10330
  timestamp: frame.timestamp,
9374
10331
  frame
9375
10332
  });
10333
+ if (source === "pipeline") this.stationaryRegistry?.noteFrame(deviceId, result.timestamp);
10334
+ if (this.stationaryRegistry && (result.stationaryConfirmed.length > 0 || result.stationaryWoken.length > 0)) this.stationaryRegistry.applyFrameOutcome({
10335
+ deviceId,
10336
+ confirmed: result.stationaryConfirmed,
10337
+ wokenEntryIds: result.stationaryWoken,
10338
+ timestamp: result.timestamp
10339
+ });
10340
+ const stationaryViews = this.stationaryRegistry?.listViews(deviceId) ?? [];
10341
+ const stationaryAsTracked = stationaryViews.map((v) => ({
10342
+ trackId: `stationary:${v.id}`,
10343
+ className: v.className,
10344
+ zones: []
10345
+ }));
9376
10346
  this.zoneAnalytics?.recordFrame({
9377
10347
  deviceId,
9378
10348
  timestamp: result.timestamp,
9379
10349
  frameWidth: result.frameWidth,
9380
10350
  frameHeight: result.frameHeight,
9381
- tracked: result.tracked,
9382
- zones: liveZones
10351
+ tracked: stationaryAsTracked.length > 0 ? [...result.tracked, ...stationaryAsTracked] : result.tracked,
10352
+ zones: liveZones,
10353
+ ...stationaryViews.length > 0 ? { stationaryObjects: stationaryViews } : {}
10354
+ });
10355
+ if (result.frameWidth > 0 && result.frameHeight > 0) this.lastFrameDimsByDevice.set(deviceId, {
10356
+ w: result.frameWidth,
10357
+ h: result.frameHeight
9383
10358
  });
9384
10359
  const currentTrackIds = /* @__PURE__ */ new Set();
10360
+ const positionsCountById = /* @__PURE__ */ new Map();
9385
10361
  for (const t of result.tracked) {
9386
10362
  currentTrackIds.add(t.trackId);
9387
10363
  const center = {
9388
10364
  x: t.bbox.x + t.bbox.w / 2,
9389
10365
  y: t.bbox.y + t.bbox.h / 2
9390
10366
  };
9391
- this.trackStore.upsert({
10367
+ const upserted = this.trackStore.upsert({
9392
10368
  trackId: t.trackId,
9393
10369
  deviceId,
9394
10370
  className: t.className,
@@ -9403,6 +10379,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
9403
10379
  zones: t.zones,
9404
10380
  state: t.state
9405
10381
  });
10382
+ positionsCountById.set(t.trackId, upserted.positions.length);
9406
10383
  }
9407
10384
  const log = this.ctx.logger.withTags({ deviceId });
9408
10385
  const prevIds = this.lastActiveTrackIds.get(key) ?? /* @__PURE__ */ new Set();
@@ -9411,6 +10388,18 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
9411
10388
  for (const id of currentTrackIds) if (!prevIds.has(id)) {
9412
10389
  const t = result.tracked.find((x) => x.trackId === id);
9413
10390
  if (t) {
10391
+ if (classifyTrackAppearance({
10392
+ inPrevActive: false,
10393
+ positionsCount: positionsCountById.get(id) ?? 1
10394
+ }) === "resurrection") {
10395
+ log.info("track resumed", { meta: {
10396
+ trackId: id,
10397
+ className: t.className,
10398
+ source,
10399
+ resurrected: true
10400
+ } });
10401
+ continue;
10402
+ }
9414
10403
  newTrackCount += 1;
9415
10404
  log.info("track started", { meta: {
9416
10405
  trackId: id,
@@ -9424,7 +10413,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
9424
10413
  bbox: { ...t.bbox },
9425
10414
  ...t.label ? { label: t.label } : {}
9426
10415
  });
9427
- this.trackStore.seedSnapshotClock(id, result.timestamp);
10416
+ this.trackStore.seedSnapshotClock(id, result.timestamp, t.bbox);
9428
10417
  }
9429
10418
  this.ctx.eventBus.emit({
9430
10419
  id: `pa-${(0, node_crypto.randomUUID)()}`,
@@ -9470,6 +10459,34 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
9470
10459
  } });
9471
10460
  }
9472
10461
  this.lastActiveTrackIds.set(key, currentTrackIds);
10462
+ if (source === "pipeline" && this.stationaryRegistry) {
10463
+ const dims = this.lastFrameDimsByDevice.get(deviceId);
10464
+ if (dims && dims.w > 0 && dims.h > 0) {
10465
+ const refDiag = Math.hypot(dims.w, dims.h);
10466
+ for (const t of result.tracked) {
10467
+ const active = this.trackStore.peekActive(t.trackId);
10468
+ if (!active) continue;
10469
+ const { promote } = evaluateStationaryPromotion({
10470
+ positions: active.positions,
10471
+ referenceDiagonalPx: refDiag,
10472
+ now: result.timestamp,
10473
+ config: DEFAULT_PROMOTION_CONFIG
10474
+ });
10475
+ if (!promote) continue;
10476
+ this.promoteToStationary({
10477
+ deviceId,
10478
+ key,
10479
+ processor,
10480
+ track: t,
10481
+ firstSeen: active.firstSeen,
10482
+ label: active.label,
10483
+ frameWidth: result.frameWidth,
10484
+ frameHeight: result.frameHeight,
10485
+ timestamp: result.timestamp
10486
+ });
10487
+ }
10488
+ }
10489
+ }
9473
10490
  if (this.detailDispatcher && detailSteps && detailSteps.length > 0) {
9474
10491
  const dispatcher = this.detailDispatcher;
9475
10492
  const steps = detailSteps;
@@ -9538,7 +10555,9 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
9538
10555
  let plateCrops = 0;
9539
10556
  for (const crops of childCropsByEvent.values()) for (const c of crops) if (c.kind === "faceCrop") faceCrops += 1;
9540
10557
  else plateCrops += 1;
9541
- const snapshotTargets = this.buildSnapshotTargets(deviceId, result.tracked, result.timestamp, mediaSettings);
10558
+ const snapshotTargets = this.buildSnapshotTargets(deviceId, result.tracked, result.timestamp, mediaSettings, result.frameWidth, result.frameHeight);
10559
+ const keyFrameTrackIds = selectKeyFrameTrackIds(snapshotTargets);
10560
+ if (keyFrameTrackIds.length > 0) this.persistKeyFrames(deviceId, result.timestamp, keyFrameTrackIds, frameHandle, result.frameWidth, result.frameHeight);
9542
10561
  if (eventTargets.length > 0 || firstFrameTargets.length > 0 || snapshotTargets.length > 0) {
9543
10562
  const captureCounts = {
9544
10563
  events: eventTargets.length,
@@ -9753,14 +10772,36 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
9753
10772
  if (isFaceDetail && d.embedding !== void 0) await this.routeFaceDetail(deviceId, trackId, d, frame);
9754
10773
  else if (d.embedding !== void 0) await this.routeClipDetail(deviceId, trackId, d, frame.timestamp);
9755
10774
  else if (d.label !== void 0 && d.label.length > 0) {
9756
- if (d.className === "plate" && d.bbox !== void 0) this.overlayState.notePlateDetail(deviceId, trackId, {
9757
- x: d.bbox.x,
9758
- y: d.bbox.y,
9759
- w: d.bbox.w,
9760
- h: d.bbox.h
9761
- }, d.score, d.label, frame.timestamp);
9762
- const label = d.className === "plate" ? this.plateRecognizer?.resolveLabel(d.label, d.score) ?? d.label : d.label;
9763
- await this.applyTrackEnrichmentLabel(deviceId, trackId, label);
10775
+ if (d.className === "plate" && d.bbox !== void 0) {
10776
+ this.overlayState.notePlateDetail(deviceId, trackId, {
10777
+ x: d.bbox.x,
10778
+ y: d.bbox.y,
10779
+ w: d.bbox.w,
10780
+ h: d.bbox.h
10781
+ }, d.score, d.label, frame.timestamp);
10782
+ if (this.plateRecognizer) {
10783
+ const mediaSettings = await this.resolveDeviceMediaSettings(deviceId);
10784
+ await this.plateRecognizer.observePlateRead({
10785
+ deviceId,
10786
+ trackId,
10787
+ text: d.label,
10788
+ score: d.score,
10789
+ bbox: {
10790
+ x: d.bbox.x,
10791
+ y: d.bbox.y,
10792
+ w: d.bbox.w,
10793
+ h: d.bbox.h
10794
+ },
10795
+ timestamp: frame.timestamp,
10796
+ frameWidth: frame.frameWidth,
10797
+ frameHeight: frame.frameHeight,
10798
+ cropPadding: mediaSettings.cropPadding,
10799
+ ...frame.frameHandle !== void 0 ? { frameHandle: frame.frameHandle } : {}
10800
+ });
10801
+ }
10802
+ }
10803
+ const label = d.className === "plate" ? this.plateRecognizer?.resolveLabel(d.label, d.score) ?? null : d.label;
10804
+ if (label !== null && label !== void 0) await this.applyTrackEnrichmentLabel(deviceId, trackId, label);
9764
10805
  }
9765
10806
  } catch (err) {
9766
10807
  this.ctx.logger.warn("detail result route failed", {
@@ -9907,55 +10948,26 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
9907
10948
  await Promise.all(bests.map(async (t) => {
9908
10949
  if (!isClipObjectEmbedding(t)) return;
9909
10950
  let mediaKey;
9910
- let keyFrameMediaKey;
9911
- if (frameHandle !== void 0 && this.captureCrop && this.mediaStore) {
9912
- try {
9913
- const crop = await this.captureCrop(frameHandle, t.bbox, frameWidth, frameHeight, cropPadding);
9914
- if (crop) mediaKey = await this.mediaStore.putReplacing({
9915
- deviceId,
9916
- ownerKind: "track",
9917
- ownerId: t.trackId,
9918
- kind: "crop",
9919
- timestamp,
9920
- data: crop
9921
- });
9922
- } catch (err) {
9923
- this.ctx.logger.debug("object-embedding crop capture failed", {
9924
- tags: { deviceId },
9925
- meta: {
9926
- trackId: t.trackId,
9927
- error: require_dist.errMsg(err)
9928
- }
9929
- });
9930
- }
9931
- try {
9932
- const keyFrame = await this.captureCrop(frameHandle, {
9933
- x: 0,
9934
- y: 0,
9935
- w: frameWidth,
9936
- h: frameHeight
9937
- }, frameWidth, frameHeight, 0, KEYFRAME_NATIVE_MAX_WIDTH);
9938
- if (keyFrame) {
9939
- keyFrameMediaKey = await this.mediaStore.putReplacing({
9940
- deviceId,
9941
- ownerKind: "track",
9942
- ownerId: t.trackId,
9943
- kind: "keyFrame",
9944
- timestamp,
9945
- data: keyFrame
9946
- });
9947
- this.keyFrameKeyByTrackId.set(t.trackId, keyFrameMediaKey);
10951
+ if (frameHandle !== void 0 && this.captureCrop && this.mediaStore) try {
10952
+ const crop = await this.captureCrop(frameHandle, t.bbox, frameWidth, frameHeight, cropPadding);
10953
+ if (crop) mediaKey = await this.mediaStore.putReplacing({
10954
+ deviceId,
10955
+ ownerKind: "track",
10956
+ ownerId: t.trackId,
10957
+ kind: "crop",
10958
+ timestamp,
10959
+ data: crop
10960
+ });
10961
+ } catch (err) {
10962
+ this.ctx.logger.debug("object-embedding crop capture failed", {
10963
+ tags: { deviceId },
10964
+ meta: {
10965
+ trackId: t.trackId,
10966
+ error: require_dist.errMsg(err)
9948
10967
  }
9949
- } catch (err) {
9950
- this.ctx.logger.debug("key-frame capture failed", {
9951
- tags: { deviceId },
9952
- meta: {
9953
- trackId: t.trackId,
9954
- error: require_dist.errMsg(err)
9955
- }
9956
- });
9957
- }
10968
+ });
9958
10969
  }
10970
+ const keyFrameMediaKey = this.keyFrameKeyByTrackId.get(t.trackId);
9959
10971
  await store.upsertIfBetter({
9960
10972
  trackId: t.trackId,
9961
10973
  deviceId,
@@ -9969,6 +10981,48 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
9969
10981
  });
9970
10982
  }));
9971
10983
  }
10984
+ /**
10985
+ * Capture ONE native-resolution KEY FRAME per given track at this best-
10986
+ * detection frame and store it (`putReplacing` → one keyFrame per track).
10987
+ *
10988
+ * The full frame is cropped NATIVE-FIRST via `captureCrop`: the request is the
10989
+ * FULL frame (no padding) at `KEYFRAME_NATIVE_MAX_WIDTH`, which routes through
10990
+ * `pipelineRunner.getNativeCrop` (the decode worker's retained native surface)
10991
+ * and only falls back to the ≤640 detection frame when the native lease is
10992
+ * gone. The stored key is recorded in `keyFrameKeyByTrackId` so the face /
10993
+ * plate / object-embedding rows LINK the SAME native key frame (Design B).
10994
+ * Issued in the live-frame window so the native lease is still held. Best-
10995
+ * effort (D8) — a per-track failure is logged and never thrown.
10996
+ */
10997
+ async persistKeyFrames(deviceId, timestamp, trackIds, frameHandle, frameWidth, frameHeight) {
10998
+ const capture = this.captureCrop;
10999
+ const mediaStore = this.mediaStore;
11000
+ if (!capture || !mediaStore) return;
11001
+ const req = buildKeyFrameCaptureRequest(frameWidth, frameHeight);
11002
+ await Promise.all(trackIds.map(async (trackId) => {
11003
+ try {
11004
+ const keyFrame = await capture(frameHandle, req.bbox, frameWidth, frameHeight, req.padding, req.maxWidth);
11005
+ if (!keyFrame) return;
11006
+ const key = await mediaStore.putReplacing({
11007
+ deviceId,
11008
+ ownerKind: "track",
11009
+ ownerId: trackId,
11010
+ kind: "keyFrame",
11011
+ timestamp,
11012
+ data: keyFrame
11013
+ });
11014
+ this.keyFrameKeyByTrackId.set(trackId, key);
11015
+ } catch (err) {
11016
+ this.ctx.logger.debug("key-frame capture failed", {
11017
+ tags: { deviceId },
11018
+ meta: {
11019
+ trackId,
11020
+ error: require_dist.errMsg(err)
11021
+ }
11022
+ });
11023
+ }
11024
+ }));
11025
+ }
9972
11026
  /** Emit a `PipelineAnalyticsTrackLifecycle` event (start / update / end). */
9973
11027
  emitTrackLifecycle(payload, timestamp) {
9974
11028
  this.ctx.eventBus.emit({
@@ -10018,27 +11072,48 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
10018
11072
  ...track?.zonesVisited !== void 0 ? { zonesVisited: track.zonesVisited } : {},
10019
11073
  ...track?.totalDistance !== void 0 ? { totalDistance: track.totalDistance } : {},
10020
11074
  ...track?.positions !== void 0 ? { positionsCount: track.positions.length } : {},
11075
+ ...track?.audioLabels !== void 0 ? { audioLabels: track.audioLabels } : {},
10021
11076
  ...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {},
10022
11077
  ...t.embeddingModelId !== void 0 ? { embeddingModelId: t.embeddingModelId } : {}
10023
11078
  });
10024
11079
  this.emitTrackLifecycle(payload, timestamp);
10025
11080
  }
10026
- buildSnapshotTargets(deviceId, tracked, timestamp, media) {
11081
+ buildSnapshotTargets(deviceId, tracked, timestamp, media, frameWidth, frameHeight) {
10027
11082
  const targets = [];
10028
11083
  for (const t of tracked) {
10029
11084
  const lastSnap = this.trackStore.lastSnapshotAt(t.trackId);
10030
- const dueSnapshot = media.saveThumbnails && lastSnap > 0 && timestamp - lastSnap >= media.snapshotIntervalMs;
11085
+ const dueSnapshot = media.saveThumbnails && evaluatePeriodicSnapshot({
11086
+ lastSnapshotAt: lastSnap,
11087
+ lastSnapshotBbox: this.trackStore.lastSnapshotBbox(t.trackId),
11088
+ currentBbox: t.bbox,
11089
+ now: timestamp,
11090
+ frameWidth,
11091
+ frameHeight,
11092
+ intervalMs: media.snapshotIntervalMs,
11093
+ movementThreshold: media.snapshotMovementThreshold,
11094
+ maxIdleMs: media.snapshotMaxIdleMs
11095
+ }).capture;
10031
11096
  const isNewBest = this.bestFrameTracker.observe(t.trackId, t.confidence, timestamp);
10032
11097
  this.maybeEmitTrackLifecycleUpdate(deviceId, t, timestamp, isNewBest);
10033
- if (!dueSnapshot && !isNewBest) continue;
11098
+ const plan = planPeriodicMedia({
11099
+ saveThumbnails: media.saveThumbnails,
11100
+ dueSnapshot,
11101
+ isNewBest,
11102
+ lastFrameAt: this.lastFrameAtByTrack.get(t.trackId) ?? 0,
11103
+ now: timestamp,
11104
+ intervalMs: media.snapshotIntervalMs
11105
+ });
11106
+ if (plan.rollingLastFrame) this.lastFrameAtByTrack.set(t.trackId, timestamp);
11107
+ if (plan.appendSnapshot) this.trackStore.markSnapshotPending(t.trackId, timestamp, t.bbox);
11108
+ if (!plan.appendSnapshot && !plan.rollingLastFrame && !plan.bestThumbnail) continue;
10034
11109
  targets.push({
10035
11110
  trackId: t.trackId,
10036
11111
  timestamp,
10037
11112
  bbox: { ...t.bbox },
10038
11113
  ...t.label ? { label: t.label } : {},
10039
- appendSnapshot: dueSnapshot,
10040
- rollingLastFrame: dueSnapshot,
10041
- bestThumbnail: isNewBest
11114
+ appendSnapshot: plan.appendSnapshot,
11115
+ rollingLastFrame: plan.rollingLastFrame,
11116
+ bestThumbnail: plan.bestThumbnail
10042
11117
  });
10043
11118
  }
10044
11119
  return targets;
@@ -10094,6 +11169,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
10094
11169
  atMs: timestamp
10095
11170
  });
10096
11171
  await this.eventStore.insertAudio(ev);
11172
+ this.trackStore?.addAudioLabelEpisode(deviceId, route.className, topClassification.score, timestamp);
10097
11173
  this.ctx.eventBus.emit({
10098
11174
  id: `pa-${ev.id}`,
10099
11175
  timestamp: new Date(ev.timestamp),
@@ -10311,6 +11387,11 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
10311
11387
  const peak = await this.eventStore?.peakForTrack(t.trackId);
10312
11388
  if (peak) {
10313
11389
  endBestEventId = peak.bestEventId;
11390
+ const dims = this.lastFrameDimsByDevice.get(t.deviceId);
11391
+ const staticMetrics = dims ? computeStaticTrackMetrics(t.positions.map((p) => ({
11392
+ x: p.x,
11393
+ y: p.y
11394
+ })), Math.hypot(dims.w, dims.h)) : void 0;
10314
11395
  const { importance, reason } = computeImportance({
10315
11396
  peakConfidence: trackerPeak?.confidence ?? peak.peakConfidence,
10316
11397
  className: t.className,
@@ -10318,7 +11399,11 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
10318
11399
  peakBboxAreaFrac: peak.peakBboxAreaFrac,
10319
11400
  totalDistance: t.totalDistance,
10320
11401
  zonesVisited: t.zonesVisited,
10321
- ...t.label !== void 0 ? { label: t.label } : {}
11402
+ ...t.label !== void 0 ? { label: t.label } : {},
11403
+ ...staticMetrics ? {
11404
+ netDisplacementFrac: staticMetrics.netDisplacementFrac,
11405
+ pathSpanFrac: staticMetrics.pathSpanFrac
11406
+ } : {}
10322
11407
  });
10323
11408
  endImportance = importance;
10324
11409
  endImportanceReason = reason;
@@ -10333,6 +11418,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
10333
11418
  }
10334
11419
  this.bestFrameTracker.delete(t.trackId);
10335
11420
  this.objectEmbeddingBestSelector.delete(t.trackId);
11421
+ this.lastFrameAtByTrack.delete(t.trackId);
10336
11422
  this.detailDispatcher?.onTrackEnded(t.deviceId, t.trackId);
10337
11423
  this.overlayState.onTrackEnded(t.deviceId, t.trackId);
10338
11424
  if ((this.trackStore?.getActive(t.deviceId).length ?? 0) === 0) {
@@ -10377,6 +11463,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
10377
11463
  positionsCount: t.positions.length,
10378
11464
  ...endImportance !== void 0 ? { importance: endImportance } : {},
10379
11465
  ...endImportanceReason !== void 0 ? { importanceReason: endImportanceReason } : {},
11466
+ ...t.audioLabels !== void 0 ? { audioLabels: t.audioLabels } : {},
10380
11467
  ...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {},
10381
11468
  ...endBestEventId !== void 0 ? { bestEventId: endBestEventId } : {}
10382
11469
  });
@@ -10539,6 +11626,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
10539
11626
  for (const k of this.processors.keys()) if (k.startsWith(prefix)) this.processors.delete(k);
10540
11627
  for (const k of this.lastActiveTrackIds.keys()) if (k.startsWith(prefix)) this.lastActiveTrackIds.delete(k);
10541
11628
  for (const k of this.dropoutSkipsByKey.keys()) if (k.startsWith(prefix)) this.dropoutSkipsByKey.delete(k);
11629
+ this.lastFrameDimsByDevice.delete(deviceId);
10542
11630
  }
10543
11631
  /** Apply a mutation to every live source-processor of a device (zones/rules
10544
11632
  * are device-level and must reach all sources). */
@@ -10546,6 +11634,57 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
10546
11634
  const prefix = `${deviceId}:`;
10547
11635
  for (const [k, p] of this.processors) if (k.startsWith(prefix)) fn(p);
10548
11636
  }
11637
+ /**
11638
+ * Turn a parked track into a stationary-registry entry and tear the track
11639
+ * down WITHOUT firing an 'end' key-event / importance scoring / media flush
11640
+ * (a parked object is not a highlight; the durable record is the entry). The
11641
+ * tracker + store forget the track so its detections stop re-spawning tracks,
11642
+ * and the registry suppresses them from the next frame on.
11643
+ */
11644
+ promoteToStationary(input) {
11645
+ const { deviceId, key, processor, track, firstSeen, label, frameWidth, frameHeight, timestamp } = input;
11646
+ const keyFrameMediaKey = this.keyFrameKeyByTrackId.get(track.trackId);
11647
+ const entry = {
11648
+ id: (0, node_crypto.randomUUID)(),
11649
+ deviceId,
11650
+ className: track.className,
11651
+ bbox: { ...track.bbox },
11652
+ frameWidth,
11653
+ frameHeight,
11654
+ firstSeenAt: firstSeen,
11655
+ becameStationaryAt: timestamp,
11656
+ lastConfirmedAt: timestamp,
11657
+ sourceTrackId: track.trackId,
11658
+ ...label !== void 0 ? { label } : {},
11659
+ ...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {}
11660
+ };
11661
+ this.stationaryRegistry?.promote(entry);
11662
+ processor.dropTrack(track.trackId);
11663
+ this.trackStore?.dropActive(track.trackId);
11664
+ const faceEnd = this.faceRecognizer?.onTrackEnd(deviceId, track.trackId);
11665
+ const dropKeyFrame = () => {
11666
+ this.keyFrameKeyByTrackId.delete(track.trackId);
11667
+ };
11668
+ if (faceEnd) faceEnd.finally(dropKeyFrame);
11669
+ else dropKeyFrame();
11670
+ this.plateRecognizer?.onTrackEnd(deviceId, track.trackId);
11671
+ this.bestFrameTracker.delete(track.trackId);
11672
+ this.objectEmbeddingBestSelector.delete(track.trackId);
11673
+ this.lastFrameAtByTrack.delete(track.trackId);
11674
+ this.trackLifecycleUpdateMem.delete(track.trackId);
11675
+ this.detailDispatcher?.onTrackEnded(deviceId, track.trackId);
11676
+ this.overlayState.onTrackEnded(deviceId, track.trackId);
11677
+ this.lastActiveTrackIds.get(key)?.delete(track.trackId);
11678
+ this.ctx.logger.info("track promoted to stationary", {
11679
+ tags: { deviceId },
11680
+ meta: {
11681
+ trackId: track.trackId,
11682
+ className: track.className,
11683
+ entryId: entry.id,
11684
+ ...label ? { label } : {}
11685
+ }
11686
+ });
11687
+ }
10549
11688
  async getOrCreateProcessor(deviceId, source) {
10550
11689
  const key = this.procKey(deviceId, source);
10551
11690
  let p = this.processors.get(key);
@@ -10571,6 +11710,18 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
10571
11710
  cooldownSec,
10572
11711
  minTrackAgeMs: trk.minTrackAgeMs
10573
11712
  }, source);
11713
+ if (source === "pipeline" && this.stationaryRegistry) {
11714
+ const registry = this.stationaryRegistry;
11715
+ p.setStationaryGate({ filter: (input) => registry.filter({
11716
+ deviceId,
11717
+ detections: input.detections.map((d) => ({
11718
+ bbox: d.bbox,
11719
+ className: d.class
11720
+ })),
11721
+ frameWidth: input.frameWidth,
11722
+ frameHeight: input.frameHeight
11723
+ }) });
11724
+ }
10574
11725
  this.processors.set(key, p);
10575
11726
  }
10576
11727
  return p;
@@ -10582,6 +11733,16 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
10582
11733
  * forward updates to the per-device FrameProcessor so a rule change
10583
11734
  * applies to the very next frame even when frames stop briefly
10584
11735
  * (e.g. during binding flips).
11736
+ *
11737
+ * RECONCILE: the push channel behind `subscribe` (`device.state-changed`
11738
+ * via `live.onEvent`) does not reliably reach a forked addon child — a
11739
+ * zone created AFTER the proxy's cold read stayed invisible until the
11740
+ * addon respawned (live-diagnosed on device 617, 2026-07-16: zone slice
11741
+ * populated hub-side, `zones: []` in every snapshot). Events are lossy
11742
+ * telemetry (D8); the durable channel is RPC + reconcile — so each
11743
+ * proxy also refreshes its two slices on a slow timer. `refresh()`
11744
+ * round-trips `deviceState.getCapSlice` and fans out through the SAME
11745
+ * subscribe callbacks above, so a zone edit lands within one interval.
10585
11746
  */
10586
11747
  async ensureProxy(deviceId) {
10587
11748
  const cached = this.proxies.get(deviceId);
@@ -10590,13 +11751,22 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
10590
11751
  const proxy = await this.ctx.api.deviceManager ? await this.ctx.fetchDevice(deviceId) : null;
10591
11752
  if (!proxy) return null;
10592
11753
  this.proxies.set(deviceId, proxy);
10593
- const unsubs = [proxy.state.zones.subscribe((slice) => {
10594
- const zones = slice?.zones ?? [];
10595
- this.forEachDeviceProcessor(deviceId, (p) => p.setZones(zones));
10596
- }), proxy.state.zoneRules.subscribe((slice) => {
10597
- const rules = slice?.detection ?? [];
10598
- this.forEachDeviceProcessor(deviceId, (p) => p.setDetectionRules(rules));
10599
- })];
11754
+ const reconcile = setInterval(() => {
11755
+ proxy.state.zones.refresh().catch(() => void 0);
11756
+ proxy.state.zoneRules.refresh().catch(() => void 0);
11757
+ }, ZONE_SLICE_RECONCILE_MS);
11758
+ reconcile.unref?.();
11759
+ const unsubs = [
11760
+ proxy.state.zones.subscribe((slice) => {
11761
+ const zones = slice?.zones ?? [];
11762
+ this.forEachDeviceProcessor(deviceId, (p) => p.setZones(zones));
11763
+ }),
11764
+ proxy.state.zoneRules.subscribe((slice) => {
11765
+ const rules = slice?.detection ?? [];
11766
+ this.forEachDeviceProcessor(deviceId, (p) => p.setDetectionRules(rules));
11767
+ }),
11768
+ () => clearInterval(reconcile)
11769
+ ];
10600
11770
  this.proxyUnsubs.set(deviceId, unsubs);
10601
11771
  return proxy;
10602
11772
  } catch (err) {
@@ -10628,6 +11798,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
10628
11798
  }
10629
11799
  async clearTracks(input) {
10630
11800
  this.trackStore?.clearDevice(input.deviceId);
11801
+ this.stationaryRegistry?.clearDevice(input.deviceId);
10631
11802
  this.overlayState.clearDevice(input.deviceId);
10632
11803
  this.overlaySynthesisWarnAt.delete(input.deviceId);
10633
11804
  const prefix = `${input.deviceId}:`;
@@ -10929,6 +12100,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
10929
12100
  * Enrolled gallery + identity media are exempt.
10930
12101
  */
10931
12102
  async wipeAllAnalytics(input) {
12103
+ await this.stationaryRegistry?.clearDevice(input.deviceId);
10932
12104
  return this.pruneTracksBefore({
10933
12105
  deviceId: input.deviceId,
10934
12106
  cutoffMs: Date.now()
@@ -10991,13 +12163,19 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
10991
12163
  }
10992
12164
  return this.readEventThumbnail(id);
10993
12165
  }
10994
- async readEventThumbnail(eventId) {
10995
- const files = await (this.mediaStore?.listByOwner("event", eventId) ?? Promise.resolve([]));
10996
- const chosen = files.find((f) => f.kind === "crop") ?? files.find((f) => f.kind === "fullFrameBoxed") ?? files[0];
10997
- if (!chosen) return null;
12166
+ async readEventThumbnail(id) {
12167
+ const eventFiles = await (this.mediaStore?.listByOwner("event", id) ?? Promise.resolve([]));
12168
+ const chosenEvent = eventFiles.find((f) => f.kind === "crop") ?? eventFiles.find((f) => f.kind === "fullFrameBoxed") ?? eventFiles[0];
12169
+ if (chosenEvent) return {
12170
+ bytes: Buffer.from(chosenEvent.base64, "base64"),
12171
+ key: chosenEvent.key
12172
+ };
12173
+ const trackFiles = await (this.mediaStore?.listByOwner("track", id) ?? Promise.resolve([]));
12174
+ 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];
12175
+ if (!chosenTrack) return null;
10998
12176
  return {
10999
- bytes: Buffer.from(chosen.base64, "base64"),
11000
- key: chosen.key
12177
+ bytes: Buffer.from(chosenTrack.base64, "base64"),
12178
+ key: chosenTrack.key
11001
12179
  };
11002
12180
  }
11003
12181
  /**
@@ -11090,6 +12268,32 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
11090
12268
  unit: "s",
11091
12269
  displayScale: 1e3
11092
12270
  },
12271
+ {
12272
+ type: "slider",
12273
+ key: "snapshotMovementThreshold",
12274
+ label: "Snapshot movement gate",
12275
+ 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.",
12276
+ min: 0,
12277
+ max: .15,
12278
+ step: .005,
12279
+ default: .03,
12280
+ showValue: true,
12281
+ unit: "%",
12282
+ displayScale: .01
12283
+ },
12284
+ {
12285
+ type: "slider",
12286
+ key: "snapshotMaxIdleMs",
12287
+ label: "Snapshot max idle",
12288
+ description: "Force a snapshot for a stationary but still-present track after this long without one, so its filmstrip is never empty.",
12289
+ min: 5e3,
12290
+ max: 12e4,
12291
+ step: 5e3,
12292
+ default: 3e4,
12293
+ showValue: true,
12294
+ unit: "s",
12295
+ displayScale: 1e3
12296
+ },
11093
12297
  {
11094
12298
  type: "select",
11095
12299
  key: "mediaAttachPolicy",