@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.
@@ -2,11 +2,10 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-Bnb58pyL.js");
5
+ const require_dist = require("../dist-U51kCBdm.js");
6
6
  let node_crypto = require("node:crypto");
7
7
  let sharp = require("sharp");
8
8
  sharp = require_dist.__toESM(sharp);
9
- let _camstack_shm_ring = require("@camstack/shm-ring");
10
9
  //#region src/pipeline-analytics/videoclips-provider.ts
11
10
  var SOURCE = "analytics";
12
11
  function clipIdFor(eventId, startMs, endMs) {
@@ -151,6 +150,17 @@ var WEIGHT_IDENTITY = .05;
151
150
  var DWELL_FULL_MS = 6e4;
152
151
  /** Peak bbox area (as a fraction of frame area) that saturates the size term. */
153
152
  var SIZE_FULL = .15;
153
+ /** A track whose net centroid displacement stays below this fraction of the
154
+ * frame diagonal over its whole life counts as stationary on the net signal. */
155
+ var STATIC_DISPLACEMENT_FRAC = .02;
156
+ /** ...and whose entire centroid path fits within this fraction of the frame
157
+ * diagonal counts as stationary on the span signal. Both must hold. */
158
+ var STATIC_SPAN_FRAC = .04;
159
+ /** Importance multiplier applied to a stationary track that has NO resolved
160
+ * identity — strongly demotes "ghost" detections on static objects (a pile of
161
+ * clothes read as a person, a kitchen object read as an animal) below the
162
+ * key-event threshold. Identified tracks (face/plate) are never suppressed. */
163
+ var STATIC_IMPORTANCE_MULTIPLIER = .1;
154
164
  var CLASS_RANK_VEHICLE = .8;
155
165
  var CLASS_RANK_ANIMAL = .5;
156
166
  var CLASS_RANK_DEFAULT = .25;
@@ -225,6 +235,11 @@ function computeImportance(input) {
225
235
  sum += term.value;
226
236
  if (term.value > best.value) best = term;
227
237
  }
238
+ const identified = input.label !== void 0 && input.label.length > 0;
239
+ if (input.netDisplacementFrac !== void 0 && input.pathSpanFrac !== void 0 && input.netDisplacementFrac < .02 && input.pathSpanFrac < .04 && !identified) return {
240
+ importance: clamp01(sum * STATIC_IMPORTANCE_MULTIPLIER),
241
+ reason: best.reason
242
+ };
228
243
  return {
229
244
  importance: clamp01(sum),
230
245
  reason: best.reason
@@ -618,6 +633,20 @@ function normalizePlate(text) {
618
633
  return text.toUpperCase().replace(/[^A-Z0-9]/g, "");
619
634
  }
620
635
  /**
636
+ * Quality gate for a raw OCR plate read BEFORE it becomes a track label or a
637
+ * gallery row. Distant/oblique parked plates produce junk reads ("N", "Idag",
638
+ * "@em") that otherwise flood track labels (live-observed on the parking
639
+ * camera, 2026-07-16). A plausible European plate is ≥{@link PLATE_MIN_LENGTH}
640
+ * alphanumerics and mixes letters AND digits; anything else — or a read below
641
+ * {@link PLATE_MIN_SCORE} — is discarded, not stored.
642
+ */
643
+ function isPlausiblePlateRead(text, score) {
644
+ if (score < .4) return false;
645
+ const norm = normalizePlate(text);
646
+ if (norm.length < 4) return false;
647
+ return /[0-9]/.test(norm) && /[A-Z]/.test(norm);
648
+ }
649
+ /**
621
650
  * Fold characters that OCR routinely confuses onto a single canonical symbol, so
622
651
  * "AB0" and "ABO" compare as equal. Applied to BOTH operands of a distance
623
652
  * comparison only — never stored. Conservative set covering the common Latin
@@ -1102,7 +1131,7 @@ var MAX_PATH_LENGTH = 300;
1102
1131
  function clamp(value, min, max) {
1103
1132
  return Math.max(min, Math.min(max, value));
1104
1133
  }
1105
- function iou$1(a, b) {
1134
+ function iou$2(a, b) {
1106
1135
  const ax1 = a.x, ay1 = a.y, ax2 = a.x + a.w, ay2 = a.y + a.h;
1107
1136
  const bx1 = b.x, by1 = b.y, bx2 = b.x + b.w, by2 = b.y + b.h;
1108
1137
  const ix1 = Math.max(ax1, bx1), iy1 = Math.max(ay1, by1);
@@ -1157,7 +1186,7 @@ var SortTracker = class {
1157
1186
  */
1158
1187
  looseMatch(track, det) {
1159
1188
  if (track.class !== det.class) return false;
1160
- if (iou$1(track.bbox, det.bbox) >= this.config.rescueIouThreshold) return true;
1189
+ if (iou$2(track.bbox, det.bbox) >= this.config.rescueIouThreshold) return true;
1161
1190
  const tc = bboxCentroid(track.bbox);
1162
1191
  const dc = bboxCentroid(det.bbox);
1163
1192
  const dist = Math.hypot(tc.x - dc.x, tc.y - dc.y);
@@ -1185,7 +1214,7 @@ var SortTracker = class {
1185
1214
  for (let di = 0; di < detections.length; di++) {
1186
1215
  const det = detections[di];
1187
1216
  if (this.config.classGating && track.class !== det.class) continue;
1188
- const score = iou$1(pbox, det.bbox);
1217
+ const score = iou$2(pbox, det.bbox);
1189
1218
  if (score >= this.config.iouThreshold) pairs.push({
1190
1219
  track,
1191
1220
  detIdx: di,
@@ -1210,7 +1239,7 @@ var SortTracker = class {
1210
1239
  rescuePairs.push({
1211
1240
  track,
1212
1241
  detIdx: di,
1213
- score: iou$1(track.bbox, det.bbox)
1242
+ score: iou$2(track.bbox, det.bbox)
1214
1243
  });
1215
1244
  }
1216
1245
  }
@@ -1275,7 +1304,7 @@ var SortTracker = class {
1275
1304
  if (!lost.resurrectable) continue;
1276
1305
  if (timestamp - lost.lostAt > this.config.resurrectionWindowMs) continue;
1277
1306
  if (!this.looseMatch(lost, det)) continue;
1278
- const score = iou$1(lost.bbox, det.bbox);
1307
+ const score = iou$2(lost.bbox, det.bbox);
1279
1308
  if (score > bestScore) {
1280
1309
  best = lost;
1281
1310
  bestScore = score;
@@ -1336,6 +1365,16 @@ var SortTracker = class {
1336
1365
  path: [...t.path]
1337
1366
  }));
1338
1367
  }
1368
+ /**
1369
+ * Remove a track from BOTH the live and graveyard sets so it neither emits
1370
+ * nor resurrects. Used when a track is PROMOTED to a stationary-object
1371
+ * registry entry: the registry now owns that parked object, and the tracker
1372
+ * must forget it so the object's detections don't re-spawn a duplicate track.
1373
+ */
1374
+ dropTrack(trackId) {
1375
+ this.tracks = this.tracks.filter((t) => t.id !== trackId);
1376
+ this.lostTracks = this.lostTracks.filter((t) => t.id !== trackId);
1377
+ }
1339
1378
  getActiveTracks() {
1340
1379
  return this.tracks;
1341
1380
  }
@@ -1749,6 +1788,9 @@ var FrameProcessor = class {
1749
1788
  */
1750
1789
  detectionRules;
1751
1790
  zoneEngine = new ZoneEngine();
1791
+ /** Optional stationary-object gate (parked-object suppression). Null until
1792
+ * the addon wires it via {@link setStationaryGate}. */
1793
+ stationaryGate = null;
1752
1794
  constructor(deviceId, trackerConfig = {}, stateConfig = {}, emitterConfig = {}, source = "pipeline") {
1753
1795
  this.deviceId = deviceId;
1754
1796
  this.source = source;
@@ -1764,6 +1806,16 @@ var FrameProcessor = class {
1764
1806
  setDetectionRules(rules) {
1765
1807
  this.detectionRules = rules;
1766
1808
  }
1809
+ /** Wire (or clear) the stationary-object gate. Called by the addon per
1810
+ * device so parked-object suppression applies from the next frame. */
1811
+ setStationaryGate(gate) {
1812
+ this.stationaryGate = gate;
1813
+ }
1814
+ /** Forget a track in the underlying tracker (used when the addon promotes it
1815
+ * to a stationary-object registry entry). */
1816
+ dropTrack(trackId) {
1817
+ this.tracker.dropTrack(trackId);
1818
+ }
1767
1819
  process(input) {
1768
1820
  const { timestamp, frame } = input;
1769
1821
  const frameWidth = frame.width;
@@ -1842,7 +1894,17 @@ var FrameProcessor = class {
1842
1894
  }
1843
1895
  const { passed } = this.zoneEngine.filterDetections(flatDetections, this.zones, this.detectionRules, frameWidth, frameHeight, (fd) => fd.detection.class);
1844
1896
  const filteredDetections = passed.map((fd) => fd.detection);
1845
- const trackedDetections = this.tracker.update(filteredDetections, timestamp);
1897
+ const gate = this.stationaryGate ? this.stationaryGate.filter({
1898
+ detections: filteredDetections,
1899
+ frameWidth,
1900
+ frameHeight
1901
+ }) : {
1902
+ suppressedIndices: /* @__PURE__ */ new Set(),
1903
+ confirmed: [],
1904
+ wokenEntryIds: []
1905
+ };
1906
+ const trackerInput = gate.suppressedIndices.size > 0 ? filteredDetections.filter((_, i) => !gate.suppressedIndices.has(i)) : filteredDetections;
1907
+ const trackedDetections = this.tracker.update(trackerInput, timestamp);
1846
1908
  const objectStates = this.stateAnalyzer.analyze(trackedDetections, timestamp);
1847
1909
  const rawEvents = this.eventEmitter.emit(trackedDetections, objectStates, [], [], String(this.deviceId));
1848
1910
  const zonesByTrack = /* @__PURE__ */ new Map();
@@ -1924,7 +1986,9 @@ var FrameProcessor = class {
1924
1986
  frameHeight,
1925
1987
  tracked,
1926
1988
  objectEvents,
1927
- rawTrackedDetections: trackedDetections
1989
+ rawTrackedDetections: trackedDetections,
1990
+ stationaryConfirmed: gate.confirmed,
1991
+ stationaryWoken: gate.wokenEntryIds
1928
1992
  };
1929
1993
  }
1930
1994
  };
@@ -2001,6 +2065,7 @@ function buildTrackLifecyclePayload(input) {
2001
2065
  ...input.positionsCount !== void 0 ? { positionsCount: input.positionsCount } : {},
2002
2066
  ...input.importance !== void 0 ? { importance: input.importance } : {},
2003
2067
  ...input.importanceReason !== void 0 ? { importanceReason: input.importanceReason } : {},
2068
+ ...input.audioLabels !== void 0 && input.audioLabels.length > 0 ? { audioLabels: input.audioLabels } : {},
2004
2069
  ...input.embeddingId !== void 0 ? { embeddingId: input.embeddingId } : {},
2005
2070
  ...input.embeddingModelId !== void 0 ? { embeddingModelId: input.embeddingModelId } : {},
2006
2071
  ...hasMedia ? { media } : {}
@@ -2117,6 +2182,583 @@ function resolveSearchThumbnailUrl(input) {
2117
2182
  return `${input.baseUrl}/${encodeURIComponent(id)}`;
2118
2183
  }
2119
2184
  //#endregion
2185
+ //#region src/pipeline-analytics/pipeline/static-track-gate.ts
2186
+ /**
2187
+ * Net displacement + path span for a track's centroid path, normalized to
2188
+ * `referenceDiagonalPx`. Returns undefined when there is nothing meaningful to
2189
+ * measure (fewer than two points, or a degenerate ≤0 reference) so the caller
2190
+ * leaves the importance score untouched.
2191
+ */
2192
+ function computeStaticTrackMetrics(centroids, referenceDiagonalPx) {
2193
+ if (!(referenceDiagonalPx > 0) || centroids.length < 2) return void 0;
2194
+ const first = centroids[0];
2195
+ const last = centroids[centroids.length - 1];
2196
+ const netDisplacementFrac = Math.hypot(last.x - first.x, last.y - first.y) / referenceDiagonalPx;
2197
+ let minX = Infinity;
2198
+ let minY = Infinity;
2199
+ let maxX = -Infinity;
2200
+ let maxY = -Infinity;
2201
+ for (const c of centroids) {
2202
+ if (c.x < minX) minX = c.x;
2203
+ if (c.x > maxX) maxX = c.x;
2204
+ if (c.y < minY) minY = c.y;
2205
+ if (c.y > maxY) maxY = c.y;
2206
+ }
2207
+ return {
2208
+ netDisplacementFrac,
2209
+ pathSpanFrac: Math.hypot(maxX - minX, maxY - minY) / referenceDiagonalPx
2210
+ };
2211
+ }
2212
+ /** Average bbox diagonal (px) across a track's positions — the scale reference
2213
+ * when frame dimensions aren't available. Returns 0 for an empty list. */
2214
+ function averageBboxDiagonal(boxes) {
2215
+ if (boxes.length === 0) return 0;
2216
+ let sum = 0;
2217
+ for (const b of boxes) sum += Math.hypot(b.w, b.h);
2218
+ return sum / boxes.length;
2219
+ }
2220
+ //#endregion
2221
+ //#region src/pipeline-analytics/pipeline/stationary/stationary-types.ts
2222
+ function entryToView(e) {
2223
+ return {
2224
+ id: e.id,
2225
+ className: e.className,
2226
+ bbox: { ...e.bbox },
2227
+ frameWidth: e.frameWidth,
2228
+ frameHeight: e.frameHeight,
2229
+ firstSeenAt: e.firstSeenAt,
2230
+ becameStationaryAt: e.becameStationaryAt,
2231
+ lastConfirmedAt: e.lastConfirmedAt,
2232
+ ...e.label !== void 0 ? { label: e.label } : {},
2233
+ ...e.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: e.keyFrameMediaKey } : {}
2234
+ };
2235
+ }
2236
+ /** IoU at/above which a detection is "the same parked object" → suppress its
2237
+ * spawn and refresh the entry's `lastConfirmedAt`. Matches decision #2 (0.6). */
2238
+ var SUPPRESS_IOU = .6;
2239
+ /** Centroid move (fraction of the frame diagonal) beyond which a near
2240
+ * same-class detection means the parked object actually MOVED → wake. */
2241
+ var WAKE_MOVE_FRAC = .08;
2242
+ /** How near (fraction of frame diagonal) a same-class detection's centroid must
2243
+ * be to an entry to be considered "this entry's object" when testing for a
2244
+ * wake. Keeps an unrelated object elsewhere in the frame from waking it. */
2245
+ var WAKE_SEARCH_FRAC = .5;
2246
+ /**
2247
+ * Look-back window over which a track must have stayed put to be PROMOTED. A
2248
+ * car that drives in then parks has a large whole-life displacement but a tiny
2249
+ * last-`windowMs` displacement — so promotion is judged on the recent window,
2250
+ * not the full path. Also the minimum age (the track must have EXISTED this
2251
+ * long) so a freshly-spawned static blob isn't promoted instantly.
2252
+ */
2253
+ var PROMOTION_WINDOW_MS = 3e4;
2254
+ var DEFAULT_MATCH_CONFIG = {
2255
+ suppressIou: SUPPRESS_IOU,
2256
+ wakeMoveFrac: WAKE_MOVE_FRAC,
2257
+ wakeSearchFrac: WAKE_SEARCH_FRAC
2258
+ };
2259
+ //#endregion
2260
+ //#region src/pipeline-analytics/pipeline/stationary/stationary-match.ts
2261
+ /**
2262
+ * stationary-match — PURE geometry for the stationary-object registry.
2263
+ *
2264
+ * Two decisions, both unit-testable in isolation:
2265
+ *
2266
+ * 1. `partitionDetectionsAgainstRegistry` — given the current registry entries
2267
+ * and this frame's (zone-filtered) detections, decides which detections are
2268
+ * suppressed (they keep confirming a known parked object → no track spawns),
2269
+ * which entries are confirmed present, and which entries WOKE (their object
2270
+ * moved → retire the entry and let the detection spawn a normal track).
2271
+ *
2272
+ * 2. `evaluateStationaryPromotion` — given a track's recent centroid path,
2273
+ * decides whether it has stayed put long enough to become a stationary
2274
+ * entry. Reuses the static-track-gate metrics (net displacement + path span
2275
+ * normalised to the frame diagonal) over the recent look-back window.
2276
+ */
2277
+ /** Default promotion tunables — static thresholds shared with the key-event
2278
+ * static gate so "stationary" means the same thing in both places. */
2279
+ var DEFAULT_PROMOTION_CONFIG = {
2280
+ windowMs: PROMOTION_WINDOW_MS,
2281
+ netFracMax: STATIC_DISPLACEMENT_FRAC,
2282
+ spanFracMax: STATIC_SPAN_FRAC,
2283
+ minPoints: 4
2284
+ };
2285
+ function iou$1(a, b) {
2286
+ const ax2 = a.x + a.w;
2287
+ const ay2 = a.y + a.h;
2288
+ const bx2 = b.x + b.w;
2289
+ const by2 = b.y + b.h;
2290
+ const ix1 = Math.max(a.x, b.x);
2291
+ const iy1 = Math.max(a.y, b.y);
2292
+ const ix2 = Math.min(ax2, bx2);
2293
+ const iy2 = Math.min(ay2, by2);
2294
+ const inter = Math.max(0, ix2 - ix1) * Math.max(0, iy2 - iy1);
2295
+ const union = a.w * a.h + b.w * b.h - inter;
2296
+ return union > 0 ? inter / union : 0;
2297
+ }
2298
+ function centroid(b) {
2299
+ return {
2300
+ x: b.x + b.w / 2,
2301
+ y: b.y + b.h / 2
2302
+ };
2303
+ }
2304
+ function diagonalOf(width, height) {
2305
+ return Math.hypot(width, height);
2306
+ }
2307
+ /**
2308
+ * Decide, per stationary entry, whether the current frame confirms it, wakes
2309
+ * it, or misses it (no matching detection — leave it for the TTL sweep).
2310
+ *
2311
+ * Per entry, over same-class detections:
2312
+ * - best IoU ≥ `suppressIou` → SUPPRESS the best-overlap detection (it is the
2313
+ * parked object, unmoved) and mark the entry confirmed.
2314
+ * - else if a same-class detection sits within `wakeSearchFrac × diag` of the
2315
+ * entry centroid but has moved > `wakeMoveFrac × diag` → WAKE the entry (the
2316
+ * object slid out of its parked box). The detection is NOT suppressed, so it
2317
+ * spawns a fresh moving track.
2318
+ * - else → MISS (occlusion / brief absence): neither suppress nor wake.
2319
+ *
2320
+ * A detection can suppress at most one spawn even if it overlaps two entries
2321
+ * (`suppressedIndices` is a set).
2322
+ */
2323
+ function partitionDetectionsAgainstRegistry(input) {
2324
+ const { entries, detections, referenceDiagonalPx, config } = input;
2325
+ const suppressed = /* @__PURE__ */ new Set();
2326
+ const confirmed = [];
2327
+ const woken = [];
2328
+ const diag = referenceDiagonalPx;
2329
+ for (const entry of entries) {
2330
+ const ec = centroid(entry.bbox);
2331
+ let bestIou = 0;
2332
+ let bestIdx = -1;
2333
+ let wakeCandidate = false;
2334
+ for (let di = 0; di < detections.length; di++) {
2335
+ const det = detections[di];
2336
+ if (det.className !== entry.className) continue;
2337
+ const o = iou$1(entry.bbox, det.bbox);
2338
+ if (o > bestIou) {
2339
+ bestIou = o;
2340
+ bestIdx = di;
2341
+ }
2342
+ if (diag > 0) {
2343
+ const dc = centroid(det.bbox);
2344
+ const dist = Math.hypot(dc.x - ec.x, dc.y - ec.y);
2345
+ if (dist <= config.wakeSearchFrac * diag && dist > config.wakeMoveFrac * diag) wakeCandidate = true;
2346
+ }
2347
+ }
2348
+ if (bestIou >= config.suppressIou && bestIdx >= 0) {
2349
+ suppressed.add(bestIdx);
2350
+ confirmed.push({
2351
+ entryId: entry.id,
2352
+ className: entry.className,
2353
+ bbox: { ...entry.bbox }
2354
+ });
2355
+ } else if (wakeCandidate) woken.push(entry.id);
2356
+ }
2357
+ return {
2358
+ suppressedIndices: suppressed,
2359
+ confirmed,
2360
+ wokenEntryIds: woken
2361
+ };
2362
+ }
2363
+ /**
2364
+ * A track is promoted when, over the recent `windowMs`, its centroid barely
2365
+ * moved (both net displacement and path span below the static thresholds) AND
2366
+ * the track has actually EXISTED for at least `windowMs` (so a car that just
2367
+ * arrived isn't parked yet). Judging on the recent window — not the whole life
2368
+ * — is what lets a car that drove in then parked be recognised as stationary.
2369
+ */
2370
+ function evaluateStationaryPromotion(input) {
2371
+ const { positions, referenceDiagonalPx, now, config } = input;
2372
+ if (!(referenceDiagonalPx > 0) || positions.length === 0) return { promote: false };
2373
+ if (now - positions[0].timestamp < config.windowMs) return { promote: false };
2374
+ const cutoff = now - config.windowMs;
2375
+ const window = positions.filter((p) => p.timestamp >= cutoff);
2376
+ if (window.length < config.minPoints) return { promote: false };
2377
+ const metrics = computeStaticTrackMetrics(window.map((p) => ({
2378
+ x: p.x,
2379
+ y: p.y
2380
+ })), referenceDiagonalPx);
2381
+ if (metrics === void 0) return { promote: false };
2382
+ return {
2383
+ promote: metrics.netDisplacementFrac < config.netFracMax && metrics.pathSpanFrac < config.spanFracMax,
2384
+ netFrac: metrics.netDisplacementFrac,
2385
+ spanFrac: metrics.pathSpanFrac
2386
+ };
2387
+ }
2388
+ //#endregion
2389
+ //#region src/pipeline-analytics/pipeline/stationary/stationary-registry.ts
2390
+ var STATIONARY_COLLECTION = "pipeline-analytics:stationary-objects";
2391
+ var STATIONARY_COLUMNS = [
2392
+ {
2393
+ name: "id",
2394
+ type: "TEXT",
2395
+ primaryKey: true,
2396
+ notNull: true
2397
+ },
2398
+ {
2399
+ name: "deviceId",
2400
+ type: "INTEGER",
2401
+ notNull: true
2402
+ },
2403
+ {
2404
+ name: "className",
2405
+ type: "TEXT",
2406
+ notNull: true
2407
+ },
2408
+ {
2409
+ name: "bbox",
2410
+ type: "JSON"
2411
+ },
2412
+ {
2413
+ name: "frameWidth",
2414
+ type: "INTEGER"
2415
+ },
2416
+ {
2417
+ name: "frameHeight",
2418
+ type: "INTEGER"
2419
+ },
2420
+ {
2421
+ name: "firstSeenAt",
2422
+ type: "INTEGER"
2423
+ },
2424
+ {
2425
+ name: "becameStationaryAt",
2426
+ type: "INTEGER"
2427
+ },
2428
+ {
2429
+ name: "lastConfirmedAt",
2430
+ type: "INTEGER"
2431
+ },
2432
+ {
2433
+ name: "sourceTrackId",
2434
+ type: "TEXT"
2435
+ },
2436
+ {
2437
+ name: "label",
2438
+ type: "TEXT"
2439
+ },
2440
+ {
2441
+ name: "keyFrameMediaKey",
2442
+ type: "TEXT"
2443
+ }
2444
+ ];
2445
+ var STATIONARY_INDEXES = [{
2446
+ name: "idx_stationary_device",
2447
+ columns: ["deviceId"]
2448
+ }];
2449
+ var StationaryObjectRegistry = class {
2450
+ byDevice = /* @__PURE__ */ new Map();
2451
+ dirty = /* @__PURE__ */ new Set();
2452
+ store;
2453
+ logger;
2454
+ matchConfig;
2455
+ entryTtlMs;
2456
+ onChange;
2457
+ /** Latest processed-frame timestamp per device — expiry counts OBSERVED
2458
+ * time, not wall-clock. A session-dispatch camera produces no frames
2459
+ * between motion sessions; that silence is not evidence the object left,
2460
+ * so quiet minutes must not age the entries (see {@link sweep}). */
2461
+ lastFrameAtByDevice = /* @__PURE__ */ new Map();
2462
+ constructor(deps) {
2463
+ this.store = deps.store;
2464
+ this.logger = deps.logger;
2465
+ this.matchConfig = deps.matchConfig ?? DEFAULT_MATCH_CONFIG;
2466
+ this.entryTtlMs = deps.entryTtlMs ?? 3e5;
2467
+ this.onChange = deps.onChange;
2468
+ }
2469
+ static async declare(store) {
2470
+ await store.declareCollection.mutate({
2471
+ collection: STATIONARY_COLLECTION,
2472
+ columns: [...STATIONARY_COLUMNS],
2473
+ indexes: [...STATIONARY_INDEXES]
2474
+ });
2475
+ }
2476
+ /** Hydrate all persisted entries into memory (call once at boot, after
2477
+ * `declare`). Best-effort — a query failure leaves the registry empty. */
2478
+ async load() {
2479
+ try {
2480
+ const rows = await this.store.query.query({
2481
+ collection: STATIONARY_COLLECTION,
2482
+ filter: { limit: 1e5 }
2483
+ });
2484
+ for (const row of rows) {
2485
+ const entry = rowToEntry(row.id, row.data);
2486
+ if (!entry) continue;
2487
+ this.deviceMap(entry.deviceId).set(entry.id, entry);
2488
+ }
2489
+ this.logger.info("stationary registry loaded", { meta: { entries: rows.length } });
2490
+ } catch (err) {
2491
+ this.logger.warn("stationary registry load failed", { meta: { error: String(err) } });
2492
+ }
2493
+ }
2494
+ list(deviceId) {
2495
+ const m = this.byDevice.get(deviceId);
2496
+ return m ? [...m.values()] : [];
2497
+ }
2498
+ listViews(deviceId) {
2499
+ return this.list(deviceId).map(entryToView);
2500
+ }
2501
+ count(deviceId) {
2502
+ return this.byDevice.get(deviceId)?.size ?? 0;
2503
+ }
2504
+ /** Device ids that currently hold at least one parked entry — drives the
2505
+ * occupancy baseline sampler (a detached camera with parked cars still
2506
+ * gets a flat history baseline). */
2507
+ deviceIds() {
2508
+ const ids = [];
2509
+ for (const [deviceId, m] of this.byDevice) if (m.size > 0) ids.push(deviceId);
2510
+ return ids;
2511
+ }
2512
+ /** Record that a frame was processed for a device — advances the OBSERVED
2513
+ * clock that drives entry expiry in {@link sweep}. */
2514
+ noteFrame(deviceId, timestamp) {
2515
+ if (timestamp > (this.lastFrameAtByDevice.get(deviceId) ?? 0)) this.lastFrameAtByDevice.set(deviceId, timestamp);
2516
+ }
2517
+ /**
2518
+ * Per-frame gate: partition this frame's detections against the device's
2519
+ * entries. PURE with respect to registry state — apply the outcome with
2520
+ * {@link applyFrameOutcome} once the frame result is assembled.
2521
+ */
2522
+ filter(input) {
2523
+ const entries = this.list(input.deviceId);
2524
+ if (entries.length === 0) return {
2525
+ suppressedIndices: /* @__PURE__ */ new Set(),
2526
+ confirmed: [],
2527
+ wokenEntryIds: []
2528
+ };
2529
+ return partitionDetectionsAgainstRegistry({
2530
+ entries,
2531
+ detections: input.detections,
2532
+ referenceDiagonalPx: diagonalOf(input.frameWidth, input.frameHeight),
2533
+ config: this.matchConfig
2534
+ });
2535
+ }
2536
+ /** Fold a frame's gate result back into state: advance confirmed entries'
2537
+ * `lastConfirmedAt` and retire woken entries (their object departed). */
2538
+ applyFrameOutcome(input) {
2539
+ const m = this.byDevice.get(input.deviceId);
2540
+ if (!m) return;
2541
+ for (const c of input.confirmed) {
2542
+ const e = m.get(c.entryId);
2543
+ if (!e) continue;
2544
+ m.set(c.entryId, {
2545
+ ...e,
2546
+ lastConfirmedAt: input.timestamp
2547
+ });
2548
+ this.dirty.add(c.entryId);
2549
+ }
2550
+ for (const id of input.wokenEntryIds) {
2551
+ const e = m.get(id);
2552
+ if (!e) continue;
2553
+ m.delete(id);
2554
+ this.dirty.delete(id);
2555
+ this.deletePersisted(id);
2556
+ this.onChange?.({
2557
+ phase: "departed",
2558
+ entry: e,
2559
+ timestamp: input.timestamp
2560
+ });
2561
+ }
2562
+ }
2563
+ /** Promote a parked track into a persisted stationary entry. */
2564
+ async promote(entry) {
2565
+ this.deviceMap(entry.deviceId).set(entry.id, entry);
2566
+ this.dirty.delete(entry.id);
2567
+ try {
2568
+ await this.persist(entry);
2569
+ } catch (err) {
2570
+ this.logger.warn("stationary promote persist failed", {
2571
+ tags: { deviceId: entry.deviceId },
2572
+ meta: {
2573
+ entryId: entry.id,
2574
+ error: String(err)
2575
+ }
2576
+ });
2577
+ }
2578
+ this.onChange?.({
2579
+ phase: "appeared",
2580
+ entry,
2581
+ timestamp: entry.becameStationaryAt
2582
+ });
2583
+ }
2584
+ /**
2585
+ * Retire entries unconfirmed for longer than the TTL of OBSERVED time, and
2586
+ * flush any advanced `lastConfirmedAt`s to the store. Returns retired
2587
+ * entries (for logging).
2588
+ *
2589
+ * Expiry is measured against the device's latest processed-frame timestamp
2590
+ * ({@link noteFrame}), NOT the wall clock: a session-dispatch camera emits
2591
+ * no frames between motion sessions, and that silence says nothing about
2592
+ * the object. Only when the camera has actually been WATCHING for `ttl`
2593
+ * beyond the last confirmation (frames flowed, object never matched) is the
2594
+ * object considered removed. A device with no recorded frame yet never
2595
+ * expires its entries. `now` only stamps the departed telemetry.
2596
+ */
2597
+ async sweep(now) {
2598
+ const retired = [];
2599
+ for (const [deviceId, m] of this.byDevice) {
2600
+ const observedAt = this.lastFrameAtByDevice.get(deviceId);
2601
+ if (observedAt === void 0) continue;
2602
+ for (const [id, e] of m) if (observedAt - e.lastConfirmedAt > this.entryTtlMs) {
2603
+ m.delete(id);
2604
+ this.dirty.delete(id);
2605
+ retired.push(e);
2606
+ this.deletePersisted(id);
2607
+ this.onChange?.({
2608
+ phase: "departed",
2609
+ entry: e,
2610
+ timestamp: now
2611
+ });
2612
+ }
2613
+ if (m.size === 0) this.byDevice.delete(deviceId);
2614
+ }
2615
+ for (const id of [...this.dirty]) {
2616
+ this.dirty.delete(id);
2617
+ const entry = this.findById(id);
2618
+ if (!entry) continue;
2619
+ try {
2620
+ await this.store.update.mutate({
2621
+ collection: STATIONARY_COLLECTION,
2622
+ id,
2623
+ data: { lastConfirmedAt: entry.lastConfirmedAt }
2624
+ });
2625
+ } catch (err) {
2626
+ this.logger.debug("stationary lastConfirmedAt flush failed", { meta: {
2627
+ entryId: id,
2628
+ error: String(err)
2629
+ } });
2630
+ }
2631
+ }
2632
+ return retired;
2633
+ }
2634
+ /** Drop a device's entries from memory WITHOUT deleting persisted rows.
2635
+ * Used on device unbind; a rebind reloads from the store. */
2636
+ forgetDevice(deviceId) {
2637
+ this.lastFrameAtByDevice.delete(deviceId);
2638
+ const m = this.byDevice.get(deviceId);
2639
+ if (!m) return;
2640
+ for (const id of m.keys()) this.dirty.delete(id);
2641
+ this.byDevice.delete(deviceId);
2642
+ }
2643
+ /** Delete every persisted + in-memory entry for a device (operator wipe). */
2644
+ async clearDevice(deviceId) {
2645
+ this.lastFrameAtByDevice.delete(deviceId);
2646
+ const m = this.byDevice.get(deviceId);
2647
+ if (m) {
2648
+ for (const id of [...m.keys()]) {
2649
+ this.dirty.delete(id);
2650
+ this.deletePersisted(id);
2651
+ }
2652
+ this.byDevice.delete(deviceId);
2653
+ }
2654
+ }
2655
+ deviceMap(deviceId) {
2656
+ let m = this.byDevice.get(deviceId);
2657
+ if (!m) {
2658
+ m = /* @__PURE__ */ new Map();
2659
+ this.byDevice.set(deviceId, m);
2660
+ }
2661
+ return m;
2662
+ }
2663
+ findById(id) {
2664
+ for (const m of this.byDevice.values()) {
2665
+ const e = m.get(id);
2666
+ if (e) return e;
2667
+ }
2668
+ }
2669
+ async persist(e) {
2670
+ await this.store.set.mutate({
2671
+ collection: STATIONARY_COLLECTION,
2672
+ key: e.id,
2673
+ value: {
2674
+ deviceId: e.deviceId,
2675
+ className: e.className,
2676
+ bbox: { ...e.bbox },
2677
+ frameWidth: e.frameWidth,
2678
+ frameHeight: e.frameHeight,
2679
+ firstSeenAt: e.firstSeenAt,
2680
+ becameStationaryAt: e.becameStationaryAt,
2681
+ lastConfirmedAt: e.lastConfirmedAt,
2682
+ ...e.sourceTrackId !== void 0 ? { sourceTrackId: e.sourceTrackId } : {},
2683
+ ...e.label !== void 0 ? { label: e.label } : {},
2684
+ ...e.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: e.keyFrameMediaKey } : {}
2685
+ }
2686
+ });
2687
+ }
2688
+ async deletePersisted(id) {
2689
+ try {
2690
+ await this.store.delete.mutate({
2691
+ collection: STATIONARY_COLLECTION,
2692
+ key: id
2693
+ });
2694
+ } catch (err) {
2695
+ this.logger.debug("stationary delete failed", { meta: {
2696
+ entryId: id,
2697
+ error: String(err)
2698
+ } });
2699
+ }
2700
+ }
2701
+ };
2702
+ function rowToEntry(id, data) {
2703
+ const deviceId = Number(data["deviceId"]);
2704
+ const className = data["className"];
2705
+ const bbox = data["bbox"];
2706
+ if (!Number.isFinite(deviceId) || typeof className !== "string" || !bbox) return null;
2707
+ const sourceTrackId = data["sourceTrackId"];
2708
+ const label = data["label"];
2709
+ const keyFrameMediaKey = data["keyFrameMediaKey"];
2710
+ return {
2711
+ id,
2712
+ deviceId,
2713
+ className,
2714
+ bbox: {
2715
+ x: Number(bbox.x),
2716
+ y: Number(bbox.y),
2717
+ w: Number(bbox.w),
2718
+ h: Number(bbox.h)
2719
+ },
2720
+ frameWidth: Number(data["frameWidth"] ?? 0),
2721
+ frameHeight: Number(data["frameHeight"] ?? 0),
2722
+ firstSeenAt: Number(data["firstSeenAt"] ?? 0),
2723
+ becameStationaryAt: Number(data["becameStationaryAt"] ?? 0),
2724
+ lastConfirmedAt: Number(data["lastConfirmedAt"] ?? 0),
2725
+ ...typeof sourceTrackId === "string" ? { sourceTrackId } : {},
2726
+ ...typeof label === "string" ? { label } : {},
2727
+ ...typeof keyFrameMediaKey === "string" ? { keyFrameMediaKey } : {}
2728
+ };
2729
+ }
2730
+ //#endregion
2731
+ //#region src/pipeline-analytics/pipeline/stationary/stationary-zones.ts
2732
+ /**
2733
+ * Zone ids whose 0–1 polygon contains the entry's normalised bbox centroid.
2734
+ * Empty when the entry has no frame dims (can't normalise) or no zone matches.
2735
+ */
2736
+ function computeStationaryEntryZones(entry, zones) {
2737
+ if (entry.frameWidth <= 0 || entry.frameHeight <= 0 || zones.length === 0) return [];
2738
+ const centroidPx = bboxCentroid(entry.bbox);
2739
+ const point = {
2740
+ x: centroidPx.x / entry.frameWidth,
2741
+ y: centroidPx.y / entry.frameHeight
2742
+ };
2743
+ const matched = [];
2744
+ for (const zone of zones) {
2745
+ if (zone.polygon.length < 3) continue;
2746
+ if (pointInPolygon(point, zone.polygon)) matched.push(zone.id);
2747
+ }
2748
+ return matched;
2749
+ }
2750
+ //#endregion
2751
+ //#region src/pipeline-analytics/pipeline/track-appearance.ts
2752
+ /**
2753
+ * Pure: no side effects. `continuing` = still active from last frame;
2754
+ * `birth` = a brand-new track's first sighting; `resurrection` = a known track
2755
+ * re-entering the active set after being lost.
2756
+ */
2757
+ function classifyTrackAppearance(input) {
2758
+ if (input.inPrevActive) return "continuing";
2759
+ return input.positionsCount > 1 ? "resurrection" : "birth";
2760
+ }
2761
+ //#endregion
2120
2762
  //#region src/pipeline-analytics/pipeline/key-event-query.ts
2121
2763
  async function rankKeyEvents(candidates, options, peakLookup) {
2122
2764
  const scored = [];
@@ -2126,6 +2768,10 @@ async function rankKeyEvents(candidates, options, peakLookup) {
2126
2768
  let bestEventId = t.bestEventId;
2127
2769
  if (importance === void 0) {
2128
2770
  const peak = await peakLookup(t.trackId);
2771
+ const staticMetrics = computeStaticTrackMetrics(t.positions.map((p) => ({
2772
+ x: p.x,
2773
+ y: p.y
2774
+ })), averageBboxDiagonal(t.positions.map((p) => p.bbox)));
2129
2775
  importance = computeImportance({
2130
2776
  peakConfidence: peak.peakConfidence,
2131
2777
  className: t.className,
@@ -2133,7 +2779,11 @@ async function rankKeyEvents(candidates, options, peakLookup) {
2133
2779
  peakBboxAreaFrac: peak.peakBboxAreaFrac,
2134
2780
  totalDistance: t.totalDistance,
2135
2781
  zonesVisited: t.zonesVisited,
2136
- ...t.label !== void 0 ? { label: t.label } : {}
2782
+ ...t.label !== void 0 ? { label: t.label } : {},
2783
+ ...staticMetrics ? {
2784
+ netDisplacementFrac: staticMetrics.netDisplacementFrac,
2785
+ pathSpanFrac: staticMetrics.pathSpanFrac
2786
+ } : {}
2137
2787
  }).importance;
2138
2788
  bestEventId = bestEventId ?? peak.bestEventId;
2139
2789
  }
@@ -2145,7 +2795,7 @@ async function rankKeyEvents(candidates, options, peakLookup) {
2145
2795
  className: t.className,
2146
2796
  ...t.label !== void 0 ? { label: t.label } : {},
2147
2797
  importance,
2148
- bestEventId: bestEventId ?? "",
2798
+ bestEventId: bestEventId ?? t.trackId,
2149
2799
  windowMs: t.lastSeen - t.firstSeen
2150
2800
  });
2151
2801
  }
@@ -2382,6 +3032,10 @@ var TRACKS_COLUMNS = [
2382
3032
  {
2383
3033
  name: "importanceReason",
2384
3034
  type: "TEXT"
3035
+ },
3036
+ {
3037
+ name: "audioLabels",
3038
+ type: "JSON"
2385
3039
  }
2386
3040
  ];
2387
3041
  var TRACKS_INDEXES = [{
@@ -2391,6 +3045,17 @@ var TRACKS_INDEXES = [{
2391
3045
  name: "idx_tracks_device_firstSeen",
2392
3046
  columns: ["deviceId", "firstSeen"]
2393
3047
  }];
3048
+ /** Serialize the per-label aggregate map into the `Track.audioLabels`
3049
+ * array shape, most-frequent label first. */
3050
+ function audioLabelsToArray(agg) {
3051
+ return [...agg.entries()].map(([label, a]) => ({
3052
+ label,
3053
+ peakScore: a.peakScore,
3054
+ count: a.count,
3055
+ firstAt: a.firstAt,
3056
+ lastAt: a.lastAt
3057
+ })).sort((a, b) => b.count - a.count);
3058
+ }
2394
3059
  function cloneTrack(t) {
2395
3060
  return {
2396
3061
  trackId: t.trackId,
@@ -2417,7 +3082,8 @@ function cloneTrack(t) {
2417
3082
  active: t.active,
2418
3083
  ...t.importance !== void 0 ? { importance: t.importance } : {},
2419
3084
  ...t.bestEventId !== void 0 ? { bestEventId: t.bestEventId } : {},
2420
- ...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {}
3085
+ ...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {},
3086
+ ...t.audioLabels !== void 0 && t.audioLabels.size > 0 ? { audioLabels: audioLabelsToArray(t.audioLabels) } : {}
2421
3087
  };
2422
3088
  }
2423
3089
  var TrackStore = class {
@@ -2478,25 +3144,81 @@ var TrackStore = class {
2478
3144
  this.active.set(params.trackId, fresh);
2479
3145
  return fresh;
2480
3146
  }
3147
+ /**
3148
+ * Record one audio-classification EPISODE against every track currently
3149
+ * active on the device — "what was heard on this camera while the track
3150
+ * was alive". Called from the confident-classification audio-event insert
3151
+ * (score ≥ device `classificationMinScore`, class-change-or-heartbeat
3152
+ * coalesced), so counts stay episode-scaled rather than 30 Hz chunk-scaled.
3153
+ */
3154
+ addAudioLabelEpisode(deviceId, label, score, timestamp) {
3155
+ for (const t of this.active.values()) {
3156
+ if (t.deviceId !== deviceId || !t.active) continue;
3157
+ const agg = t.audioLabels ??= /* @__PURE__ */ new Map();
3158
+ const entry = agg.get(label);
3159
+ if (entry) {
3160
+ entry.peakScore = Math.max(entry.peakScore, score);
3161
+ entry.count += 1;
3162
+ entry.lastAt = timestamp;
3163
+ } else agg.set(label, {
3164
+ peakScore: score,
3165
+ count: 1,
3166
+ firstAt: timestamp,
3167
+ lastAt: timestamp
3168
+ });
3169
+ }
3170
+ }
2481
3171
  /** Attach a snapshot reference to an active track. */
2482
3172
  addSnapshot(trackId, snapshot) {
2483
3173
  const t = this.active.get(trackId);
2484
3174
  if (!t) return;
2485
3175
  t.snapshots.push(snapshot);
2486
3176
  t.lastSnapshotAt = snapshot.timestamp;
3177
+ t.lastSnapshotBbox = { ...snapshot.position.bbox };
3178
+ }
3179
+ /**
3180
+ * Synchronously advance the periodic-snapshot gate reference (clock + bbox) at
3181
+ * the MOMENT a snapshot write is DECIDED — before the async encode/dispatch
3182
+ * lands the real snapshot via {@link addSnapshot}. Without it, `lastSnapshotAt`
3183
+ * only advances when the dispatcher round-trip returns (~50-200ms of sharp
3184
+ * encode), so at 10-25fps several consecutive frames pass
3185
+ * `evaluatePeriodicSnapshot` before the clock moves → a burst of near-identical
3186
+ * snapshots. Mirrors the synchronous `lastFrameAtByTrack` advance for the
3187
+ * rolling `lastFrame`.
3188
+ *
3189
+ * No rollback: if the write later fails the slot is simply lost (a rare dropped
3190
+ * snapshot is preferable to a burst). `addSnapshot` re-stamps the same
3191
+ * clock/bbox when the real snapshot lands, so the two stay consistent. No-op for
3192
+ * an unknown/expired track (the active entry is dropped on expiry, so there is
3193
+ * no separate map to leak).
3194
+ */
3195
+ markSnapshotPending(trackId, timestamp, bbox) {
3196
+ const t = this.active.get(trackId);
3197
+ if (!t) return;
3198
+ t.lastSnapshotAt = timestamp;
3199
+ t.lastSnapshotBbox = { ...bbox };
2487
3200
  }
2488
3201
  lastSnapshotAt(trackId) {
2489
3202
  return this.active.get(trackId)?.lastSnapshotAt ?? 0;
2490
3203
  }
3204
+ /** Bbox reference of the last captured snapshot (or the seed bbox), for the
3205
+ * periodic-snapshot movement gate. Undefined until the clock is seeded. */
3206
+ lastSnapshotBbox(trackId) {
3207
+ const b = this.active.get(trackId)?.lastSnapshotBbox;
3208
+ return b ? { ...b } : void 0;
3209
+ }
2491
3210
  /**
2492
3211
  * Seed the snapshot cadence clock (once, at track start) WITHOUT appending a
2493
3212
  * snapshot, so the first periodic snapshot fires ~snapshotIntervalMs after the
2494
3213
  * track begins rather than immediately — the `firstFrame` already covers the
2495
3214
  * track's start. No-op if a snapshot was already taken (clock already set).
2496
3215
  */
2497
- seedSnapshotClock(trackId, timestamp) {
3216
+ seedSnapshotClock(trackId, timestamp, bbox) {
2498
3217
  const t = this.active.get(trackId);
2499
- if (t && t.lastSnapshotAt === 0) t.lastSnapshotAt = timestamp;
3218
+ if (t && t.lastSnapshotAt === 0) {
3219
+ t.lastSnapshotAt = timestamp;
3220
+ if (bbox) t.lastSnapshotBbox = { ...bbox };
3221
+ }
2500
3222
  }
2501
3223
  getActive(deviceId) {
2502
3224
  const out = [];
@@ -2507,6 +3229,21 @@ var TrackStore = class {
2507
3229
  const t = this.active.get(trackId);
2508
3230
  return t && t.active ? cloneTrack(t) : null;
2509
3231
  }
3232
+ /**
3233
+ * Cheap read of an active track's promotion-relevant fields WITHOUT the deep
3234
+ * clone `getActiveByTrack` does — the returned `positions` is the live
3235
+ * internal array (read-only; callers must not mutate). Feeds the per-frame
3236
+ * stationary-promotion check, called at inference fps. Null if unknown/expired.
3237
+ */
3238
+ peekActive(trackId) {
3239
+ const t = this.active.get(trackId);
3240
+ if (!t || !t.active) return null;
3241
+ return {
3242
+ firstSeen: t.firstSeen,
3243
+ positions: t.positions,
3244
+ ...t.label !== void 0 ? { label: t.label } : {}
3245
+ };
3246
+ }
2510
3247
  /** Expire tracks whose `lastSeen` is older than TTL. Persists each
2511
3248
  * expired track to the declared collection and returns them. */
2512
3249
  async expireStale(nowMs) {
@@ -2612,6 +3349,15 @@ var TrackStore = class {
2612
3349
  clearAll() {
2613
3350
  this.active.clear();
2614
3351
  }
3352
+ /**
3353
+ * Drop a single active track WITHOUT persisting it as a historical row. Used
3354
+ * when a track is PROMOTED to a stationary-object registry entry: the durable
3355
+ * record for a parked object is the registry entry, not a Track, so the track
3356
+ * must NOT land in the key-event feed. No-op for an unknown/expired track.
3357
+ */
3358
+ dropActive(trackId) {
3359
+ this.active.delete(trackId);
3360
+ }
2615
3361
  /** Delete the persisted track row (keyed by trackId) and drop the in-RAM
2616
3362
  * active entry if present. Used by the whole-track deletion cascade. */
2617
3363
  async deletePersisted(trackId) {
@@ -2744,7 +3490,8 @@ var TrackStore = class {
2744
3490
  state: t.state,
2745
3491
  ...t.importance !== void 0 ? { importance: t.importance } : {},
2746
3492
  ...t.bestEventId !== void 0 ? { bestEventId: t.bestEventId } : {},
2747
- ...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {}
3493
+ ...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {},
3494
+ ...t.audioLabels !== void 0 ? { audioLabels: [...t.audioLabels] } : {}
2748
3495
  }
2749
3496
  });
2750
3497
  }
@@ -2757,6 +3504,7 @@ var TrackStore = class {
2757
3504
  const importance = data["importance"];
2758
3505
  const bestEventId = data["bestEventId"];
2759
3506
  const importanceReason = data["importanceReason"];
3507
+ const audioLabels = data["audioLabels"];
2760
3508
  return {
2761
3509
  trackId: id,
2762
3510
  deviceId: Number(data["deviceId"]),
@@ -2773,7 +3521,8 @@ var TrackStore = class {
2773
3521
  active: false,
2774
3522
  ...typeof importance === "number" ? { importance } : {},
2775
3523
  ...typeof bestEventId === "string" ? { bestEventId } : {},
2776
- ...typeof importanceReason === "string" ? { importanceReason } : {}
3524
+ ...typeof importanceReason === "string" ? { importanceReason } : {},
3525
+ ...Array.isArray(audioLabels) && audioLabels.length > 0 ? { audioLabels } : {}
2777
3526
  };
2778
3527
  }
2779
3528
  };
@@ -3886,13 +4635,10 @@ function stripNulls(data) {
3886
4635
  //#endregion
3887
4636
  //#region src/shared/frame/resolve-frame.ts
3888
4637
  /**
3889
- * Resolve the pixels a `FrameHandle` refers to. Local shm read when the
3890
- * handle's `nodeId` matches `deps.ownNodeId`, else routed via
3891
- * `deps.getRemoteFrame`. Returns `null` when the frame is no longer
3892
- * available (slot recycled locally, or the remote node reports no frame).
4638
+ * Resolve the pixels a `FrameHandle` refers to via the node-routed fetch.
4639
+ * Returns `null` when the frame is no longer available.
3893
4640
  */
3894
4641
  async function resolveFrame(handle, deps) {
3895
- if (handle.nodeId === deps.ownNodeId) return deps.readers.read(handle);
3896
4642
  return deps.getRemoteFrame(handle);
3897
4643
  }
3898
4644
  //#endregion
@@ -4069,11 +4815,7 @@ var EventMediaDispatcher = class {
4069
4815
  if (events.length === 0 && trackFrames.length === 0 && snapshots.length === 0) return empty;
4070
4816
  let decoded;
4071
4817
  try {
4072
- decoded = await resolveFrame(frameHandle, {
4073
- ownNodeId: this.deps.ownNodeId,
4074
- readers: this.deps.readers,
4075
- getRemoteFrame: this.deps.getRemoteFrame
4076
- });
4818
+ decoded = await resolveFrame(frameHandle, { getRemoteFrame: this.deps.getRemoteFrame });
4077
4819
  } catch (err) {
4078
4820
  this.deps.logger.debug("event media: resolveFrame threw", {
4079
4821
  tags: { deviceId },
@@ -4384,8 +5126,6 @@ var EmbeddingDispatcher = class {
4384
5126
  encoder;
4385
5127
  eventBus;
4386
5128
  logger;
4387
- ownNodeId;
4388
- readers;
4389
5129
  getRemoteFrame;
4390
5130
  lastEmbedTime = /* @__PURE__ */ new Map();
4391
5131
  pendingCrops = /* @__PURE__ */ new Map();
@@ -4398,8 +5138,6 @@ var EmbeddingDispatcher = class {
4398
5138
  this.encoder = deps.encoder;
4399
5139
  this.eventBus = deps.eventBus;
4400
5140
  this.logger = deps.logger;
4401
- this.ownNodeId = deps.ownNodeId;
4402
- this.readers = deps.readers;
4403
5141
  this.getRemoteFrame = deps.getRemoteFrame;
4404
5142
  }
4405
5143
  async start() {
@@ -4451,11 +5189,7 @@ var EmbeddingDispatcher = class {
4451
5189
  }
4452
5190
  let decoded;
4453
5191
  try {
4454
- decoded = await resolveFrame(handle, {
4455
- ownNodeId: this.ownNodeId,
4456
- readers: this.readers,
4457
- getRemoteFrame: this.getRemoteFrame
4458
- });
5192
+ decoded = await resolveFrame(handle, { getRemoteFrame: this.getRemoteFrame });
4459
5193
  } catch (err) {
4460
5194
  this.logger.debug("skip: resolveFrame threw", {
4461
5195
  tags: { deviceId: Number(deviceId) },
@@ -4702,6 +5436,17 @@ var RESOLUTION_MS = {
4702
5436
  * latest state is never lost.
4703
5437
  */
4704
5438
  var SLICE_WRITE_INTERVAL_MS$1 = 1e3;
5439
+ /**
5440
+ * Cadence of the synthetic occupancy baseline. When a camera is detached (no
5441
+ * inference frames) but has persisted parked objects, the history ring would
5442
+ * otherwise stay empty and the chart would read "No occupancy history yet". A
5443
+ * device WITH parked entries gets one hydrated sample per this interval — a
5444
+ * flat baseline of the parked count — so the graph shows the parking lot's
5445
+ * standing occupancy instead of a gap. No sample is emitted for a device
5446
+ * without entries, and a real `recordFrame` in the same window suppresses the
5447
+ * baseline (it already appended a richer sample).
5448
+ */
5449
+ var BASELINE_SAMPLE_INTERVAL_MS = 6e4;
4705
5450
  var ZoneAnalyticsProvider = class {
4706
5451
  ctx;
4707
5452
  snapshots = /* @__PURE__ */ new Map();
@@ -4718,8 +5463,17 @@ var ZoneAnalyticsProvider = class {
4718
5463
  /** Last logged frame-wide occupancy total per device — so the occupancy log
4719
5464
  * fires only when the count actually changes, not every inference frame. */
4720
5465
  lastOccupancyTotal = /* @__PURE__ */ new Map();
5466
+ /** Low-cadence baseline sampler — appends a hydrated occupancy sample for any
5467
+ * device with parked objects but no live frames. `null` when disabled. */
5468
+ baselineTimer = null;
4721
5469
  constructor(ctx) {
4722
5470
  this.ctx = ctx;
5471
+ if (ctx.listStationaryDeviceIds && ctx.listStationaryObjects) {
5472
+ this.baselineTimer = setInterval(() => {
5473
+ this.appendBaselineSamples();
5474
+ }, BASELINE_SAMPLE_INTERVAL_MS);
5475
+ this.baselineTimer.unref?.();
5476
+ }
4723
5477
  this.sliceThrottle = new SliceThrottler({
4724
5478
  intervalMs: SLICE_WRITE_INTERVAL_MS$1,
4725
5479
  equalsIgnoringTs: snapshotEqualsIgnoringTs,
@@ -4741,9 +5495,10 @@ var ZoneAnalyticsProvider = class {
4741
5495
  /** Stop pending throttle timers — called from addon shutdown. */
4742
5496
  destroy() {
4743
5497
  this.sliceThrottle.destroy();
5498
+ if (this.baselineTimer) clearInterval(this.baselineTimer);
4744
5499
  }
4745
5500
  async getCurrentSnapshot({ deviceId }) {
4746
- return this.snapshots.get(deviceId) ?? null;
5501
+ return this.snapshots.get(deviceId) ?? await this.hydrateFromRegistry(deviceId);
4747
5502
  }
4748
5503
  async getZoneHistory(input) {
4749
5504
  return this.bucketize(input.deviceId, input.from, input.to, input.resolution, (snap) => {
@@ -4796,6 +5551,65 @@ var ZoneAnalyticsProvider = class {
4796
5551
  this.lastOccupancyTotal.delete(deviceId);
4797
5552
  this.sliceThrottle.forgetDevice(deviceId);
4798
5553
  }
5554
+ /**
5555
+ * Build an occupancy snapshot for a device purely from its parked-object
5556
+ * registry (no live frame). Returns `null` when hydration is unavailable or
5557
+ * the device has no parked objects — a device with neither frames nor entries
5558
+ * legitimately reports `null`. The snapshot's `ts` is the most recent
5559
+ * `lastConfirmedAt` across entries, falling back to the current tick.
5560
+ */
5561
+ async hydrateFromRegistry(deviceId) {
5562
+ const listStationary = this.ctx.listStationaryObjects;
5563
+ if (!listStationary) return null;
5564
+ const entries = listStationary(deviceId);
5565
+ if (entries.length === 0) return null;
5566
+ let zones = [];
5567
+ try {
5568
+ zones = await this.ctx.resolveZones?.(deviceId) ?? [];
5569
+ } catch (err) {
5570
+ this.ctx.logger.debug("zone-analytics hydrate zone resolve failed", {
5571
+ tags: { deviceId },
5572
+ meta: { error: err instanceof Error ? err.message : String(err) }
5573
+ });
5574
+ }
5575
+ const ts = mostRecentStationaryConfirmedAt(entries) || Date.now();
5576
+ return buildStationarySnapshot({
5577
+ deviceId,
5578
+ entries,
5579
+ zones,
5580
+ timestamp: ts
5581
+ });
5582
+ }
5583
+ /**
5584
+ * Baseline sampler tick: for every device with parked objects, append a
5585
+ * hydrated sample to the history ring at the CURRENT time — but only when a
5586
+ * real frame hasn't already appended a sample within this interval (frames
5587
+ * flowing = richer samples, no synthetic baseline needed).
5588
+ */
5589
+ async appendBaselineSamples() {
5590
+ const deviceIds = this.ctx.listStationaryDeviceIds?.() ?? [];
5591
+ const now = Date.now();
5592
+ for (const deviceId of deviceIds) {
5593
+ const ring = this.history.get(deviceId);
5594
+ if (now - (ring && ring.length > 0 ? ring[ring.length - 1].ts : 0) < BASELINE_SAMPLE_INTERVAL_MS) continue;
5595
+ const entries = this.ctx.listStationaryObjects?.(deviceId) ?? [];
5596
+ if (entries.length === 0) continue;
5597
+ let zones = [];
5598
+ try {
5599
+ zones = await this.ctx.resolveZones?.(deviceId) ?? [];
5600
+ } catch {}
5601
+ const snapshot = buildStationarySnapshot({
5602
+ deviceId,
5603
+ entries,
5604
+ zones,
5605
+ timestamp: now
5606
+ });
5607
+ if (snapshot) {
5608
+ this.appendHistory(deviceId, snapshot);
5609
+ this.sliceThrottle.push(deviceId, snapshot);
5610
+ }
5611
+ }
5612
+ }
4799
5613
  appendHistory(deviceId, snapshot) {
4800
5614
  const ring = this.history.get(deviceId) ?? [];
4801
5615
  const cutoff = snapshot.ts - HISTORY_WINDOW_MS;
@@ -4882,9 +5696,43 @@ function computeSnapshot(input) {
4882
5696
  unzoned: {
4883
5697
  totalObjects: unzonedTotal,
4884
5698
  byClass: unzonedByClass
4885
- }
5699
+ },
5700
+ ...input.stationaryObjects !== void 0 && input.stationaryObjects.length > 0 ? { stationaryObjects: input.stationaryObjects } : {}
4886
5701
  };
4887
5702
  }
5703
+ /** Most recent `lastConfirmedAt` across parked entries (0 when none). */
5704
+ function mostRecentStationaryConfirmedAt(entries) {
5705
+ let max = 0;
5706
+ for (const e of entries) if (e.lastConfirmedAt > max) max = e.lastConfirmedAt;
5707
+ return max;
5708
+ }
5709
+ /**
5710
+ * Build an occupancy snapshot from parked-object registry entries alone —
5711
+ * used when no live inference frame is available (fresh respawn, camera
5712
+ * detached). Each entry is folded into the frame aggregate AND attributed to
5713
+ * the zones its normalised bbox centroid falls inside (via
5714
+ * {@link computeStationaryEntryZones}), so a zone drawn over a parked car
5715
+ * reports a count of 1. Returns `null` for an empty entry list. Reuses
5716
+ * {@link computeSnapshot} — the SAME aggregation the live frame path runs.
5717
+ */
5718
+ function buildStationarySnapshot(input) {
5719
+ if (input.entries.length === 0) return null;
5720
+ const tracked = input.entries.map((e) => ({
5721
+ trackId: `stationary:${e.id}`,
5722
+ className: e.className,
5723
+ zones: computeStationaryEntryZones(e, input.zones)
5724
+ }));
5725
+ const first = input.entries[0];
5726
+ return computeSnapshot({
5727
+ deviceId: input.deviceId,
5728
+ timestamp: input.timestamp,
5729
+ frameWidth: first.frameWidth,
5730
+ frameHeight: first.frameHeight,
5731
+ tracked,
5732
+ zones: input.zones,
5733
+ stationaryObjects: input.entries
5734
+ });
5735
+ }
4888
5736
  //#endregion
4889
5737
  //#region src/pipeline-analytics/audio-metrics-provider.ts
4890
5738
  var AUDIO_METRICS_CAP_NAME = "audio-metrics";
@@ -5458,7 +6306,18 @@ var MediaSettingsSchema = require_dist.object({
5458
6306
  /** Cadence (ms) for the periodic per-track `snapshot` + rolling `lastFrame`.
5459
6307
  * A snapshot is captured for an active track only after this much wall-clock
5460
6308
  * has elapsed since its previous one. */
5461
- snapshotIntervalMs: require_dist.number().int().min(500).max(6e4).default(5e3)
6309
+ snapshotIntervalMs: require_dist.number().int().min(500).max(6e4).default(5e3),
6310
+ /** Movement gate for the periodic `snapshot`: once `snapshotIntervalMs` has
6311
+ * elapsed, a fresh snapshot is only captured when the track's centroid moved
6312
+ * at least this fraction of the frame DIAGONAL since the last captured
6313
+ * snapshot. Suppresses near-identical frames from a long-lived / stationary
6314
+ * track. 0 disables the gate (pure-time behaviour). Default 0.03 ≈ 3% of the
6315
+ * frame diagonal (~66px on 1080p). */
6316
+ snapshotMovementThreshold: require_dist.number().min(0).max(1).default(.03),
6317
+ /** Loiterer fallback (ms): force a periodic `snapshot` for a stationary but
6318
+ * still-present track after this much wall-clock without one, so its
6319
+ * filmstrip is never empty. Effectively clamped to ≥ `snapshotIntervalMs`. */
6320
+ snapshotMaxIdleMs: require_dist.number().int().min(1e3).max(6e5).default(3e4)
5462
6321
  });
5463
6322
  var MEDIA_DEFAULTS = MediaSettingsSchema.parse({});
5464
6323
  /**
@@ -5473,7 +6332,148 @@ function resolveMediaSettings(raw) {
5473
6332
  return {
5474
6333
  cropPadding: pick("cropPadding"),
5475
6334
  saveThumbnails: pick("saveThumbnails"),
5476
- snapshotIntervalMs: pick("snapshotIntervalMs")
6335
+ snapshotIntervalMs: pick("snapshotIntervalMs"),
6336
+ snapshotMovementThreshold: pick("snapshotMovementThreshold"),
6337
+ snapshotMaxIdleMs: pick("snapshotMaxIdleMs")
6338
+ };
6339
+ }
6340
+ function centroidOf(b) {
6341
+ return {
6342
+ x: b.x + b.w / 2,
6343
+ y: b.y + b.h / 2
6344
+ };
6345
+ }
6346
+ /** Centroid displacement between two boxes as a fraction of the frame diagonal.
6347
+ * Returns 0 for a degenerate (≤0) frame diagonal so the caller can fall back
6348
+ * to pure-time behaviour instead of dividing by zero. */
6349
+ function centroidMovedFraction(a, b, frameWidth, frameHeight) {
6350
+ const diag = Math.hypot(frameWidth, frameHeight);
6351
+ if (diag <= 0) return 0;
6352
+ const ca = centroidOf(a);
6353
+ const cb = centroidOf(b);
6354
+ return Math.hypot(cb.x - ca.x, cb.y - ca.y) / diag;
6355
+ }
6356
+ /**
6357
+ * Decide whether the periodic `snapshot` should be captured for a track THIS
6358
+ * frame. Pure: no side effects. The caller keeps the `saveThumbnails` master
6359
+ * switch and advances `lastSnapshotAt`/`lastSnapshotBbox` only when a capture
6360
+ * actually lands — so a skipped (stationary) frame leaves the clock untouched,
6361
+ * which naturally lets `maxIdleMs` fire and re-evaluates movement every frame
6362
+ * until the object moves.
6363
+ */
6364
+ function evaluatePeriodicSnapshot(input) {
6365
+ const { lastSnapshotAt, lastSnapshotBbox, currentBbox, now, frameWidth, frameHeight, intervalMs, movementThreshold, maxIdleMs } = input;
6366
+ if (lastSnapshotAt <= 0 || now - lastSnapshotAt < intervalMs) {
6367
+ if (lastSnapshotAt > 0 && lastSnapshotBbox !== void 0 && now - lastSnapshotAt >= 1500) {
6368
+ const fastMoved = centroidMovedFraction(lastSnapshotBbox, currentBbox, frameWidth, frameHeight);
6369
+ if (fastMoved >= .08) return {
6370
+ capture: true,
6371
+ reason: "fast-mover",
6372
+ movedFraction: fastMoved
6373
+ };
6374
+ }
6375
+ return {
6376
+ capture: false,
6377
+ reason: "interval-not-elapsed",
6378
+ movedFraction: 0
6379
+ };
6380
+ }
6381
+ if (lastSnapshotBbox === void 0) return {
6382
+ capture: true,
6383
+ reason: "no-reference",
6384
+ movedFraction: 0
6385
+ };
6386
+ const movedFraction = centroidMovedFraction(lastSnapshotBbox, currentBbox, frameWidth, frameHeight);
6387
+ if (movedFraction >= movementThreshold) return {
6388
+ capture: true,
6389
+ reason: "moved",
6390
+ movedFraction
6391
+ };
6392
+ const idleLimit = Math.max(maxIdleMs, intervalMs);
6393
+ if (now - lastSnapshotAt >= idleLimit) return {
6394
+ capture: true,
6395
+ reason: "idle-forced",
6396
+ movedFraction
6397
+ };
6398
+ return {
6399
+ capture: false,
6400
+ reason: "stationary-skip",
6401
+ movedFraction
6402
+ };
6403
+ }
6404
+ //#endregion
6405
+ //#region src/pipeline-analytics/periodic-media-plan.ts
6406
+ /**
6407
+ * Decide the periodic media writes for one track on one frame. Pure: no side
6408
+ * effects. The caller advances its own `lastFrameAt` clock only when the
6409
+ * returned `rollingLastFrame` is true.
6410
+ *
6411
+ * INVARIANT: `appendSnapshot` and `rollingLastFrame` are never both true — the
6412
+ * rolling `lastFrame` is never the same frame as an appended `snapshot`, so it
6413
+ * can never duplicate one.
6414
+ */
6415
+ function planPeriodicMedia(input) {
6416
+ const appendSnapshot = input.dueSnapshot;
6417
+ return {
6418
+ appendSnapshot,
6419
+ rollingLastFrame: input.saveThumbnails && input.now - input.lastFrameAt >= input.intervalMs && !appendSnapshot,
6420
+ bestThumbnail: input.isNewBest
6421
+ };
6422
+ }
6423
+ //#endregion
6424
+ //#region src/pipeline-analytics/pipeline/key-frame-capture.ts
6425
+ /**
6426
+ * Pure helpers for the per-track NATIVE-resolution `keyFrame` capture
6427
+ * (Design B — one native full-frame per track at its best-detection moment).
6428
+ *
6429
+ * ## Why this exists (the missing native keyFrame)
6430
+ *
6431
+ * `keyFrame` was historically captured ONLY inside the CLIP object-embedding
6432
+ * best path (`persistObjectEmbeddingBests`, gated by `isClipObjectEmbedding`).
6433
+ * Under the two-plane pipeline the root frame carries NO CLIP embedding (clip is
6434
+ * a per-track DETAIL served via `runDetailSubtree`, and is disabled cluster-
6435
+ * wide), so that gate was never satisfied and the native `keyFrame` was NEVER
6436
+ * produced — every stored frame stayed at the ≤640×360 detection resolution.
6437
+ *
6438
+ * The fix decouples the `keyFrame` from the clip path: it is captured on the
6439
+ * GENERAL best-frame signal (the same `bestThumbnail` decision that drives the
6440
+ * `thumbnail`), reusing the WORKING native crop path (`captureCrop` →
6441
+ * `pipelineRunner.getNativeCrop`, which cuts the ROI from the decode worker's
6442
+ * retained NATIVE surface and only falls back to the detection frame on a miss).
6443
+ * A full-frame ROI at {@link KEYFRAME_NATIVE_MAX_WIDTH} therefore yields a frame
6444
+ * LARGER than the detection raster (up to the cap), which is the whole point of
6445
+ * the `keyFrame` kind.
6446
+ */
6447
+ /** Cap (px) on the width of the native KEY FRAME (full-frame native capture).
6448
+ * Native resolution is the point, but a full 4K RGB surface over the transport
6449
+ * per new-best is wasteful for a web detail view — 1920px keeps a sharp native
6450
+ * frame while bounding the copy (a miss falls back to the detection-res frame,
6451
+ * which is already ≤640px). */
6452
+ var KEYFRAME_NATIVE_MAX_WIDTH = 1920;
6453
+ /**
6454
+ * The tracks whose native `keyFrame` should be (re)captured THIS frame: exactly
6455
+ * the tracks that hit a new best-frame moment (`bestThumbnail`). `putReplacing`
6456
+ * downstream keeps one `keyFrame` per track (the current peak).
6457
+ */
6458
+ function selectKeyFrameTrackIds(targets) {
6459
+ return targets.filter((t) => t.bestThumbnail).map((t) => t.trackId);
6460
+ }
6461
+ /**
6462
+ * Build the `captureCrop` request for a track's native `keyFrame`: the FULL
6463
+ * frame (no padding) at the native width cap. The full-frame box is what makes
6464
+ * the capture route through the native surface at native resolution instead of
6465
+ * a tight ≤640 detection crop.
6466
+ */
6467
+ function buildKeyFrameCaptureRequest(frameWidth, frameHeight) {
6468
+ return {
6469
+ bbox: {
6470
+ x: 0,
6471
+ y: 0,
6472
+ w: frameWidth,
6473
+ h: frameHeight
6474
+ },
6475
+ padding: 0,
6476
+ maxWidth: KEYFRAME_NATIVE_MAX_WIDTH
5477
6477
  };
5478
6478
  }
5479
6479
  //#endregion
@@ -6810,6 +7810,17 @@ var DEFAULT_MIN_INTERVAL_MS = 1e3;
6810
7810
  /** Default fire cap for a `once` step when its announce omits `maxPerTrack`. */
6811
7811
  var DEFAULT_ONCE_MAX_PER_TRACK = 3;
6812
7812
  /**
7813
+ * Consecutive frame-plane misses ("frame + crop both missed") after which a step
7814
+ * is ABANDONED for the track. The decode worker serves native crops from a RAM
7815
+ * lease store with a ~500ms TTL, so a retry that arrives seconds later re-cuts
7816
+ * from an evicted handle and is a guaranteed miss forever. Retrying a
7817
+ * permanently-gone frame just burns cross-process RPC + CPU + log lines. Three
7818
+ * consecutive misses (each ≥ one tick apart) confidently means the frame is gone
7819
+ * for good, while still tolerating a single transient decode-worker hiccup /
7820
+ * respawn on a genuinely live track (the counter resets on any resolved result).
7821
+ */
7822
+ var MAX_CONSECUTIVE_FRAME_MISSES = 3;
7823
+ /**
6813
7824
  * Pure per-(track, step) scheduling state machine for detail-subtree
6814
7825
  * dispatch. Given a camera's announced child steps (`DetailStepAnnounce[]`,
6815
7826
  * read off `PipelineInferenceResultPayload.detailSteps`), decides WHEN each
@@ -6830,7 +7841,9 @@ var DetailScheduler = class {
6830
7841
  firedCount: 1,
6831
7842
  lastFiredAt: nowMs,
6832
7843
  sticky: false,
6833
- retryPending: false
7844
+ retryPending: false,
7845
+ consecutiveFrameMisses: 0,
7846
+ abandoned: false
6834
7847
  };
6835
7848
  steps.set(stepAnnounce.stepId, state);
6836
7849
  requests.push({
@@ -6863,7 +7876,7 @@ var DetailScheduler = class {
6863
7876
  tick(nowMs) {
6864
7877
  const requests = [];
6865
7878
  for (const [trackId, steps] of this.tracks) for (const state of steps.values()) {
6866
- if (state.sticky) continue;
7879
+ if (state.sticky || state.abandoned) continue;
6867
7880
  if (state.retryPending) {
6868
7881
  if (!this.intervalElapsed(state, nowMs)) continue;
6869
7882
  if (!this.underMaxPerTrack(state)) {
@@ -6901,7 +7914,8 @@ var DetailScheduler = class {
6901
7914
  if (!steps) return;
6902
7915
  const state = steps.get(stepId);
6903
7916
  if (!state) return;
6904
- if (state.sticky) return;
7917
+ if (state.sticky || state.abandoned) return;
7918
+ state.consecutiveFrameMisses = 0;
6905
7919
  const { stickyOnConfidence } = state.announce.cadence;
6906
7920
  if (confidence !== null && stickyOnConfidence !== void 0 && confidence >= stickyOnConfidence) {
6907
7921
  state.sticky = true;
@@ -6916,11 +7930,37 @@ var DetailScheduler = class {
6916
7930
  if (this.underMaxPerTrack(state)) state.retryPending = true;
6917
7931
  }
6918
7932
  }
7933
+ /**
7934
+ * A dispatched request could not resolve a frame AT ALL — the frame handle
7935
+ * lease was evicted AND the crop fallback was unavailable (the "frame + crop
7936
+ * both missed" outcome). This is fundamentally different from `onResult(null)`:
7937
+ * there the frame plane WORKED and the model merely returned nothing (worth a
7938
+ * retry on a fresh frame). A frame-plane miss re-cuts from the SAME evicted
7939
+ * handle every time, so it can never recover from this request. It is
7940
+ * retry-eligible only for a bounded number of CONSECUTIVE attempts; after
7941
+ * {@link MAX_CONSECUTIVE_FRAME_MISSES} in a row the step is abandoned for the
7942
+ * track — this is the give-up that breaks the permanent-retry loop. `_nowMs`
7943
+ * is accepted for signature symmetry (backoff is anchored to `lastFiredAt`).
7944
+ */
7945
+ onFrameMiss(trackId, stepId, _nowMs) {
7946
+ const steps = this.tracks.get(trackId);
7947
+ if (!steps) return;
7948
+ const state = steps.get(stepId);
7949
+ if (!state) return;
7950
+ if (state.sticky || state.abandoned) return;
7951
+ state.consecutiveFrameMisses += 1;
7952
+ if (state.consecutiveFrameMisses >= MAX_CONSECUTIVE_FRAME_MISSES) {
7953
+ state.abandoned = true;
7954
+ state.retryPending = false;
7955
+ return;
7956
+ }
7957
+ if (this.underMaxPerTrack(state)) state.retryPending = true;
7958
+ }
6919
7959
  onTrackEnded(trackId) {
6920
7960
  this.tracks.delete(trackId);
6921
7961
  }
6922
7962
  canFire(state, nowMs) {
6923
- if (state.sticky) return false;
7963
+ if (state.sticky || state.abandoned) return false;
6924
7964
  if (!this.underMaxPerTrack(state)) return false;
6925
7965
  return this.intervalElapsed(state, nowMs);
6926
7966
  }
@@ -6943,7 +7983,7 @@ var DetailScheduler = class {
6943
7983
  //#region src/pipeline-analytics/detail-dispatcher.ts
6944
7984
  /**
6945
7985
  * Compose the `steps` list sent to `runDetailSubtree` for one request —
6946
- * identity-aware for the face chain.
7986
+ * chain-aware for the multi-step detail subtrees.
6947
7987
  *
6948
7988
  * A `face-detection` request ALSO includes `'face-embedding'` (the full
6949
7989
  * detect→recognize chain) EXCEPT when it is a PERIODIC geometry refresh on a
@@ -6952,13 +7992,35 @@ var DetailScheduler = class {
6952
7992
  * that case runs the detector geometry ALONE. Every recognition-bearing reason
6953
7993
  * (new-track / improve / retry) keeps the embedding regardless of the label.
6954
7994
  *
6955
- * Non-face steps are unchanged (`[req.stepId]`). Pairs with the pipeline's
6956
- * strict-`steps` pruning (`pruneChildStepsToRequested`) including
6957
- * `'face-embedding'` here is what keeps the nested child in the executed chain.
7995
+ * A `plate-detection` request ALWAYS includes `'plate-ocr'` symmetric to the
7996
+ * face chain. Without `'plate-ocr'` in the array the pipeline's strict-`steps`
7997
+ * pruning (`pruneChildStepsToRequested`) drops the OCR child, so a detected
7998
+ * plate never gets read and no plate text is ever produced. (The dispatcher
7999
+ * cannot import the pipeline catalog to derive the child chain — this hardcode
8000
+ * mirrors it; keep the two in sync when the catalog's plate subtree changes.)
8001
+ *
8002
+ * Other steps are unchanged (`[req.stepId]`). Pairs with the pipeline's
8003
+ * strict-`steps` pruning — naming the nested child here is what keeps it in the
8004
+ * executed chain.
6958
8005
  */
6959
8006
  function composeDetailSteps(req, hasTrackLabel) {
6960
- if (req.stepId !== "face-detection") return [req.stepId];
6961
- return req.reason === "periodic" && hasTrackLabel(req.trackId) ? ["face-detection"] : ["face-detection", "face-embedding"];
8007
+ if (req.stepId === "face-detection") return req.reason === "periodic" && hasTrackLabel(req.trackId) ? ["face-detection"] : ["face-detection", "face-embedding"];
8008
+ if (req.stepId === "plate-detection") return ["plate-detection", "plate-ocr"];
8009
+ return [req.stepId];
8010
+ }
8011
+ /**
8012
+ * Does `steps` name a nested-enrichment chain (root detector + a child that
8013
+ * produces a `label`/`embedding`), i.e. more than the bare root step? Used to
8014
+ * surface a silent enrichment miss (BUG C): a plate detected but never read, a
8015
+ * face detected but never embedded — the root detail still routes so the miss
8016
+ * is otherwise invisible. `['plate-detection']` alone is NOT a chain.
8017
+ */
8018
+ function isEnrichmentChain(steps) {
8019
+ return steps.length > 1;
8020
+ }
8021
+ /** Does any returned detail carry the enrichment a chain request asked for? */
8022
+ function detailsCarryEnrichment(details) {
8023
+ return details.some((d) => d.label !== void 0 || d.embedding !== void 0);
6962
8024
  }
6963
8025
  /** Throttle for the per-device "detail call failed" warn — one line / minute. */
6964
8026
  var FAIL_WARN_THROTTLE_MS = 6e4;
@@ -7035,7 +8097,8 @@ var TrackDetailDispatcher = class {
7035
8097
  queue: [],
7036
8098
  inFlight: 0,
7037
8099
  timer: null,
7038
- lastFailWarnAt: 0
8100
+ lastFailWarnAt: 0,
8101
+ lastEnrichWarnAt: 0
7039
8102
  };
7040
8103
  this.devices.set(deviceId, dev);
7041
8104
  }
@@ -7078,9 +8141,15 @@ var TrackDetailDispatcher = class {
7078
8141
  }
7079
8142
  async dispatch(deviceId, dev, req, frame) {
7080
8143
  const details = await this.runOnce(deviceId, dev, req, frame);
8144
+ if (details === null) {
8145
+ dev.scheduler.onFrameMiss(req.trackId, req.stepId, Date.now());
8146
+ return;
8147
+ }
7081
8148
  let topScore = null;
7082
- if (details !== null && details.length > 0) {
8149
+ if (details.length > 0) {
7083
8150
  topScore = details.reduce((max, d) => d.score > max ? d.score : max, Number.NEGATIVE_INFINITY);
8151
+ const steps = composeDetailSteps(req, (id) => this.deps.hasTrackLabel?.(id) ?? false);
8152
+ if (isEnrichmentChain(steps) && !detailsCarryEnrichment(details)) this.warnEnrichmentMissThrottled(deviceId, dev, req, steps);
7084
8153
  try {
7085
8154
  await this.deps.routeResults(deviceId, req.trackId, details, frame);
7086
8155
  } catch (err) {
@@ -7162,6 +8231,20 @@ var TrackDetailDispatcher = class {
7162
8231
  }
7163
8232
  });
7164
8233
  }
8234
+ warnEnrichmentMissThrottled(deviceId, dev, req, steps) {
8235
+ const now = Date.now();
8236
+ if (now - dev.lastEnrichWarnAt < FAIL_WARN_THROTTLE_MS) return;
8237
+ dev.lastEnrichWarnAt = now;
8238
+ this.deps.logger.warn("detail chain ran but produced no enrichment (root detected, child yielded no label/embedding)", {
8239
+ tags: { deviceId },
8240
+ meta: {
8241
+ trackId: req.trackId,
8242
+ stepId: req.stepId,
8243
+ reason: req.reason,
8244
+ steps
8245
+ }
8246
+ });
8247
+ }
7165
8248
  };
7166
8249
  //#endregion
7167
8250
  //#region src/pipeline-analytics/overlay-state.ts
@@ -7999,38 +9082,68 @@ var PlateRecognizer = class {
7999
9082
  name
8000
9083
  } : null;
8001
9084
  }
8002
- /** Live label for a plate read: the recognized vehicle NAME when matched, else
8003
- * the raw OCR text (today's behavior). Used by the ingest label path. */
9085
+ /** Live label for a plate read: the recognized vehicle NAME when matched,
9086
+ * else the raw OCR text. Returns `null` for an implausible read (junk OCR
9087
+ * off a distant/oblique plate) — the caller must NOT stamp a label then. */
8004
9088
  resolveLabel(text, score) {
9089
+ if (!isPlausiblePlateRead(text, score)) return null;
8005
9090
  return this.matchVehicle(text, score)?.name ?? text;
8006
9091
  }
8007
9092
  async processFrame(input) {
8008
9093
  const minConfidence = input.minConfidence ?? 0;
8009
9094
  const candidates = input.tracked.filter((t) => typeof t.plateText === "string" && t.plateText.length > 0 && typeof t.plateScore === "number" && t.plateScore >= minConfidence && t.plateBbox !== void 0);
8010
9095
  if (candidates.length === 0) return;
8011
- for (const c of candidates) {
8012
- const held = this.bestPlate.get(c.trackId);
8013
- if (held !== void 0 && c.plateScore <= held.score) continue;
8014
- let crop;
8015
- if (input.frameHandle !== void 0) try {
8016
- crop = await this.deps.captureCrop(input.frameHandle, c.plateBbox, input.frameWidth, input.frameHeight, input.cropPadding) ?? void 0;
8017
- } catch (err) {
8018
- this.deps.logger.debug("PlateRecognizer crop capture failed", {
8019
- tags: { deviceId: input.deviceId },
8020
- meta: {
8021
- trackId: c.trackId,
8022
- error: String(err)
8023
- }
8024
- });
8025
- }
8026
- this.bestPlate.set(c.trackId, {
8027
- text: c.plateText,
8028
- score: c.plateScore,
8029
- bbox: c.plateBbox,
8030
- timestamp: input.timestamp,
8031
- ...crop !== void 0 ? { crop } : {}
9096
+ for (const c of candidates) await this.holdBest({
9097
+ deviceId: input.deviceId,
9098
+ trackId: c.trackId,
9099
+ text: c.plateText,
9100
+ score: c.plateScore,
9101
+ bbox: c.plateBbox,
9102
+ timestamp: input.timestamp,
9103
+ frameWidth: input.frameWidth,
9104
+ frameHeight: input.frameHeight,
9105
+ cropPadding: input.cropPadding,
9106
+ ...input.frameHandle !== void 0 ? { frameHandle: input.frameHandle } : {}
9107
+ });
9108
+ }
9109
+ /**
9110
+ * Detail-plane entry point (two-plane design): plate-ocr runs on demand per
9111
+ * track via `pipelineRunner.runDetailSubtree`, NOT per frame, so the OCR read
9112
+ * never lands on the per-frame `tracked[]` that {@link processFrame} scans.
9113
+ * The dispatcher's result router calls this with each plate detail so the
9114
+ * gallery still collects the best read + tight crop (persisted on
9115
+ * {@link onTrackEnd}). Without it the plate label rides the event but the
9116
+ * gallery stays empty (0 plateCrop) the observed live gap.
9117
+ */
9118
+ async observePlateRead(input) {
9119
+ if (!isPlausiblePlateRead(input.text, input.score)) return;
9120
+ if (input.score < (input.minConfidence ?? 0)) return;
9121
+ await this.holdBest(input);
9122
+ }
9123
+ /** Hold the highest-scoring plate read per track, capturing a tight crop the
9124
+ * first time a new best is seen (shared by the per-frame + detail-plane paths). */
9125
+ async holdBest(input) {
9126
+ const held = this.bestPlate.get(input.trackId);
9127
+ if (held !== void 0 && input.score <= held.score) return;
9128
+ let crop;
9129
+ if (input.frameHandle !== void 0) try {
9130
+ crop = await this.deps.captureCrop(input.frameHandle, input.bbox, input.frameWidth, input.frameHeight, input.cropPadding) ?? void 0;
9131
+ } catch (err) {
9132
+ this.deps.logger.debug("PlateRecognizer crop capture failed", {
9133
+ tags: { deviceId: input.deviceId },
9134
+ meta: {
9135
+ trackId: input.trackId,
9136
+ error: String(err)
9137
+ }
8032
9138
  });
8033
9139
  }
9140
+ this.bestPlate.set(input.trackId, {
9141
+ text: input.text,
9142
+ score: input.score,
9143
+ bbox: input.bbox,
9144
+ timestamp: input.timestamp,
9145
+ ...crop !== void 0 ? { crop } : {}
9146
+ });
8034
9147
  }
8035
9148
  /** Persist the held best plate for a finished track as one PlateStore row
8036
9149
  * (crop → MediaStore under ownerKind 'plate'), then drop in-memory state. */
@@ -8366,6 +9479,40 @@ function classifyAudioFrame(top, cfg) {
8366
9479
  //#endregion
8367
9480
  //#region src/pipeline-analytics/event-media-handler.ts
8368
9481
  var CACHE_CONTROL = "public, max-age=31536000, immutable";
9482
+ /** Default / clamp bounds for the `thumb` variant edge (px). Mirrors
9483
+ * `shared/frame/square-thumb.ts`; kept here so query parsing stays pure. */
9484
+ var THUMB_DEFAULT_SIZE = 160;
9485
+ var THUMB_MIN_SIZE$1 = 64;
9486
+ var THUMB_MAX_SIZE$1 = 320;
9487
+ /**
9488
+ * Parse the `?kind=…` query into a preferred stored media kind. Returns null
9489
+ * when unset. The value is a free-form kind token (e.g. `crop`); the resolver
9490
+ * validates it against the known kinds.
9491
+ */
9492
+ function parseEventMediaKind(query) {
9493
+ const kind = new URLSearchParams(query).get("kind");
9494
+ return kind !== null && kind.length > 0 ? kind : null;
9495
+ }
9496
+ /**
9497
+ * Parse the `?variant=…` query into an {@link EventMediaVariant}. Returns null
9498
+ * when no small-square rendering was requested (the caller then serves the
9499
+ * stored blob). Accepts `variant=thumb` or `square=1`; the edge comes from
9500
+ * `size` / `w` / `h` (clamped to [64, 320], default 160).
9501
+ */
9502
+ function parseEventMediaVariant(query) {
9503
+ const params = new URLSearchParams(query);
9504
+ if (!(params.get("variant") === "thumb" || params.get("square") === "1")) return null;
9505
+ const sizeRaw = params.get("size") ?? params.get("w") ?? params.get("h");
9506
+ let size = THUMB_DEFAULT_SIZE;
9507
+ if (sizeRaw !== null) {
9508
+ const n = Number.parseInt(sizeRaw, 10);
9509
+ if (Number.isFinite(n)) size = Math.max(THUMB_MIN_SIZE$1, Math.min(THUMB_MAX_SIZE$1, n));
9510
+ }
9511
+ return {
9512
+ kind: "thumb",
9513
+ size
9514
+ };
9515
+ }
8369
9516
  /**
8370
9517
  * Create a data-plane handler that serves event thumbnails as JPEG images.
8371
9518
  *
@@ -8379,14 +9526,20 @@ function createEventMediaHandler(deps) {
8379
9526
  res.writeHead(405, { allow: "GET, HEAD" }).end();
8380
9527
  return;
8381
9528
  }
8382
- const eventId = ((req.url ?? "/").split("?")[0] ?? "/").replace(/^\/+/, "");
9529
+ const url = req.url ?? "/";
9530
+ const qIdx = url.indexOf("?");
9531
+ const rawPath = qIdx === -1 ? url : url.slice(0, qIdx);
9532
+ const query = qIdx === -1 ? "" : url.slice(qIdx + 1);
9533
+ const eventId = rawPath.replace(/^\/+/, "");
8383
9534
  if (!eventId || eventId.includes("/")) {
8384
9535
  res.writeHead(404).end();
8385
9536
  return;
8386
9537
  }
9538
+ const variant = parseEventMediaVariant(query);
9539
+ const preferKind = parseEventMediaKind(query);
8387
9540
  let media = null;
8388
9541
  try {
8389
- media = await deps.getMedia(eventId);
9542
+ media = await deps.getMedia(eventId, variant ?? void 0, preferKind ?? void 0);
8390
9543
  } catch {
8391
9544
  const body = "Internal server error";
8392
9545
  res.writeHead(500, {
@@ -8419,6 +9572,27 @@ function createEventMediaHandler(deps) {
8419
9572
  else res.end(Buffer.from(media.bytes));
8420
9573
  };
8421
9574
  }
9575
+ /** JPEG quality for the small square thumbnail (visibly fine at ≤192px, tiny). */
9576
+ var THUMB_QUALITY = 70;
9577
+ /**
9578
+ * Produce a SMALL SQUARE JPEG from an already-encoded image (typically the
9579
+ * stored 640×360 `crop`). Center-crop cover to a square then downscale to
9580
+ * `size`×`size` at JPEG q70 — the reel/list surfaces want a compact square tile
9581
+ * showing the object, not the full 16:9 crop. Output is a fraction of the source
9582
+ * (~3–8 KB at 144–192 px vs ~65 KB for the crop), so a fleet-wide reel renders
9583
+ * from tiny HTTP-cached tiles instead of full base64 payloads.
9584
+ *
9585
+ * `fit: 'cover'` + `position: 'centre'` scales the shorter side to `size` and
9586
+ * crops the overflow symmetrically — the center square of a square-safe crop
9587
+ * fully contains the detector bbox, so the object stays framed.
9588
+ */
9589
+ async function makeSquareThumb(bytes, size) {
9590
+ const edge = Math.max(64, Math.min(320, Math.round(size)));
9591
+ return (0, sharp.default)(Buffer.from(bytes)).resize(edge, edge, {
9592
+ fit: "cover",
9593
+ position: "centre"
9594
+ }).jpeg({ quality: THUMB_QUALITY }).toBuffer();
9595
+ }
8422
9596
  //#endregion
8423
9597
  //#region src/pipeline-analytics/index.ts
8424
9598
  /**
@@ -8441,18 +9615,17 @@ var DETAIL_FALLBACK_CROP_PADDING = .15;
8441
9615
  * before re-reading. */
8442
9616
  var CLIP_MODEL_ID_CACHE_TTL_MS = 6e4;
8443
9617
  var SETTINGS_CACHE_TTL_MS = 5e3;
9618
+ /** Reconcile cadence for the per-device `zones` / `zoneRules` slice handles.
9619
+ * The `device.state-changed` push doesn't reliably reach a forked child, so
9620
+ * each cached proxy re-pulls both slices on this timer (see ensureProxy) —
9621
+ * a zone drawn in the editor shows up in per-zone stats within one tick. */
9622
+ var ZONE_SLICE_RECONCILE_MS = 3e4;
8444
9623
  /** §5 best-frame: a track's `thumbnail` is overwritten only when the current
8445
9624
  * detection confidence beats the held best by at least this margin (hysteresis
8446
9625
  * so jitter around a plateau doesn't churn the write). */
8447
9626
  var BEST_FRAME_HYSTERESIS = .05;
8448
9627
  /** §5 best-frame: at most one best-thumbnail capture per this interval per track. */
8449
9628
  var BEST_FRAME_MIN_GAP_MS = 2e3;
8450
- /** Design B: cap (px) on the width of the native KEY FRAME (full-frame native
8451
- * capture). Native resolution is the point, but a full 4K RGB surface over the
8452
- * transport per new-best is wasteful for a web detail view — 1920px keeps a
8453
- * sharp native frame while bounding the copy (a miss falls back to the
8454
- * detection-res frame, which is already ≤640px). */
8455
- var KEYFRAME_NATIVE_MAX_WIDTH = 1920;
8456
9629
  /** getKeyEvents: max completed tracks pulled from a window before importance
8457
9630
  * ranking. Ordering is by importance (not firstSeen) and legacy rows score on
8458
9631
  * read, so we over-fetch candidates and trim to `limit` after sorting. */
@@ -8475,6 +9648,34 @@ var OVERLAY_SYNTHESIS_WARN_THROTTLE_MS = 6e4;
8475
9648
  var AUDIO_EVENT_HEARTBEAT_MS = 5e3;
8476
9649
  var MOTION_EVENT_HEARTBEAT_MS = 5e3;
8477
9650
  /**
9651
+ * Stored media kinds that carry NO drawn bounding box, in fallback preference
9652
+ * order. The reel forces `?kind=crop`; when a track has no crop the endpoint may
9653
+ * degrade only to one of these CLEAN frames — never `fullFrameBoxed` /
9654
+ * `thumbnail` / `lastFrame` / `firstFrame` / `snapshot` (all server-boxed).
9655
+ */
9656
+ var CLEAN_MEDIA_KINDS = [
9657
+ "crop",
9658
+ "fullFrame",
9659
+ "keyFrame"
9660
+ ];
9661
+ /**
9662
+ * Pick a CLEAN (never boxed) media file for a crop-forced request: the exact
9663
+ * `preferKind` if it is itself clean and present, else the first available
9664
+ * {@link CLEAN_MEDIA_KINDS} frame. Returns undefined when only boxed / no media
9665
+ * exists (caller 404s → the viewer shows an icon).
9666
+ */
9667
+ function pickCleanMedia(files, preferKind) {
9668
+ const isClean = (k) => CLEAN_MEDIA_KINDS.includes(k);
9669
+ if (isClean(preferKind)) {
9670
+ const exact = files.find((f) => f.kind === preferKind);
9671
+ if (exact) return exact;
9672
+ }
9673
+ for (const kind of CLEAN_MEDIA_KINDS) {
9674
+ const found = files.find((f) => f.kind === kind);
9675
+ if (found) return found;
9676
+ }
9677
+ }
9678
+ /**
8478
9679
  * Decode a base64 little-endian float32 vector (the `DetailResult.embedding`
8479
9680
  * wire encoding produced by `runDetailSubtree`) back into a plain number[].
8480
9681
  */
@@ -8525,6 +9726,10 @@ function stripGlobalOnlyFields(sections) {
8525
9726
  var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
8526
9727
  processors = /* @__PURE__ */ new Map();
8527
9728
  trackStore = null;
9729
+ /** Parked-object registry: promotes a track that stopped moving into a
9730
+ * lightweight entry, suppresses its detections from re-spawning tracks, and
9731
+ * wakes it when the object departs. Null until onInitialize. */
9732
+ stationaryRegistry = null;
8528
9733
  mediaStore = null;
8529
9734
  eventStore = null;
8530
9735
  identityStore = null;
@@ -8550,9 +9755,6 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
8550
9755
  * detection-pipeline DECODED frame — the ONLY image source (never the
8551
9756
  * snapshot cap). Null when shm frame access is unavailable. */
8552
9757
  eventMediaDispatcher = null;
8553
- /** Shared shm-ring reader cache for resolving `frameHandle`s to pixels.
8554
- * Owned here so segments stay open across frames; closed once on shutdown. */
8555
- frameReaders = null;
8556
9758
  /** Object/face embedding dispatcher — migrated from the retired
8557
9759
  * enrichment-engine addon. Runs ONLY on the post-processing node; on each
8558
9760
  * detection it resolves the frame, crops the ROI, and calls the
@@ -8589,6 +9791,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
8589
9791
  * dataPlane facility in the current environment). */
8590
9792
  eventMediaBaseUrl = null;
8591
9793
  lastActiveTrackIds = /* @__PURE__ */ new Map();
9794
+ lastFrameDimsByDevice = /* @__PURE__ */ new Map();
8592
9795
  lastAudioInsertByDevice = /* @__PURE__ */ new Map();
8593
9796
  lastMotionInsertByDevice = /* @__PURE__ */ new Map();
8594
9797
  levelStateByDevice = /* @__PURE__ */ new Map();
@@ -8620,10 +9823,15 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
8620
9823
  * cross-restart "best" gate stays in `ObjectEmbeddingStore.upsertIfBetter`. */
8621
9824
  objectEmbeddingBestSelector = new TrackBestSelector();
8622
9825
  /** Design B: the track's shared native key-frame media key, captured at the
8623
- * best-detection moment (object-embedding best path). Read by the face path
8624
- * at track end so a face row links to the SAME single key frame. Cleared on
8625
- * track end. */
9826
+ * best-detection moment (general best-frame path). Read by the face / plate /
9827
+ * object-embedding rows so they LINK the SAME single native key frame.
9828
+ * Cleared on track end. */
8626
9829
  keyFrameKeyByTrackId = /* @__PURE__ */ new Map();
9830
+ /** Wall-clock of each track's last ACTUALLY-written rolling `lastFrame`. The
9831
+ * rolling `lastFrame` runs on its OWN pure-time cadence and only on frames
9832
+ * where no `snapshot` is appended, so it is never byte-identical to a stored
9833
+ * `snapshot` (kills the end-of-track duplicate). Cleared on track end. */
9834
+ lastFrameAtByTrack = /* @__PURE__ */ new Map();
8627
9835
  /** Per-track update-gate memory for `PipelineAnalyticsTrackLifecycle`
8628
9836
  * `phase:'update'` — the last-emitted best (confidence / label / crop
8629
9837
  * area) + emit time, so a material improvement is measured against the
@@ -8674,11 +9882,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
8674
9882
  await PlateStore.declare(api.settingsStore);
8675
9883
  await VehicleStore.declare(api.settingsStore);
8676
9884
  await ObjectEmbeddingStore.declare(api.settingsStore);
9885
+ await StationaryObjectRegistry.declare(api.settingsStore);
8677
9886
  const logger = this.ctx.logger;
8678
9887
  let storage = this.ctx.kernel.storage;
8679
9888
  const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
8680
9889
  if (mediaRoot) {
8681
- const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-Cvhwrf43.js"));
9890
+ const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-BFF5_uIc.js"));
8682
9891
  storage = new FilesystemStorageProvider(mediaRoot);
8683
9892
  logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
8684
9893
  }
@@ -8687,6 +9896,30 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
8687
9896
  store: api.settingsStore,
8688
9897
  logger: logger.child("TrackStore")
8689
9898
  });
9899
+ this.stationaryRegistry = new StationaryObjectRegistry({
9900
+ store: api.settingsStore,
9901
+ logger: logger.child("StationaryRegistry"),
9902
+ onChange: ({ phase, entry, timestamp }) => {
9903
+ this.ctx.eventBus.emit({
9904
+ id: `pa-stationary-${entry.id}-${phase}`,
9905
+ timestamp: new Date(timestamp),
9906
+ source: {
9907
+ type: "addon",
9908
+ id: "pipeline-analytics",
9909
+ addonId: "pipeline-analytics"
9910
+ },
9911
+ category: require_dist.EventCategory.PipelineAnalyticsStationaryChanged,
9912
+ data: {
9913
+ deviceId: entry.deviceId,
9914
+ entryId: entry.id,
9915
+ className: entry.className,
9916
+ phase,
9917
+ timestamp
9918
+ }
9919
+ });
9920
+ }
9921
+ });
9922
+ await this.stationaryRegistry.load();
8690
9923
  this.mediaStore = new MediaStore({
8691
9924
  storage,
8692
9925
  store: api.settingsStore,
@@ -8723,33 +9956,33 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
8723
9956
  designatedNode: designated
8724
9957
  } });
8725
9958
  }
8726
- this.frameReaders = new _camstack_shm_ring.FrameRingReaderCache(logger.child("shm-readers"));
8727
- const decoderApi = api.decoder;
9959
+ const pipelineRunnerApi = api.pipelineRunner;
8728
9960
  const getRemoteFrame = async (handle) => {
8729
- if (!decoderApi?.getFrame) return null;
8730
- const remote = await decoderApi.getFrame.query({
9961
+ if (!pipelineRunnerApi?.getNativeCrop) return null;
9962
+ const full = await pipelineRunnerApi.getNativeCrop.query({
8731
9963
  handle,
8732
- nodeId: handle.nodeId
8733
- });
8734
- if (!remote) return null;
9964
+ bbox: {
9965
+ x: 0,
9966
+ y: 0,
9967
+ w: 1,
9968
+ h: 1
9969
+ },
9970
+ maxWidth: handle.width
9971
+ }, require_dist.nodePin(handle.nodeId));
9972
+ if (!full || full.width <= 0 || full.height <= 0) return null;
8735
9973
  return {
8736
- data: Buffer.from(remote.data),
8737
- width: remote.width,
8738
- height: remote.height,
8739
- format: remote.format,
8740
- timestamp: remote.timestamp
9974
+ data: Buffer.from(full.bytes),
9975
+ width: full.width,
9976
+ height: full.height,
9977
+ format: "rgb",
9978
+ timestamp: 0
8741
9979
  };
8742
9980
  };
8743
9981
  this.eventMediaDispatcher = new EventMediaDispatcher({
8744
- ownNodeId,
8745
- readers: this.frameReaders,
8746
9982
  getRemoteFrame,
8747
9983
  mediaStore: this.mediaStore,
8748
9984
  logger: logger.child("EventMediaDispatcher")
8749
9985
  });
8750
- const ownNodeIdForFaces = ownNodeId;
8751
- const frameReadersForFaces = this.frameReaders;
8752
- const pipelineRunnerApi = api.pipelineRunner;
8753
9986
  const cropMetricLogger = logger.child("NativeCrop");
8754
9987
  let nativeHits = 0;
8755
9988
  let nativeFallbacks = 0;
@@ -8781,11 +10014,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
8781
10014
  return null;
8782
10015
  }
8783
10016
  };
8784
- const resolveFrameShared = createSharedFrameResolver((frameHandle) => resolveFrame(frameHandle, {
8785
- ownNodeId: ownNodeIdForFaces,
8786
- readers: frameReadersForFaces,
8787
- getRemoteFrame
8788
- }));
10017
+ const resolveFrameShared = createSharedFrameResolver((frameHandle) => resolveFrame(frameHandle, { getRemoteFrame }));
8789
10018
  const captureCrop = async (frameHandle, bbox, frameWidth, frameHeight, padding, maxWidth) => {
8790
10019
  const paddedNorm = padBbox({
8791
10020
  x: bbox.x / frameWidth,
@@ -8865,7 +10094,10 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
8865
10094
  });
8866
10095
  this.zoneAnalytics = new ZoneAnalyticsProvider({
8867
10096
  logger: logger.child("ZoneAnalytics"),
8868
- fetchDevice: (deviceId) => this.ctx.fetchDevice(deviceId)
10097
+ fetchDevice: (deviceId) => this.ctx.fetchDevice(deviceId),
10098
+ listStationaryObjects: (deviceId) => this.stationaryRegistry?.listViews(deviceId) ?? [],
10099
+ listStationaryDeviceIds: () => this.stationaryRegistry?.deviceIds() ?? [],
10100
+ resolveZones: (deviceId) => this.resolveDeviceZones(deviceId)
8869
10101
  });
8870
10102
  this.audioMetrics = new AudioMetricsProvider({
8871
10103
  logger: logger.child("AudioMetrics"),
@@ -8881,9 +10113,9 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
8881
10113
  }
8882
10114
  });
8883
10115
  try {
8884
- const handler = createEventMediaHandler({ getMedia: async (id) => {
10116
+ const handler = createEventMediaHandler({ getMedia: async (id, variant, preferKind) => {
8885
10117
  try {
8886
- return await this.readMediaByEventOrKey(id);
10118
+ return await this.readMediaByEventOrKey(id, variant, preferKind);
8887
10119
  } catch (err) {
8888
10120
  this.ctx.logger.warn("readEventThumbnail failed", { meta: {
8889
10121
  eventId: id,
@@ -8941,8 +10173,6 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
8941
10173
  encoder: encoderClient,
8942
10174
  eventBus: this.ctx.eventBus,
8943
10175
  logger: logger.child("EmbeddingDispatcher"),
8944
- ownNodeId,
8945
- readers: frameReadersForFaces,
8946
10176
  getRemoteFrame
8947
10177
  });
8948
10178
  await this.embeddingDispatcher.start();
@@ -8953,6 +10183,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
8953
10183
  this.bindingCache?.onBindingsChanged(data);
8954
10184
  if (data.capName === "pipeline-analytics" && data.reason === "wrapper-deactivated") {
8955
10185
  this.trackStore?.clearDevice(data.deviceId);
10186
+ this.stationaryRegistry?.forgetDevice(data.deviceId);
8956
10187
  this.overlayState.clearDevice(data.deviceId);
8957
10188
  this.overlaySynthesisWarnAt.delete(data.deviceId);
8958
10189
  this.forgetDeviceProcessors(data.deviceId);
@@ -8967,6 +10198,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
8967
10198
  this.unsubDeviceUnreg = this.ctx.eventBus.subscribe({ category: require_dist.EventCategory.DeviceUnregistered }, (ev) => {
8968
10199
  const { deviceId } = ev.data;
8969
10200
  this.trackStore?.clearDevice(deviceId);
10201
+ this.stationaryRegistry?.forgetDevice(deviceId);
8970
10202
  this.overlayState.clearDevice(deviceId);
8971
10203
  this.overlaySynthesisWarnAt.delete(deviceId);
8972
10204
  this.forgetDeviceProcessors(deviceId);
@@ -8987,6 +10219,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
8987
10219
  this.retentionSweepTimer = setInterval(() => {
8988
10220
  this.sweepRetention();
8989
10221
  this.runTrackRetentionSweep();
10222
+ this.stationaryRegistry?.sweep(Date.now());
8990
10223
  }, RETENTION_SWEEP_INTERVAL_MS);
8991
10224
  this.ctx.logger.info("pipeline-analytics subscribers installed");
8992
10225
  const widgetsProvider = { listWidgets: async () => [
@@ -9307,8 +10540,10 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
9307
10540
  this.overlaySynthesisWarnAt.clear();
9308
10541
  this.processors.clear();
9309
10542
  this.lastActiveTrackIds.clear();
10543
+ this.lastFrameDimsByDevice.clear();
9310
10544
  this.dropoutSkipsByKey.clear();
9311
10545
  this.bestFrameTracker.clear();
10546
+ this.lastFrameAtByTrack.clear();
9312
10547
  this.trackLifecycleUpdateMem.clear();
9313
10548
  this.objectEmbeddingBestSelector.clear();
9314
10549
  this.levelStateByDevice.clear();
@@ -9319,13 +10554,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
9319
10554
  this.faceGlobalEnabledCache = null;
9320
10555
  this.mediaCacheByDevice.clear();
9321
10556
  this.trackStore?.clearAll();
10557
+ this.stationaryRegistry = null;
9322
10558
  this.bindingCache?.clearAll();
9323
10559
  await this.eventMediaDataPlane?.dispose();
9324
10560
  this.eventMediaDataPlane = null;
9325
10561
  this.eventMediaBaseUrl = null;
9326
10562
  this.eventMediaDispatcher = null;
9327
- this.frameReaders?.close();
9328
- this.frameReaders = null;
9329
10563
  }
9330
10564
  async handleInferenceResult(data) {
9331
10565
  if (this.shuttingDown) return;
@@ -9373,22 +10607,41 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
9373
10607
  timestamp: frame.timestamp,
9374
10608
  frame
9375
10609
  });
10610
+ if (source === "pipeline") this.stationaryRegistry?.noteFrame(deviceId, result.timestamp);
10611
+ if (this.stationaryRegistry && (result.stationaryConfirmed.length > 0 || result.stationaryWoken.length > 0)) this.stationaryRegistry.applyFrameOutcome({
10612
+ deviceId,
10613
+ confirmed: result.stationaryConfirmed,
10614
+ wokenEntryIds: result.stationaryWoken,
10615
+ timestamp: result.timestamp
10616
+ });
10617
+ const stationaryViews = this.stationaryRegistry?.listViews(deviceId) ?? [];
10618
+ const stationaryAsTracked = stationaryViews.map((v) => ({
10619
+ trackId: `stationary:${v.id}`,
10620
+ className: v.className,
10621
+ zones: computeStationaryEntryZones(v, liveZones)
10622
+ }));
9376
10623
  this.zoneAnalytics?.recordFrame({
9377
10624
  deviceId,
9378
10625
  timestamp: result.timestamp,
9379
10626
  frameWidth: result.frameWidth,
9380
10627
  frameHeight: result.frameHeight,
9381
- tracked: result.tracked,
9382
- zones: liveZones
10628
+ tracked: stationaryAsTracked.length > 0 ? [...result.tracked, ...stationaryAsTracked] : result.tracked,
10629
+ zones: liveZones,
10630
+ ...stationaryViews.length > 0 ? { stationaryObjects: stationaryViews } : {}
10631
+ });
10632
+ if (result.frameWidth > 0 && result.frameHeight > 0) this.lastFrameDimsByDevice.set(deviceId, {
10633
+ w: result.frameWidth,
10634
+ h: result.frameHeight
9383
10635
  });
9384
10636
  const currentTrackIds = /* @__PURE__ */ new Set();
10637
+ const positionsCountById = /* @__PURE__ */ new Map();
9385
10638
  for (const t of result.tracked) {
9386
10639
  currentTrackIds.add(t.trackId);
9387
10640
  const center = {
9388
10641
  x: t.bbox.x + t.bbox.w / 2,
9389
10642
  y: t.bbox.y + t.bbox.h / 2
9390
10643
  };
9391
- this.trackStore.upsert({
10644
+ const upserted = this.trackStore.upsert({
9392
10645
  trackId: t.trackId,
9393
10646
  deviceId,
9394
10647
  className: t.className,
@@ -9403,6 +10656,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
9403
10656
  zones: t.zones,
9404
10657
  state: t.state
9405
10658
  });
10659
+ positionsCountById.set(t.trackId, upserted.positions.length);
9406
10660
  }
9407
10661
  const log = this.ctx.logger.withTags({ deviceId });
9408
10662
  const prevIds = this.lastActiveTrackIds.get(key) ?? /* @__PURE__ */ new Set();
@@ -9411,6 +10665,18 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
9411
10665
  for (const id of currentTrackIds) if (!prevIds.has(id)) {
9412
10666
  const t = result.tracked.find((x) => x.trackId === id);
9413
10667
  if (t) {
10668
+ if (classifyTrackAppearance({
10669
+ inPrevActive: false,
10670
+ positionsCount: positionsCountById.get(id) ?? 1
10671
+ }) === "resurrection") {
10672
+ log.info("track resumed", { meta: {
10673
+ trackId: id,
10674
+ className: t.className,
10675
+ source,
10676
+ resurrected: true
10677
+ } });
10678
+ continue;
10679
+ }
9414
10680
  newTrackCount += 1;
9415
10681
  log.info("track started", { meta: {
9416
10682
  trackId: id,
@@ -9424,7 +10690,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
9424
10690
  bbox: { ...t.bbox },
9425
10691
  ...t.label ? { label: t.label } : {}
9426
10692
  });
9427
- this.trackStore.seedSnapshotClock(id, result.timestamp);
10693
+ this.trackStore.seedSnapshotClock(id, result.timestamp, t.bbox);
9428
10694
  }
9429
10695
  this.ctx.eventBus.emit({
9430
10696
  id: `pa-${(0, node_crypto.randomUUID)()}`,
@@ -9470,6 +10736,34 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
9470
10736
  } });
9471
10737
  }
9472
10738
  this.lastActiveTrackIds.set(key, currentTrackIds);
10739
+ if (source === "pipeline" && this.stationaryRegistry) {
10740
+ const dims = this.lastFrameDimsByDevice.get(deviceId);
10741
+ if (dims && dims.w > 0 && dims.h > 0) {
10742
+ const refDiag = Math.hypot(dims.w, dims.h);
10743
+ for (const t of result.tracked) {
10744
+ const active = this.trackStore.peekActive(t.trackId);
10745
+ if (!active) continue;
10746
+ const { promote } = evaluateStationaryPromotion({
10747
+ positions: active.positions,
10748
+ referenceDiagonalPx: refDiag,
10749
+ now: result.timestamp,
10750
+ config: DEFAULT_PROMOTION_CONFIG
10751
+ });
10752
+ if (!promote) continue;
10753
+ this.promoteToStationary({
10754
+ deviceId,
10755
+ key,
10756
+ processor,
10757
+ track: t,
10758
+ firstSeen: active.firstSeen,
10759
+ label: active.label,
10760
+ frameWidth: result.frameWidth,
10761
+ frameHeight: result.frameHeight,
10762
+ timestamp: result.timestamp
10763
+ });
10764
+ }
10765
+ }
10766
+ }
9473
10767
  if (this.detailDispatcher && detailSteps && detailSteps.length > 0) {
9474
10768
  const dispatcher = this.detailDispatcher;
9475
10769
  const steps = detailSteps;
@@ -9538,7 +10832,9 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
9538
10832
  let plateCrops = 0;
9539
10833
  for (const crops of childCropsByEvent.values()) for (const c of crops) if (c.kind === "faceCrop") faceCrops += 1;
9540
10834
  else plateCrops += 1;
9541
- const snapshotTargets = this.buildSnapshotTargets(deviceId, result.tracked, result.timestamp, mediaSettings);
10835
+ const snapshotTargets = this.buildSnapshotTargets(deviceId, result.tracked, result.timestamp, mediaSettings, result.frameWidth, result.frameHeight);
10836
+ const keyFrameTrackIds = selectKeyFrameTrackIds(snapshotTargets);
10837
+ if (keyFrameTrackIds.length > 0) this.persistKeyFrames(deviceId, result.timestamp, keyFrameTrackIds, frameHandle, result.frameWidth, result.frameHeight);
9542
10838
  if (eventTargets.length > 0 || firstFrameTargets.length > 0 || snapshotTargets.length > 0) {
9543
10839
  const captureCounts = {
9544
10840
  events: eventTargets.length,
@@ -9753,14 +11049,36 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
9753
11049
  if (isFaceDetail && d.embedding !== void 0) await this.routeFaceDetail(deviceId, trackId, d, frame);
9754
11050
  else if (d.embedding !== void 0) await this.routeClipDetail(deviceId, trackId, d, frame.timestamp);
9755
11051
  else if (d.label !== void 0 && d.label.length > 0) {
9756
- if (d.className === "plate" && d.bbox !== void 0) this.overlayState.notePlateDetail(deviceId, trackId, {
9757
- x: d.bbox.x,
9758
- y: d.bbox.y,
9759
- w: d.bbox.w,
9760
- h: d.bbox.h
9761
- }, d.score, d.label, frame.timestamp);
9762
- const label = d.className === "plate" ? this.plateRecognizer?.resolveLabel(d.label, d.score) ?? d.label : d.label;
9763
- await this.applyTrackEnrichmentLabel(deviceId, trackId, label);
11052
+ if (d.className === "plate" && d.bbox !== void 0) {
11053
+ this.overlayState.notePlateDetail(deviceId, trackId, {
11054
+ x: d.bbox.x,
11055
+ y: d.bbox.y,
11056
+ w: d.bbox.w,
11057
+ h: d.bbox.h
11058
+ }, d.score, d.label, frame.timestamp);
11059
+ if (this.plateRecognizer) {
11060
+ const mediaSettings = await this.resolveDeviceMediaSettings(deviceId);
11061
+ await this.plateRecognizer.observePlateRead({
11062
+ deviceId,
11063
+ trackId,
11064
+ text: d.label,
11065
+ score: d.score,
11066
+ bbox: {
11067
+ x: d.bbox.x,
11068
+ y: d.bbox.y,
11069
+ w: d.bbox.w,
11070
+ h: d.bbox.h
11071
+ },
11072
+ timestamp: frame.timestamp,
11073
+ frameWidth: frame.frameWidth,
11074
+ frameHeight: frame.frameHeight,
11075
+ cropPadding: mediaSettings.cropPadding,
11076
+ ...frame.frameHandle !== void 0 ? { frameHandle: frame.frameHandle } : {}
11077
+ });
11078
+ }
11079
+ }
11080
+ const label = d.className === "plate" ? this.plateRecognizer?.resolveLabel(d.label, d.score) ?? null : d.label;
11081
+ if (label !== null && label !== void 0) await this.applyTrackEnrichmentLabel(deviceId, trackId, label);
9764
11082
  }
9765
11083
  } catch (err) {
9766
11084
  this.ctx.logger.warn("detail result route failed", {
@@ -9907,55 +11225,26 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
9907
11225
  await Promise.all(bests.map(async (t) => {
9908
11226
  if (!isClipObjectEmbedding(t)) return;
9909
11227
  let mediaKey;
9910
- let keyFrameMediaKey;
9911
- if (frameHandle !== void 0 && this.captureCrop && this.mediaStore) {
9912
- try {
9913
- const crop = await this.captureCrop(frameHandle, t.bbox, frameWidth, frameHeight, cropPadding);
9914
- if (crop) mediaKey = await this.mediaStore.putReplacing({
9915
- deviceId,
9916
- ownerKind: "track",
9917
- ownerId: t.trackId,
9918
- kind: "crop",
9919
- timestamp,
9920
- data: crop
9921
- });
9922
- } catch (err) {
9923
- this.ctx.logger.debug("object-embedding crop capture failed", {
9924
- tags: { deviceId },
9925
- meta: {
9926
- trackId: t.trackId,
9927
- error: require_dist.errMsg(err)
9928
- }
9929
- });
9930
- }
9931
- try {
9932
- const keyFrame = await this.captureCrop(frameHandle, {
9933
- x: 0,
9934
- y: 0,
9935
- w: frameWidth,
9936
- h: frameHeight
9937
- }, frameWidth, frameHeight, 0, KEYFRAME_NATIVE_MAX_WIDTH);
9938
- if (keyFrame) {
9939
- keyFrameMediaKey = await this.mediaStore.putReplacing({
9940
- deviceId,
9941
- ownerKind: "track",
9942
- ownerId: t.trackId,
9943
- kind: "keyFrame",
9944
- timestamp,
9945
- data: keyFrame
9946
- });
9947
- this.keyFrameKeyByTrackId.set(t.trackId, keyFrameMediaKey);
11228
+ if (frameHandle !== void 0 && this.captureCrop && this.mediaStore) try {
11229
+ const crop = await this.captureCrop(frameHandle, t.bbox, frameWidth, frameHeight, cropPadding);
11230
+ if (crop) mediaKey = await this.mediaStore.putReplacing({
11231
+ deviceId,
11232
+ ownerKind: "track",
11233
+ ownerId: t.trackId,
11234
+ kind: "crop",
11235
+ timestamp,
11236
+ data: crop
11237
+ });
11238
+ } catch (err) {
11239
+ this.ctx.logger.debug("object-embedding crop capture failed", {
11240
+ tags: { deviceId },
11241
+ meta: {
11242
+ trackId: t.trackId,
11243
+ error: require_dist.errMsg(err)
9948
11244
  }
9949
- } catch (err) {
9950
- this.ctx.logger.debug("key-frame capture failed", {
9951
- tags: { deviceId },
9952
- meta: {
9953
- trackId: t.trackId,
9954
- error: require_dist.errMsg(err)
9955
- }
9956
- });
9957
- }
11245
+ });
9958
11246
  }
11247
+ const keyFrameMediaKey = this.keyFrameKeyByTrackId.get(t.trackId);
9959
11248
  await store.upsertIfBetter({
9960
11249
  trackId: t.trackId,
9961
11250
  deviceId,
@@ -9969,6 +11258,48 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
9969
11258
  });
9970
11259
  }));
9971
11260
  }
11261
+ /**
11262
+ * Capture ONE native-resolution KEY FRAME per given track at this best-
11263
+ * detection frame and store it (`putReplacing` → one keyFrame per track).
11264
+ *
11265
+ * The full frame is cropped NATIVE-FIRST via `captureCrop`: the request is the
11266
+ * FULL frame (no padding) at `KEYFRAME_NATIVE_MAX_WIDTH`, which routes through
11267
+ * `pipelineRunner.getNativeCrop` (the decode worker's retained native surface)
11268
+ * and only falls back to the ≤640 detection frame when the native lease is
11269
+ * gone. The stored key is recorded in `keyFrameKeyByTrackId` so the face /
11270
+ * plate / object-embedding rows LINK the SAME native key frame (Design B).
11271
+ * Issued in the live-frame window so the native lease is still held. Best-
11272
+ * effort (D8) — a per-track failure is logged and never thrown.
11273
+ */
11274
+ async persistKeyFrames(deviceId, timestamp, trackIds, frameHandle, frameWidth, frameHeight) {
11275
+ const capture = this.captureCrop;
11276
+ const mediaStore = this.mediaStore;
11277
+ if (!capture || !mediaStore) return;
11278
+ const req = buildKeyFrameCaptureRequest(frameWidth, frameHeight);
11279
+ await Promise.all(trackIds.map(async (trackId) => {
11280
+ try {
11281
+ const keyFrame = await capture(frameHandle, req.bbox, frameWidth, frameHeight, req.padding, req.maxWidth);
11282
+ if (!keyFrame) return;
11283
+ const key = await mediaStore.putReplacing({
11284
+ deviceId,
11285
+ ownerKind: "track",
11286
+ ownerId: trackId,
11287
+ kind: "keyFrame",
11288
+ timestamp,
11289
+ data: keyFrame
11290
+ });
11291
+ this.keyFrameKeyByTrackId.set(trackId, key);
11292
+ } catch (err) {
11293
+ this.ctx.logger.debug("key-frame capture failed", {
11294
+ tags: { deviceId },
11295
+ meta: {
11296
+ trackId,
11297
+ error: require_dist.errMsg(err)
11298
+ }
11299
+ });
11300
+ }
11301
+ }));
11302
+ }
9972
11303
  /** Emit a `PipelineAnalyticsTrackLifecycle` event (start / update / end). */
9973
11304
  emitTrackLifecycle(payload, timestamp) {
9974
11305
  this.ctx.eventBus.emit({
@@ -10018,27 +11349,48 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
10018
11349
  ...track?.zonesVisited !== void 0 ? { zonesVisited: track.zonesVisited } : {},
10019
11350
  ...track?.totalDistance !== void 0 ? { totalDistance: track.totalDistance } : {},
10020
11351
  ...track?.positions !== void 0 ? { positionsCount: track.positions.length } : {},
11352
+ ...track?.audioLabels !== void 0 ? { audioLabels: track.audioLabels } : {},
10021
11353
  ...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {},
10022
11354
  ...t.embeddingModelId !== void 0 ? { embeddingModelId: t.embeddingModelId } : {}
10023
11355
  });
10024
11356
  this.emitTrackLifecycle(payload, timestamp);
10025
11357
  }
10026
- buildSnapshotTargets(deviceId, tracked, timestamp, media) {
11358
+ buildSnapshotTargets(deviceId, tracked, timestamp, media, frameWidth, frameHeight) {
10027
11359
  const targets = [];
10028
11360
  for (const t of tracked) {
10029
11361
  const lastSnap = this.trackStore.lastSnapshotAt(t.trackId);
10030
- const dueSnapshot = media.saveThumbnails && lastSnap > 0 && timestamp - lastSnap >= media.snapshotIntervalMs;
11362
+ const dueSnapshot = media.saveThumbnails && evaluatePeriodicSnapshot({
11363
+ lastSnapshotAt: lastSnap,
11364
+ lastSnapshotBbox: this.trackStore.lastSnapshotBbox(t.trackId),
11365
+ currentBbox: t.bbox,
11366
+ now: timestamp,
11367
+ frameWidth,
11368
+ frameHeight,
11369
+ intervalMs: media.snapshotIntervalMs,
11370
+ movementThreshold: media.snapshotMovementThreshold,
11371
+ maxIdleMs: media.snapshotMaxIdleMs
11372
+ }).capture;
10031
11373
  const isNewBest = this.bestFrameTracker.observe(t.trackId, t.confidence, timestamp);
10032
11374
  this.maybeEmitTrackLifecycleUpdate(deviceId, t, timestamp, isNewBest);
10033
- if (!dueSnapshot && !isNewBest) continue;
11375
+ const plan = planPeriodicMedia({
11376
+ saveThumbnails: media.saveThumbnails,
11377
+ dueSnapshot,
11378
+ isNewBest,
11379
+ lastFrameAt: this.lastFrameAtByTrack.get(t.trackId) ?? 0,
11380
+ now: timestamp,
11381
+ intervalMs: media.snapshotIntervalMs
11382
+ });
11383
+ if (plan.rollingLastFrame) this.lastFrameAtByTrack.set(t.trackId, timestamp);
11384
+ if (plan.appendSnapshot) this.trackStore.markSnapshotPending(t.trackId, timestamp, t.bbox);
11385
+ if (!plan.appendSnapshot && !plan.rollingLastFrame && !plan.bestThumbnail) continue;
10034
11386
  targets.push({
10035
11387
  trackId: t.trackId,
10036
11388
  timestamp,
10037
11389
  bbox: { ...t.bbox },
10038
11390
  ...t.label ? { label: t.label } : {},
10039
- appendSnapshot: dueSnapshot,
10040
- rollingLastFrame: dueSnapshot,
10041
- bestThumbnail: isNewBest
11391
+ appendSnapshot: plan.appendSnapshot,
11392
+ rollingLastFrame: plan.rollingLastFrame,
11393
+ bestThumbnail: plan.bestThumbnail
10042
11394
  });
10043
11395
  }
10044
11396
  return targets;
@@ -10094,6 +11446,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
10094
11446
  atMs: timestamp
10095
11447
  });
10096
11448
  await this.eventStore.insertAudio(ev);
11449
+ this.trackStore?.addAudioLabelEpisode(deviceId, route.className, topClassification.score, timestamp);
10097
11450
  this.ctx.eventBus.emit({
10098
11451
  id: `pa-${ev.id}`,
10099
11452
  timestamp: new Date(ev.timestamp),
@@ -10311,6 +11664,11 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
10311
11664
  const peak = await this.eventStore?.peakForTrack(t.trackId);
10312
11665
  if (peak) {
10313
11666
  endBestEventId = peak.bestEventId;
11667
+ const dims = this.lastFrameDimsByDevice.get(t.deviceId);
11668
+ const staticMetrics = dims ? computeStaticTrackMetrics(t.positions.map((p) => ({
11669
+ x: p.x,
11670
+ y: p.y
11671
+ })), Math.hypot(dims.w, dims.h)) : void 0;
10314
11672
  const { importance, reason } = computeImportance({
10315
11673
  peakConfidence: trackerPeak?.confidence ?? peak.peakConfidence,
10316
11674
  className: t.className,
@@ -10318,7 +11676,11 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
10318
11676
  peakBboxAreaFrac: peak.peakBboxAreaFrac,
10319
11677
  totalDistance: t.totalDistance,
10320
11678
  zonesVisited: t.zonesVisited,
10321
- ...t.label !== void 0 ? { label: t.label } : {}
11679
+ ...t.label !== void 0 ? { label: t.label } : {},
11680
+ ...staticMetrics ? {
11681
+ netDisplacementFrac: staticMetrics.netDisplacementFrac,
11682
+ pathSpanFrac: staticMetrics.pathSpanFrac
11683
+ } : {}
10322
11684
  });
10323
11685
  endImportance = importance;
10324
11686
  endImportanceReason = reason;
@@ -10333,6 +11695,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
10333
11695
  }
10334
11696
  this.bestFrameTracker.delete(t.trackId);
10335
11697
  this.objectEmbeddingBestSelector.delete(t.trackId);
11698
+ this.lastFrameAtByTrack.delete(t.trackId);
10336
11699
  this.detailDispatcher?.onTrackEnded(t.deviceId, t.trackId);
10337
11700
  this.overlayState.onTrackEnded(t.deviceId, t.trackId);
10338
11701
  if ((this.trackStore?.getActive(t.deviceId).length ?? 0) === 0) {
@@ -10377,6 +11740,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
10377
11740
  positionsCount: t.positions.length,
10378
11741
  ...endImportance !== void 0 ? { importance: endImportance } : {},
10379
11742
  ...endImportanceReason !== void 0 ? { importanceReason: endImportanceReason } : {},
11743
+ ...t.audioLabels !== void 0 ? { audioLabels: t.audioLabels } : {},
10380
11744
  ...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {},
10381
11745
  ...endBestEventId !== void 0 ? { bestEventId: endBestEventId } : {}
10382
11746
  });
@@ -10539,6 +11903,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
10539
11903
  for (const k of this.processors.keys()) if (k.startsWith(prefix)) this.processors.delete(k);
10540
11904
  for (const k of this.lastActiveTrackIds.keys()) if (k.startsWith(prefix)) this.lastActiveTrackIds.delete(k);
10541
11905
  for (const k of this.dropoutSkipsByKey.keys()) if (k.startsWith(prefix)) this.dropoutSkipsByKey.delete(k);
11906
+ this.lastFrameDimsByDevice.delete(deviceId);
10542
11907
  }
10543
11908
  /** Apply a mutation to every live source-processor of a device (zones/rules
10544
11909
  * are device-level and must reach all sources). */
@@ -10546,6 +11911,57 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
10546
11911
  const prefix = `${deviceId}:`;
10547
11912
  for (const [k, p] of this.processors) if (k.startsWith(prefix)) fn(p);
10548
11913
  }
11914
+ /**
11915
+ * Turn a parked track into a stationary-registry entry and tear the track
11916
+ * down WITHOUT firing an 'end' key-event / importance scoring / media flush
11917
+ * (a parked object is not a highlight; the durable record is the entry). The
11918
+ * tracker + store forget the track so its detections stop re-spawning tracks,
11919
+ * and the registry suppresses them from the next frame on.
11920
+ */
11921
+ promoteToStationary(input) {
11922
+ const { deviceId, key, processor, track, firstSeen, label, frameWidth, frameHeight, timestamp } = input;
11923
+ const keyFrameMediaKey = this.keyFrameKeyByTrackId.get(track.trackId);
11924
+ const entry = {
11925
+ id: (0, node_crypto.randomUUID)(),
11926
+ deviceId,
11927
+ className: track.className,
11928
+ bbox: { ...track.bbox },
11929
+ frameWidth,
11930
+ frameHeight,
11931
+ firstSeenAt: firstSeen,
11932
+ becameStationaryAt: timestamp,
11933
+ lastConfirmedAt: timestamp,
11934
+ sourceTrackId: track.trackId,
11935
+ ...label !== void 0 ? { label } : {},
11936
+ ...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {}
11937
+ };
11938
+ this.stationaryRegistry?.promote(entry);
11939
+ processor.dropTrack(track.trackId);
11940
+ this.trackStore?.dropActive(track.trackId);
11941
+ const faceEnd = this.faceRecognizer?.onTrackEnd(deviceId, track.trackId);
11942
+ const dropKeyFrame = () => {
11943
+ this.keyFrameKeyByTrackId.delete(track.trackId);
11944
+ };
11945
+ if (faceEnd) faceEnd.finally(dropKeyFrame);
11946
+ else dropKeyFrame();
11947
+ this.plateRecognizer?.onTrackEnd(deviceId, track.trackId);
11948
+ this.bestFrameTracker.delete(track.trackId);
11949
+ this.objectEmbeddingBestSelector.delete(track.trackId);
11950
+ this.lastFrameAtByTrack.delete(track.trackId);
11951
+ this.trackLifecycleUpdateMem.delete(track.trackId);
11952
+ this.detailDispatcher?.onTrackEnded(deviceId, track.trackId);
11953
+ this.overlayState.onTrackEnded(deviceId, track.trackId);
11954
+ this.lastActiveTrackIds.get(key)?.delete(track.trackId);
11955
+ this.ctx.logger.info("track promoted to stationary", {
11956
+ tags: { deviceId },
11957
+ meta: {
11958
+ trackId: track.trackId,
11959
+ className: track.className,
11960
+ entryId: entry.id,
11961
+ ...label ? { label } : {}
11962
+ }
11963
+ });
11964
+ }
10549
11965
  async getOrCreateProcessor(deviceId, source) {
10550
11966
  const key = this.procKey(deviceId, source);
10551
11967
  let p = this.processors.get(key);
@@ -10571,6 +11987,18 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
10571
11987
  cooldownSec,
10572
11988
  minTrackAgeMs: trk.minTrackAgeMs
10573
11989
  }, source);
11990
+ if (source === "pipeline" && this.stationaryRegistry) {
11991
+ const registry = this.stationaryRegistry;
11992
+ p.setStationaryGate({ filter: (input) => registry.filter({
11993
+ deviceId,
11994
+ detections: input.detections.map((d) => ({
11995
+ bbox: d.bbox,
11996
+ className: d.class
11997
+ })),
11998
+ frameWidth: input.frameWidth,
11999
+ frameHeight: input.frameHeight
12000
+ }) });
12001
+ }
10574
12002
  this.processors.set(key, p);
10575
12003
  }
10576
12004
  return p;
@@ -10582,6 +12010,16 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
10582
12010
  * forward updates to the per-device FrameProcessor so a rule change
10583
12011
  * applies to the very next frame even when frames stop briefly
10584
12012
  * (e.g. during binding flips).
12013
+ *
12014
+ * RECONCILE: the push channel behind `subscribe` (`device.state-changed`
12015
+ * via `live.onEvent`) does not reliably reach a forked addon child — a
12016
+ * zone created AFTER the proxy's cold read stayed invisible until the
12017
+ * addon respawned (live-diagnosed on device 617, 2026-07-16: zone slice
12018
+ * populated hub-side, `zones: []` in every snapshot). Events are lossy
12019
+ * telemetry (D8); the durable channel is RPC + reconcile — so each
12020
+ * proxy also refreshes its two slices on a slow timer. `refresh()`
12021
+ * round-trips `deviceState.getCapSlice` and fans out through the SAME
12022
+ * subscribe callbacks above, so a zone edit lands within one interval.
10585
12023
  */
10586
12024
  async ensureProxy(deviceId) {
10587
12025
  const cached = this.proxies.get(deviceId);
@@ -10590,13 +12028,22 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
10590
12028
  const proxy = await this.ctx.api.deviceManager ? await this.ctx.fetchDevice(deviceId) : null;
10591
12029
  if (!proxy) return null;
10592
12030
  this.proxies.set(deviceId, proxy);
10593
- const unsubs = [proxy.state.zones.subscribe((slice) => {
10594
- const zones = slice?.zones ?? [];
10595
- this.forEachDeviceProcessor(deviceId, (p) => p.setZones(zones));
10596
- }), proxy.state.zoneRules.subscribe((slice) => {
10597
- const rules = slice?.detection ?? [];
10598
- this.forEachDeviceProcessor(deviceId, (p) => p.setDetectionRules(rules));
10599
- })];
12031
+ const reconcile = setInterval(() => {
12032
+ proxy.state.zones.refresh().catch(() => void 0);
12033
+ proxy.state.zoneRules.refresh().catch(() => void 0);
12034
+ }, ZONE_SLICE_RECONCILE_MS);
12035
+ reconcile.unref?.();
12036
+ const unsubs = [
12037
+ proxy.state.zones.subscribe((slice) => {
12038
+ const zones = slice?.zones ?? [];
12039
+ this.forEachDeviceProcessor(deviceId, (p) => p.setZones(zones));
12040
+ }),
12041
+ proxy.state.zoneRules.subscribe((slice) => {
12042
+ const rules = slice?.detection ?? [];
12043
+ this.forEachDeviceProcessor(deviceId, (p) => p.setDetectionRules(rules));
12044
+ }),
12045
+ () => clearInterval(reconcile)
12046
+ ];
10600
12047
  this.proxyUnsubs.set(deviceId, unsubs);
10601
12048
  return proxy;
10602
12049
  } catch (err) {
@@ -10607,6 +12054,21 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
10607
12054
  return null;
10608
12055
  }
10609
12056
  }
12057
+ /**
12058
+ * Resolve a device's current 0–1 zone catalogue independent of the live
12059
+ * frame path — used by zone-analytics snapshot hydration + the occupancy
12060
+ * baseline sampler when the camera is detached (no frames). Warms the proxy
12061
+ * (cold read via `fetchDevice`) and, when the cached slice is empty, forces
12062
+ * one `refresh()` round-trip so a just-created proxy returns real zones.
12063
+ */
12064
+ async resolveDeviceZones(deviceId) {
12065
+ const proxy = await this.ensureProxy(deviceId);
12066
+ if (!proxy) return [];
12067
+ const cached = proxy.state.zones.value?.zones;
12068
+ if (cached && cached.length > 0) return cached;
12069
+ await proxy.state.zones.refresh().catch(() => void 0);
12070
+ return proxy.state.zones.value?.zones ?? [];
12071
+ }
10610
12072
  releaseProxy(deviceId) {
10611
12073
  const unsubs = this.proxyUnsubs.get(deviceId);
10612
12074
  if (unsubs) for (const u of unsubs) try {
@@ -10628,6 +12090,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
10628
12090
  }
10629
12091
  async clearTracks(input) {
10630
12092
  this.trackStore?.clearDevice(input.deviceId);
12093
+ this.stationaryRegistry?.clearDevice(input.deviceId);
10631
12094
  this.overlayState.clearDevice(input.deviceId);
10632
12095
  this.overlaySynthesisWarnAt.delete(input.deviceId);
10633
12096
  const prefix = `${input.deviceId}:`;
@@ -10929,6 +12392,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
10929
12392
  * Enrolled gallery + identity media are exempt.
10930
12393
  */
10931
12394
  async wipeAllAnalytics(input) {
12395
+ await this.stationaryRegistry?.clearDevice(input.deviceId);
10932
12396
  return this.pruneTracksBefore({
10933
12397
  deviceId: input.deviceId,
10934
12398
  cutoffMs: Date.now()
@@ -10980,24 +12444,62 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
10980
12444
  * points its thumbnail at the embedding row's crop key (a `track:…:crop:…`
10981
12445
  * key), so this resolves that crop; event ids stay on the event-crop path.
10982
12446
  */
10983
- async readMediaByEventOrKey(id) {
10984
- if (id.includes(":")) {
10985
- const file = await (this.mediaStore?.getByKey(id) ?? Promise.resolve(null));
10986
- if (!file) return null;
12447
+ async readMediaByEventOrKey(id, variant, preferKind) {
12448
+ const base = id.includes(":") ? await this.readMediaByKey(id) : await this.readEventThumbnail(id, preferKind);
12449
+ if (base === null || variant === void 0) return base;
12450
+ return this.applyThumbVariant(base, variant);
12451
+ }
12452
+ async readMediaByKey(id) {
12453
+ const file = await (this.mediaStore?.getByKey(id) ?? Promise.resolve(null));
12454
+ if (!file) return null;
12455
+ return {
12456
+ bytes: Buffer.from(file.base64, "base64"),
12457
+ key: file.key
12458
+ };
12459
+ }
12460
+ /**
12461
+ * Render a small center-cropped square from a resolved event media blob for
12462
+ * the reel / list surfaces. The returned `key` is variant-distinct so the
12463
+ * data-plane ETag never collides with the full-size blob's. On any encode
12464
+ * failure the full blob is served (a thumb must never 500 / blank a tile).
12465
+ */
12466
+ async applyThumbVariant(media, variant) {
12467
+ try {
10987
12468
  return {
10988
- bytes: Buffer.from(file.base64, "base64"),
10989
- key: file.key
12469
+ bytes: await makeSquareThumb(media.bytes, variant.size),
12470
+ key: `${media.key}|t${variant.size}`
10990
12471
  };
12472
+ } catch (err) {
12473
+ this.ctx.logger.debug("event media: thumb variant failed — serving full", { meta: {
12474
+ key: media.key,
12475
+ size: variant.size,
12476
+ error: require_dist.errMsg(err)
12477
+ } });
12478
+ return media;
10991
12479
  }
10992
- return this.readEventThumbnail(id);
10993
12480
  }
10994
- async readEventThumbnail(eventId) {
10995
- const files = await (this.mediaStore?.listByOwner("event", eventId) ?? Promise.resolve([]));
10996
- const chosen = files.find((f) => f.kind === "crop") ?? files.find((f) => f.kind === "fullFrameBoxed") ?? files[0];
10997
- if (!chosen) return null;
12481
+ async readEventThumbnail(id, preferKind) {
12482
+ const eventFiles = await (this.mediaStore?.listByOwner("event", id) ?? Promise.resolve([]));
12483
+ if (preferKind !== void 0 && preferKind.length > 0) {
12484
+ const trackFiles = await (this.mediaStore?.listByOwner("track", id) ?? Promise.resolve([]));
12485
+ const clean = pickCleanMedia([...eventFiles, ...trackFiles], preferKind);
12486
+ if (!clean) return null;
12487
+ return {
12488
+ bytes: Buffer.from(clean.base64, "base64"),
12489
+ key: clean.key
12490
+ };
12491
+ }
12492
+ const chosenEvent = eventFiles.find((f) => f.kind === "crop") ?? eventFiles.find((f) => f.kind === "fullFrameBoxed") ?? eventFiles[0];
12493
+ if (chosenEvent) return {
12494
+ bytes: Buffer.from(chosenEvent.base64, "base64"),
12495
+ key: chosenEvent.key
12496
+ };
12497
+ const trackFiles = await (this.mediaStore?.listByOwner("track", id) ?? Promise.resolve([]));
12498
+ 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];
12499
+ if (!chosenTrack) return null;
10998
12500
  return {
10999
- bytes: Buffer.from(chosen.base64, "base64"),
11000
- key: chosen.key
12501
+ bytes: Buffer.from(chosenTrack.base64, "base64"),
12502
+ key: chosenTrack.key
11001
12503
  };
11002
12504
  }
11003
12505
  /**
@@ -11090,6 +12592,32 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
11090
12592
  unit: "s",
11091
12593
  displayScale: 1e3
11092
12594
  },
12595
+ {
12596
+ type: "slider",
12597
+ key: "snapshotMovementThreshold",
12598
+ label: "Snapshot movement gate",
12599
+ 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.",
12600
+ min: 0,
12601
+ max: .15,
12602
+ step: .005,
12603
+ default: .03,
12604
+ showValue: true,
12605
+ unit: "%",
12606
+ displayScale: .01
12607
+ },
12608
+ {
12609
+ type: "slider",
12610
+ key: "snapshotMaxIdleMs",
12611
+ label: "Snapshot max idle",
12612
+ description: "Force a snapshot for a stationary but still-present track after this long without one, so its filmstrip is never empty.",
12613
+ min: 5e3,
12614
+ max: 12e4,
12615
+ step: 5e3,
12616
+ default: 3e4,
12617
+ showValue: true,
12618
+ unit: "s",
12619
+ displayScale: 1e3
12620
+ },
11093
12621
  {
11094
12622
  type: "select",
11095
12623
  key: "mediaAttachPolicy",
@@ -11546,5 +13074,6 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
11546
13074
  };
11547
13075
  //#endregion
11548
13076
  exports.default = PipelineAnalyticsAddon;
13077
+ exports.pickCleanMedia = pickCleanMedia;
11549
13078
  exports.stripGlobalOnlyFields = stripGlobalOnlyFields;
11550
13079
  exports.toAnalyticsDeviceSections = toAnalyticsDeviceSections;