@camstack/addon-post-analysis 1.1.28 → 1.1.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,6 @@
1
- import { S as string, _ as createEvent, b as number, c as nodePin, d as videoclipsCapability, f as zoneAnalyticsCapability, g as EventCategory, h as DeviceType, i as cosineSimilarity, l as pipelineAnalyticsCapability, m as BaseAddon, n as addonWidgetsSourceCapability, o as faceGalleryCapability, p as errMsg, r as audioMetricsCapability, t as EVENT_PAD_MS, u as plateGalleryCapability, v as hydrateSchema, x as object, y as boolean } from "../dist-Blpsv-M0.mjs";
1
+ import { S as EventCategory, _ as hydrateSchema, b as object, c as nodePin, d as videoclipsCapability, f as zoneAnalyticsCapability, g as createEvent, h as DeviceType, i as cosineSimilarity, l as pipelineAnalyticsCapability, m as BaseAddon, n as addonWidgetsSourceCapability, o as faceGalleryCapability, p as errMsg, r as audioMetricsCapability, t as EVENT_PAD_MS, u as plateGalleryCapability, v as boolean, x as string, y as number } from "../dist-yPsKFcJL.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import sharp from "sharp";
4
- import { FrameRingReaderCache } from "@camstack/shm-ring";
5
4
  //#region src/pipeline-analytics/videoclips-provider.ts
6
5
  var SOURCE = "analytics";
7
6
  function clipIdFor(eventId, startMs, endMs) {
@@ -146,6 +145,17 @@ var WEIGHT_IDENTITY = .05;
146
145
  var DWELL_FULL_MS = 6e4;
147
146
  /** Peak bbox area (as a fraction of frame area) that saturates the size term. */
148
147
  var SIZE_FULL = .15;
148
+ /** A track whose net centroid displacement stays below this fraction of the
149
+ * frame diagonal over its whole life counts as stationary on the net signal. */
150
+ var STATIC_DISPLACEMENT_FRAC = .02;
151
+ /** ...and whose entire centroid path fits within this fraction of the frame
152
+ * diagonal counts as stationary on the span signal. Both must hold. */
153
+ var STATIC_SPAN_FRAC = .04;
154
+ /** Importance multiplier applied to a stationary track that has NO resolved
155
+ * identity — strongly demotes "ghost" detections on static objects (a pile of
156
+ * clothes read as a person, a kitchen object read as an animal) below the
157
+ * key-event threshold. Identified tracks (face/plate) are never suppressed. */
158
+ var STATIC_IMPORTANCE_MULTIPLIER = .1;
149
159
  var CLASS_RANK_VEHICLE = .8;
150
160
  var CLASS_RANK_ANIMAL = .5;
151
161
  var CLASS_RANK_DEFAULT = .25;
@@ -220,6 +230,11 @@ function computeImportance(input) {
220
230
  sum += term.value;
221
231
  if (term.value > best.value) best = term;
222
232
  }
233
+ const identified = input.label !== void 0 && input.label.length > 0;
234
+ if (input.netDisplacementFrac !== void 0 && input.pathSpanFrac !== void 0 && input.netDisplacementFrac < .02 && input.pathSpanFrac < .04 && !identified) return {
235
+ importance: clamp01(sum * STATIC_IMPORTANCE_MULTIPLIER),
236
+ reason: best.reason
237
+ };
223
238
  return {
224
239
  importance: clamp01(sum),
225
240
  reason: best.reason
@@ -613,6 +628,20 @@ function normalizePlate(text) {
613
628
  return text.toUpperCase().replace(/[^A-Z0-9]/g, "");
614
629
  }
615
630
  /**
631
+ * Quality gate for a raw OCR plate read BEFORE it becomes a track label or a
632
+ * gallery row. Distant/oblique parked plates produce junk reads ("N", "Idag",
633
+ * "@em") that otherwise flood track labels (live-observed on the parking
634
+ * camera, 2026-07-16). A plausible European plate is ≥{@link PLATE_MIN_LENGTH}
635
+ * alphanumerics and mixes letters AND digits; anything else — or a read below
636
+ * {@link PLATE_MIN_SCORE} — is discarded, not stored.
637
+ */
638
+ function isPlausiblePlateRead(text, score) {
639
+ if (score < .4) return false;
640
+ const norm = normalizePlate(text);
641
+ if (norm.length < 4) return false;
642
+ return /[0-9]/.test(norm) && /[A-Z]/.test(norm);
643
+ }
644
+ /**
616
645
  * Fold characters that OCR routinely confuses onto a single canonical symbol, so
617
646
  * "AB0" and "ABO" compare as equal. Applied to BOTH operands of a distance
618
647
  * comparison only — never stored. Conservative set covering the common Latin
@@ -1097,7 +1126,7 @@ var MAX_PATH_LENGTH = 300;
1097
1126
  function clamp(value, min, max) {
1098
1127
  return Math.max(min, Math.min(max, value));
1099
1128
  }
1100
- function iou$1(a, b) {
1129
+ function iou$2(a, b) {
1101
1130
  const ax1 = a.x, ay1 = a.y, ax2 = a.x + a.w, ay2 = a.y + a.h;
1102
1131
  const bx1 = b.x, by1 = b.y, bx2 = b.x + b.w, by2 = b.y + b.h;
1103
1132
  const ix1 = Math.max(ax1, bx1), iy1 = Math.max(ay1, by1);
@@ -1152,7 +1181,7 @@ var SortTracker = class {
1152
1181
  */
1153
1182
  looseMatch(track, det) {
1154
1183
  if (track.class !== det.class) return false;
1155
- if (iou$1(track.bbox, det.bbox) >= this.config.rescueIouThreshold) return true;
1184
+ if (iou$2(track.bbox, det.bbox) >= this.config.rescueIouThreshold) return true;
1156
1185
  const tc = bboxCentroid(track.bbox);
1157
1186
  const dc = bboxCentroid(det.bbox);
1158
1187
  const dist = Math.hypot(tc.x - dc.x, tc.y - dc.y);
@@ -1180,7 +1209,7 @@ var SortTracker = class {
1180
1209
  for (let di = 0; di < detections.length; di++) {
1181
1210
  const det = detections[di];
1182
1211
  if (this.config.classGating && track.class !== det.class) continue;
1183
- const score = iou$1(pbox, det.bbox);
1212
+ const score = iou$2(pbox, det.bbox);
1184
1213
  if (score >= this.config.iouThreshold) pairs.push({
1185
1214
  track,
1186
1215
  detIdx: di,
@@ -1205,7 +1234,7 @@ var SortTracker = class {
1205
1234
  rescuePairs.push({
1206
1235
  track,
1207
1236
  detIdx: di,
1208
- score: iou$1(track.bbox, det.bbox)
1237
+ score: iou$2(track.bbox, det.bbox)
1209
1238
  });
1210
1239
  }
1211
1240
  }
@@ -1270,7 +1299,7 @@ var SortTracker = class {
1270
1299
  if (!lost.resurrectable) continue;
1271
1300
  if (timestamp - lost.lostAt > this.config.resurrectionWindowMs) continue;
1272
1301
  if (!this.looseMatch(lost, det)) continue;
1273
- const score = iou$1(lost.bbox, det.bbox);
1302
+ const score = iou$2(lost.bbox, det.bbox);
1274
1303
  if (score > bestScore) {
1275
1304
  best = lost;
1276
1305
  bestScore = score;
@@ -1331,6 +1360,16 @@ var SortTracker = class {
1331
1360
  path: [...t.path]
1332
1361
  }));
1333
1362
  }
1363
+ /**
1364
+ * Remove a track from BOTH the live and graveyard sets so it neither emits
1365
+ * nor resurrects. Used when a track is PROMOTED to a stationary-object
1366
+ * registry entry: the registry now owns that parked object, and the tracker
1367
+ * must forget it so the object's detections don't re-spawn a duplicate track.
1368
+ */
1369
+ dropTrack(trackId) {
1370
+ this.tracks = this.tracks.filter((t) => t.id !== trackId);
1371
+ this.lostTracks = this.lostTracks.filter((t) => t.id !== trackId);
1372
+ }
1334
1373
  getActiveTracks() {
1335
1374
  return this.tracks;
1336
1375
  }
@@ -1744,6 +1783,9 @@ var FrameProcessor = class {
1744
1783
  */
1745
1784
  detectionRules;
1746
1785
  zoneEngine = new ZoneEngine();
1786
+ /** Optional stationary-object gate (parked-object suppression). Null until
1787
+ * the addon wires it via {@link setStationaryGate}. */
1788
+ stationaryGate = null;
1747
1789
  constructor(deviceId, trackerConfig = {}, stateConfig = {}, emitterConfig = {}, source = "pipeline") {
1748
1790
  this.deviceId = deviceId;
1749
1791
  this.source = source;
@@ -1759,6 +1801,16 @@ var FrameProcessor = class {
1759
1801
  setDetectionRules(rules) {
1760
1802
  this.detectionRules = rules;
1761
1803
  }
1804
+ /** Wire (or clear) the stationary-object gate. Called by the addon per
1805
+ * device so parked-object suppression applies from the next frame. */
1806
+ setStationaryGate(gate) {
1807
+ this.stationaryGate = gate;
1808
+ }
1809
+ /** Forget a track in the underlying tracker (used when the addon promotes it
1810
+ * to a stationary-object registry entry). */
1811
+ dropTrack(trackId) {
1812
+ this.tracker.dropTrack(trackId);
1813
+ }
1762
1814
  process(input) {
1763
1815
  const { timestamp, frame } = input;
1764
1816
  const frameWidth = frame.width;
@@ -1837,7 +1889,17 @@ var FrameProcessor = class {
1837
1889
  }
1838
1890
  const { passed } = this.zoneEngine.filterDetections(flatDetections, this.zones, this.detectionRules, frameWidth, frameHeight, (fd) => fd.detection.class);
1839
1891
  const filteredDetections = passed.map((fd) => fd.detection);
1840
- const trackedDetections = this.tracker.update(filteredDetections, timestamp);
1892
+ const gate = this.stationaryGate ? this.stationaryGate.filter({
1893
+ detections: filteredDetections,
1894
+ frameWidth,
1895
+ frameHeight
1896
+ }) : {
1897
+ suppressedIndices: /* @__PURE__ */ new Set(),
1898
+ confirmed: [],
1899
+ wokenEntryIds: []
1900
+ };
1901
+ const trackerInput = gate.suppressedIndices.size > 0 ? filteredDetections.filter((_, i) => !gate.suppressedIndices.has(i)) : filteredDetections;
1902
+ const trackedDetections = this.tracker.update(trackerInput, timestamp);
1841
1903
  const objectStates = this.stateAnalyzer.analyze(trackedDetections, timestamp);
1842
1904
  const rawEvents = this.eventEmitter.emit(trackedDetections, objectStates, [], [], String(this.deviceId));
1843
1905
  const zonesByTrack = /* @__PURE__ */ new Map();
@@ -1919,7 +1981,9 @@ var FrameProcessor = class {
1919
1981
  frameHeight,
1920
1982
  tracked,
1921
1983
  objectEvents,
1922
- rawTrackedDetections: trackedDetections
1984
+ rawTrackedDetections: trackedDetections,
1985
+ stationaryConfirmed: gate.confirmed,
1986
+ stationaryWoken: gate.wokenEntryIds
1923
1987
  };
1924
1988
  }
1925
1989
  };
@@ -1996,6 +2060,7 @@ function buildTrackLifecyclePayload(input) {
1996
2060
  ...input.positionsCount !== void 0 ? { positionsCount: input.positionsCount } : {},
1997
2061
  ...input.importance !== void 0 ? { importance: input.importance } : {},
1998
2062
  ...input.importanceReason !== void 0 ? { importanceReason: input.importanceReason } : {},
2063
+ ...input.audioLabels !== void 0 && input.audioLabels.length > 0 ? { audioLabels: input.audioLabels } : {},
1999
2064
  ...input.embeddingId !== void 0 ? { embeddingId: input.embeddingId } : {},
2000
2065
  ...input.embeddingModelId !== void 0 ? { embeddingModelId: input.embeddingModelId } : {},
2001
2066
  ...hasMedia ? { media } : {}
@@ -2112,6 +2177,583 @@ function resolveSearchThumbnailUrl(input) {
2112
2177
  return `${input.baseUrl}/${encodeURIComponent(id)}`;
2113
2178
  }
2114
2179
  //#endregion
2180
+ //#region src/pipeline-analytics/pipeline/static-track-gate.ts
2181
+ /**
2182
+ * Net displacement + path span for a track's centroid path, normalized to
2183
+ * `referenceDiagonalPx`. Returns undefined when there is nothing meaningful to
2184
+ * measure (fewer than two points, or a degenerate ≤0 reference) so the caller
2185
+ * leaves the importance score untouched.
2186
+ */
2187
+ function computeStaticTrackMetrics(centroids, referenceDiagonalPx) {
2188
+ if (!(referenceDiagonalPx > 0) || centroids.length < 2) return void 0;
2189
+ const first = centroids[0];
2190
+ const last = centroids[centroids.length - 1];
2191
+ const netDisplacementFrac = Math.hypot(last.x - first.x, last.y - first.y) / referenceDiagonalPx;
2192
+ let minX = Infinity;
2193
+ let minY = Infinity;
2194
+ let maxX = -Infinity;
2195
+ let maxY = -Infinity;
2196
+ for (const c of centroids) {
2197
+ if (c.x < minX) minX = c.x;
2198
+ if (c.x > maxX) maxX = c.x;
2199
+ if (c.y < minY) minY = c.y;
2200
+ if (c.y > maxY) maxY = c.y;
2201
+ }
2202
+ return {
2203
+ netDisplacementFrac,
2204
+ pathSpanFrac: Math.hypot(maxX - minX, maxY - minY) / referenceDiagonalPx
2205
+ };
2206
+ }
2207
+ /** Average bbox diagonal (px) across a track's positions — the scale reference
2208
+ * when frame dimensions aren't available. Returns 0 for an empty list. */
2209
+ function averageBboxDiagonal(boxes) {
2210
+ if (boxes.length === 0) return 0;
2211
+ let sum = 0;
2212
+ for (const b of boxes) sum += Math.hypot(b.w, b.h);
2213
+ return sum / boxes.length;
2214
+ }
2215
+ //#endregion
2216
+ //#region src/pipeline-analytics/pipeline/stationary/stationary-types.ts
2217
+ function entryToView(e) {
2218
+ return {
2219
+ id: e.id,
2220
+ className: e.className,
2221
+ bbox: { ...e.bbox },
2222
+ frameWidth: e.frameWidth,
2223
+ frameHeight: e.frameHeight,
2224
+ firstSeenAt: e.firstSeenAt,
2225
+ becameStationaryAt: e.becameStationaryAt,
2226
+ lastConfirmedAt: e.lastConfirmedAt,
2227
+ ...e.label !== void 0 ? { label: e.label } : {},
2228
+ ...e.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: e.keyFrameMediaKey } : {}
2229
+ };
2230
+ }
2231
+ /** IoU at/above which a detection is "the same parked object" → suppress its
2232
+ * spawn and refresh the entry's `lastConfirmedAt`. Matches decision #2 (0.6). */
2233
+ var SUPPRESS_IOU = .6;
2234
+ /** Centroid move (fraction of the frame diagonal) beyond which a near
2235
+ * same-class detection means the parked object actually MOVED → wake. */
2236
+ var WAKE_MOVE_FRAC = .08;
2237
+ /** How near (fraction of frame diagonal) a same-class detection's centroid must
2238
+ * be to an entry to be considered "this entry's object" when testing for a
2239
+ * wake. Keeps an unrelated object elsewhere in the frame from waking it. */
2240
+ var WAKE_SEARCH_FRAC = .5;
2241
+ /**
2242
+ * Look-back window over which a track must have stayed put to be PROMOTED. A
2243
+ * car that drives in then parks has a large whole-life displacement but a tiny
2244
+ * last-`windowMs` displacement — so promotion is judged on the recent window,
2245
+ * not the full path. Also the minimum age (the track must have EXISTED this
2246
+ * long) so a freshly-spawned static blob isn't promoted instantly.
2247
+ */
2248
+ var PROMOTION_WINDOW_MS = 3e4;
2249
+ var DEFAULT_MATCH_CONFIG = {
2250
+ suppressIou: SUPPRESS_IOU,
2251
+ wakeMoveFrac: WAKE_MOVE_FRAC,
2252
+ wakeSearchFrac: WAKE_SEARCH_FRAC
2253
+ };
2254
+ //#endregion
2255
+ //#region src/pipeline-analytics/pipeline/stationary/stationary-match.ts
2256
+ /**
2257
+ * stationary-match — PURE geometry for the stationary-object registry.
2258
+ *
2259
+ * Two decisions, both unit-testable in isolation:
2260
+ *
2261
+ * 1. `partitionDetectionsAgainstRegistry` — given the current registry entries
2262
+ * and this frame's (zone-filtered) detections, decides which detections are
2263
+ * suppressed (they keep confirming a known parked object → no track spawns),
2264
+ * which entries are confirmed present, and which entries WOKE (their object
2265
+ * moved → retire the entry and let the detection spawn a normal track).
2266
+ *
2267
+ * 2. `evaluateStationaryPromotion` — given a track's recent centroid path,
2268
+ * decides whether it has stayed put long enough to become a stationary
2269
+ * entry. Reuses the static-track-gate metrics (net displacement + path span
2270
+ * normalised to the frame diagonal) over the recent look-back window.
2271
+ */
2272
+ /** Default promotion tunables — static thresholds shared with the key-event
2273
+ * static gate so "stationary" means the same thing in both places. */
2274
+ var DEFAULT_PROMOTION_CONFIG = {
2275
+ windowMs: PROMOTION_WINDOW_MS,
2276
+ netFracMax: STATIC_DISPLACEMENT_FRAC,
2277
+ spanFracMax: STATIC_SPAN_FRAC,
2278
+ minPoints: 4
2279
+ };
2280
+ function iou$1(a, b) {
2281
+ const ax2 = a.x + a.w;
2282
+ const ay2 = a.y + a.h;
2283
+ const bx2 = b.x + b.w;
2284
+ const by2 = b.y + b.h;
2285
+ const ix1 = Math.max(a.x, b.x);
2286
+ const iy1 = Math.max(a.y, b.y);
2287
+ const ix2 = Math.min(ax2, bx2);
2288
+ const iy2 = Math.min(ay2, by2);
2289
+ const inter = Math.max(0, ix2 - ix1) * Math.max(0, iy2 - iy1);
2290
+ const union = a.w * a.h + b.w * b.h - inter;
2291
+ return union > 0 ? inter / union : 0;
2292
+ }
2293
+ function centroid(b) {
2294
+ return {
2295
+ x: b.x + b.w / 2,
2296
+ y: b.y + b.h / 2
2297
+ };
2298
+ }
2299
+ function diagonalOf(width, height) {
2300
+ return Math.hypot(width, height);
2301
+ }
2302
+ /**
2303
+ * Decide, per stationary entry, whether the current frame confirms it, wakes
2304
+ * it, or misses it (no matching detection — leave it for the TTL sweep).
2305
+ *
2306
+ * Per entry, over same-class detections:
2307
+ * - best IoU ≥ `suppressIou` → SUPPRESS the best-overlap detection (it is the
2308
+ * parked object, unmoved) and mark the entry confirmed.
2309
+ * - else if a same-class detection sits within `wakeSearchFrac × diag` of the
2310
+ * entry centroid but has moved > `wakeMoveFrac × diag` → WAKE the entry (the
2311
+ * object slid out of its parked box). The detection is NOT suppressed, so it
2312
+ * spawns a fresh moving track.
2313
+ * - else → MISS (occlusion / brief absence): neither suppress nor wake.
2314
+ *
2315
+ * A detection can suppress at most one spawn even if it overlaps two entries
2316
+ * (`suppressedIndices` is a set).
2317
+ */
2318
+ function partitionDetectionsAgainstRegistry(input) {
2319
+ const { entries, detections, referenceDiagonalPx, config } = input;
2320
+ const suppressed = /* @__PURE__ */ new Set();
2321
+ const confirmed = [];
2322
+ const woken = [];
2323
+ const diag = referenceDiagonalPx;
2324
+ for (const entry of entries) {
2325
+ const ec = centroid(entry.bbox);
2326
+ let bestIou = 0;
2327
+ let bestIdx = -1;
2328
+ let wakeCandidate = false;
2329
+ for (let di = 0; di < detections.length; di++) {
2330
+ const det = detections[di];
2331
+ if (det.className !== entry.className) continue;
2332
+ const o = iou$1(entry.bbox, det.bbox);
2333
+ if (o > bestIou) {
2334
+ bestIou = o;
2335
+ bestIdx = di;
2336
+ }
2337
+ if (diag > 0) {
2338
+ const dc = centroid(det.bbox);
2339
+ const dist = Math.hypot(dc.x - ec.x, dc.y - ec.y);
2340
+ if (dist <= config.wakeSearchFrac * diag && dist > config.wakeMoveFrac * diag) wakeCandidate = true;
2341
+ }
2342
+ }
2343
+ if (bestIou >= config.suppressIou && bestIdx >= 0) {
2344
+ suppressed.add(bestIdx);
2345
+ confirmed.push({
2346
+ entryId: entry.id,
2347
+ className: entry.className,
2348
+ bbox: { ...entry.bbox }
2349
+ });
2350
+ } else if (wakeCandidate) woken.push(entry.id);
2351
+ }
2352
+ return {
2353
+ suppressedIndices: suppressed,
2354
+ confirmed,
2355
+ wokenEntryIds: woken
2356
+ };
2357
+ }
2358
+ /**
2359
+ * A track is promoted when, over the recent `windowMs`, its centroid barely
2360
+ * moved (both net displacement and path span below the static thresholds) AND
2361
+ * the track has actually EXISTED for at least `windowMs` (so a car that just
2362
+ * arrived isn't parked yet). Judging on the recent window — not the whole life
2363
+ * — is what lets a car that drove in then parked be recognised as stationary.
2364
+ */
2365
+ function evaluateStationaryPromotion(input) {
2366
+ const { positions, referenceDiagonalPx, now, config } = input;
2367
+ if (!(referenceDiagonalPx > 0) || positions.length === 0) return { promote: false };
2368
+ if (now - positions[0].timestamp < config.windowMs) return { promote: false };
2369
+ const cutoff = now - config.windowMs;
2370
+ const window = positions.filter((p) => p.timestamp >= cutoff);
2371
+ if (window.length < config.minPoints) return { promote: false };
2372
+ const metrics = computeStaticTrackMetrics(window.map((p) => ({
2373
+ x: p.x,
2374
+ y: p.y
2375
+ })), referenceDiagonalPx);
2376
+ if (metrics === void 0) return { promote: false };
2377
+ return {
2378
+ promote: metrics.netDisplacementFrac < config.netFracMax && metrics.pathSpanFrac < config.spanFracMax,
2379
+ netFrac: metrics.netDisplacementFrac,
2380
+ spanFrac: metrics.pathSpanFrac
2381
+ };
2382
+ }
2383
+ //#endregion
2384
+ //#region src/pipeline-analytics/pipeline/stationary/stationary-registry.ts
2385
+ var STATIONARY_COLLECTION = "pipeline-analytics:stationary-objects";
2386
+ var STATIONARY_COLUMNS = [
2387
+ {
2388
+ name: "id",
2389
+ type: "TEXT",
2390
+ primaryKey: true,
2391
+ notNull: true
2392
+ },
2393
+ {
2394
+ name: "deviceId",
2395
+ type: "INTEGER",
2396
+ notNull: true
2397
+ },
2398
+ {
2399
+ name: "className",
2400
+ type: "TEXT",
2401
+ notNull: true
2402
+ },
2403
+ {
2404
+ name: "bbox",
2405
+ type: "JSON"
2406
+ },
2407
+ {
2408
+ name: "frameWidth",
2409
+ type: "INTEGER"
2410
+ },
2411
+ {
2412
+ name: "frameHeight",
2413
+ type: "INTEGER"
2414
+ },
2415
+ {
2416
+ name: "firstSeenAt",
2417
+ type: "INTEGER"
2418
+ },
2419
+ {
2420
+ name: "becameStationaryAt",
2421
+ type: "INTEGER"
2422
+ },
2423
+ {
2424
+ name: "lastConfirmedAt",
2425
+ type: "INTEGER"
2426
+ },
2427
+ {
2428
+ name: "sourceTrackId",
2429
+ type: "TEXT"
2430
+ },
2431
+ {
2432
+ name: "label",
2433
+ type: "TEXT"
2434
+ },
2435
+ {
2436
+ name: "keyFrameMediaKey",
2437
+ type: "TEXT"
2438
+ }
2439
+ ];
2440
+ var STATIONARY_INDEXES = [{
2441
+ name: "idx_stationary_device",
2442
+ columns: ["deviceId"]
2443
+ }];
2444
+ var StationaryObjectRegistry = class {
2445
+ byDevice = /* @__PURE__ */ new Map();
2446
+ dirty = /* @__PURE__ */ new Set();
2447
+ store;
2448
+ logger;
2449
+ matchConfig;
2450
+ entryTtlMs;
2451
+ onChange;
2452
+ /** Latest processed-frame timestamp per device — expiry counts OBSERVED
2453
+ * time, not wall-clock. A session-dispatch camera produces no frames
2454
+ * between motion sessions; that silence is not evidence the object left,
2455
+ * so quiet minutes must not age the entries (see {@link sweep}). */
2456
+ lastFrameAtByDevice = /* @__PURE__ */ new Map();
2457
+ constructor(deps) {
2458
+ this.store = deps.store;
2459
+ this.logger = deps.logger;
2460
+ this.matchConfig = deps.matchConfig ?? DEFAULT_MATCH_CONFIG;
2461
+ this.entryTtlMs = deps.entryTtlMs ?? 3e5;
2462
+ this.onChange = deps.onChange;
2463
+ }
2464
+ static async declare(store) {
2465
+ await store.declareCollection.mutate({
2466
+ collection: STATIONARY_COLLECTION,
2467
+ columns: [...STATIONARY_COLUMNS],
2468
+ indexes: [...STATIONARY_INDEXES]
2469
+ });
2470
+ }
2471
+ /** Hydrate all persisted entries into memory (call once at boot, after
2472
+ * `declare`). Best-effort — a query failure leaves the registry empty. */
2473
+ async load() {
2474
+ try {
2475
+ const rows = await this.store.query.query({
2476
+ collection: STATIONARY_COLLECTION,
2477
+ filter: { limit: 1e5 }
2478
+ });
2479
+ for (const row of rows) {
2480
+ const entry = rowToEntry(row.id, row.data);
2481
+ if (!entry) continue;
2482
+ this.deviceMap(entry.deviceId).set(entry.id, entry);
2483
+ }
2484
+ this.logger.info("stationary registry loaded", { meta: { entries: rows.length } });
2485
+ } catch (err) {
2486
+ this.logger.warn("stationary registry load failed", { meta: { error: String(err) } });
2487
+ }
2488
+ }
2489
+ list(deviceId) {
2490
+ const m = this.byDevice.get(deviceId);
2491
+ return m ? [...m.values()] : [];
2492
+ }
2493
+ listViews(deviceId) {
2494
+ return this.list(deviceId).map(entryToView);
2495
+ }
2496
+ count(deviceId) {
2497
+ return this.byDevice.get(deviceId)?.size ?? 0;
2498
+ }
2499
+ /** Device ids that currently hold at least one parked entry — drives the
2500
+ * occupancy baseline sampler (a detached camera with parked cars still
2501
+ * gets a flat history baseline). */
2502
+ deviceIds() {
2503
+ const ids = [];
2504
+ for (const [deviceId, m] of this.byDevice) if (m.size > 0) ids.push(deviceId);
2505
+ return ids;
2506
+ }
2507
+ /** Record that a frame was processed for a device — advances the OBSERVED
2508
+ * clock that drives entry expiry in {@link sweep}. */
2509
+ noteFrame(deviceId, timestamp) {
2510
+ if (timestamp > (this.lastFrameAtByDevice.get(deviceId) ?? 0)) this.lastFrameAtByDevice.set(deviceId, timestamp);
2511
+ }
2512
+ /**
2513
+ * Per-frame gate: partition this frame's detections against the device's
2514
+ * entries. PURE with respect to registry state — apply the outcome with
2515
+ * {@link applyFrameOutcome} once the frame result is assembled.
2516
+ */
2517
+ filter(input) {
2518
+ const entries = this.list(input.deviceId);
2519
+ if (entries.length === 0) return {
2520
+ suppressedIndices: /* @__PURE__ */ new Set(),
2521
+ confirmed: [],
2522
+ wokenEntryIds: []
2523
+ };
2524
+ return partitionDetectionsAgainstRegistry({
2525
+ entries,
2526
+ detections: input.detections,
2527
+ referenceDiagonalPx: diagonalOf(input.frameWidth, input.frameHeight),
2528
+ config: this.matchConfig
2529
+ });
2530
+ }
2531
+ /** Fold a frame's gate result back into state: advance confirmed entries'
2532
+ * `lastConfirmedAt` and retire woken entries (their object departed). */
2533
+ applyFrameOutcome(input) {
2534
+ const m = this.byDevice.get(input.deviceId);
2535
+ if (!m) return;
2536
+ for (const c of input.confirmed) {
2537
+ const e = m.get(c.entryId);
2538
+ if (!e) continue;
2539
+ m.set(c.entryId, {
2540
+ ...e,
2541
+ lastConfirmedAt: input.timestamp
2542
+ });
2543
+ this.dirty.add(c.entryId);
2544
+ }
2545
+ for (const id of input.wokenEntryIds) {
2546
+ const e = m.get(id);
2547
+ if (!e) continue;
2548
+ m.delete(id);
2549
+ this.dirty.delete(id);
2550
+ this.deletePersisted(id);
2551
+ this.onChange?.({
2552
+ phase: "departed",
2553
+ entry: e,
2554
+ timestamp: input.timestamp
2555
+ });
2556
+ }
2557
+ }
2558
+ /** Promote a parked track into a persisted stationary entry. */
2559
+ async promote(entry) {
2560
+ this.deviceMap(entry.deviceId).set(entry.id, entry);
2561
+ this.dirty.delete(entry.id);
2562
+ try {
2563
+ await this.persist(entry);
2564
+ } catch (err) {
2565
+ this.logger.warn("stationary promote persist failed", {
2566
+ tags: { deviceId: entry.deviceId },
2567
+ meta: {
2568
+ entryId: entry.id,
2569
+ error: String(err)
2570
+ }
2571
+ });
2572
+ }
2573
+ this.onChange?.({
2574
+ phase: "appeared",
2575
+ entry,
2576
+ timestamp: entry.becameStationaryAt
2577
+ });
2578
+ }
2579
+ /**
2580
+ * Retire entries unconfirmed for longer than the TTL of OBSERVED time, and
2581
+ * flush any advanced `lastConfirmedAt`s to the store. Returns retired
2582
+ * entries (for logging).
2583
+ *
2584
+ * Expiry is measured against the device's latest processed-frame timestamp
2585
+ * ({@link noteFrame}), NOT the wall clock: a session-dispatch camera emits
2586
+ * no frames between motion sessions, and that silence says nothing about
2587
+ * the object. Only when the camera has actually been WATCHING for `ttl`
2588
+ * beyond the last confirmation (frames flowed, object never matched) is the
2589
+ * object considered removed. A device with no recorded frame yet never
2590
+ * expires its entries. `now` only stamps the departed telemetry.
2591
+ */
2592
+ async sweep(now) {
2593
+ const retired = [];
2594
+ for (const [deviceId, m] of this.byDevice) {
2595
+ const observedAt = this.lastFrameAtByDevice.get(deviceId);
2596
+ if (observedAt === void 0) continue;
2597
+ for (const [id, e] of m) if (observedAt - e.lastConfirmedAt > this.entryTtlMs) {
2598
+ m.delete(id);
2599
+ this.dirty.delete(id);
2600
+ retired.push(e);
2601
+ this.deletePersisted(id);
2602
+ this.onChange?.({
2603
+ phase: "departed",
2604
+ entry: e,
2605
+ timestamp: now
2606
+ });
2607
+ }
2608
+ if (m.size === 0) this.byDevice.delete(deviceId);
2609
+ }
2610
+ for (const id of [...this.dirty]) {
2611
+ this.dirty.delete(id);
2612
+ const entry = this.findById(id);
2613
+ if (!entry) continue;
2614
+ try {
2615
+ await this.store.update.mutate({
2616
+ collection: STATIONARY_COLLECTION,
2617
+ id,
2618
+ data: { lastConfirmedAt: entry.lastConfirmedAt }
2619
+ });
2620
+ } catch (err) {
2621
+ this.logger.debug("stationary lastConfirmedAt flush failed", { meta: {
2622
+ entryId: id,
2623
+ error: String(err)
2624
+ } });
2625
+ }
2626
+ }
2627
+ return retired;
2628
+ }
2629
+ /** Drop a device's entries from memory WITHOUT deleting persisted rows.
2630
+ * Used on device unbind; a rebind reloads from the store. */
2631
+ forgetDevice(deviceId) {
2632
+ this.lastFrameAtByDevice.delete(deviceId);
2633
+ const m = this.byDevice.get(deviceId);
2634
+ if (!m) return;
2635
+ for (const id of m.keys()) this.dirty.delete(id);
2636
+ this.byDevice.delete(deviceId);
2637
+ }
2638
+ /** Delete every persisted + in-memory entry for a device (operator wipe). */
2639
+ async clearDevice(deviceId) {
2640
+ this.lastFrameAtByDevice.delete(deviceId);
2641
+ const m = this.byDevice.get(deviceId);
2642
+ if (m) {
2643
+ for (const id of [...m.keys()]) {
2644
+ this.dirty.delete(id);
2645
+ this.deletePersisted(id);
2646
+ }
2647
+ this.byDevice.delete(deviceId);
2648
+ }
2649
+ }
2650
+ deviceMap(deviceId) {
2651
+ let m = this.byDevice.get(deviceId);
2652
+ if (!m) {
2653
+ m = /* @__PURE__ */ new Map();
2654
+ this.byDevice.set(deviceId, m);
2655
+ }
2656
+ return m;
2657
+ }
2658
+ findById(id) {
2659
+ for (const m of this.byDevice.values()) {
2660
+ const e = m.get(id);
2661
+ if (e) return e;
2662
+ }
2663
+ }
2664
+ async persist(e) {
2665
+ await this.store.set.mutate({
2666
+ collection: STATIONARY_COLLECTION,
2667
+ key: e.id,
2668
+ value: {
2669
+ deviceId: e.deviceId,
2670
+ className: e.className,
2671
+ bbox: { ...e.bbox },
2672
+ frameWidth: e.frameWidth,
2673
+ frameHeight: e.frameHeight,
2674
+ firstSeenAt: e.firstSeenAt,
2675
+ becameStationaryAt: e.becameStationaryAt,
2676
+ lastConfirmedAt: e.lastConfirmedAt,
2677
+ ...e.sourceTrackId !== void 0 ? { sourceTrackId: e.sourceTrackId } : {},
2678
+ ...e.label !== void 0 ? { label: e.label } : {},
2679
+ ...e.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: e.keyFrameMediaKey } : {}
2680
+ }
2681
+ });
2682
+ }
2683
+ async deletePersisted(id) {
2684
+ try {
2685
+ await this.store.delete.mutate({
2686
+ collection: STATIONARY_COLLECTION,
2687
+ key: id
2688
+ });
2689
+ } catch (err) {
2690
+ this.logger.debug("stationary delete failed", { meta: {
2691
+ entryId: id,
2692
+ error: String(err)
2693
+ } });
2694
+ }
2695
+ }
2696
+ };
2697
+ function rowToEntry(id, data) {
2698
+ const deviceId = Number(data["deviceId"]);
2699
+ const className = data["className"];
2700
+ const bbox = data["bbox"];
2701
+ if (!Number.isFinite(deviceId) || typeof className !== "string" || !bbox) return null;
2702
+ const sourceTrackId = data["sourceTrackId"];
2703
+ const label = data["label"];
2704
+ const keyFrameMediaKey = data["keyFrameMediaKey"];
2705
+ return {
2706
+ id,
2707
+ deviceId,
2708
+ className,
2709
+ bbox: {
2710
+ x: Number(bbox.x),
2711
+ y: Number(bbox.y),
2712
+ w: Number(bbox.w),
2713
+ h: Number(bbox.h)
2714
+ },
2715
+ frameWidth: Number(data["frameWidth"] ?? 0),
2716
+ frameHeight: Number(data["frameHeight"] ?? 0),
2717
+ firstSeenAt: Number(data["firstSeenAt"] ?? 0),
2718
+ becameStationaryAt: Number(data["becameStationaryAt"] ?? 0),
2719
+ lastConfirmedAt: Number(data["lastConfirmedAt"] ?? 0),
2720
+ ...typeof sourceTrackId === "string" ? { sourceTrackId } : {},
2721
+ ...typeof label === "string" ? { label } : {},
2722
+ ...typeof keyFrameMediaKey === "string" ? { keyFrameMediaKey } : {}
2723
+ };
2724
+ }
2725
+ //#endregion
2726
+ //#region src/pipeline-analytics/pipeline/stationary/stationary-zones.ts
2727
+ /**
2728
+ * Zone ids whose 0–1 polygon contains the entry's normalised bbox centroid.
2729
+ * Empty when the entry has no frame dims (can't normalise) or no zone matches.
2730
+ */
2731
+ function computeStationaryEntryZones(entry, zones) {
2732
+ if (entry.frameWidth <= 0 || entry.frameHeight <= 0 || zones.length === 0) return [];
2733
+ const centroidPx = bboxCentroid(entry.bbox);
2734
+ const point = {
2735
+ x: centroidPx.x / entry.frameWidth,
2736
+ y: centroidPx.y / entry.frameHeight
2737
+ };
2738
+ const matched = [];
2739
+ for (const zone of zones) {
2740
+ if (zone.polygon.length < 3) continue;
2741
+ if (pointInPolygon(point, zone.polygon)) matched.push(zone.id);
2742
+ }
2743
+ return matched;
2744
+ }
2745
+ //#endregion
2746
+ //#region src/pipeline-analytics/pipeline/track-appearance.ts
2747
+ /**
2748
+ * Pure: no side effects. `continuing` = still active from last frame;
2749
+ * `birth` = a brand-new track's first sighting; `resurrection` = a known track
2750
+ * re-entering the active set after being lost.
2751
+ */
2752
+ function classifyTrackAppearance(input) {
2753
+ if (input.inPrevActive) return "continuing";
2754
+ return input.positionsCount > 1 ? "resurrection" : "birth";
2755
+ }
2756
+ //#endregion
2115
2757
  //#region src/pipeline-analytics/pipeline/key-event-query.ts
2116
2758
  async function rankKeyEvents(candidates, options, peakLookup) {
2117
2759
  const scored = [];
@@ -2121,6 +2763,10 @@ async function rankKeyEvents(candidates, options, peakLookup) {
2121
2763
  let bestEventId = t.bestEventId;
2122
2764
  if (importance === void 0) {
2123
2765
  const peak = await peakLookup(t.trackId);
2766
+ const staticMetrics = computeStaticTrackMetrics(t.positions.map((p) => ({
2767
+ x: p.x,
2768
+ y: p.y
2769
+ })), averageBboxDiagonal(t.positions.map((p) => p.bbox)));
2124
2770
  importance = computeImportance({
2125
2771
  peakConfidence: peak.peakConfidence,
2126
2772
  className: t.className,
@@ -2128,7 +2774,11 @@ async function rankKeyEvents(candidates, options, peakLookup) {
2128
2774
  peakBboxAreaFrac: peak.peakBboxAreaFrac,
2129
2775
  totalDistance: t.totalDistance,
2130
2776
  zonesVisited: t.zonesVisited,
2131
- ...t.label !== void 0 ? { label: t.label } : {}
2777
+ ...t.label !== void 0 ? { label: t.label } : {},
2778
+ ...staticMetrics ? {
2779
+ netDisplacementFrac: staticMetrics.netDisplacementFrac,
2780
+ pathSpanFrac: staticMetrics.pathSpanFrac
2781
+ } : {}
2132
2782
  }).importance;
2133
2783
  bestEventId = bestEventId ?? peak.bestEventId;
2134
2784
  }
@@ -2140,7 +2790,7 @@ async function rankKeyEvents(candidates, options, peakLookup) {
2140
2790
  className: t.className,
2141
2791
  ...t.label !== void 0 ? { label: t.label } : {},
2142
2792
  importance,
2143
- bestEventId: bestEventId ?? "",
2793
+ bestEventId: bestEventId ?? t.trackId,
2144
2794
  windowMs: t.lastSeen - t.firstSeen
2145
2795
  });
2146
2796
  }
@@ -2377,6 +3027,10 @@ var TRACKS_COLUMNS = [
2377
3027
  {
2378
3028
  name: "importanceReason",
2379
3029
  type: "TEXT"
3030
+ },
3031
+ {
3032
+ name: "audioLabels",
3033
+ type: "JSON"
2380
3034
  }
2381
3035
  ];
2382
3036
  var TRACKS_INDEXES = [{
@@ -2386,6 +3040,17 @@ var TRACKS_INDEXES = [{
2386
3040
  name: "idx_tracks_device_firstSeen",
2387
3041
  columns: ["deviceId", "firstSeen"]
2388
3042
  }];
3043
+ /** Serialize the per-label aggregate map into the `Track.audioLabels`
3044
+ * array shape, most-frequent label first. */
3045
+ function audioLabelsToArray(agg) {
3046
+ return [...agg.entries()].map(([label, a]) => ({
3047
+ label,
3048
+ peakScore: a.peakScore,
3049
+ count: a.count,
3050
+ firstAt: a.firstAt,
3051
+ lastAt: a.lastAt
3052
+ })).sort((a, b) => b.count - a.count);
3053
+ }
2389
3054
  function cloneTrack(t) {
2390
3055
  return {
2391
3056
  trackId: t.trackId,
@@ -2412,7 +3077,8 @@ function cloneTrack(t) {
2412
3077
  active: t.active,
2413
3078
  ...t.importance !== void 0 ? { importance: t.importance } : {},
2414
3079
  ...t.bestEventId !== void 0 ? { bestEventId: t.bestEventId } : {},
2415
- ...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {}
3080
+ ...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {},
3081
+ ...t.audioLabels !== void 0 && t.audioLabels.size > 0 ? { audioLabels: audioLabelsToArray(t.audioLabels) } : {}
2416
3082
  };
2417
3083
  }
2418
3084
  var TrackStore = class {
@@ -2473,25 +3139,81 @@ var TrackStore = class {
2473
3139
  this.active.set(params.trackId, fresh);
2474
3140
  return fresh;
2475
3141
  }
3142
+ /**
3143
+ * Record one audio-classification EPISODE against every track currently
3144
+ * active on the device — "what was heard on this camera while the track
3145
+ * was alive". Called from the confident-classification audio-event insert
3146
+ * (score ≥ device `classificationMinScore`, class-change-or-heartbeat
3147
+ * coalesced), so counts stay episode-scaled rather than 30 Hz chunk-scaled.
3148
+ */
3149
+ addAudioLabelEpisode(deviceId, label, score, timestamp) {
3150
+ for (const t of this.active.values()) {
3151
+ if (t.deviceId !== deviceId || !t.active) continue;
3152
+ const agg = t.audioLabels ??= /* @__PURE__ */ new Map();
3153
+ const entry = agg.get(label);
3154
+ if (entry) {
3155
+ entry.peakScore = Math.max(entry.peakScore, score);
3156
+ entry.count += 1;
3157
+ entry.lastAt = timestamp;
3158
+ } else agg.set(label, {
3159
+ peakScore: score,
3160
+ count: 1,
3161
+ firstAt: timestamp,
3162
+ lastAt: timestamp
3163
+ });
3164
+ }
3165
+ }
2476
3166
  /** Attach a snapshot reference to an active track. */
2477
3167
  addSnapshot(trackId, snapshot) {
2478
3168
  const t = this.active.get(trackId);
2479
3169
  if (!t) return;
2480
3170
  t.snapshots.push(snapshot);
2481
3171
  t.lastSnapshotAt = snapshot.timestamp;
3172
+ t.lastSnapshotBbox = { ...snapshot.position.bbox };
3173
+ }
3174
+ /**
3175
+ * Synchronously advance the periodic-snapshot gate reference (clock + bbox) at
3176
+ * the MOMENT a snapshot write is DECIDED — before the async encode/dispatch
3177
+ * lands the real snapshot via {@link addSnapshot}. Without it, `lastSnapshotAt`
3178
+ * only advances when the dispatcher round-trip returns (~50-200ms of sharp
3179
+ * encode), so at 10-25fps several consecutive frames pass
3180
+ * `evaluatePeriodicSnapshot` before the clock moves → a burst of near-identical
3181
+ * snapshots. Mirrors the synchronous `lastFrameAtByTrack` advance for the
3182
+ * rolling `lastFrame`.
3183
+ *
3184
+ * No rollback: if the write later fails the slot is simply lost (a rare dropped
3185
+ * snapshot is preferable to a burst). `addSnapshot` re-stamps the same
3186
+ * clock/bbox when the real snapshot lands, so the two stay consistent. No-op for
3187
+ * an unknown/expired track (the active entry is dropped on expiry, so there is
3188
+ * no separate map to leak).
3189
+ */
3190
+ markSnapshotPending(trackId, timestamp, bbox) {
3191
+ const t = this.active.get(trackId);
3192
+ if (!t) return;
3193
+ t.lastSnapshotAt = timestamp;
3194
+ t.lastSnapshotBbox = { ...bbox };
2482
3195
  }
2483
3196
  lastSnapshotAt(trackId) {
2484
3197
  return this.active.get(trackId)?.lastSnapshotAt ?? 0;
2485
3198
  }
3199
+ /** Bbox reference of the last captured snapshot (or the seed bbox), for the
3200
+ * periodic-snapshot movement gate. Undefined until the clock is seeded. */
3201
+ lastSnapshotBbox(trackId) {
3202
+ const b = this.active.get(trackId)?.lastSnapshotBbox;
3203
+ return b ? { ...b } : void 0;
3204
+ }
2486
3205
  /**
2487
3206
  * Seed the snapshot cadence clock (once, at track start) WITHOUT appending a
2488
3207
  * snapshot, so the first periodic snapshot fires ~snapshotIntervalMs after the
2489
3208
  * track begins rather than immediately — the `firstFrame` already covers the
2490
3209
  * track's start. No-op if a snapshot was already taken (clock already set).
2491
3210
  */
2492
- seedSnapshotClock(trackId, timestamp) {
3211
+ seedSnapshotClock(trackId, timestamp, bbox) {
2493
3212
  const t = this.active.get(trackId);
2494
- if (t && t.lastSnapshotAt === 0) t.lastSnapshotAt = timestamp;
3213
+ if (t && t.lastSnapshotAt === 0) {
3214
+ t.lastSnapshotAt = timestamp;
3215
+ if (bbox) t.lastSnapshotBbox = { ...bbox };
3216
+ }
2495
3217
  }
2496
3218
  getActive(deviceId) {
2497
3219
  const out = [];
@@ -2502,6 +3224,21 @@ var TrackStore = class {
2502
3224
  const t = this.active.get(trackId);
2503
3225
  return t && t.active ? cloneTrack(t) : null;
2504
3226
  }
3227
+ /**
3228
+ * Cheap read of an active track's promotion-relevant fields WITHOUT the deep
3229
+ * clone `getActiveByTrack` does — the returned `positions` is the live
3230
+ * internal array (read-only; callers must not mutate). Feeds the per-frame
3231
+ * stationary-promotion check, called at inference fps. Null if unknown/expired.
3232
+ */
3233
+ peekActive(trackId) {
3234
+ const t = this.active.get(trackId);
3235
+ if (!t || !t.active) return null;
3236
+ return {
3237
+ firstSeen: t.firstSeen,
3238
+ positions: t.positions,
3239
+ ...t.label !== void 0 ? { label: t.label } : {}
3240
+ };
3241
+ }
2505
3242
  /** Expire tracks whose `lastSeen` is older than TTL. Persists each
2506
3243
  * expired track to the declared collection and returns them. */
2507
3244
  async expireStale(nowMs) {
@@ -2607,6 +3344,15 @@ var TrackStore = class {
2607
3344
  clearAll() {
2608
3345
  this.active.clear();
2609
3346
  }
3347
+ /**
3348
+ * Drop a single active track WITHOUT persisting it as a historical row. Used
3349
+ * when a track is PROMOTED to a stationary-object registry entry: the durable
3350
+ * record for a parked object is the registry entry, not a Track, so the track
3351
+ * must NOT land in the key-event feed. No-op for an unknown/expired track.
3352
+ */
3353
+ dropActive(trackId) {
3354
+ this.active.delete(trackId);
3355
+ }
2610
3356
  /** Delete the persisted track row (keyed by trackId) and drop the in-RAM
2611
3357
  * active entry if present. Used by the whole-track deletion cascade. */
2612
3358
  async deletePersisted(trackId) {
@@ -2739,7 +3485,8 @@ var TrackStore = class {
2739
3485
  state: t.state,
2740
3486
  ...t.importance !== void 0 ? { importance: t.importance } : {},
2741
3487
  ...t.bestEventId !== void 0 ? { bestEventId: t.bestEventId } : {},
2742
- ...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {}
3488
+ ...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {},
3489
+ ...t.audioLabels !== void 0 ? { audioLabels: [...t.audioLabels] } : {}
2743
3490
  }
2744
3491
  });
2745
3492
  }
@@ -2752,6 +3499,7 @@ var TrackStore = class {
2752
3499
  const importance = data["importance"];
2753
3500
  const bestEventId = data["bestEventId"];
2754
3501
  const importanceReason = data["importanceReason"];
3502
+ const audioLabels = data["audioLabels"];
2755
3503
  return {
2756
3504
  trackId: id,
2757
3505
  deviceId: Number(data["deviceId"]),
@@ -2768,7 +3516,8 @@ var TrackStore = class {
2768
3516
  active: false,
2769
3517
  ...typeof importance === "number" ? { importance } : {},
2770
3518
  ...typeof bestEventId === "string" ? { bestEventId } : {},
2771
- ...typeof importanceReason === "string" ? { importanceReason } : {}
3519
+ ...typeof importanceReason === "string" ? { importanceReason } : {},
3520
+ ...Array.isArray(audioLabels) && audioLabels.length > 0 ? { audioLabels } : {}
2772
3521
  };
2773
3522
  }
2774
3523
  };
@@ -3881,13 +4630,10 @@ function stripNulls(data) {
3881
4630
  //#endregion
3882
4631
  //#region src/shared/frame/resolve-frame.ts
3883
4632
  /**
3884
- * Resolve the pixels a `FrameHandle` refers to. Local shm read when the
3885
- * handle's `nodeId` matches `deps.ownNodeId`, else routed via
3886
- * `deps.getRemoteFrame`. Returns `null` when the frame is no longer
3887
- * available (slot recycled locally, or the remote node reports no frame).
4633
+ * Resolve the pixels a `FrameHandle` refers to via the node-routed fetch.
4634
+ * Returns `null` when the frame is no longer available.
3888
4635
  */
3889
4636
  async function resolveFrame(handle, deps) {
3890
- if (handle.nodeId === deps.ownNodeId) return deps.readers.read(handle);
3891
4637
  return deps.getRemoteFrame(handle);
3892
4638
  }
3893
4639
  //#endregion
@@ -4064,11 +4810,7 @@ var EventMediaDispatcher = class {
4064
4810
  if (events.length === 0 && trackFrames.length === 0 && snapshots.length === 0) return empty;
4065
4811
  let decoded;
4066
4812
  try {
4067
- decoded = await resolveFrame(frameHandle, {
4068
- ownNodeId: this.deps.ownNodeId,
4069
- readers: this.deps.readers,
4070
- getRemoteFrame: this.deps.getRemoteFrame
4071
- });
4813
+ decoded = await resolveFrame(frameHandle, { getRemoteFrame: this.deps.getRemoteFrame });
4072
4814
  } catch (err) {
4073
4815
  this.deps.logger.debug("event media: resolveFrame threw", {
4074
4816
  tags: { deviceId },
@@ -4379,8 +5121,6 @@ var EmbeddingDispatcher = class {
4379
5121
  encoder;
4380
5122
  eventBus;
4381
5123
  logger;
4382
- ownNodeId;
4383
- readers;
4384
5124
  getRemoteFrame;
4385
5125
  lastEmbedTime = /* @__PURE__ */ new Map();
4386
5126
  pendingCrops = /* @__PURE__ */ new Map();
@@ -4393,8 +5133,6 @@ var EmbeddingDispatcher = class {
4393
5133
  this.encoder = deps.encoder;
4394
5134
  this.eventBus = deps.eventBus;
4395
5135
  this.logger = deps.logger;
4396
- this.ownNodeId = deps.ownNodeId;
4397
- this.readers = deps.readers;
4398
5136
  this.getRemoteFrame = deps.getRemoteFrame;
4399
5137
  }
4400
5138
  async start() {
@@ -4446,11 +5184,7 @@ var EmbeddingDispatcher = class {
4446
5184
  }
4447
5185
  let decoded;
4448
5186
  try {
4449
- decoded = await resolveFrame(handle, {
4450
- ownNodeId: this.ownNodeId,
4451
- readers: this.readers,
4452
- getRemoteFrame: this.getRemoteFrame
4453
- });
5187
+ decoded = await resolveFrame(handle, { getRemoteFrame: this.getRemoteFrame });
4454
5188
  } catch (err) {
4455
5189
  this.logger.debug("skip: resolveFrame threw", {
4456
5190
  tags: { deviceId: Number(deviceId) },
@@ -4697,6 +5431,17 @@ var RESOLUTION_MS = {
4697
5431
  * latest state is never lost.
4698
5432
  */
4699
5433
  var SLICE_WRITE_INTERVAL_MS$1 = 1e3;
5434
+ /**
5435
+ * Cadence of the synthetic occupancy baseline. When a camera is detached (no
5436
+ * inference frames) but has persisted parked objects, the history ring would
5437
+ * otherwise stay empty and the chart would read "No occupancy history yet". A
5438
+ * device WITH parked entries gets one hydrated sample per this interval — a
5439
+ * flat baseline of the parked count — so the graph shows the parking lot's
5440
+ * standing occupancy instead of a gap. No sample is emitted for a device
5441
+ * without entries, and a real `recordFrame` in the same window suppresses the
5442
+ * baseline (it already appended a richer sample).
5443
+ */
5444
+ var BASELINE_SAMPLE_INTERVAL_MS = 6e4;
4700
5445
  var ZoneAnalyticsProvider = class {
4701
5446
  ctx;
4702
5447
  snapshots = /* @__PURE__ */ new Map();
@@ -4713,8 +5458,17 @@ var ZoneAnalyticsProvider = class {
4713
5458
  /** Last logged frame-wide occupancy total per device — so the occupancy log
4714
5459
  * fires only when the count actually changes, not every inference frame. */
4715
5460
  lastOccupancyTotal = /* @__PURE__ */ new Map();
5461
+ /** Low-cadence baseline sampler — appends a hydrated occupancy sample for any
5462
+ * device with parked objects but no live frames. `null` when disabled. */
5463
+ baselineTimer = null;
4716
5464
  constructor(ctx) {
4717
5465
  this.ctx = ctx;
5466
+ if (ctx.listStationaryDeviceIds && ctx.listStationaryObjects) {
5467
+ this.baselineTimer = setInterval(() => {
5468
+ this.appendBaselineSamples();
5469
+ }, BASELINE_SAMPLE_INTERVAL_MS);
5470
+ this.baselineTimer.unref?.();
5471
+ }
4718
5472
  this.sliceThrottle = new SliceThrottler({
4719
5473
  intervalMs: SLICE_WRITE_INTERVAL_MS$1,
4720
5474
  equalsIgnoringTs: snapshotEqualsIgnoringTs,
@@ -4736,9 +5490,10 @@ var ZoneAnalyticsProvider = class {
4736
5490
  /** Stop pending throttle timers — called from addon shutdown. */
4737
5491
  destroy() {
4738
5492
  this.sliceThrottle.destroy();
5493
+ if (this.baselineTimer) clearInterval(this.baselineTimer);
4739
5494
  }
4740
5495
  async getCurrentSnapshot({ deviceId }) {
4741
- return this.snapshots.get(deviceId) ?? null;
5496
+ return this.snapshots.get(deviceId) ?? await this.hydrateFromRegistry(deviceId);
4742
5497
  }
4743
5498
  async getZoneHistory(input) {
4744
5499
  return this.bucketize(input.deviceId, input.from, input.to, input.resolution, (snap) => {
@@ -4791,6 +5546,65 @@ var ZoneAnalyticsProvider = class {
4791
5546
  this.lastOccupancyTotal.delete(deviceId);
4792
5547
  this.sliceThrottle.forgetDevice(deviceId);
4793
5548
  }
5549
+ /**
5550
+ * Build an occupancy snapshot for a device purely from its parked-object
5551
+ * registry (no live frame). Returns `null` when hydration is unavailable or
5552
+ * the device has no parked objects — a device with neither frames nor entries
5553
+ * legitimately reports `null`. The snapshot's `ts` is the most recent
5554
+ * `lastConfirmedAt` across entries, falling back to the current tick.
5555
+ */
5556
+ async hydrateFromRegistry(deviceId) {
5557
+ const listStationary = this.ctx.listStationaryObjects;
5558
+ if (!listStationary) return null;
5559
+ const entries = listStationary(deviceId);
5560
+ if (entries.length === 0) return null;
5561
+ let zones = [];
5562
+ try {
5563
+ zones = await this.ctx.resolveZones?.(deviceId) ?? [];
5564
+ } catch (err) {
5565
+ this.ctx.logger.debug("zone-analytics hydrate zone resolve failed", {
5566
+ tags: { deviceId },
5567
+ meta: { error: err instanceof Error ? err.message : String(err) }
5568
+ });
5569
+ }
5570
+ const ts = mostRecentStationaryConfirmedAt(entries) || Date.now();
5571
+ return buildStationarySnapshot({
5572
+ deviceId,
5573
+ entries,
5574
+ zones,
5575
+ timestamp: ts
5576
+ });
5577
+ }
5578
+ /**
5579
+ * Baseline sampler tick: for every device with parked objects, append a
5580
+ * hydrated sample to the history ring at the CURRENT time — but only when a
5581
+ * real frame hasn't already appended a sample within this interval (frames
5582
+ * flowing = richer samples, no synthetic baseline needed).
5583
+ */
5584
+ async appendBaselineSamples() {
5585
+ const deviceIds = this.ctx.listStationaryDeviceIds?.() ?? [];
5586
+ const now = Date.now();
5587
+ for (const deviceId of deviceIds) {
5588
+ const ring = this.history.get(deviceId);
5589
+ if (now - (ring && ring.length > 0 ? ring[ring.length - 1].ts : 0) < BASELINE_SAMPLE_INTERVAL_MS) continue;
5590
+ const entries = this.ctx.listStationaryObjects?.(deviceId) ?? [];
5591
+ if (entries.length === 0) continue;
5592
+ let zones = [];
5593
+ try {
5594
+ zones = await this.ctx.resolveZones?.(deviceId) ?? [];
5595
+ } catch {}
5596
+ const snapshot = buildStationarySnapshot({
5597
+ deviceId,
5598
+ entries,
5599
+ zones,
5600
+ timestamp: now
5601
+ });
5602
+ if (snapshot) {
5603
+ this.appendHistory(deviceId, snapshot);
5604
+ this.sliceThrottle.push(deviceId, snapshot);
5605
+ }
5606
+ }
5607
+ }
4794
5608
  appendHistory(deviceId, snapshot) {
4795
5609
  const ring = this.history.get(deviceId) ?? [];
4796
5610
  const cutoff = snapshot.ts - HISTORY_WINDOW_MS;
@@ -4877,9 +5691,43 @@ function computeSnapshot(input) {
4877
5691
  unzoned: {
4878
5692
  totalObjects: unzonedTotal,
4879
5693
  byClass: unzonedByClass
4880
- }
5694
+ },
5695
+ ...input.stationaryObjects !== void 0 && input.stationaryObjects.length > 0 ? { stationaryObjects: input.stationaryObjects } : {}
4881
5696
  };
4882
5697
  }
5698
+ /** Most recent `lastConfirmedAt` across parked entries (0 when none). */
5699
+ function mostRecentStationaryConfirmedAt(entries) {
5700
+ let max = 0;
5701
+ for (const e of entries) if (e.lastConfirmedAt > max) max = e.lastConfirmedAt;
5702
+ return max;
5703
+ }
5704
+ /**
5705
+ * Build an occupancy snapshot from parked-object registry entries alone —
5706
+ * used when no live inference frame is available (fresh respawn, camera
5707
+ * detached). Each entry is folded into the frame aggregate AND attributed to
5708
+ * the zones its normalised bbox centroid falls inside (via
5709
+ * {@link computeStationaryEntryZones}), so a zone drawn over a parked car
5710
+ * reports a count of 1. Returns `null` for an empty entry list. Reuses
5711
+ * {@link computeSnapshot} — the SAME aggregation the live frame path runs.
5712
+ */
5713
+ function buildStationarySnapshot(input) {
5714
+ if (input.entries.length === 0) return null;
5715
+ const tracked = input.entries.map((e) => ({
5716
+ trackId: `stationary:${e.id}`,
5717
+ className: e.className,
5718
+ zones: computeStationaryEntryZones(e, input.zones)
5719
+ }));
5720
+ const first = input.entries[0];
5721
+ return computeSnapshot({
5722
+ deviceId: input.deviceId,
5723
+ timestamp: input.timestamp,
5724
+ frameWidth: first.frameWidth,
5725
+ frameHeight: first.frameHeight,
5726
+ tracked,
5727
+ zones: input.zones,
5728
+ stationaryObjects: input.entries
5729
+ });
5730
+ }
4883
5731
  //#endregion
4884
5732
  //#region src/pipeline-analytics/audio-metrics-provider.ts
4885
5733
  var AUDIO_METRICS_CAP_NAME = "audio-metrics";
@@ -5453,7 +6301,18 @@ var MediaSettingsSchema = object({
5453
6301
  /** Cadence (ms) for the periodic per-track `snapshot` + rolling `lastFrame`.
5454
6302
  * A snapshot is captured for an active track only after this much wall-clock
5455
6303
  * has elapsed since its previous one. */
5456
- snapshotIntervalMs: number().int().min(500).max(6e4).default(5e3)
6304
+ snapshotIntervalMs: number().int().min(500).max(6e4).default(5e3),
6305
+ /** Movement gate for the periodic `snapshot`: once `snapshotIntervalMs` has
6306
+ * elapsed, a fresh snapshot is only captured when the track's centroid moved
6307
+ * at least this fraction of the frame DIAGONAL since the last captured
6308
+ * snapshot. Suppresses near-identical frames from a long-lived / stationary
6309
+ * track. 0 disables the gate (pure-time behaviour). Default 0.03 ≈ 3% of the
6310
+ * frame diagonal (~66px on 1080p). */
6311
+ snapshotMovementThreshold: number().min(0).max(1).default(.03),
6312
+ /** Loiterer fallback (ms): force a periodic `snapshot` for a stationary but
6313
+ * still-present track after this much wall-clock without one, so its
6314
+ * filmstrip is never empty. Effectively clamped to ≥ `snapshotIntervalMs`. */
6315
+ snapshotMaxIdleMs: number().int().min(1e3).max(6e5).default(3e4)
5457
6316
  });
5458
6317
  var MEDIA_DEFAULTS = MediaSettingsSchema.parse({});
5459
6318
  /**
@@ -5468,7 +6327,148 @@ function resolveMediaSettings(raw) {
5468
6327
  return {
5469
6328
  cropPadding: pick("cropPadding"),
5470
6329
  saveThumbnails: pick("saveThumbnails"),
5471
- snapshotIntervalMs: pick("snapshotIntervalMs")
6330
+ snapshotIntervalMs: pick("snapshotIntervalMs"),
6331
+ snapshotMovementThreshold: pick("snapshotMovementThreshold"),
6332
+ snapshotMaxIdleMs: pick("snapshotMaxIdleMs")
6333
+ };
6334
+ }
6335
+ function centroidOf(b) {
6336
+ return {
6337
+ x: b.x + b.w / 2,
6338
+ y: b.y + b.h / 2
6339
+ };
6340
+ }
6341
+ /** Centroid displacement between two boxes as a fraction of the frame diagonal.
6342
+ * Returns 0 for a degenerate (≤0) frame diagonal so the caller can fall back
6343
+ * to pure-time behaviour instead of dividing by zero. */
6344
+ function centroidMovedFraction(a, b, frameWidth, frameHeight) {
6345
+ const diag = Math.hypot(frameWidth, frameHeight);
6346
+ if (diag <= 0) return 0;
6347
+ const ca = centroidOf(a);
6348
+ const cb = centroidOf(b);
6349
+ return Math.hypot(cb.x - ca.x, cb.y - ca.y) / diag;
6350
+ }
6351
+ /**
6352
+ * Decide whether the periodic `snapshot` should be captured for a track THIS
6353
+ * frame. Pure: no side effects. The caller keeps the `saveThumbnails` master
6354
+ * switch and advances `lastSnapshotAt`/`lastSnapshotBbox` only when a capture
6355
+ * actually lands — so a skipped (stationary) frame leaves the clock untouched,
6356
+ * which naturally lets `maxIdleMs` fire and re-evaluates movement every frame
6357
+ * until the object moves.
6358
+ */
6359
+ function evaluatePeriodicSnapshot(input) {
6360
+ const { lastSnapshotAt, lastSnapshotBbox, currentBbox, now, frameWidth, frameHeight, intervalMs, movementThreshold, maxIdleMs } = input;
6361
+ if (lastSnapshotAt <= 0 || now - lastSnapshotAt < intervalMs) {
6362
+ if (lastSnapshotAt > 0 && lastSnapshotBbox !== void 0 && now - lastSnapshotAt >= 1500) {
6363
+ const fastMoved = centroidMovedFraction(lastSnapshotBbox, currentBbox, frameWidth, frameHeight);
6364
+ if (fastMoved >= .08) return {
6365
+ capture: true,
6366
+ reason: "fast-mover",
6367
+ movedFraction: fastMoved
6368
+ };
6369
+ }
6370
+ return {
6371
+ capture: false,
6372
+ reason: "interval-not-elapsed",
6373
+ movedFraction: 0
6374
+ };
6375
+ }
6376
+ if (lastSnapshotBbox === void 0) return {
6377
+ capture: true,
6378
+ reason: "no-reference",
6379
+ movedFraction: 0
6380
+ };
6381
+ const movedFraction = centroidMovedFraction(lastSnapshotBbox, currentBbox, frameWidth, frameHeight);
6382
+ if (movedFraction >= movementThreshold) return {
6383
+ capture: true,
6384
+ reason: "moved",
6385
+ movedFraction
6386
+ };
6387
+ const idleLimit = Math.max(maxIdleMs, intervalMs);
6388
+ if (now - lastSnapshotAt >= idleLimit) return {
6389
+ capture: true,
6390
+ reason: "idle-forced",
6391
+ movedFraction
6392
+ };
6393
+ return {
6394
+ capture: false,
6395
+ reason: "stationary-skip",
6396
+ movedFraction
6397
+ };
6398
+ }
6399
+ //#endregion
6400
+ //#region src/pipeline-analytics/periodic-media-plan.ts
6401
+ /**
6402
+ * Decide the periodic media writes for one track on one frame. Pure: no side
6403
+ * effects. The caller advances its own `lastFrameAt` clock only when the
6404
+ * returned `rollingLastFrame` is true.
6405
+ *
6406
+ * INVARIANT: `appendSnapshot` and `rollingLastFrame` are never both true — the
6407
+ * rolling `lastFrame` is never the same frame as an appended `snapshot`, so it
6408
+ * can never duplicate one.
6409
+ */
6410
+ function planPeriodicMedia(input) {
6411
+ const appendSnapshot = input.dueSnapshot;
6412
+ return {
6413
+ appendSnapshot,
6414
+ rollingLastFrame: input.saveThumbnails && input.now - input.lastFrameAt >= input.intervalMs && !appendSnapshot,
6415
+ bestThumbnail: input.isNewBest
6416
+ };
6417
+ }
6418
+ //#endregion
6419
+ //#region src/pipeline-analytics/pipeline/key-frame-capture.ts
6420
+ /**
6421
+ * Pure helpers for the per-track NATIVE-resolution `keyFrame` capture
6422
+ * (Design B — one native full-frame per track at its best-detection moment).
6423
+ *
6424
+ * ## Why this exists (the missing native keyFrame)
6425
+ *
6426
+ * `keyFrame` was historically captured ONLY inside the CLIP object-embedding
6427
+ * best path (`persistObjectEmbeddingBests`, gated by `isClipObjectEmbedding`).
6428
+ * Under the two-plane pipeline the root frame carries NO CLIP embedding (clip is
6429
+ * a per-track DETAIL served via `runDetailSubtree`, and is disabled cluster-
6430
+ * wide), so that gate was never satisfied and the native `keyFrame` was NEVER
6431
+ * produced — every stored frame stayed at the ≤640×360 detection resolution.
6432
+ *
6433
+ * The fix decouples the `keyFrame` from the clip path: it is captured on the
6434
+ * GENERAL best-frame signal (the same `bestThumbnail` decision that drives the
6435
+ * `thumbnail`), reusing the WORKING native crop path (`captureCrop` →
6436
+ * `pipelineRunner.getNativeCrop`, which cuts the ROI from the decode worker's
6437
+ * retained NATIVE surface and only falls back to the detection frame on a miss).
6438
+ * A full-frame ROI at {@link KEYFRAME_NATIVE_MAX_WIDTH} therefore yields a frame
6439
+ * LARGER than the detection raster (up to the cap), which is the whole point of
6440
+ * the `keyFrame` kind.
6441
+ */
6442
+ /** Cap (px) on the width of the native KEY FRAME (full-frame native capture).
6443
+ * Native resolution is the point, but a full 4K RGB surface over the transport
6444
+ * per new-best is wasteful for a web detail view — 1920px keeps a sharp native
6445
+ * frame while bounding the copy (a miss falls back to the detection-res frame,
6446
+ * which is already ≤640px). */
6447
+ var KEYFRAME_NATIVE_MAX_WIDTH = 1920;
6448
+ /**
6449
+ * The tracks whose native `keyFrame` should be (re)captured THIS frame: exactly
6450
+ * the tracks that hit a new best-frame moment (`bestThumbnail`). `putReplacing`
6451
+ * downstream keeps one `keyFrame` per track (the current peak).
6452
+ */
6453
+ function selectKeyFrameTrackIds(targets) {
6454
+ return targets.filter((t) => t.bestThumbnail).map((t) => t.trackId);
6455
+ }
6456
+ /**
6457
+ * Build the `captureCrop` request for a track's native `keyFrame`: the FULL
6458
+ * frame (no padding) at the native width cap. The full-frame box is what makes
6459
+ * the capture route through the native surface at native resolution instead of
6460
+ * a tight ≤640 detection crop.
6461
+ */
6462
+ function buildKeyFrameCaptureRequest(frameWidth, frameHeight) {
6463
+ return {
6464
+ bbox: {
6465
+ x: 0,
6466
+ y: 0,
6467
+ w: frameWidth,
6468
+ h: frameHeight
6469
+ },
6470
+ padding: 0,
6471
+ maxWidth: KEYFRAME_NATIVE_MAX_WIDTH
5472
6472
  };
5473
6473
  }
5474
6474
  //#endregion
@@ -6805,6 +7805,17 @@ var DEFAULT_MIN_INTERVAL_MS = 1e3;
6805
7805
  /** Default fire cap for a `once` step when its announce omits `maxPerTrack`. */
6806
7806
  var DEFAULT_ONCE_MAX_PER_TRACK = 3;
6807
7807
  /**
7808
+ * Consecutive frame-plane misses ("frame + crop both missed") after which a step
7809
+ * is ABANDONED for the track. The decode worker serves native crops from a RAM
7810
+ * lease store with a ~500ms TTL, so a retry that arrives seconds later re-cuts
7811
+ * from an evicted handle and is a guaranteed miss forever. Retrying a
7812
+ * permanently-gone frame just burns cross-process RPC + CPU + log lines. Three
7813
+ * consecutive misses (each ≥ one tick apart) confidently means the frame is gone
7814
+ * for good, while still tolerating a single transient decode-worker hiccup /
7815
+ * respawn on a genuinely live track (the counter resets on any resolved result).
7816
+ */
7817
+ var MAX_CONSECUTIVE_FRAME_MISSES = 3;
7818
+ /**
6808
7819
  * Pure per-(track, step) scheduling state machine for detail-subtree
6809
7820
  * dispatch. Given a camera's announced child steps (`DetailStepAnnounce[]`,
6810
7821
  * read off `PipelineInferenceResultPayload.detailSteps`), decides WHEN each
@@ -6825,7 +7836,9 @@ var DetailScheduler = class {
6825
7836
  firedCount: 1,
6826
7837
  lastFiredAt: nowMs,
6827
7838
  sticky: false,
6828
- retryPending: false
7839
+ retryPending: false,
7840
+ consecutiveFrameMisses: 0,
7841
+ abandoned: false
6829
7842
  };
6830
7843
  steps.set(stepAnnounce.stepId, state);
6831
7844
  requests.push({
@@ -6858,7 +7871,7 @@ var DetailScheduler = class {
6858
7871
  tick(nowMs) {
6859
7872
  const requests = [];
6860
7873
  for (const [trackId, steps] of this.tracks) for (const state of steps.values()) {
6861
- if (state.sticky) continue;
7874
+ if (state.sticky || state.abandoned) continue;
6862
7875
  if (state.retryPending) {
6863
7876
  if (!this.intervalElapsed(state, nowMs)) continue;
6864
7877
  if (!this.underMaxPerTrack(state)) {
@@ -6896,7 +7909,8 @@ var DetailScheduler = class {
6896
7909
  if (!steps) return;
6897
7910
  const state = steps.get(stepId);
6898
7911
  if (!state) return;
6899
- if (state.sticky) return;
7912
+ if (state.sticky || state.abandoned) return;
7913
+ state.consecutiveFrameMisses = 0;
6900
7914
  const { stickyOnConfidence } = state.announce.cadence;
6901
7915
  if (confidence !== null && stickyOnConfidence !== void 0 && confidence >= stickyOnConfidence) {
6902
7916
  state.sticky = true;
@@ -6911,11 +7925,37 @@ var DetailScheduler = class {
6911
7925
  if (this.underMaxPerTrack(state)) state.retryPending = true;
6912
7926
  }
6913
7927
  }
7928
+ /**
7929
+ * A dispatched request could not resolve a frame AT ALL — the frame handle
7930
+ * lease was evicted AND the crop fallback was unavailable (the "frame + crop
7931
+ * both missed" outcome). This is fundamentally different from `onResult(null)`:
7932
+ * there the frame plane WORKED and the model merely returned nothing (worth a
7933
+ * retry on a fresh frame). A frame-plane miss re-cuts from the SAME evicted
7934
+ * handle every time, so it can never recover from this request. It is
7935
+ * retry-eligible only for a bounded number of CONSECUTIVE attempts; after
7936
+ * {@link MAX_CONSECUTIVE_FRAME_MISSES} in a row the step is abandoned for the
7937
+ * track — this is the give-up that breaks the permanent-retry loop. `_nowMs`
7938
+ * is accepted for signature symmetry (backoff is anchored to `lastFiredAt`).
7939
+ */
7940
+ onFrameMiss(trackId, stepId, _nowMs) {
7941
+ const steps = this.tracks.get(trackId);
7942
+ if (!steps) return;
7943
+ const state = steps.get(stepId);
7944
+ if (!state) return;
7945
+ if (state.sticky || state.abandoned) return;
7946
+ state.consecutiveFrameMisses += 1;
7947
+ if (state.consecutiveFrameMisses >= MAX_CONSECUTIVE_FRAME_MISSES) {
7948
+ state.abandoned = true;
7949
+ state.retryPending = false;
7950
+ return;
7951
+ }
7952
+ if (this.underMaxPerTrack(state)) state.retryPending = true;
7953
+ }
6914
7954
  onTrackEnded(trackId) {
6915
7955
  this.tracks.delete(trackId);
6916
7956
  }
6917
7957
  canFire(state, nowMs) {
6918
- if (state.sticky) return false;
7958
+ if (state.sticky || state.abandoned) return false;
6919
7959
  if (!this.underMaxPerTrack(state)) return false;
6920
7960
  return this.intervalElapsed(state, nowMs);
6921
7961
  }
@@ -6938,7 +7978,7 @@ var DetailScheduler = class {
6938
7978
  //#region src/pipeline-analytics/detail-dispatcher.ts
6939
7979
  /**
6940
7980
  * Compose the `steps` list sent to `runDetailSubtree` for one request —
6941
- * identity-aware for the face chain.
7981
+ * chain-aware for the multi-step detail subtrees.
6942
7982
  *
6943
7983
  * A `face-detection` request ALSO includes `'face-embedding'` (the full
6944
7984
  * detect→recognize chain) EXCEPT when it is a PERIODIC geometry refresh on a
@@ -6947,13 +7987,35 @@ var DetailScheduler = class {
6947
7987
  * that case runs the detector geometry ALONE. Every recognition-bearing reason
6948
7988
  * (new-track / improve / retry) keeps the embedding regardless of the label.
6949
7989
  *
6950
- * Non-face steps are unchanged (`[req.stepId]`). Pairs with the pipeline's
6951
- * strict-`steps` pruning (`pruneChildStepsToRequested`) including
6952
- * `'face-embedding'` here is what keeps the nested child in the executed chain.
7990
+ * A `plate-detection` request ALWAYS includes `'plate-ocr'` symmetric to the
7991
+ * face chain. Without `'plate-ocr'` in the array the pipeline's strict-`steps`
7992
+ * pruning (`pruneChildStepsToRequested`) drops the OCR child, so a detected
7993
+ * plate never gets read and no plate text is ever produced. (The dispatcher
7994
+ * cannot import the pipeline catalog to derive the child chain — this hardcode
7995
+ * mirrors it; keep the two in sync when the catalog's plate subtree changes.)
7996
+ *
7997
+ * Other steps are unchanged (`[req.stepId]`). Pairs with the pipeline's
7998
+ * strict-`steps` pruning — naming the nested child here is what keeps it in the
7999
+ * executed chain.
6953
8000
  */
6954
8001
  function composeDetailSteps(req, hasTrackLabel) {
6955
- if (req.stepId !== "face-detection") return [req.stepId];
6956
- return req.reason === "periodic" && hasTrackLabel(req.trackId) ? ["face-detection"] : ["face-detection", "face-embedding"];
8002
+ if (req.stepId === "face-detection") return req.reason === "periodic" && hasTrackLabel(req.trackId) ? ["face-detection"] : ["face-detection", "face-embedding"];
8003
+ if (req.stepId === "plate-detection") return ["plate-detection", "plate-ocr"];
8004
+ return [req.stepId];
8005
+ }
8006
+ /**
8007
+ * Does `steps` name a nested-enrichment chain (root detector + a child that
8008
+ * produces a `label`/`embedding`), i.e. more than the bare root step? Used to
8009
+ * surface a silent enrichment miss (BUG C): a plate detected but never read, a
8010
+ * face detected but never embedded — the root detail still routes so the miss
8011
+ * is otherwise invisible. `['plate-detection']` alone is NOT a chain.
8012
+ */
8013
+ function isEnrichmentChain(steps) {
8014
+ return steps.length > 1;
8015
+ }
8016
+ /** Does any returned detail carry the enrichment a chain request asked for? */
8017
+ function detailsCarryEnrichment(details) {
8018
+ return details.some((d) => d.label !== void 0 || d.embedding !== void 0);
6957
8019
  }
6958
8020
  /** Throttle for the per-device "detail call failed" warn — one line / minute. */
6959
8021
  var FAIL_WARN_THROTTLE_MS = 6e4;
@@ -7030,7 +8092,8 @@ var TrackDetailDispatcher = class {
7030
8092
  queue: [],
7031
8093
  inFlight: 0,
7032
8094
  timer: null,
7033
- lastFailWarnAt: 0
8095
+ lastFailWarnAt: 0,
8096
+ lastEnrichWarnAt: 0
7034
8097
  };
7035
8098
  this.devices.set(deviceId, dev);
7036
8099
  }
@@ -7073,9 +8136,15 @@ var TrackDetailDispatcher = class {
7073
8136
  }
7074
8137
  async dispatch(deviceId, dev, req, frame) {
7075
8138
  const details = await this.runOnce(deviceId, dev, req, frame);
8139
+ if (details === null) {
8140
+ dev.scheduler.onFrameMiss(req.trackId, req.stepId, Date.now());
8141
+ return;
8142
+ }
7076
8143
  let topScore = null;
7077
- if (details !== null && details.length > 0) {
8144
+ if (details.length > 0) {
7078
8145
  topScore = details.reduce((max, d) => d.score > max ? d.score : max, Number.NEGATIVE_INFINITY);
8146
+ const steps = composeDetailSteps(req, (id) => this.deps.hasTrackLabel?.(id) ?? false);
8147
+ if (isEnrichmentChain(steps) && !detailsCarryEnrichment(details)) this.warnEnrichmentMissThrottled(deviceId, dev, req, steps);
7079
8148
  try {
7080
8149
  await this.deps.routeResults(deviceId, req.trackId, details, frame);
7081
8150
  } catch (err) {
@@ -7157,6 +8226,20 @@ var TrackDetailDispatcher = class {
7157
8226
  }
7158
8227
  });
7159
8228
  }
8229
+ warnEnrichmentMissThrottled(deviceId, dev, req, steps) {
8230
+ const now = Date.now();
8231
+ if (now - dev.lastEnrichWarnAt < FAIL_WARN_THROTTLE_MS) return;
8232
+ dev.lastEnrichWarnAt = now;
8233
+ this.deps.logger.warn("detail chain ran but produced no enrichment (root detected, child yielded no label/embedding)", {
8234
+ tags: { deviceId },
8235
+ meta: {
8236
+ trackId: req.trackId,
8237
+ stepId: req.stepId,
8238
+ reason: req.reason,
8239
+ steps
8240
+ }
8241
+ });
8242
+ }
7160
8243
  };
7161
8244
  //#endregion
7162
8245
  //#region src/pipeline-analytics/overlay-state.ts
@@ -7994,38 +9077,68 @@ var PlateRecognizer = class {
7994
9077
  name
7995
9078
  } : null;
7996
9079
  }
7997
- /** Live label for a plate read: the recognized vehicle NAME when matched, else
7998
- * the raw OCR text (today's behavior). Used by the ingest label path. */
9080
+ /** Live label for a plate read: the recognized vehicle NAME when matched,
9081
+ * else the raw OCR text. Returns `null` for an implausible read (junk OCR
9082
+ * off a distant/oblique plate) — the caller must NOT stamp a label then. */
7999
9083
  resolveLabel(text, score) {
9084
+ if (!isPlausiblePlateRead(text, score)) return null;
8000
9085
  return this.matchVehicle(text, score)?.name ?? text;
8001
9086
  }
8002
9087
  async processFrame(input) {
8003
9088
  const minConfidence = input.minConfidence ?? 0;
8004
9089
  const candidates = input.tracked.filter((t) => typeof t.plateText === "string" && t.plateText.length > 0 && typeof t.plateScore === "number" && t.plateScore >= minConfidence && t.plateBbox !== void 0);
8005
9090
  if (candidates.length === 0) return;
8006
- for (const c of candidates) {
8007
- const held = this.bestPlate.get(c.trackId);
8008
- if (held !== void 0 && c.plateScore <= held.score) continue;
8009
- let crop;
8010
- if (input.frameHandle !== void 0) try {
8011
- crop = await this.deps.captureCrop(input.frameHandle, c.plateBbox, input.frameWidth, input.frameHeight, input.cropPadding) ?? void 0;
8012
- } catch (err) {
8013
- this.deps.logger.debug("PlateRecognizer crop capture failed", {
8014
- tags: { deviceId: input.deviceId },
8015
- meta: {
8016
- trackId: c.trackId,
8017
- error: String(err)
8018
- }
8019
- });
8020
- }
8021
- this.bestPlate.set(c.trackId, {
8022
- text: c.plateText,
8023
- score: c.plateScore,
8024
- bbox: c.plateBbox,
8025
- timestamp: input.timestamp,
8026
- ...crop !== void 0 ? { crop } : {}
9091
+ for (const c of candidates) await this.holdBest({
9092
+ deviceId: input.deviceId,
9093
+ trackId: c.trackId,
9094
+ text: c.plateText,
9095
+ score: c.plateScore,
9096
+ bbox: c.plateBbox,
9097
+ timestamp: input.timestamp,
9098
+ frameWidth: input.frameWidth,
9099
+ frameHeight: input.frameHeight,
9100
+ cropPadding: input.cropPadding,
9101
+ ...input.frameHandle !== void 0 ? { frameHandle: input.frameHandle } : {}
9102
+ });
9103
+ }
9104
+ /**
9105
+ * Detail-plane entry point (two-plane design): plate-ocr runs on demand per
9106
+ * track via `pipelineRunner.runDetailSubtree`, NOT per frame, so the OCR read
9107
+ * never lands on the per-frame `tracked[]` that {@link processFrame} scans.
9108
+ * The dispatcher's result router calls this with each plate detail so the
9109
+ * gallery still collects the best read + tight crop (persisted on
9110
+ * {@link onTrackEnd}). Without it the plate label rides the event but the
9111
+ * gallery stays empty (0 plateCrop) the observed live gap.
9112
+ */
9113
+ async observePlateRead(input) {
9114
+ if (!isPlausiblePlateRead(input.text, input.score)) return;
9115
+ if (input.score < (input.minConfidence ?? 0)) return;
9116
+ await this.holdBest(input);
9117
+ }
9118
+ /** Hold the highest-scoring plate read per track, capturing a tight crop the
9119
+ * first time a new best is seen (shared by the per-frame + detail-plane paths). */
9120
+ async holdBest(input) {
9121
+ const held = this.bestPlate.get(input.trackId);
9122
+ if (held !== void 0 && input.score <= held.score) return;
9123
+ let crop;
9124
+ if (input.frameHandle !== void 0) try {
9125
+ crop = await this.deps.captureCrop(input.frameHandle, input.bbox, input.frameWidth, input.frameHeight, input.cropPadding) ?? void 0;
9126
+ } catch (err) {
9127
+ this.deps.logger.debug("PlateRecognizer crop capture failed", {
9128
+ tags: { deviceId: input.deviceId },
9129
+ meta: {
9130
+ trackId: input.trackId,
9131
+ error: String(err)
9132
+ }
8027
9133
  });
8028
9134
  }
9135
+ this.bestPlate.set(input.trackId, {
9136
+ text: input.text,
9137
+ score: input.score,
9138
+ bbox: input.bbox,
9139
+ timestamp: input.timestamp,
9140
+ ...crop !== void 0 ? { crop } : {}
9141
+ });
8029
9142
  }
8030
9143
  /** Persist the held best plate for a finished track as one PlateStore row
8031
9144
  * (crop → MediaStore under ownerKind 'plate'), then drop in-memory state. */
@@ -8361,6 +9474,40 @@ function classifyAudioFrame(top, cfg) {
8361
9474
  //#endregion
8362
9475
  //#region src/pipeline-analytics/event-media-handler.ts
8363
9476
  var CACHE_CONTROL = "public, max-age=31536000, immutable";
9477
+ /** Default / clamp bounds for the `thumb` variant edge (px). Mirrors
9478
+ * `shared/frame/square-thumb.ts`; kept here so query parsing stays pure. */
9479
+ var THUMB_DEFAULT_SIZE = 160;
9480
+ var THUMB_MIN_SIZE$1 = 64;
9481
+ var THUMB_MAX_SIZE$1 = 320;
9482
+ /**
9483
+ * Parse the `?kind=…` query into a preferred stored media kind. Returns null
9484
+ * when unset. The value is a free-form kind token (e.g. `crop`); the resolver
9485
+ * validates it against the known kinds.
9486
+ */
9487
+ function parseEventMediaKind(query) {
9488
+ const kind = new URLSearchParams(query).get("kind");
9489
+ return kind !== null && kind.length > 0 ? kind : null;
9490
+ }
9491
+ /**
9492
+ * Parse the `?variant=…` query into an {@link EventMediaVariant}. Returns null
9493
+ * when no small-square rendering was requested (the caller then serves the
9494
+ * stored blob). Accepts `variant=thumb` or `square=1`; the edge comes from
9495
+ * `size` / `w` / `h` (clamped to [64, 320], default 160).
9496
+ */
9497
+ function parseEventMediaVariant(query) {
9498
+ const params = new URLSearchParams(query);
9499
+ if (!(params.get("variant") === "thumb" || params.get("square") === "1")) return null;
9500
+ const sizeRaw = params.get("size") ?? params.get("w") ?? params.get("h");
9501
+ let size = THUMB_DEFAULT_SIZE;
9502
+ if (sizeRaw !== null) {
9503
+ const n = Number.parseInt(sizeRaw, 10);
9504
+ if (Number.isFinite(n)) size = Math.max(THUMB_MIN_SIZE$1, Math.min(THUMB_MAX_SIZE$1, n));
9505
+ }
9506
+ return {
9507
+ kind: "thumb",
9508
+ size
9509
+ };
9510
+ }
8364
9511
  /**
8365
9512
  * Create a data-plane handler that serves event thumbnails as JPEG images.
8366
9513
  *
@@ -8374,14 +9521,20 @@ function createEventMediaHandler(deps) {
8374
9521
  res.writeHead(405, { allow: "GET, HEAD" }).end();
8375
9522
  return;
8376
9523
  }
8377
- const eventId = ((req.url ?? "/").split("?")[0] ?? "/").replace(/^\/+/, "");
9524
+ const url = req.url ?? "/";
9525
+ const qIdx = url.indexOf("?");
9526
+ const rawPath = qIdx === -1 ? url : url.slice(0, qIdx);
9527
+ const query = qIdx === -1 ? "" : url.slice(qIdx + 1);
9528
+ const eventId = rawPath.replace(/^\/+/, "");
8378
9529
  if (!eventId || eventId.includes("/")) {
8379
9530
  res.writeHead(404).end();
8380
9531
  return;
8381
9532
  }
9533
+ const variant = parseEventMediaVariant(query);
9534
+ const preferKind = parseEventMediaKind(query);
8382
9535
  let media = null;
8383
9536
  try {
8384
- media = await deps.getMedia(eventId);
9537
+ media = await deps.getMedia(eventId, variant ?? void 0, preferKind ?? void 0);
8385
9538
  } catch {
8386
9539
  const body = "Internal server error";
8387
9540
  res.writeHead(500, {
@@ -8414,6 +9567,27 @@ function createEventMediaHandler(deps) {
8414
9567
  else res.end(Buffer.from(media.bytes));
8415
9568
  };
8416
9569
  }
9570
+ /** JPEG quality for the small square thumbnail (visibly fine at ≤192px, tiny). */
9571
+ var THUMB_QUALITY = 70;
9572
+ /**
9573
+ * Produce a SMALL SQUARE JPEG from an already-encoded image (typically the
9574
+ * stored 640×360 `crop`). Center-crop cover to a square then downscale to
9575
+ * `size`×`size` at JPEG q70 — the reel/list surfaces want a compact square tile
9576
+ * showing the object, not the full 16:9 crop. Output is a fraction of the source
9577
+ * (~3–8 KB at 144–192 px vs ~65 KB for the crop), so a fleet-wide reel renders
9578
+ * from tiny HTTP-cached tiles instead of full base64 payloads.
9579
+ *
9580
+ * `fit: 'cover'` + `position: 'centre'` scales the shorter side to `size` and
9581
+ * crops the overflow symmetrically — the center square of a square-safe crop
9582
+ * fully contains the detector bbox, so the object stays framed.
9583
+ */
9584
+ async function makeSquareThumb(bytes, size) {
9585
+ const edge = Math.max(64, Math.min(320, Math.round(size)));
9586
+ return sharp(Buffer.from(bytes)).resize(edge, edge, {
9587
+ fit: "cover",
9588
+ position: "centre"
9589
+ }).jpeg({ quality: THUMB_QUALITY }).toBuffer();
9590
+ }
8417
9591
  //#endregion
8418
9592
  //#region src/pipeline-analytics/index.ts
8419
9593
  /**
@@ -8436,18 +9610,17 @@ var DETAIL_FALLBACK_CROP_PADDING = .15;
8436
9610
  * before re-reading. */
8437
9611
  var CLIP_MODEL_ID_CACHE_TTL_MS = 6e4;
8438
9612
  var SETTINGS_CACHE_TTL_MS = 5e3;
9613
+ /** Reconcile cadence for the per-device `zones` / `zoneRules` slice handles.
9614
+ * The `device.state-changed` push doesn't reliably reach a forked child, so
9615
+ * each cached proxy re-pulls both slices on this timer (see ensureProxy) —
9616
+ * a zone drawn in the editor shows up in per-zone stats within one tick. */
9617
+ var ZONE_SLICE_RECONCILE_MS = 3e4;
8439
9618
  /** §5 best-frame: a track's `thumbnail` is overwritten only when the current
8440
9619
  * detection confidence beats the held best by at least this margin (hysteresis
8441
9620
  * so jitter around a plateau doesn't churn the write). */
8442
9621
  var BEST_FRAME_HYSTERESIS = .05;
8443
9622
  /** §5 best-frame: at most one best-thumbnail capture per this interval per track. */
8444
9623
  var BEST_FRAME_MIN_GAP_MS = 2e3;
8445
- /** Design B: cap (px) on the width of the native KEY FRAME (full-frame native
8446
- * capture). Native resolution is the point, but a full 4K RGB surface over the
8447
- * transport per new-best is wasteful for a web detail view — 1920px keeps a
8448
- * sharp native frame while bounding the copy (a miss falls back to the
8449
- * detection-res frame, which is already ≤640px). */
8450
- var KEYFRAME_NATIVE_MAX_WIDTH = 1920;
8451
9624
  /** getKeyEvents: max completed tracks pulled from a window before importance
8452
9625
  * ranking. Ordering is by importance (not firstSeen) and legacy rows score on
8453
9626
  * read, so we over-fetch candidates and trim to `limit` after sorting. */
@@ -8470,6 +9643,34 @@ var OVERLAY_SYNTHESIS_WARN_THROTTLE_MS = 6e4;
8470
9643
  var AUDIO_EVENT_HEARTBEAT_MS = 5e3;
8471
9644
  var MOTION_EVENT_HEARTBEAT_MS = 5e3;
8472
9645
  /**
9646
+ * Stored media kinds that carry NO drawn bounding box, in fallback preference
9647
+ * order. The reel forces `?kind=crop`; when a track has no crop the endpoint may
9648
+ * degrade only to one of these CLEAN frames — never `fullFrameBoxed` /
9649
+ * `thumbnail` / `lastFrame` / `firstFrame` / `snapshot` (all server-boxed).
9650
+ */
9651
+ var CLEAN_MEDIA_KINDS = [
9652
+ "crop",
9653
+ "fullFrame",
9654
+ "keyFrame"
9655
+ ];
9656
+ /**
9657
+ * Pick a CLEAN (never boxed) media file for a crop-forced request: the exact
9658
+ * `preferKind` if it is itself clean and present, else the first available
9659
+ * {@link CLEAN_MEDIA_KINDS} frame. Returns undefined when only boxed / no media
9660
+ * exists (caller 404s → the viewer shows an icon).
9661
+ */
9662
+ function pickCleanMedia(files, preferKind) {
9663
+ const isClean = (k) => CLEAN_MEDIA_KINDS.includes(k);
9664
+ if (isClean(preferKind)) {
9665
+ const exact = files.find((f) => f.kind === preferKind);
9666
+ if (exact) return exact;
9667
+ }
9668
+ for (const kind of CLEAN_MEDIA_KINDS) {
9669
+ const found = files.find((f) => f.kind === kind);
9670
+ if (found) return found;
9671
+ }
9672
+ }
9673
+ /**
8473
9674
  * Decode a base64 little-endian float32 vector (the `DetailResult.embedding`
8474
9675
  * wire encoding produced by `runDetailSubtree`) back into a plain number[].
8475
9676
  */
@@ -8520,6 +9721,10 @@ function stripGlobalOnlyFields(sections) {
8520
9721
  var PipelineAnalyticsAddon = class extends BaseAddon {
8521
9722
  processors = /* @__PURE__ */ new Map();
8522
9723
  trackStore = null;
9724
+ /** Parked-object registry: promotes a track that stopped moving into a
9725
+ * lightweight entry, suppresses its detections from re-spawning tracks, and
9726
+ * wakes it when the object departs. Null until onInitialize. */
9727
+ stationaryRegistry = null;
8523
9728
  mediaStore = null;
8524
9729
  eventStore = null;
8525
9730
  identityStore = null;
@@ -8545,9 +9750,6 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
8545
9750
  * detection-pipeline DECODED frame — the ONLY image source (never the
8546
9751
  * snapshot cap). Null when shm frame access is unavailable. */
8547
9752
  eventMediaDispatcher = null;
8548
- /** Shared shm-ring reader cache for resolving `frameHandle`s to pixels.
8549
- * Owned here so segments stay open across frames; closed once on shutdown. */
8550
- frameReaders = null;
8551
9753
  /** Object/face embedding dispatcher — migrated from the retired
8552
9754
  * enrichment-engine addon. Runs ONLY on the post-processing node; on each
8553
9755
  * detection it resolves the frame, crops the ROI, and calls the
@@ -8584,6 +9786,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
8584
9786
  * dataPlane facility in the current environment). */
8585
9787
  eventMediaBaseUrl = null;
8586
9788
  lastActiveTrackIds = /* @__PURE__ */ new Map();
9789
+ lastFrameDimsByDevice = /* @__PURE__ */ new Map();
8587
9790
  lastAudioInsertByDevice = /* @__PURE__ */ new Map();
8588
9791
  lastMotionInsertByDevice = /* @__PURE__ */ new Map();
8589
9792
  levelStateByDevice = /* @__PURE__ */ new Map();
@@ -8615,10 +9818,15 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
8615
9818
  * cross-restart "best" gate stays in `ObjectEmbeddingStore.upsertIfBetter`. */
8616
9819
  objectEmbeddingBestSelector = new TrackBestSelector();
8617
9820
  /** Design B: the track's shared native key-frame media key, captured at the
8618
- * best-detection moment (object-embedding best path). Read by the face path
8619
- * at track end so a face row links to the SAME single key frame. Cleared on
8620
- * track end. */
9821
+ * best-detection moment (general best-frame path). Read by the face / plate /
9822
+ * object-embedding rows so they LINK the SAME single native key frame.
9823
+ * Cleared on track end. */
8621
9824
  keyFrameKeyByTrackId = /* @__PURE__ */ new Map();
9825
+ /** Wall-clock of each track's last ACTUALLY-written rolling `lastFrame`. The
9826
+ * rolling `lastFrame` runs on its OWN pure-time cadence and only on frames
9827
+ * where no `snapshot` is appended, so it is never byte-identical to a stored
9828
+ * `snapshot` (kills the end-of-track duplicate). Cleared on track end. */
9829
+ lastFrameAtByTrack = /* @__PURE__ */ new Map();
8622
9830
  /** Per-track update-gate memory for `PipelineAnalyticsTrackLifecycle`
8623
9831
  * `phase:'update'` — the last-emitted best (confidence / label / crop
8624
9832
  * area) + emit time, so a material improvement is measured against the
@@ -8669,6 +9877,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
8669
9877
  await PlateStore.declare(api.settingsStore);
8670
9878
  await VehicleStore.declare(api.settingsStore);
8671
9879
  await ObjectEmbeddingStore.declare(api.settingsStore);
9880
+ await StationaryObjectRegistry.declare(api.settingsStore);
8672
9881
  const logger = this.ctx.logger;
8673
9882
  let storage = this.ctx.kernel.storage;
8674
9883
  const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
@@ -8682,6 +9891,30 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
8682
9891
  store: api.settingsStore,
8683
9892
  logger: logger.child("TrackStore")
8684
9893
  });
9894
+ this.stationaryRegistry = new StationaryObjectRegistry({
9895
+ store: api.settingsStore,
9896
+ logger: logger.child("StationaryRegistry"),
9897
+ onChange: ({ phase, entry, timestamp }) => {
9898
+ this.ctx.eventBus.emit({
9899
+ id: `pa-stationary-${entry.id}-${phase}`,
9900
+ timestamp: new Date(timestamp),
9901
+ source: {
9902
+ type: "addon",
9903
+ id: "pipeline-analytics",
9904
+ addonId: "pipeline-analytics"
9905
+ },
9906
+ category: EventCategory.PipelineAnalyticsStationaryChanged,
9907
+ data: {
9908
+ deviceId: entry.deviceId,
9909
+ entryId: entry.id,
9910
+ className: entry.className,
9911
+ phase,
9912
+ timestamp
9913
+ }
9914
+ });
9915
+ }
9916
+ });
9917
+ await this.stationaryRegistry.load();
8685
9918
  this.mediaStore = new MediaStore({
8686
9919
  storage,
8687
9920
  store: api.settingsStore,
@@ -8718,33 +9951,33 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
8718
9951
  designatedNode: designated
8719
9952
  } });
8720
9953
  }
8721
- this.frameReaders = new FrameRingReaderCache(logger.child("shm-readers"));
8722
- const decoderApi = api.decoder;
9954
+ const pipelineRunnerApi = api.pipelineRunner;
8723
9955
  const getRemoteFrame = async (handle) => {
8724
- if (!decoderApi?.getFrame) return null;
8725
- const remote = await decoderApi.getFrame.query({
9956
+ if (!pipelineRunnerApi?.getNativeCrop) return null;
9957
+ const full = await pipelineRunnerApi.getNativeCrop.query({
8726
9958
  handle,
8727
- nodeId: handle.nodeId
8728
- });
8729
- if (!remote) return null;
9959
+ bbox: {
9960
+ x: 0,
9961
+ y: 0,
9962
+ w: 1,
9963
+ h: 1
9964
+ },
9965
+ maxWidth: handle.width
9966
+ }, nodePin(handle.nodeId));
9967
+ if (!full || full.width <= 0 || full.height <= 0) return null;
8730
9968
  return {
8731
- data: Buffer.from(remote.data),
8732
- width: remote.width,
8733
- height: remote.height,
8734
- format: remote.format,
8735
- timestamp: remote.timestamp
9969
+ data: Buffer.from(full.bytes),
9970
+ width: full.width,
9971
+ height: full.height,
9972
+ format: "rgb",
9973
+ timestamp: 0
8736
9974
  };
8737
9975
  };
8738
9976
  this.eventMediaDispatcher = new EventMediaDispatcher({
8739
- ownNodeId,
8740
- readers: this.frameReaders,
8741
9977
  getRemoteFrame,
8742
9978
  mediaStore: this.mediaStore,
8743
9979
  logger: logger.child("EventMediaDispatcher")
8744
9980
  });
8745
- const ownNodeIdForFaces = ownNodeId;
8746
- const frameReadersForFaces = this.frameReaders;
8747
- const pipelineRunnerApi = api.pipelineRunner;
8748
9981
  const cropMetricLogger = logger.child("NativeCrop");
8749
9982
  let nativeHits = 0;
8750
9983
  let nativeFallbacks = 0;
@@ -8776,11 +10009,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
8776
10009
  return null;
8777
10010
  }
8778
10011
  };
8779
- const resolveFrameShared = createSharedFrameResolver((frameHandle) => resolveFrame(frameHandle, {
8780
- ownNodeId: ownNodeIdForFaces,
8781
- readers: frameReadersForFaces,
8782
- getRemoteFrame
8783
- }));
10012
+ const resolveFrameShared = createSharedFrameResolver((frameHandle) => resolveFrame(frameHandle, { getRemoteFrame }));
8784
10013
  const captureCrop = async (frameHandle, bbox, frameWidth, frameHeight, padding, maxWidth) => {
8785
10014
  const paddedNorm = padBbox({
8786
10015
  x: bbox.x / frameWidth,
@@ -8860,7 +10089,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
8860
10089
  });
8861
10090
  this.zoneAnalytics = new ZoneAnalyticsProvider({
8862
10091
  logger: logger.child("ZoneAnalytics"),
8863
- fetchDevice: (deviceId) => this.ctx.fetchDevice(deviceId)
10092
+ fetchDevice: (deviceId) => this.ctx.fetchDevice(deviceId),
10093
+ listStationaryObjects: (deviceId) => this.stationaryRegistry?.listViews(deviceId) ?? [],
10094
+ listStationaryDeviceIds: () => this.stationaryRegistry?.deviceIds() ?? [],
10095
+ resolveZones: (deviceId) => this.resolveDeviceZones(deviceId)
8864
10096
  });
8865
10097
  this.audioMetrics = new AudioMetricsProvider({
8866
10098
  logger: logger.child("AudioMetrics"),
@@ -8876,9 +10108,9 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
8876
10108
  }
8877
10109
  });
8878
10110
  try {
8879
- const handler = createEventMediaHandler({ getMedia: async (id) => {
10111
+ const handler = createEventMediaHandler({ getMedia: async (id, variant, preferKind) => {
8880
10112
  try {
8881
- return await this.readMediaByEventOrKey(id);
10113
+ return await this.readMediaByEventOrKey(id, variant, preferKind);
8882
10114
  } catch (err) {
8883
10115
  this.ctx.logger.warn("readEventThumbnail failed", { meta: {
8884
10116
  eventId: id,
@@ -8936,8 +10168,6 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
8936
10168
  encoder: encoderClient,
8937
10169
  eventBus: this.ctx.eventBus,
8938
10170
  logger: logger.child("EmbeddingDispatcher"),
8939
- ownNodeId,
8940
- readers: frameReadersForFaces,
8941
10171
  getRemoteFrame
8942
10172
  });
8943
10173
  await this.embeddingDispatcher.start();
@@ -8948,6 +10178,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
8948
10178
  this.bindingCache?.onBindingsChanged(data);
8949
10179
  if (data.capName === "pipeline-analytics" && data.reason === "wrapper-deactivated") {
8950
10180
  this.trackStore?.clearDevice(data.deviceId);
10181
+ this.stationaryRegistry?.forgetDevice(data.deviceId);
8951
10182
  this.overlayState.clearDevice(data.deviceId);
8952
10183
  this.overlaySynthesisWarnAt.delete(data.deviceId);
8953
10184
  this.forgetDeviceProcessors(data.deviceId);
@@ -8962,6 +10193,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
8962
10193
  this.unsubDeviceUnreg = this.ctx.eventBus.subscribe({ category: EventCategory.DeviceUnregistered }, (ev) => {
8963
10194
  const { deviceId } = ev.data;
8964
10195
  this.trackStore?.clearDevice(deviceId);
10196
+ this.stationaryRegistry?.forgetDevice(deviceId);
8965
10197
  this.overlayState.clearDevice(deviceId);
8966
10198
  this.overlaySynthesisWarnAt.delete(deviceId);
8967
10199
  this.forgetDeviceProcessors(deviceId);
@@ -8982,6 +10214,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
8982
10214
  this.retentionSweepTimer = setInterval(() => {
8983
10215
  this.sweepRetention();
8984
10216
  this.runTrackRetentionSweep();
10217
+ this.stationaryRegistry?.sweep(Date.now());
8985
10218
  }, RETENTION_SWEEP_INTERVAL_MS);
8986
10219
  this.ctx.logger.info("pipeline-analytics subscribers installed");
8987
10220
  const widgetsProvider = { listWidgets: async () => [
@@ -9302,8 +10535,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
9302
10535
  this.overlaySynthesisWarnAt.clear();
9303
10536
  this.processors.clear();
9304
10537
  this.lastActiveTrackIds.clear();
10538
+ this.lastFrameDimsByDevice.clear();
9305
10539
  this.dropoutSkipsByKey.clear();
9306
10540
  this.bestFrameTracker.clear();
10541
+ this.lastFrameAtByTrack.clear();
9307
10542
  this.trackLifecycleUpdateMem.clear();
9308
10543
  this.objectEmbeddingBestSelector.clear();
9309
10544
  this.levelStateByDevice.clear();
@@ -9314,13 +10549,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
9314
10549
  this.faceGlobalEnabledCache = null;
9315
10550
  this.mediaCacheByDevice.clear();
9316
10551
  this.trackStore?.clearAll();
10552
+ this.stationaryRegistry = null;
9317
10553
  this.bindingCache?.clearAll();
9318
10554
  await this.eventMediaDataPlane?.dispose();
9319
10555
  this.eventMediaDataPlane = null;
9320
10556
  this.eventMediaBaseUrl = null;
9321
10557
  this.eventMediaDispatcher = null;
9322
- this.frameReaders?.close();
9323
- this.frameReaders = null;
9324
10558
  }
9325
10559
  async handleInferenceResult(data) {
9326
10560
  if (this.shuttingDown) return;
@@ -9368,22 +10602,41 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
9368
10602
  timestamp: frame.timestamp,
9369
10603
  frame
9370
10604
  });
10605
+ if (source === "pipeline") this.stationaryRegistry?.noteFrame(deviceId, result.timestamp);
10606
+ if (this.stationaryRegistry && (result.stationaryConfirmed.length > 0 || result.stationaryWoken.length > 0)) this.stationaryRegistry.applyFrameOutcome({
10607
+ deviceId,
10608
+ confirmed: result.stationaryConfirmed,
10609
+ wokenEntryIds: result.stationaryWoken,
10610
+ timestamp: result.timestamp
10611
+ });
10612
+ const stationaryViews = this.stationaryRegistry?.listViews(deviceId) ?? [];
10613
+ const stationaryAsTracked = stationaryViews.map((v) => ({
10614
+ trackId: `stationary:${v.id}`,
10615
+ className: v.className,
10616
+ zones: computeStationaryEntryZones(v, liveZones)
10617
+ }));
9371
10618
  this.zoneAnalytics?.recordFrame({
9372
10619
  deviceId,
9373
10620
  timestamp: result.timestamp,
9374
10621
  frameWidth: result.frameWidth,
9375
10622
  frameHeight: result.frameHeight,
9376
- tracked: result.tracked,
9377
- zones: liveZones
10623
+ tracked: stationaryAsTracked.length > 0 ? [...result.tracked, ...stationaryAsTracked] : result.tracked,
10624
+ zones: liveZones,
10625
+ ...stationaryViews.length > 0 ? { stationaryObjects: stationaryViews } : {}
10626
+ });
10627
+ if (result.frameWidth > 0 && result.frameHeight > 0) this.lastFrameDimsByDevice.set(deviceId, {
10628
+ w: result.frameWidth,
10629
+ h: result.frameHeight
9378
10630
  });
9379
10631
  const currentTrackIds = /* @__PURE__ */ new Set();
10632
+ const positionsCountById = /* @__PURE__ */ new Map();
9380
10633
  for (const t of result.tracked) {
9381
10634
  currentTrackIds.add(t.trackId);
9382
10635
  const center = {
9383
10636
  x: t.bbox.x + t.bbox.w / 2,
9384
10637
  y: t.bbox.y + t.bbox.h / 2
9385
10638
  };
9386
- this.trackStore.upsert({
10639
+ const upserted = this.trackStore.upsert({
9387
10640
  trackId: t.trackId,
9388
10641
  deviceId,
9389
10642
  className: t.className,
@@ -9398,6 +10651,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
9398
10651
  zones: t.zones,
9399
10652
  state: t.state
9400
10653
  });
10654
+ positionsCountById.set(t.trackId, upserted.positions.length);
9401
10655
  }
9402
10656
  const log = this.ctx.logger.withTags({ deviceId });
9403
10657
  const prevIds = this.lastActiveTrackIds.get(key) ?? /* @__PURE__ */ new Set();
@@ -9406,6 +10660,18 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
9406
10660
  for (const id of currentTrackIds) if (!prevIds.has(id)) {
9407
10661
  const t = result.tracked.find((x) => x.trackId === id);
9408
10662
  if (t) {
10663
+ if (classifyTrackAppearance({
10664
+ inPrevActive: false,
10665
+ positionsCount: positionsCountById.get(id) ?? 1
10666
+ }) === "resurrection") {
10667
+ log.info("track resumed", { meta: {
10668
+ trackId: id,
10669
+ className: t.className,
10670
+ source,
10671
+ resurrected: true
10672
+ } });
10673
+ continue;
10674
+ }
9409
10675
  newTrackCount += 1;
9410
10676
  log.info("track started", { meta: {
9411
10677
  trackId: id,
@@ -9419,7 +10685,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
9419
10685
  bbox: { ...t.bbox },
9420
10686
  ...t.label ? { label: t.label } : {}
9421
10687
  });
9422
- this.trackStore.seedSnapshotClock(id, result.timestamp);
10688
+ this.trackStore.seedSnapshotClock(id, result.timestamp, t.bbox);
9423
10689
  }
9424
10690
  this.ctx.eventBus.emit({
9425
10691
  id: `pa-${randomUUID()}`,
@@ -9465,6 +10731,34 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
9465
10731
  } });
9466
10732
  }
9467
10733
  this.lastActiveTrackIds.set(key, currentTrackIds);
10734
+ if (source === "pipeline" && this.stationaryRegistry) {
10735
+ const dims = this.lastFrameDimsByDevice.get(deviceId);
10736
+ if (dims && dims.w > 0 && dims.h > 0) {
10737
+ const refDiag = Math.hypot(dims.w, dims.h);
10738
+ for (const t of result.tracked) {
10739
+ const active = this.trackStore.peekActive(t.trackId);
10740
+ if (!active) continue;
10741
+ const { promote } = evaluateStationaryPromotion({
10742
+ positions: active.positions,
10743
+ referenceDiagonalPx: refDiag,
10744
+ now: result.timestamp,
10745
+ config: DEFAULT_PROMOTION_CONFIG
10746
+ });
10747
+ if (!promote) continue;
10748
+ this.promoteToStationary({
10749
+ deviceId,
10750
+ key,
10751
+ processor,
10752
+ track: t,
10753
+ firstSeen: active.firstSeen,
10754
+ label: active.label,
10755
+ frameWidth: result.frameWidth,
10756
+ frameHeight: result.frameHeight,
10757
+ timestamp: result.timestamp
10758
+ });
10759
+ }
10760
+ }
10761
+ }
9468
10762
  if (this.detailDispatcher && detailSteps && detailSteps.length > 0) {
9469
10763
  const dispatcher = this.detailDispatcher;
9470
10764
  const steps = detailSteps;
@@ -9533,7 +10827,9 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
9533
10827
  let plateCrops = 0;
9534
10828
  for (const crops of childCropsByEvent.values()) for (const c of crops) if (c.kind === "faceCrop") faceCrops += 1;
9535
10829
  else plateCrops += 1;
9536
- const snapshotTargets = this.buildSnapshotTargets(deviceId, result.tracked, result.timestamp, mediaSettings);
10830
+ const snapshotTargets = this.buildSnapshotTargets(deviceId, result.tracked, result.timestamp, mediaSettings, result.frameWidth, result.frameHeight);
10831
+ const keyFrameTrackIds = selectKeyFrameTrackIds(snapshotTargets);
10832
+ if (keyFrameTrackIds.length > 0) this.persistKeyFrames(deviceId, result.timestamp, keyFrameTrackIds, frameHandle, result.frameWidth, result.frameHeight);
9537
10833
  if (eventTargets.length > 0 || firstFrameTargets.length > 0 || snapshotTargets.length > 0) {
9538
10834
  const captureCounts = {
9539
10835
  events: eventTargets.length,
@@ -9748,14 +11044,36 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
9748
11044
  if (isFaceDetail && d.embedding !== void 0) await this.routeFaceDetail(deviceId, trackId, d, frame);
9749
11045
  else if (d.embedding !== void 0) await this.routeClipDetail(deviceId, trackId, d, frame.timestamp);
9750
11046
  else if (d.label !== void 0 && d.label.length > 0) {
9751
- if (d.className === "plate" && d.bbox !== void 0) this.overlayState.notePlateDetail(deviceId, trackId, {
9752
- x: d.bbox.x,
9753
- y: d.bbox.y,
9754
- w: d.bbox.w,
9755
- h: d.bbox.h
9756
- }, d.score, d.label, frame.timestamp);
9757
- const label = d.className === "plate" ? this.plateRecognizer?.resolveLabel(d.label, d.score) ?? d.label : d.label;
9758
- await this.applyTrackEnrichmentLabel(deviceId, trackId, label);
11047
+ if (d.className === "plate" && d.bbox !== void 0) {
11048
+ this.overlayState.notePlateDetail(deviceId, trackId, {
11049
+ x: d.bbox.x,
11050
+ y: d.bbox.y,
11051
+ w: d.bbox.w,
11052
+ h: d.bbox.h
11053
+ }, d.score, d.label, frame.timestamp);
11054
+ if (this.plateRecognizer) {
11055
+ const mediaSettings = await this.resolveDeviceMediaSettings(deviceId);
11056
+ await this.plateRecognizer.observePlateRead({
11057
+ deviceId,
11058
+ trackId,
11059
+ text: d.label,
11060
+ score: d.score,
11061
+ bbox: {
11062
+ x: d.bbox.x,
11063
+ y: d.bbox.y,
11064
+ w: d.bbox.w,
11065
+ h: d.bbox.h
11066
+ },
11067
+ timestamp: frame.timestamp,
11068
+ frameWidth: frame.frameWidth,
11069
+ frameHeight: frame.frameHeight,
11070
+ cropPadding: mediaSettings.cropPadding,
11071
+ ...frame.frameHandle !== void 0 ? { frameHandle: frame.frameHandle } : {}
11072
+ });
11073
+ }
11074
+ }
11075
+ const label = d.className === "plate" ? this.plateRecognizer?.resolveLabel(d.label, d.score) ?? null : d.label;
11076
+ if (label !== null && label !== void 0) await this.applyTrackEnrichmentLabel(deviceId, trackId, label);
9759
11077
  }
9760
11078
  } catch (err) {
9761
11079
  this.ctx.logger.warn("detail result route failed", {
@@ -9902,55 +11220,26 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
9902
11220
  await Promise.all(bests.map(async (t) => {
9903
11221
  if (!isClipObjectEmbedding(t)) return;
9904
11222
  let mediaKey;
9905
- let keyFrameMediaKey;
9906
- if (frameHandle !== void 0 && this.captureCrop && this.mediaStore) {
9907
- try {
9908
- const crop = await this.captureCrop(frameHandle, t.bbox, frameWidth, frameHeight, cropPadding);
9909
- if (crop) mediaKey = await this.mediaStore.putReplacing({
9910
- deviceId,
9911
- ownerKind: "track",
9912
- ownerId: t.trackId,
9913
- kind: "crop",
9914
- timestamp,
9915
- data: crop
9916
- });
9917
- } catch (err) {
9918
- this.ctx.logger.debug("object-embedding crop capture failed", {
9919
- tags: { deviceId },
9920
- meta: {
9921
- trackId: t.trackId,
9922
- error: errMsg(err)
9923
- }
9924
- });
9925
- }
9926
- try {
9927
- const keyFrame = await this.captureCrop(frameHandle, {
9928
- x: 0,
9929
- y: 0,
9930
- w: frameWidth,
9931
- h: frameHeight
9932
- }, frameWidth, frameHeight, 0, KEYFRAME_NATIVE_MAX_WIDTH);
9933
- if (keyFrame) {
9934
- keyFrameMediaKey = await this.mediaStore.putReplacing({
9935
- deviceId,
9936
- ownerKind: "track",
9937
- ownerId: t.trackId,
9938
- kind: "keyFrame",
9939
- timestamp,
9940
- data: keyFrame
9941
- });
9942
- this.keyFrameKeyByTrackId.set(t.trackId, keyFrameMediaKey);
11223
+ if (frameHandle !== void 0 && this.captureCrop && this.mediaStore) try {
11224
+ const crop = await this.captureCrop(frameHandle, t.bbox, frameWidth, frameHeight, cropPadding);
11225
+ if (crop) mediaKey = await this.mediaStore.putReplacing({
11226
+ deviceId,
11227
+ ownerKind: "track",
11228
+ ownerId: t.trackId,
11229
+ kind: "crop",
11230
+ timestamp,
11231
+ data: crop
11232
+ });
11233
+ } catch (err) {
11234
+ this.ctx.logger.debug("object-embedding crop capture failed", {
11235
+ tags: { deviceId },
11236
+ meta: {
11237
+ trackId: t.trackId,
11238
+ error: errMsg(err)
9943
11239
  }
9944
- } catch (err) {
9945
- this.ctx.logger.debug("key-frame capture failed", {
9946
- tags: { deviceId },
9947
- meta: {
9948
- trackId: t.trackId,
9949
- error: errMsg(err)
9950
- }
9951
- });
9952
- }
11240
+ });
9953
11241
  }
11242
+ const keyFrameMediaKey = this.keyFrameKeyByTrackId.get(t.trackId);
9954
11243
  await store.upsertIfBetter({
9955
11244
  trackId: t.trackId,
9956
11245
  deviceId,
@@ -9964,6 +11253,48 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
9964
11253
  });
9965
11254
  }));
9966
11255
  }
11256
+ /**
11257
+ * Capture ONE native-resolution KEY FRAME per given track at this best-
11258
+ * detection frame and store it (`putReplacing` → one keyFrame per track).
11259
+ *
11260
+ * The full frame is cropped NATIVE-FIRST via `captureCrop`: the request is the
11261
+ * FULL frame (no padding) at `KEYFRAME_NATIVE_MAX_WIDTH`, which routes through
11262
+ * `pipelineRunner.getNativeCrop` (the decode worker's retained native surface)
11263
+ * and only falls back to the ≤640 detection frame when the native lease is
11264
+ * gone. The stored key is recorded in `keyFrameKeyByTrackId` so the face /
11265
+ * plate / object-embedding rows LINK the SAME native key frame (Design B).
11266
+ * Issued in the live-frame window so the native lease is still held. Best-
11267
+ * effort (D8) — a per-track failure is logged and never thrown.
11268
+ */
11269
+ async persistKeyFrames(deviceId, timestamp, trackIds, frameHandle, frameWidth, frameHeight) {
11270
+ const capture = this.captureCrop;
11271
+ const mediaStore = this.mediaStore;
11272
+ if (!capture || !mediaStore) return;
11273
+ const req = buildKeyFrameCaptureRequest(frameWidth, frameHeight);
11274
+ await Promise.all(trackIds.map(async (trackId) => {
11275
+ try {
11276
+ const keyFrame = await capture(frameHandle, req.bbox, frameWidth, frameHeight, req.padding, req.maxWidth);
11277
+ if (!keyFrame) return;
11278
+ const key = await mediaStore.putReplacing({
11279
+ deviceId,
11280
+ ownerKind: "track",
11281
+ ownerId: trackId,
11282
+ kind: "keyFrame",
11283
+ timestamp,
11284
+ data: keyFrame
11285
+ });
11286
+ this.keyFrameKeyByTrackId.set(trackId, key);
11287
+ } catch (err) {
11288
+ this.ctx.logger.debug("key-frame capture failed", {
11289
+ tags: { deviceId },
11290
+ meta: {
11291
+ trackId,
11292
+ error: errMsg(err)
11293
+ }
11294
+ });
11295
+ }
11296
+ }));
11297
+ }
9967
11298
  /** Emit a `PipelineAnalyticsTrackLifecycle` event (start / update / end). */
9968
11299
  emitTrackLifecycle(payload, timestamp) {
9969
11300
  this.ctx.eventBus.emit({
@@ -10013,27 +11344,48 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
10013
11344
  ...track?.zonesVisited !== void 0 ? { zonesVisited: track.zonesVisited } : {},
10014
11345
  ...track?.totalDistance !== void 0 ? { totalDistance: track.totalDistance } : {},
10015
11346
  ...track?.positions !== void 0 ? { positionsCount: track.positions.length } : {},
11347
+ ...track?.audioLabels !== void 0 ? { audioLabels: track.audioLabels } : {},
10016
11348
  ...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {},
10017
11349
  ...t.embeddingModelId !== void 0 ? { embeddingModelId: t.embeddingModelId } : {}
10018
11350
  });
10019
11351
  this.emitTrackLifecycle(payload, timestamp);
10020
11352
  }
10021
- buildSnapshotTargets(deviceId, tracked, timestamp, media) {
11353
+ buildSnapshotTargets(deviceId, tracked, timestamp, media, frameWidth, frameHeight) {
10022
11354
  const targets = [];
10023
11355
  for (const t of tracked) {
10024
11356
  const lastSnap = this.trackStore.lastSnapshotAt(t.trackId);
10025
- const dueSnapshot = media.saveThumbnails && lastSnap > 0 && timestamp - lastSnap >= media.snapshotIntervalMs;
11357
+ const dueSnapshot = media.saveThumbnails && evaluatePeriodicSnapshot({
11358
+ lastSnapshotAt: lastSnap,
11359
+ lastSnapshotBbox: this.trackStore.lastSnapshotBbox(t.trackId),
11360
+ currentBbox: t.bbox,
11361
+ now: timestamp,
11362
+ frameWidth,
11363
+ frameHeight,
11364
+ intervalMs: media.snapshotIntervalMs,
11365
+ movementThreshold: media.snapshotMovementThreshold,
11366
+ maxIdleMs: media.snapshotMaxIdleMs
11367
+ }).capture;
10026
11368
  const isNewBest = this.bestFrameTracker.observe(t.trackId, t.confidence, timestamp);
10027
11369
  this.maybeEmitTrackLifecycleUpdate(deviceId, t, timestamp, isNewBest);
10028
- if (!dueSnapshot && !isNewBest) continue;
11370
+ const plan = planPeriodicMedia({
11371
+ saveThumbnails: media.saveThumbnails,
11372
+ dueSnapshot,
11373
+ isNewBest,
11374
+ lastFrameAt: this.lastFrameAtByTrack.get(t.trackId) ?? 0,
11375
+ now: timestamp,
11376
+ intervalMs: media.snapshotIntervalMs
11377
+ });
11378
+ if (plan.rollingLastFrame) this.lastFrameAtByTrack.set(t.trackId, timestamp);
11379
+ if (plan.appendSnapshot) this.trackStore.markSnapshotPending(t.trackId, timestamp, t.bbox);
11380
+ if (!plan.appendSnapshot && !plan.rollingLastFrame && !plan.bestThumbnail) continue;
10029
11381
  targets.push({
10030
11382
  trackId: t.trackId,
10031
11383
  timestamp,
10032
11384
  bbox: { ...t.bbox },
10033
11385
  ...t.label ? { label: t.label } : {},
10034
- appendSnapshot: dueSnapshot,
10035
- rollingLastFrame: dueSnapshot,
10036
- bestThumbnail: isNewBest
11386
+ appendSnapshot: plan.appendSnapshot,
11387
+ rollingLastFrame: plan.rollingLastFrame,
11388
+ bestThumbnail: plan.bestThumbnail
10037
11389
  });
10038
11390
  }
10039
11391
  return targets;
@@ -10089,6 +11441,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
10089
11441
  atMs: timestamp
10090
11442
  });
10091
11443
  await this.eventStore.insertAudio(ev);
11444
+ this.trackStore?.addAudioLabelEpisode(deviceId, route.className, topClassification.score, timestamp);
10092
11445
  this.ctx.eventBus.emit({
10093
11446
  id: `pa-${ev.id}`,
10094
11447
  timestamp: new Date(ev.timestamp),
@@ -10306,6 +11659,11 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
10306
11659
  const peak = await this.eventStore?.peakForTrack(t.trackId);
10307
11660
  if (peak) {
10308
11661
  endBestEventId = peak.bestEventId;
11662
+ const dims = this.lastFrameDimsByDevice.get(t.deviceId);
11663
+ const staticMetrics = dims ? computeStaticTrackMetrics(t.positions.map((p) => ({
11664
+ x: p.x,
11665
+ y: p.y
11666
+ })), Math.hypot(dims.w, dims.h)) : void 0;
10309
11667
  const { importance, reason } = computeImportance({
10310
11668
  peakConfidence: trackerPeak?.confidence ?? peak.peakConfidence,
10311
11669
  className: t.className,
@@ -10313,7 +11671,11 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
10313
11671
  peakBboxAreaFrac: peak.peakBboxAreaFrac,
10314
11672
  totalDistance: t.totalDistance,
10315
11673
  zonesVisited: t.zonesVisited,
10316
- ...t.label !== void 0 ? { label: t.label } : {}
11674
+ ...t.label !== void 0 ? { label: t.label } : {},
11675
+ ...staticMetrics ? {
11676
+ netDisplacementFrac: staticMetrics.netDisplacementFrac,
11677
+ pathSpanFrac: staticMetrics.pathSpanFrac
11678
+ } : {}
10317
11679
  });
10318
11680
  endImportance = importance;
10319
11681
  endImportanceReason = reason;
@@ -10328,6 +11690,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
10328
11690
  }
10329
11691
  this.bestFrameTracker.delete(t.trackId);
10330
11692
  this.objectEmbeddingBestSelector.delete(t.trackId);
11693
+ this.lastFrameAtByTrack.delete(t.trackId);
10331
11694
  this.detailDispatcher?.onTrackEnded(t.deviceId, t.trackId);
10332
11695
  this.overlayState.onTrackEnded(t.deviceId, t.trackId);
10333
11696
  if ((this.trackStore?.getActive(t.deviceId).length ?? 0) === 0) {
@@ -10372,6 +11735,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
10372
11735
  positionsCount: t.positions.length,
10373
11736
  ...endImportance !== void 0 ? { importance: endImportance } : {},
10374
11737
  ...endImportanceReason !== void 0 ? { importanceReason: endImportanceReason } : {},
11738
+ ...t.audioLabels !== void 0 ? { audioLabels: t.audioLabels } : {},
10375
11739
  ...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {},
10376
11740
  ...endBestEventId !== void 0 ? { bestEventId: endBestEventId } : {}
10377
11741
  });
@@ -10534,6 +11898,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
10534
11898
  for (const k of this.processors.keys()) if (k.startsWith(prefix)) this.processors.delete(k);
10535
11899
  for (const k of this.lastActiveTrackIds.keys()) if (k.startsWith(prefix)) this.lastActiveTrackIds.delete(k);
10536
11900
  for (const k of this.dropoutSkipsByKey.keys()) if (k.startsWith(prefix)) this.dropoutSkipsByKey.delete(k);
11901
+ this.lastFrameDimsByDevice.delete(deviceId);
10537
11902
  }
10538
11903
  /** Apply a mutation to every live source-processor of a device (zones/rules
10539
11904
  * are device-level and must reach all sources). */
@@ -10541,6 +11906,57 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
10541
11906
  const prefix = `${deviceId}:`;
10542
11907
  for (const [k, p] of this.processors) if (k.startsWith(prefix)) fn(p);
10543
11908
  }
11909
+ /**
11910
+ * Turn a parked track into a stationary-registry entry and tear the track
11911
+ * down WITHOUT firing an 'end' key-event / importance scoring / media flush
11912
+ * (a parked object is not a highlight; the durable record is the entry). The
11913
+ * tracker + store forget the track so its detections stop re-spawning tracks,
11914
+ * and the registry suppresses them from the next frame on.
11915
+ */
11916
+ promoteToStationary(input) {
11917
+ const { deviceId, key, processor, track, firstSeen, label, frameWidth, frameHeight, timestamp } = input;
11918
+ const keyFrameMediaKey = this.keyFrameKeyByTrackId.get(track.trackId);
11919
+ const entry = {
11920
+ id: randomUUID(),
11921
+ deviceId,
11922
+ className: track.className,
11923
+ bbox: { ...track.bbox },
11924
+ frameWidth,
11925
+ frameHeight,
11926
+ firstSeenAt: firstSeen,
11927
+ becameStationaryAt: timestamp,
11928
+ lastConfirmedAt: timestamp,
11929
+ sourceTrackId: track.trackId,
11930
+ ...label !== void 0 ? { label } : {},
11931
+ ...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {}
11932
+ };
11933
+ this.stationaryRegistry?.promote(entry);
11934
+ processor.dropTrack(track.trackId);
11935
+ this.trackStore?.dropActive(track.trackId);
11936
+ const faceEnd = this.faceRecognizer?.onTrackEnd(deviceId, track.trackId);
11937
+ const dropKeyFrame = () => {
11938
+ this.keyFrameKeyByTrackId.delete(track.trackId);
11939
+ };
11940
+ if (faceEnd) faceEnd.finally(dropKeyFrame);
11941
+ else dropKeyFrame();
11942
+ this.plateRecognizer?.onTrackEnd(deviceId, track.trackId);
11943
+ this.bestFrameTracker.delete(track.trackId);
11944
+ this.objectEmbeddingBestSelector.delete(track.trackId);
11945
+ this.lastFrameAtByTrack.delete(track.trackId);
11946
+ this.trackLifecycleUpdateMem.delete(track.trackId);
11947
+ this.detailDispatcher?.onTrackEnded(deviceId, track.trackId);
11948
+ this.overlayState.onTrackEnded(deviceId, track.trackId);
11949
+ this.lastActiveTrackIds.get(key)?.delete(track.trackId);
11950
+ this.ctx.logger.info("track promoted to stationary", {
11951
+ tags: { deviceId },
11952
+ meta: {
11953
+ trackId: track.trackId,
11954
+ className: track.className,
11955
+ entryId: entry.id,
11956
+ ...label ? { label } : {}
11957
+ }
11958
+ });
11959
+ }
10544
11960
  async getOrCreateProcessor(deviceId, source) {
10545
11961
  const key = this.procKey(deviceId, source);
10546
11962
  let p = this.processors.get(key);
@@ -10566,6 +11982,18 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
10566
11982
  cooldownSec,
10567
11983
  minTrackAgeMs: trk.minTrackAgeMs
10568
11984
  }, source);
11985
+ if (source === "pipeline" && this.stationaryRegistry) {
11986
+ const registry = this.stationaryRegistry;
11987
+ p.setStationaryGate({ filter: (input) => registry.filter({
11988
+ deviceId,
11989
+ detections: input.detections.map((d) => ({
11990
+ bbox: d.bbox,
11991
+ className: d.class
11992
+ })),
11993
+ frameWidth: input.frameWidth,
11994
+ frameHeight: input.frameHeight
11995
+ }) });
11996
+ }
10569
11997
  this.processors.set(key, p);
10570
11998
  }
10571
11999
  return p;
@@ -10577,6 +12005,16 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
10577
12005
  * forward updates to the per-device FrameProcessor so a rule change
10578
12006
  * applies to the very next frame even when frames stop briefly
10579
12007
  * (e.g. during binding flips).
12008
+ *
12009
+ * RECONCILE: the push channel behind `subscribe` (`device.state-changed`
12010
+ * via `live.onEvent`) does not reliably reach a forked addon child — a
12011
+ * zone created AFTER the proxy's cold read stayed invisible until the
12012
+ * addon respawned (live-diagnosed on device 617, 2026-07-16: zone slice
12013
+ * populated hub-side, `zones: []` in every snapshot). Events are lossy
12014
+ * telemetry (D8); the durable channel is RPC + reconcile — so each
12015
+ * proxy also refreshes its two slices on a slow timer. `refresh()`
12016
+ * round-trips `deviceState.getCapSlice` and fans out through the SAME
12017
+ * subscribe callbacks above, so a zone edit lands within one interval.
10580
12018
  */
10581
12019
  async ensureProxy(deviceId) {
10582
12020
  const cached = this.proxies.get(deviceId);
@@ -10585,13 +12023,22 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
10585
12023
  const proxy = await this.ctx.api.deviceManager ? await this.ctx.fetchDevice(deviceId) : null;
10586
12024
  if (!proxy) return null;
10587
12025
  this.proxies.set(deviceId, proxy);
10588
- const unsubs = [proxy.state.zones.subscribe((slice) => {
10589
- const zones = slice?.zones ?? [];
10590
- this.forEachDeviceProcessor(deviceId, (p) => p.setZones(zones));
10591
- }), proxy.state.zoneRules.subscribe((slice) => {
10592
- const rules = slice?.detection ?? [];
10593
- this.forEachDeviceProcessor(deviceId, (p) => p.setDetectionRules(rules));
10594
- })];
12026
+ const reconcile = setInterval(() => {
12027
+ proxy.state.zones.refresh().catch(() => void 0);
12028
+ proxy.state.zoneRules.refresh().catch(() => void 0);
12029
+ }, ZONE_SLICE_RECONCILE_MS);
12030
+ reconcile.unref?.();
12031
+ const unsubs = [
12032
+ proxy.state.zones.subscribe((slice) => {
12033
+ const zones = slice?.zones ?? [];
12034
+ this.forEachDeviceProcessor(deviceId, (p) => p.setZones(zones));
12035
+ }),
12036
+ proxy.state.zoneRules.subscribe((slice) => {
12037
+ const rules = slice?.detection ?? [];
12038
+ this.forEachDeviceProcessor(deviceId, (p) => p.setDetectionRules(rules));
12039
+ }),
12040
+ () => clearInterval(reconcile)
12041
+ ];
10595
12042
  this.proxyUnsubs.set(deviceId, unsubs);
10596
12043
  return proxy;
10597
12044
  } catch (err) {
@@ -10602,6 +12049,21 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
10602
12049
  return null;
10603
12050
  }
10604
12051
  }
12052
+ /**
12053
+ * Resolve a device's current 0–1 zone catalogue independent of the live
12054
+ * frame path — used by zone-analytics snapshot hydration + the occupancy
12055
+ * baseline sampler when the camera is detached (no frames). Warms the proxy
12056
+ * (cold read via `fetchDevice`) and, when the cached slice is empty, forces
12057
+ * one `refresh()` round-trip so a just-created proxy returns real zones.
12058
+ */
12059
+ async resolveDeviceZones(deviceId) {
12060
+ const proxy = await this.ensureProxy(deviceId);
12061
+ if (!proxy) return [];
12062
+ const cached = proxy.state.zones.value?.zones;
12063
+ if (cached && cached.length > 0) return cached;
12064
+ await proxy.state.zones.refresh().catch(() => void 0);
12065
+ return proxy.state.zones.value?.zones ?? [];
12066
+ }
10605
12067
  releaseProxy(deviceId) {
10606
12068
  const unsubs = this.proxyUnsubs.get(deviceId);
10607
12069
  if (unsubs) for (const u of unsubs) try {
@@ -10623,6 +12085,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
10623
12085
  }
10624
12086
  async clearTracks(input) {
10625
12087
  this.trackStore?.clearDevice(input.deviceId);
12088
+ this.stationaryRegistry?.clearDevice(input.deviceId);
10626
12089
  this.overlayState.clearDevice(input.deviceId);
10627
12090
  this.overlaySynthesisWarnAt.delete(input.deviceId);
10628
12091
  const prefix = `${input.deviceId}:`;
@@ -10924,6 +12387,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
10924
12387
  * Enrolled gallery + identity media are exempt.
10925
12388
  */
10926
12389
  async wipeAllAnalytics(input) {
12390
+ await this.stationaryRegistry?.clearDevice(input.deviceId);
10927
12391
  return this.pruneTracksBefore({
10928
12392
  deviceId: input.deviceId,
10929
12393
  cutoffMs: Date.now()
@@ -10975,24 +12439,62 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
10975
12439
  * points its thumbnail at the embedding row's crop key (a `track:…:crop:…`
10976
12440
  * key), so this resolves that crop; event ids stay on the event-crop path.
10977
12441
  */
10978
- async readMediaByEventOrKey(id) {
10979
- if (id.includes(":")) {
10980
- const file = await (this.mediaStore?.getByKey(id) ?? Promise.resolve(null));
10981
- if (!file) return null;
12442
+ async readMediaByEventOrKey(id, variant, preferKind) {
12443
+ const base = id.includes(":") ? await this.readMediaByKey(id) : await this.readEventThumbnail(id, preferKind);
12444
+ if (base === null || variant === void 0) return base;
12445
+ return this.applyThumbVariant(base, variant);
12446
+ }
12447
+ async readMediaByKey(id) {
12448
+ const file = await (this.mediaStore?.getByKey(id) ?? Promise.resolve(null));
12449
+ if (!file) return null;
12450
+ return {
12451
+ bytes: Buffer.from(file.base64, "base64"),
12452
+ key: file.key
12453
+ };
12454
+ }
12455
+ /**
12456
+ * Render a small center-cropped square from a resolved event media blob for
12457
+ * the reel / list surfaces. The returned `key` is variant-distinct so the
12458
+ * data-plane ETag never collides with the full-size blob's. On any encode
12459
+ * failure the full blob is served (a thumb must never 500 / blank a tile).
12460
+ */
12461
+ async applyThumbVariant(media, variant) {
12462
+ try {
10982
12463
  return {
10983
- bytes: Buffer.from(file.base64, "base64"),
10984
- key: file.key
12464
+ bytes: await makeSquareThumb(media.bytes, variant.size),
12465
+ key: `${media.key}|t${variant.size}`
10985
12466
  };
12467
+ } catch (err) {
12468
+ this.ctx.logger.debug("event media: thumb variant failed — serving full", { meta: {
12469
+ key: media.key,
12470
+ size: variant.size,
12471
+ error: errMsg(err)
12472
+ } });
12473
+ return media;
10986
12474
  }
10987
- return this.readEventThumbnail(id);
10988
12475
  }
10989
- async readEventThumbnail(eventId) {
10990
- const files = await (this.mediaStore?.listByOwner("event", eventId) ?? Promise.resolve([]));
10991
- const chosen = files.find((f) => f.kind === "crop") ?? files.find((f) => f.kind === "fullFrameBoxed") ?? files[0];
10992
- if (!chosen) return null;
12476
+ async readEventThumbnail(id, preferKind) {
12477
+ const eventFiles = await (this.mediaStore?.listByOwner("event", id) ?? Promise.resolve([]));
12478
+ if (preferKind !== void 0 && preferKind.length > 0) {
12479
+ const trackFiles = await (this.mediaStore?.listByOwner("track", id) ?? Promise.resolve([]));
12480
+ const clean = pickCleanMedia([...eventFiles, ...trackFiles], preferKind);
12481
+ if (!clean) return null;
12482
+ return {
12483
+ bytes: Buffer.from(clean.base64, "base64"),
12484
+ key: clean.key
12485
+ };
12486
+ }
12487
+ const chosenEvent = eventFiles.find((f) => f.kind === "crop") ?? eventFiles.find((f) => f.kind === "fullFrameBoxed") ?? eventFiles[0];
12488
+ if (chosenEvent) return {
12489
+ bytes: Buffer.from(chosenEvent.base64, "base64"),
12490
+ key: chosenEvent.key
12491
+ };
12492
+ const trackFiles = await (this.mediaStore?.listByOwner("track", id) ?? Promise.resolve([]));
12493
+ 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];
12494
+ if (!chosenTrack) return null;
10993
12495
  return {
10994
- bytes: Buffer.from(chosen.base64, "base64"),
10995
- key: chosen.key
12496
+ bytes: Buffer.from(chosenTrack.base64, "base64"),
12497
+ key: chosenTrack.key
10996
12498
  };
10997
12499
  }
10998
12500
  /**
@@ -11085,6 +12587,32 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
11085
12587
  unit: "s",
11086
12588
  displayScale: 1e3
11087
12589
  },
12590
+ {
12591
+ type: "slider",
12592
+ key: "snapshotMovementThreshold",
12593
+ label: "Snapshot movement gate",
12594
+ 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.",
12595
+ min: 0,
12596
+ max: .15,
12597
+ step: .005,
12598
+ default: .03,
12599
+ showValue: true,
12600
+ unit: "%",
12601
+ displayScale: .01
12602
+ },
12603
+ {
12604
+ type: "slider",
12605
+ key: "snapshotMaxIdleMs",
12606
+ label: "Snapshot max idle",
12607
+ description: "Force a snapshot for a stationary but still-present track after this long without one, so its filmstrip is never empty.",
12608
+ min: 5e3,
12609
+ max: 12e4,
12610
+ step: 5e3,
12611
+ default: 3e4,
12612
+ showValue: true,
12613
+ unit: "s",
12614
+ displayScale: 1e3
12615
+ },
11088
12616
  {
11089
12617
  type: "select",
11090
12618
  key: "mediaAttachPolicy",
@@ -11540,4 +13068,4 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
11540
13068
  }
11541
13069
  };
11542
13070
  //#endregion
11543
- export { PipelineAnalyticsAddon as default, stripGlobalOnlyFields, toAnalyticsDeviceSections };
13071
+ export { PipelineAnalyticsAddon as default, pickCleanMedia, stripGlobalOnlyFields, toAnalyticsDeviceSections };