@camstack/addon-post-analysis 1.1.24 → 1.1.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { C as object, S as number, c as pipelineAnalyticsCapability, d as zoneAnalyticsCapability, f as errMsg, h as EventCategory, i as cosineSimilarity, l as plateGalleryCapability, m as DeviceType, n as addonWidgetsSourceCapability, o as faceGalleryCapability, p as BaseAddon, r as audioMetricsCapability, t as EVENT_PAD_MS, u as videoclipsCapability, v as hydrateSchema, w as string, x as boolean } from "../dist-DRK78NMq.mjs";
1
+ import { C as number, S as boolean, T as string, c as nodePin, d as videoclipsCapability, f as zoneAnalyticsCapability, g as EventCategory, h as DeviceType, i as cosineSimilarity, l as pipelineAnalyticsCapability, m as BaseAddon, n as addonWidgetsSourceCapability, o as faceGalleryCapability, p as errMsg, r as audioMetricsCapability, t as EVENT_PAD_MS, u as plateGalleryCapability, w as object, y as hydrateSchema } from "../dist-DytVmDZg.mjs";
2
2
  import { n as extractCrop, t as resolveFrame } from "../resolve-frame-CT1T1tWy.mjs";
3
3
  import { FrameRingReaderCache } from "@camstack/shm-ring";
4
4
  import sharp from "sharp";
@@ -119,6 +119,138 @@ function clusterByEmbedding(items, threshold = .5) {
119
119
  })).toSorted((a, b) => b.size - a.size || b.cohesion - a.cohesion);
120
120
  }
121
121
  //#endregion
122
+ //#region src/pipeline-analytics/pipeline/key-event-scoring.ts
123
+ /**
124
+ * key-event-scoring — the SINGLE deterministic "importance" scorer for a
125
+ * completed track. A value in [0,1] the viewer sorts events by (events page)
126
+ * and a future reels feature pulls highlights from.
127
+ *
128
+ * PURE + DETERMINISTIC: no Date.now / no random. Time enters ONLY as the
129
+ * precomputed `durationMs` (the caller already knows a track's dwell). Given
130
+ * the same `ScoringInput`, `computeImportance` always returns the same
131
+ * `{ importance, reason }`.
132
+ *
133
+ * The score is a weighted sum of seven independent, individually-saturated
134
+ * signals. Every weight and knee is a NAMED EXPORT so the formula is auditable
135
+ * and the tests can assert exact wiring. The weights sum to exactly 1.0, so an
136
+ * all-signals-maxed track scores exactly 1.0 (the clamp is then a no-op) and an
137
+ * all-zero track scores 0.
138
+ */
139
+ var WEIGHT_CONFIDENCE = .3;
140
+ var WEIGHT_CLASS = .2;
141
+ var WEIGHT_DWELL = .2;
142
+ var WEIGHT_PROXIMITY = .12;
143
+ var WEIGHT_TRAVEL = .08;
144
+ var WEIGHT_ZONE = .05;
145
+ var WEIGHT_IDENTITY = .05;
146
+ /** Dwell (ms) that saturates the dwell term. */
147
+ var DWELL_FULL_MS = 6e4;
148
+ /** Peak bbox area (as a fraction of frame area) that saturates the size term. */
149
+ var SIZE_FULL = .15;
150
+ var CLASS_RANK_VEHICLE = .8;
151
+ var CLASS_RANK_ANIMAL = .5;
152
+ var CLASS_RANK_DEFAULT = .25;
153
+ var PERSON_CLASSES = new Set(["person", "face"]);
154
+ var VEHICLE_CLASSES = new Set([
155
+ "vehicle",
156
+ "car",
157
+ "truck",
158
+ "bus"
159
+ ]);
160
+ var ANIMAL_CLASSES = new Set([
161
+ "animal",
162
+ "dog",
163
+ "cat"
164
+ ]);
165
+ /** Clamp to [0,1]. */
166
+ function clamp01(x) {
167
+ if (Number.isNaN(x)) return 0;
168
+ if (x < 0) return 0;
169
+ if (x > 1) return 1;
170
+ return x;
171
+ }
172
+ /** Rank a class name into [0,1] by how event-worthy it is. Case-insensitive. */
173
+ function classRank(className) {
174
+ const c = className.toLowerCase();
175
+ if (PERSON_CLASSES.has(c)) return 1;
176
+ if (VEHICLE_CLASSES.has(c)) return CLASS_RANK_VEHICLE;
177
+ if (ANIMAL_CLASSES.has(c)) return CLASS_RANK_ANIMAL;
178
+ return CLASS_RANK_DEFAULT;
179
+ }
180
+ /**
181
+ * Compute a deterministic importance score for a completed track.
182
+ *
183
+ * `reason` is the tag of the single largest weighted term. Ties resolve to the
184
+ * first term in a fixed, weight-descending order (confidence → class → dwell →
185
+ * proximity → travel → zone → identity), so the output stays deterministic.
186
+ */
187
+ function computeImportance(input) {
188
+ const terms = [
189
+ {
190
+ reason: "confidence",
191
+ value: WEIGHT_CONFIDENCE * clamp01(input.peakConfidence)
192
+ },
193
+ {
194
+ reason: "class",
195
+ value: WEIGHT_CLASS * classRank(input.className)
196
+ },
197
+ {
198
+ reason: "dwell",
199
+ value: WEIGHT_DWELL * clamp01(input.durationMs / DWELL_FULL_MS)
200
+ },
201
+ {
202
+ reason: "proximity",
203
+ value: WEIGHT_PROXIMITY * clamp01(input.peakBboxAreaFrac / SIZE_FULL)
204
+ },
205
+ {
206
+ reason: "travel",
207
+ value: WEIGHT_TRAVEL * clamp01(input.totalDistance / 2)
208
+ },
209
+ {
210
+ reason: "zone",
211
+ value: WEIGHT_ZONE * (input.zonesVisited.length > 0 ? 1 : 0)
212
+ },
213
+ {
214
+ reason: "identity",
215
+ value: WEIGHT_IDENTITY * (input.label ? 1 : 0)
216
+ }
217
+ ];
218
+ let sum = 0;
219
+ let best = terms[0];
220
+ for (const term of terms) {
221
+ sum += term.value;
222
+ if (term.value > best.value) best = term;
223
+ }
224
+ return {
225
+ importance: clamp01(sum),
226
+ reason: best.reason
227
+ };
228
+ }
229
+ //#endregion
230
+ //#region src/pipeline-analytics/pipeline/key-event-recompute.ts
231
+ /**
232
+ * Recompute + persist a persisted track's importance from its current fields
233
+ * (including any newly-assigned label) and re-stamp its object events. No-op
234
+ * when the track is not (yet) persisted.
235
+ */
236
+ async function recomputeTrackImportance(deps, trackId) {
237
+ const track = await deps.trackStore.getPersistedByTrackId(trackId);
238
+ if (track === null) return;
239
+ const peak = await deps.eventStore.peakForTrack(trackId);
240
+ const { importance, reason } = computeImportance({
241
+ peakConfidence: peak.peakConfidence,
242
+ className: track.className,
243
+ durationMs: track.lastSeen - track.firstSeen,
244
+ peakBboxAreaFrac: peak.peakBboxAreaFrac,
245
+ totalDistance: track.totalDistance,
246
+ zonesVisited: track.zonesVisited,
247
+ ...track.label !== void 0 ? { label: track.label } : {}
248
+ });
249
+ const bestEventId = track.bestEventId ?? peak.bestEventId;
250
+ await deps.trackStore.setImportance(trackId, importance, reason, bestEventId);
251
+ await deps.eventStore.setImportanceForTrack(trackId, importance);
252
+ }
253
+ //#endregion
122
254
  //#region src/pipeline-analytics/face-gallery-provider.ts
123
255
  /** Embedding model id used when enrolling face crops as identity samples. */
124
256
  var MODEL_ID = "arcface-r100";
@@ -239,7 +371,9 @@ var FaceGalleryProvider = class {
239
371
  ...face.recognizedIdentityId != null ? { recognizedIdentityId: face.recognizedIdentityId } : {},
240
372
  ...identityName !== void 0 ? { identityName } : {},
241
373
  assigned: face.assigned,
242
- ...base64 !== void 0 ? { base64 } : {}
374
+ ...base64 !== void 0 ? { base64 } : {},
375
+ ...face.faceBbox !== void 0 ? { faceBbox: face.faceBbox } : {},
376
+ ...face.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: face.keyFrameMediaKey } : {}
243
377
  });
244
378
  }
245
379
  return result;
@@ -267,7 +401,9 @@ var FaceGalleryProvider = class {
267
401
  ...face.recognizedIdentityId != null ? { recognizedIdentityId: face.recognizedIdentityId } : {},
268
402
  ...identityName !== void 0 ? { identityName } : {},
269
403
  assigned: face.assigned,
270
- ...base64 !== void 0 ? { base64 } : {}
404
+ ...base64 !== void 0 ? { base64 } : {},
405
+ ...face.faceBbox !== void 0 ? { faceBbox: face.faceBbox } : {},
406
+ ...face.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: face.keyFrameMediaKey } : {}
271
407
  };
272
408
  }
273
409
  /**
@@ -344,6 +480,10 @@ var FaceGalleryProvider = class {
344
480
  if (identityName !== void 0) {
345
481
  await this.trackStore.setLabel(currentFace.trackId, identityName);
346
482
  await this.eventStore.setLabelForTrack(currentFace.trackId, identityName);
483
+ await recomputeTrackImportance({
484
+ trackStore: this.trackStore,
485
+ eventStore: this.eventStore
486
+ }, currentFace.trackId);
347
487
  }
348
488
  this.refreshGallery();
349
489
  }
@@ -1424,6 +1564,10 @@ var FrameProcessor = class {
1424
1564
  w: det.bbox.width,
1425
1565
  h: det.bbox.height
1426
1566
  });
1567
+ if (det.embedding !== void 0 && !embeddingByBbox.has(parentBbox)) embeddingByBbox.set(parentBbox, {
1568
+ embedding: det.embedding,
1569
+ ...det.embeddingModelId !== void 0 ? { embeddingModelId: det.embeddingModelId } : {}
1570
+ });
1427
1571
  }
1428
1572
  for (const det of frame.detections) {
1429
1573
  if (det.kind !== "detail" || det.macroClass !== "plate" || !det.parentId) continue;
@@ -1527,6 +1671,152 @@ var FrameProcessor = class {
1527
1671
  }
1528
1672
  };
1529
1673
  //#endregion
1674
+ //#region src/pipeline-analytics/pipeline/best-detection-tracker.ts
1675
+ var BestDetectionTracker = class {
1676
+ hysteresis;
1677
+ minGapMs;
1678
+ best = /* @__PURE__ */ new Map();
1679
+ constructor(options = {}) {
1680
+ this.hysteresis = options.hysteresis ?? 0;
1681
+ this.minGapMs = options.minGapMs ?? 0;
1682
+ }
1683
+ /**
1684
+ * Record a detection's `confidence` (at wall-clock `timestamp`) for `trackId`.
1685
+ * Returns true when it becomes the track's new best — the first sighting, or a
1686
+ * confidence that beats the held peak by more than `hysteresis` AND respects
1687
+ * `minGapMs`. On acceptance the held peak is advanced to this observation.
1688
+ */
1689
+ observe(trackId, confidence, timestamp) {
1690
+ const cur = this.best.get(trackId);
1691
+ const isNewBest = cur === void 0 || confidence > cur.confidence + this.hysteresis && timestamp - cur.atMs >= this.minGapMs;
1692
+ if (isNewBest) this.best.set(trackId, {
1693
+ confidence,
1694
+ atMs: timestamp
1695
+ });
1696
+ return isNewBest;
1697
+ }
1698
+ /** The held peak for a track (undefined if never observed). */
1699
+ peak(trackId) {
1700
+ return this.best.get(trackId);
1701
+ }
1702
+ /** Drop a track's peak (call at track end). */
1703
+ delete(trackId) {
1704
+ this.best.delete(trackId);
1705
+ }
1706
+ clear() {
1707
+ this.best.clear();
1708
+ }
1709
+ };
1710
+ //#endregion
1711
+ //#region src/pipeline-analytics/pipeline/track-best-detection.ts
1712
+ /**
1713
+ * `TrackBestSelector` — the ONE unified "best detection per track" primitive.
1714
+ *
1715
+ * Post-analysis derives several per-track "best" artefacts (best FRAME thumbnail,
1716
+ * best OBJECT/CLIP embedding + its crop, best FACE crop). They all rank the same
1717
+ * way: highest detector confidence per `trackId`. This selector composes the
1718
+ * canonical {@link BestDetectionTracker} ranking with the RESOLVED payload the
1719
+ * consumers need at the peak (bbox / className / embedding / faceBbox), so a
1720
+ * single "new best" DECISION can drive one capture whose output is shared:
1721
+ * - the boxed best-frame `thumbnail`,
1722
+ * - the tight object crop written onto the CLIP embedding row's `mediaKey`
1723
+ * (so a search hit's thumbnail IS the embedded crop).
1724
+ *
1725
+ * It is deliberately in-memory (the peak is per live track, dropped at track
1726
+ * end). The CLIP embedding's cross-restart persistence stays a SEPARATE store-
1727
+ * side gate (`ObjectEmbeddingStore.upsertIfBetter`): this selector unifies the
1728
+ * DECISION, not the durable store (see best-detection-tracker.ts docstring).
1729
+ *
1730
+ * The face path keeps its own hold buffer because a face crop can only come
1731
+ * from a face-bearing frame (the documented FRAME↔FACE seam) — but it shares
1732
+ * this ranking so best-frame and best-face agree on which frame is "best".
1733
+ */
1734
+ var TrackBestSelector = class {
1735
+ tracker;
1736
+ payloads = /* @__PURE__ */ new Map();
1737
+ constructor(options = {}) {
1738
+ this.tracker = new BestDetectionTracker(options);
1739
+ }
1740
+ /**
1741
+ * Record an observation for its track. Returns true when it becomes the
1742
+ * track's new best (first sighting, or a confidence that beats the held peak
1743
+ * per the tracker's hysteresis / minGap rules). On acceptance the held payload
1744
+ * advances to this observation so `peak(trackId)` returns the winning frame's
1745
+ * bbox / embedding / faceBbox.
1746
+ */
1747
+ observe(obs) {
1748
+ const isNewBest = this.tracker.observe(obs.trackId, obs.confidence, obs.atMs);
1749
+ if (isNewBest) {
1750
+ const { trackId, ...payload } = obs;
1751
+ this.payloads.set(trackId, payload);
1752
+ }
1753
+ return isNewBest;
1754
+ }
1755
+ /** The held best payload for a track (undefined if never observed). */
1756
+ peak(trackId) {
1757
+ return this.payloads.get(trackId);
1758
+ }
1759
+ /** The held peak confidence for a track (undefined if never observed). */
1760
+ peakConfidence(trackId) {
1761
+ return this.tracker.peak(trackId)?.confidence;
1762
+ }
1763
+ /** Drop a track's peak + payload (call at track end). */
1764
+ delete(trackId) {
1765
+ this.tracker.delete(trackId);
1766
+ this.payloads.delete(trackId);
1767
+ }
1768
+ clear() {
1769
+ this.tracker.clear();
1770
+ this.payloads.clear();
1771
+ }
1772
+ };
1773
+ //#endregion
1774
+ //#region src/pipeline-analytics/pipeline/object-embedding-selection.ts
1775
+ function isClipObjectEmbedding(t) {
1776
+ return Array.isArray(t.embedding) && t.embedding.length > 0 && typeof t.embeddingModelId === "string" && t.embeddingModelId.startsWith("mobileclip-");
1777
+ }
1778
+ function resolveSearchThumbnailUrl(input) {
1779
+ if (input.baseUrl === null) return void 0;
1780
+ const id = input.embeddingMediaKey ?? input.eventId;
1781
+ return `${input.baseUrl}/${encodeURIComponent(id)}`;
1782
+ }
1783
+ //#endregion
1784
+ //#region src/pipeline-analytics/pipeline/key-event-query.ts
1785
+ async function rankKeyEvents(candidates, options, peakLookup) {
1786
+ const scored = [];
1787
+ for (const t of candidates) {
1788
+ if (options.classFilter !== void 0 && t.className !== options.classFilter) continue;
1789
+ let importance = t.importance;
1790
+ let bestEventId = t.bestEventId;
1791
+ if (importance === void 0) {
1792
+ const peak = await peakLookup(t.trackId);
1793
+ importance = computeImportance({
1794
+ peakConfidence: peak.peakConfidence,
1795
+ className: t.className,
1796
+ durationMs: t.lastSeen - t.firstSeen,
1797
+ peakBboxAreaFrac: peak.peakBboxAreaFrac,
1798
+ totalDistance: t.totalDistance,
1799
+ zonesVisited: t.zonesVisited,
1800
+ ...t.label !== void 0 ? { label: t.label } : {}
1801
+ }).importance;
1802
+ bestEventId = bestEventId ?? peak.bestEventId;
1803
+ }
1804
+ if (options.minImportance !== void 0 && importance < options.minImportance) continue;
1805
+ scored.push({
1806
+ id: bestEventId ?? t.trackId,
1807
+ trackId: t.trackId,
1808
+ timestamp: t.firstSeen,
1809
+ className: t.className,
1810
+ ...t.label !== void 0 ? { label: t.label } : {},
1811
+ importance,
1812
+ bestEventId: bestEventId ?? "",
1813
+ windowMs: t.lastSeen - t.firstSeen
1814
+ });
1815
+ }
1816
+ scored.sort((a, b) => b.importance - a.importance);
1817
+ return scored.slice(0, options.limit);
1818
+ }
1819
+ //#endregion
1530
1820
  //#region src/pipeline-analytics/pipeline/native-detection.ts
1531
1821
  /**
1532
1822
  * Nominal frame size used to denormalize native `[0,1]` boxes when a
@@ -1696,6 +1986,18 @@ var TRACKS_COLUMNS = [
1696
1986
  {
1697
1987
  name: "state",
1698
1988
  type: "TEXT"
1989
+ },
1990
+ {
1991
+ name: "importance",
1992
+ type: "REAL"
1993
+ },
1994
+ {
1995
+ name: "bestEventId",
1996
+ type: "TEXT"
1997
+ },
1998
+ {
1999
+ name: "importanceReason",
2000
+ type: "TEXT"
1699
2001
  }
1700
2002
  ];
1701
2003
  var TRACKS_INDEXES = [{
@@ -1727,7 +2029,10 @@ function cloneTrack(t) {
1727
2029
  zonesVisited: [...t.zonesVisited],
1728
2030
  totalDistance: t.totalDistance,
1729
2031
  state: t.state,
1730
- active: t.active
2032
+ active: t.active,
2033
+ ...t.importance !== void 0 ? { importance: t.importance } : {},
2034
+ ...t.bestEventId !== void 0 ? { bestEventId: t.bestEventId } : {},
2035
+ ...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {}
1731
2036
  };
1732
2037
  }
1733
2038
  var TrackStore = class {
@@ -1796,6 +2101,16 @@ var TrackStore = class {
1796
2101
  lastSnapshotAt(trackId) {
1797
2102
  return this.active.get(trackId)?.lastSnapshotAt ?? 0;
1798
2103
  }
2104
+ /**
2105
+ * Seed the snapshot cadence clock (once, at track start) WITHOUT appending a
2106
+ * snapshot, so the first periodic snapshot fires ~snapshotIntervalMs after the
2107
+ * track begins rather than immediately — the `firstFrame` already covers the
2108
+ * track's start. No-op if a snapshot was already taken (clock already set).
2109
+ */
2110
+ seedSnapshotClock(trackId, timestamp) {
2111
+ const t = this.active.get(trackId);
2112
+ if (t && t.lastSnapshotAt === 0) t.lastSnapshotAt = timestamp;
2113
+ }
1799
2114
  getActive(deviceId) {
1800
2115
  const out = [];
1801
2116
  for (const t of this.active.values()) if (t.deviceId === deviceId && t.active) out.push(cloneTrack(t));
@@ -1851,6 +2166,37 @@ var TrackStore = class {
1851
2166
  }
1852
2167
  }
1853
2168
  /**
2169
+ * Stamp a track's importance score (+ dominant reason and best-event pointer).
2170
+ * Updates the in-memory active entry (so the value is carried into an eventual
2171
+ * (re)persist) AND patches the already-persisted row. Mirrors `setLabel`.
2172
+ * Forward-only — never rewrites history beyond these fields.
2173
+ */
2174
+ async setImportance(trackId, importance, reason, bestEventId) {
2175
+ const active = this.active.get(trackId);
2176
+ if (active) {
2177
+ active.importance = importance;
2178
+ active.importanceReason = reason;
2179
+ if (bestEventId !== void 0) active.bestEventId = bestEventId;
2180
+ }
2181
+ const data = {
2182
+ importance,
2183
+ importanceReason: reason
2184
+ };
2185
+ if (bestEventId !== void 0) data["bestEventId"] = bestEventId;
2186
+ try {
2187
+ await this.store.update.mutate({
2188
+ collection: TRACKS_COLLECTION,
2189
+ id: trackId,
2190
+ data
2191
+ });
2192
+ } catch (err) {
2193
+ this.logger.warn("setImportance persist failed", { meta: {
2194
+ trackId,
2195
+ error: String(err)
2196
+ } });
2197
+ }
2198
+ }
2199
+ /**
1854
2200
  * Clear a track's label. Sets label to null in the persisted row (so
1855
2201
  * rowToTrack's `typeof label === 'string'` guard omits it on read → label
1856
2202
  * is absent/undefined). Also clears the in-memory active entry if present.
@@ -1921,7 +2267,10 @@ var TrackStore = class {
1921
2267
  snapshots: [...t.snapshots],
1922
2268
  zonesVisited: [...t.zonesVisited],
1923
2269
  totalDistance: t.totalDistance,
1924
- state: t.state
2270
+ state: t.state,
2271
+ ...t.importance !== void 0 ? { importance: t.importance } : {},
2272
+ ...t.bestEventId !== void 0 ? { bestEventId: t.bestEventId } : {},
2273
+ ...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {}
1925
2274
  }
1926
2275
  });
1927
2276
  }
@@ -1930,6 +2279,9 @@ var TrackStore = class {
1930
2279
  const snapshots = data["snapshots"] ?? [];
1931
2280
  const zones = data["zonesVisited"] ?? [];
1932
2281
  const label = data["label"];
2282
+ const importance = data["importance"];
2283
+ const bestEventId = data["bestEventId"];
2284
+ const importanceReason = data["importanceReason"];
1933
2285
  return {
1934
2286
  trackId: id,
1935
2287
  deviceId: Number(data["deviceId"]),
@@ -1942,7 +2294,10 @@ var TrackStore = class {
1942
2294
  zonesVisited: zones,
1943
2295
  totalDistance: Number(data["totalDistance"] ?? 0),
1944
2296
  state: data["state"] ?? "idle",
1945
- active: false
2297
+ active: false,
2298
+ ...typeof importance === "number" ? { importance } : {},
2299
+ ...typeof bestEventId === "string" ? { bestEventId } : {},
2300
+ ...typeof importanceReason === "string" ? { importanceReason } : {}
1946
2301
  };
1947
2302
  }
1948
2303
  };
@@ -2060,6 +2415,46 @@ var MediaStore = class {
2060
2415
  }
2061
2416
  }
2062
2417
  /**
2418
+ * Overwrite semantics: keep exactly ONE media entry per (ownerKind, ownerId,
2419
+ * kind). Used for the rolling `lastFrame` and the best `thumbnail` per track —
2420
+ * each new capture replaces the previous one (blob + index row) rather than
2421
+ * accumulating a filmstrip the way `put` does. Deletes any existing rows of
2422
+ * that (owner, kind) first, then writes the fresh one. Returns the new key.
2423
+ */
2424
+ async putReplacing(params) {
2425
+ const existing = await this.store.query.query({
2426
+ collection: MEDIA_COLLECTION,
2427
+ filter: { where: {
2428
+ ownerKind: params.ownerKind,
2429
+ ownerId: params.ownerId,
2430
+ kind: params.kind
2431
+ } }
2432
+ });
2433
+ const newKey = await this.put(params);
2434
+ for (const row of existing) {
2435
+ if (row.id === newKey) continue;
2436
+ const path = String(row.data["path"] ?? "");
2437
+ if (path) try {
2438
+ await this.storage.delete({
2439
+ location: "eventMedia",
2440
+ relativePath: path
2441
+ });
2442
+ } catch {}
2443
+ try {
2444
+ await this.store.delete.mutate({
2445
+ collection: MEDIA_COLLECTION,
2446
+ key: row.id
2447
+ });
2448
+ } catch (err) {
2449
+ this.logger.debug("media putReplacing: stale row delete failed", { meta: {
2450
+ key: row.id,
2451
+ error: String(err)
2452
+ } });
2453
+ }
2454
+ }
2455
+ return newKey;
2456
+ }
2457
+ /**
2063
2458
  * Fetch one media entry by its key (id). Returns null if the key is not
2064
2459
  * found in the index or if the blob is missing from storage.
2065
2460
  */
@@ -2405,6 +2800,10 @@ var OBJECT_COLUMNS = [
2405
2800
  {
2406
2801
  name: "mediaKey",
2407
2802
  type: "TEXT"
2803
+ },
2804
+ {
2805
+ name: "importance",
2806
+ type: "REAL"
2408
2807
  }
2409
2808
  ];
2410
2809
  var AUDIO_COLUMNS = [
@@ -2602,6 +3001,63 @@ var EventStore = class {
2602
3001
  return updated;
2603
3002
  }
2604
3003
  /**
3004
+ * Forward-only: stamp `importance` on every already-emitted object event of a
3005
+ * track. Returns the number of events updated. Best-effort per row. Mirrors
3006
+ * `setLabelForTrack`. Called when a track's key-event score is computed (at
3007
+ * expiry) or recomputed (late label) so an event row carries the parent
3008
+ * track's importance without a join.
3009
+ */
3010
+ async setImportanceForTrack(trackId, importance) {
3011
+ const rows = await this.store.query.query({
3012
+ collection: OBJECT_EVENTS_COLLECTION,
3013
+ filter: { where: { trackId } }
3014
+ });
3015
+ let updated = 0;
3016
+ for (const row of rows) try {
3017
+ await this.store.update.mutate({
3018
+ collection: OBJECT_EVENTS_COLLECTION,
3019
+ id: row.id,
3020
+ data: { importance }
3021
+ });
3022
+ updated++;
3023
+ } catch (err) {
3024
+ this.logger.warn("setImportanceForTrack update failed", { meta: {
3025
+ trackId,
3026
+ eventId: row.id,
3027
+ error: String(err)
3028
+ } });
3029
+ }
3030
+ return updated;
3031
+ }
3032
+ /**
3033
+ * The track's highest-confidence object event, its bbox area (as a fraction of
3034
+ * frame area), and that event's id — the SHARED per-track ranking already used
3035
+ * for the best frame, read back from the persisted object events (index
3036
+ * `idx_object_track`). Returns zeros + undefined id when the track has none.
3037
+ * Used by the importance scorer at expiry and by `getKeyEvents` compute-on-read.
3038
+ */
3039
+ async peakForTrack(trackId) {
3040
+ const rows = await this.store.query.query({
3041
+ collection: OBJECT_EVENTS_COLLECTION,
3042
+ filter: { where: { trackId } }
3043
+ });
3044
+ let bestConf = -1;
3045
+ let bestEventId;
3046
+ let peakBboxAreaFrac = 0;
3047
+ for (const row of rows) {
3048
+ const conf = typeof row.data["confidence"] === "number" ? row.data["confidence"] : 0;
3049
+ if (conf <= bestConf) continue;
3050
+ bestConf = conf;
3051
+ bestEventId = row.id;
3052
+ peakBboxAreaFrac = bboxAreaFrac(row.data);
3053
+ }
3054
+ return {
3055
+ peakConfidence: bestConf < 0 ? 0 : bestConf,
3056
+ peakBboxAreaFrac,
3057
+ bestEventId
3058
+ };
3059
+ }
3060
+ /**
2605
3061
  * Clear `label` on every already-emitted object event of a track (sets label
2606
3062
  * to null so stripNulls/slimObject omit it on read → label is absent/undefined).
2607
3063
  * Returns the number of events updated. Best-effort per row. Mirrors
@@ -2796,7 +3252,8 @@ function slimObject(id, data) {
2796
3252
  timestamp: data["timestamp"],
2797
3253
  className: data["className"],
2798
3254
  ...typeof data["frameId"] === "string" ? { frameId: data["frameId"] } : {},
2799
- ...typeof data["trackId"] === "string" ? { trackId: data["trackId"] } : {}
3255
+ ...typeof data["trackId"] === "string" ? { trackId: data["trackId"] } : {},
3256
+ ...typeof data["importance"] === "number" ? { importance: data["importance"] } : {}
2800
3257
  };
2801
3258
  if (typeof data["label"] === "string") return {
2802
3259
  ...base,
@@ -2826,6 +3283,17 @@ function slimAudio(id, data) {
2826
3283
  }
2827
3284
  return base;
2828
3285
  }
3286
+ function bboxAreaFrac(data) {
3287
+ const bbox = data["bbox"];
3288
+ const fw = data["frameWidth"];
3289
+ const fh = data["frameHeight"];
3290
+ if (bbox === null || typeof bbox !== "object") return 0;
3291
+ if (typeof fw !== "number" || typeof fh !== "number" || fw <= 0 || fh <= 0) return 0;
3292
+ const w = "w" in bbox && typeof bbox.w === "number" ? bbox.w : 0;
3293
+ const h = "h" in bbox && typeof bbox.h === "number" ? bbox.h : 0;
3294
+ if (w <= 0 || h <= 0) return 0;
3295
+ return w * h / (fw * fh);
3296
+ }
2829
3297
  function stripNulls(data) {
2830
3298
  const out = {};
2831
3299
  for (const [k, v] of Object.entries(data)) if (v !== null) out[k] = v;
@@ -2972,7 +3440,9 @@ var EventMediaDispatcher = class {
2972
3440
  }
2973
3441
  async captureForFrame(input) {
2974
3442
  const { deviceId, frameHandle, events, trackFrames } = input;
2975
- if (events.length === 0 && trackFrames.length === 0) return;
3443
+ const snapshots = input.snapshots ?? [];
3444
+ const empty = { storedSnapshots: [] };
3445
+ if (events.length === 0 && trackFrames.length === 0 && snapshots.length === 0) return empty;
2976
3446
  let decoded;
2977
3447
  try {
2978
3448
  decoded = await resolveFrame(frameHandle, {
@@ -2989,7 +3459,7 @@ var EventMediaDispatcher = class {
2989
3459
  error: String(err)
2990
3460
  }
2991
3461
  });
2992
- return;
3462
+ return empty;
2993
3463
  }
2994
3464
  if (!decoded) {
2995
3465
  this.deps.logger.debug("event media: frame recycled before resolve", {
@@ -2999,7 +3469,7 @@ var EventMediaDispatcher = class {
2999
3469
  shmId: frameHandle.shmId
3000
3470
  }
3001
3471
  });
3002
- return;
3472
+ return empty;
3003
3473
  }
3004
3474
  if (decoded.format !== "rgb") {
3005
3475
  this.deps.logger.debug("event media: resolved frame is not RGB", {
@@ -3009,13 +3479,87 @@ var EventMediaDispatcher = class {
3009
3479
  format: decoded.format
3010
3480
  }
3011
3481
  });
3012
- return;
3482
+ return empty;
3013
3483
  }
3014
3484
  const frameData = Buffer.isBuffer(decoded.data) ? decoded.data : Buffer.from(decoded.data);
3015
3485
  const fw = decoded.width;
3016
3486
  const fh = decoded.height;
3017
3487
  for (const ev of events) await this.writeEventMedia(deviceId, frameData, fw, fh, ev, input.cropPadding);
3018
3488
  for (const tf of trackFrames) await this.writeTrackFrame(deviceId, frameData, fw, fh, tf);
3489
+ const storedSnapshots = [];
3490
+ for (const sn of snapshots) {
3491
+ const stored = await this.writeTrackSnapshot(deviceId, frameData, fw, fh, sn);
3492
+ if (stored) storedSnapshots.push(stored);
3493
+ }
3494
+ return { storedSnapshots };
3495
+ }
3496
+ /**
3497
+ * Periodic per-track media (§5). Encodes the boxed frame ONCE and fans it out
3498
+ * to whichever of the three destinations is requested: an appended `snapshot`
3499
+ * (timeline filmstrip), the rolling `lastFrame` (overwrite), and the best
3500
+ * `thumbnail` (overwrite). Returns the appended snapshot for TrackStore wiring
3501
+ * (null when `appendSnapshot` is false or the encode failed).
3502
+ */
3503
+ async writeTrackSnapshot(deviceId, frameData, fw, fh, sn) {
3504
+ if (!sn.appendSnapshot && !sn.rollingLastFrame && !sn.bestThumbnail) return null;
3505
+ let boxed;
3506
+ try {
3507
+ boxed = await drawBoxedFrame(frameData, fw, fh, [{
3508
+ ...sn.bbox,
3509
+ ...sn.label ? { label: sn.label } : {}
3510
+ }], { quality: MEDIA_QUALITY });
3511
+ } catch (err) {
3512
+ this.deps.logger.warn("event media: track snapshot encode failed", {
3513
+ tags: { deviceId },
3514
+ meta: {
3515
+ deviceId,
3516
+ trackId: sn.trackId,
3517
+ error: err instanceof Error ? err.message : String(err)
3518
+ }
3519
+ });
3520
+ return null;
3521
+ }
3522
+ let stored = null;
3523
+ if (sn.appendSnapshot) try {
3524
+ const mediaKey = await this.deps.mediaStore.put({
3525
+ deviceId,
3526
+ ownerKind: "track",
3527
+ ownerId: sn.trackId,
3528
+ kind: "snapshot",
3529
+ timestamp: sn.timestamp,
3530
+ data: boxed
3531
+ });
3532
+ stored = {
3533
+ trackId: sn.trackId,
3534
+ mediaKey,
3535
+ timestamp: sn.timestamp,
3536
+ bbox: sn.bbox
3537
+ };
3538
+ } catch {}
3539
+ if (sn.rollingLastFrame) await this.replaceKind(deviceId, sn.trackId, "lastFrame", sn.timestamp, boxed);
3540
+ if (sn.bestThumbnail) await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, boxed);
3541
+ return stored;
3542
+ }
3543
+ async replaceKind(deviceId, trackId, kind, timestamp, data) {
3544
+ try {
3545
+ await this.deps.mediaStore.putReplacing({
3546
+ deviceId,
3547
+ ownerKind: "track",
3548
+ ownerId: trackId,
3549
+ kind,
3550
+ timestamp,
3551
+ data
3552
+ });
3553
+ } catch (err) {
3554
+ this.deps.logger.debug(`event media: ${kind} replace failed`, {
3555
+ tags: { deviceId },
3556
+ meta: {
3557
+ deviceId,
3558
+ trackId,
3559
+ error: err instanceof Error ? err.message : String(err)
3560
+ }
3561
+ });
3562
+ }
3019
3563
  }
3020
3564
  async writeEventMedia(deviceId, frameData, fw, fh, ev, cropPadding) {
3021
3565
  const box = {
@@ -4010,11 +4554,20 @@ function resolveFaceSettings(raw) {
4010
4554
  * its default — parse never throws). Mirrors `face-settings` /
4011
4555
  * `audio-detection-settings`.
4012
4556
  */
4013
- var MediaSettingsSchema = object({
4014
- /** Fractional padding added around a detection bbox before cropping.
4015
- * 0 = tight crop, 0.15 = expand each side by 15% of the box dimension,
4016
- * max 2 (200%). */
4017
- cropPadding: number().min(0).max(2).default(.15) });
4557
+ var MediaSettingsSchema = object({
4558
+ /** Fractional padding added around a detection bbox before cropping.
4559
+ * 0 = tight crop, 0.15 = expand each side by 15% of the box dimension,
4560
+ * max 2 (200%). */
4561
+ cropPadding: number().min(0).max(2).default(.15),
4562
+ /** Master switch for periodic per-track snapshots (the timeline filmstrip +
4563
+ * the rolling `lastFrame` + the best `thumbnail`). When false, only the
4564
+ * per-track `firstFrame` and per-event media are produced. */
4565
+ saveThumbnails: boolean().default(true),
4566
+ /** Cadence (ms) for the periodic per-track `snapshot` + rolling `lastFrame`.
4567
+ * A snapshot is captured for an active track only after this much wall-clock
4568
+ * has elapsed since its previous one. */
4569
+ snapshotIntervalMs: number().int().min(500).max(6e4).default(5e3)
4570
+ });
4018
4571
  var MEDIA_DEFAULTS = MediaSettingsSchema.parse({});
4019
4572
  /**
4020
4573
  * Resolve a per-device store blob into typed media settings. Unknown/invalid
@@ -4025,7 +4578,11 @@ function resolveMediaSettings(raw) {
4025
4578
  const parsed = MediaSettingsSchema.shape[key].safeParse(raw[key]);
4026
4579
  return parsed.success ? parsed.data : MEDIA_DEFAULTS[key];
4027
4580
  };
4028
- return { cropPadding: pick("cropPadding") };
4581
+ return {
4582
+ cropPadding: pick("cropPadding"),
4583
+ saveThumbnails: pick("saveThumbnails"),
4584
+ snapshotIntervalMs: pick("snapshotIntervalMs")
4585
+ };
4029
4586
  }
4030
4587
  //#endregion
4031
4588
  //#region src/pipeline-analytics/store/identity-store.ts
@@ -4356,6 +4913,14 @@ var FACE_COLUMNS = [
4356
4913
  {
4357
4914
  name: "assignedSampleId",
4358
4915
  type: "TEXT"
4916
+ },
4917
+ {
4918
+ name: "keyFrameMediaKey",
4919
+ type: "TEXT"
4920
+ },
4921
+ {
4922
+ name: "faceBbox",
4923
+ type: "JSON"
4359
4924
  }
4360
4925
  ];
4361
4926
  var FACE_INDEXES = [{
@@ -4428,7 +4993,9 @@ var FaceStore = class {
4428
4993
  assigned: Boolean(r.data.assigned),
4429
4994
  mediaKey: data.mediaKey ?? void 0,
4430
4995
  recognizedIdentityId: data.recognizedIdentityId ?? void 0,
4431
- assignedSampleId: data.assignedSampleId ?? void 0
4996
+ assignedSampleId: data.assignedSampleId ?? void 0,
4997
+ keyFrameMediaKey: data.keyFrameMediaKey ?? void 0,
4998
+ faceBbox: data.faceBbox ?? void 0
4432
4999
  };
4433
5000
  }).filter((f) => !f.assigned);
4434
5001
  }
@@ -4592,8 +5159,11 @@ var FaceStore = class {
4592
5159
  id: faceId,
4593
5160
  ...data,
4594
5161
  assigned: Boolean(raw.assigned),
5162
+ mediaKey: data.mediaKey ?? void 0,
4595
5163
  recognizedIdentityId: data.recognizedIdentityId ?? void 0,
4596
- assignedSampleId: data.assignedSampleId ?? void 0
5164
+ assignedSampleId: data.assignedSampleId ?? void 0,
5165
+ keyFrameMediaKey: data.keyFrameMediaKey ?? void 0,
5166
+ faceBbox: data.faceBbox ?? void 0
4597
5167
  };
4598
5168
  }
4599
5169
  /**
@@ -4637,7 +5207,9 @@ var FaceStore = class {
4637
5207
  assigned: Boolean(r.data.assigned),
4638
5208
  mediaKey: data.mediaKey ?? void 0,
4639
5209
  recognizedIdentityId: data.recognizedIdentityId ?? void 0,
4640
- assignedSampleId: data.assignedSampleId ?? void 0
5210
+ assignedSampleId: data.assignedSampleId ?? void 0,
5211
+ keyFrameMediaKey: data.keyFrameMediaKey ?? void 0,
5212
+ faceBbox: data.faceBbox ?? void 0
4641
5213
  };
4642
5214
  });
4643
5215
  const filterMode = input.filter ?? "all";
@@ -4702,6 +5274,10 @@ var OBJECT_EMBEDDING_COLUMNS = [
4702
5274
  {
4703
5275
  name: "mediaKey",
4704
5276
  type: "TEXT"
5277
+ },
5278
+ {
5279
+ name: "keyFrameMediaKey",
5280
+ type: "TEXT"
4705
5281
  }
4706
5282
  ];
4707
5283
  var ObjectEmbeddingStore = class {
@@ -4748,7 +5324,8 @@ var ObjectEmbeddingStore = class {
4748
5324
  modelId: input.modelId,
4749
5325
  dim: input.embedding.length,
4750
5326
  confidence: input.confidence,
4751
- ...input.mediaKey !== void 0 ? { mediaKey: input.mediaKey } : {}
5327
+ ...input.mediaKey !== void 0 ? { mediaKey: input.mediaKey } : {},
5328
+ ...input.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: input.keyFrameMediaKey } : {}
4752
5329
  };
4753
5330
  try {
4754
5331
  await this.store.set.mutate({
@@ -4786,7 +5363,8 @@ var ObjectEmbeddingStore = class {
4786
5363
  return {
4787
5364
  id: r.id,
4788
5365
  ...data,
4789
- mediaKey: data.mediaKey ?? void 0
5366
+ mediaKey: data.mediaKey ?? void 0,
5367
+ keyFrameMediaKey: data.keyFrameMediaKey ?? void 0
4790
5368
  };
4791
5369
  });
4792
5370
  }
@@ -4915,12 +5493,22 @@ function updateTrackAggregate(prev, match, opts) {
4915
5493
  }
4916
5494
  //#endregion
4917
5495
  //#region src/pipeline-analytics/face-recognizer.ts
5496
+ /** At most one "dropping imageless track" log per this interval, per recognizer. */
5497
+ var FACE_IMAGELESS_LOG_THROTTLE_MS = 6e4;
4918
5498
  var FaceRecognizer = class {
4919
5499
  deps;
4920
5500
  gallery = [];
4921
5501
  names = /* @__PURE__ */ new Map();
4922
5502
  aggregates = /* @__PURE__ */ new Map();
4923
5503
  bestFace = /* @__PURE__ */ new Map();
5504
+ /** The ONE best-detection-per-track policy, shared with the best-frame path
5505
+ * (`index.ts`). Face params: no hysteresis / no rate-limit — always keep the
5506
+ * true highest-confidence face (holding a buffer in memory is cheap, and a
5507
+ * track may last well under the best-frame rate-limit window). */
5508
+ bestTracker = new BestDetectionTracker();
5509
+ /** Throttle for the "dropping imageless track" log — one line per minute at
5510
+ * most, so a busy scene that never produces a face crop can't flood logs. */
5511
+ lastImagelessLogAt = 0;
4924
5512
  constructor(deps) {
4925
5513
  this.deps = deps;
4926
5514
  }
@@ -4954,55 +5542,25 @@ var FaceRecognizer = class {
4954
5542
  threshold: settings.similarityThreshold,
4955
5543
  margin: settings.margin
4956
5544
  }) : /* @__PURE__ */ new Map();
5545
+ const labelWork = [];
4957
5546
  for (const c of candidates) {
4958
5547
  const match = matches.get(c.trackId) ?? null;
4959
5548
  const { state, changed } = updateTrackAggregate(this.aggregates.get(c.trackId) ?? EMPTY_AGGREGATE, match, { confirmFrames: settings.confirmFrames });
4960
5549
  this.aggregates.set(c.trackId, state);
4961
- if (changed && state.assignedIdentityId !== null) {
4962
- const name = this.names.get(state.assignedIdentityId);
4963
- this.deps.logger.info("face: identity assigned", {
4964
- tags: {
4965
- deviceId: input.deviceId,
4966
- trackId: c.trackId
4967
- },
4968
- meta: {
4969
- identityId: state.assignedIdentityId,
4970
- name: name ?? null,
4971
- score: match?.score ?? null
4972
- }
4973
- });
4974
- if (name !== void 0) {
4975
- try {
4976
- await this.deps.trackStore.setLabel(c.trackId, name);
4977
- } catch (err) {
4978
- this.deps.logger.warn("setLabel failed", {
4979
- tags: { deviceId: input.deviceId },
4980
- meta: {
4981
- trackId: c.trackId,
4982
- error: String(err)
4983
- }
4984
- });
4985
- }
4986
- try {
4987
- await this.deps.eventStore.setLabelForTrack(c.trackId, name);
4988
- } catch (err) {
4989
- this.deps.logger.warn("setLabelForTrack failed", {
4990
- tags: { deviceId: input.deviceId },
4991
- meta: {
4992
- trackId: c.trackId,
4993
- error: String(err)
4994
- }
4995
- });
4996
- }
4997
- }
4998
- }
5550
+ if (changed && state.assignedIdentityId !== null) labelWork.push({
5551
+ trackId: c.trackId,
5552
+ assignedIdentityId: state.assignedIdentityId,
5553
+ matchScore: match?.score ?? null
5554
+ });
4999
5555
  const recognizedIdentityId = state.assignedIdentityId ?? match?.identityId ?? void 0;
5000
5556
  const held = this.bestFace.get(c.trackId);
5001
- if (held === void 0 || c.confidence > held.score) {
5557
+ const isNewBest = this.bestTracker.observe(c.trackId, c.confidence, input.timestamp);
5558
+ const needsCrop = held !== void 0 && held.crop === void 0;
5559
+ if (isNewBest || needsCrop) {
5002
5560
  const cropBbox = c.faceBbox ?? c.bbox;
5003
5561
  let crop;
5004
- if (input.frameHandle !== void 0) try {
5005
- crop = await this.deps.captureCrop(input.frameHandle, cropBbox, input.frameWidth, input.frameHeight, input.cropPadding) ?? void 0;
5562
+ if (c.faceBbox !== void 0 && input.frameHandle !== void 0) try {
5563
+ crop = await this.deps.captureCrop(input.frameHandle, c.faceBbox, input.frameWidth, input.frameHeight, input.cropPadding) ?? void 0;
5006
5564
  } catch (err) {
5007
5565
  this.deps.logger.debug("FaceRecognizer crop capture failed", {
5008
5566
  tags: { deviceId: input.deviceId },
@@ -5012,14 +5570,20 @@ var FaceRecognizer = class {
5012
5570
  }
5013
5571
  });
5014
5572
  }
5015
- this.bestFace.set(c.trackId, {
5016
- score: c.confidence,
5017
- embedding: c.embedding,
5018
- embeddingModelId: c.embeddingModelId,
5019
- bbox: cropBbox,
5020
- timestamp: input.timestamp,
5021
- ...crop !== void 0 ? { crop } : {},
5022
- ...recognizedIdentityId !== void 0 ? { recognizedIdentityId } : {}
5573
+ if (isNewBest) {
5574
+ const bestCrop = crop ?? held?.crop;
5575
+ this.bestFace.set(c.trackId, {
5576
+ score: c.confidence,
5577
+ embedding: c.embedding,
5578
+ embeddingModelId: c.embeddingModelId,
5579
+ bbox: cropBbox,
5580
+ timestamp: input.timestamp,
5581
+ ...bestCrop !== void 0 ? { crop: bestCrop } : {},
5582
+ ...recognizedIdentityId !== void 0 ? { recognizedIdentityId } : {}
5583
+ });
5584
+ } else if (crop !== void 0 && held !== void 0) this.bestFace.set(c.trackId, {
5585
+ ...held,
5586
+ crop
5023
5587
  });
5024
5588
  this.deps.logger.debug("face: best-face held", {
5025
5589
  tags: {
@@ -5028,15 +5592,64 @@ var FaceRecognizer = class {
5028
5592
  },
5029
5593
  meta: {
5030
5594
  score: c.confidence,
5031
- hasCrop: crop !== void 0,
5595
+ isNewBest,
5596
+ hasCrop: this.bestFace.get(c.trackId)?.crop !== void 0,
5032
5597
  recognizedIdentityId: recognizedIdentityId ?? null
5033
5598
  }
5034
5599
  });
5035
- } else if (recognizedIdentityId !== void 0 && held.recognizedIdentityId === void 0) this.bestFace.set(c.trackId, {
5600
+ } else if (held !== void 0 && recognizedIdentityId !== void 0 && held.recognizedIdentityId === void 0) this.bestFace.set(c.trackId, {
5036
5601
  ...held,
5037
5602
  recognizedIdentityId
5038
5603
  });
5039
5604
  }
5605
+ for (const work of labelWork) {
5606
+ const name = this.names.get(work.assignedIdentityId);
5607
+ this.deps.logger.info("face: identity assigned", {
5608
+ tags: {
5609
+ deviceId: input.deviceId,
5610
+ trackId: work.trackId
5611
+ },
5612
+ meta: {
5613
+ identityId: work.assignedIdentityId,
5614
+ name: name ?? null,
5615
+ score: work.matchScore
5616
+ }
5617
+ });
5618
+ if (name === void 0) continue;
5619
+ try {
5620
+ await this.deps.trackStore.setLabel(work.trackId, name);
5621
+ } catch (err) {
5622
+ this.deps.logger.warn("setLabel failed", {
5623
+ tags: { deviceId: input.deviceId },
5624
+ meta: {
5625
+ trackId: work.trackId,
5626
+ error: String(err)
5627
+ }
5628
+ });
5629
+ }
5630
+ try {
5631
+ await this.deps.eventStore.setLabelForTrack(work.trackId, name);
5632
+ } catch (err) {
5633
+ this.deps.logger.warn("setLabelForTrack failed", {
5634
+ tags: { deviceId: input.deviceId },
5635
+ meta: {
5636
+ trackId: work.trackId,
5637
+ error: String(err)
5638
+ }
5639
+ });
5640
+ }
5641
+ try {
5642
+ await this.deps.recomputeImportance?.(work.trackId);
5643
+ } catch (err) {
5644
+ this.deps.logger.warn("recomputeImportance failed", {
5645
+ tags: { deviceId: input.deviceId },
5646
+ meta: {
5647
+ trackId: work.trackId,
5648
+ error: String(err)
5649
+ }
5650
+ });
5651
+ }
5652
+ }
5040
5653
  }
5041
5654
  /**
5042
5655
  * Persist the held best face for a finished track as ONE FaceStore buffer
@@ -5047,6 +5660,7 @@ var FaceRecognizer = class {
5047
5660
  const held = this.bestFace.get(trackId);
5048
5661
  this.aggregates.delete(trackId);
5049
5662
  this.bestFace.delete(trackId);
5663
+ this.bestTracker.delete(trackId);
5050
5664
  if (held === void 0) {
5051
5665
  this.deps.logger.debug("face: track ended without a held face", { tags: {
5052
5666
  deviceId,
@@ -5054,9 +5668,23 @@ var FaceRecognizer = class {
5054
5668
  } });
5055
5669
  return;
5056
5670
  }
5671
+ if (held.crop === void 0) {
5672
+ const now = Date.now();
5673
+ if (now - this.lastImagelessLogAt >= FACE_IMAGELESS_LOG_THROTTLE_MS) {
5674
+ this.lastImagelessLogAt = now;
5675
+ this.deps.logger.info("face: dropping imageless track (no crop captured)", {
5676
+ tags: {
5677
+ deviceId,
5678
+ trackId
5679
+ },
5680
+ meta: { score: held.score }
5681
+ });
5682
+ }
5683
+ return;
5684
+ }
5057
5685
  const faceId = `face-${trackId}`;
5058
5686
  let mediaKey;
5059
- if (held.crop !== void 0) try {
5687
+ try {
5060
5688
  mediaKey = await this.deps.mediaStore.put({
5061
5689
  deviceId,
5062
5690
  ownerKind: "face",
@@ -5074,6 +5702,17 @@ var FaceRecognizer = class {
5074
5702
  }
5075
5703
  });
5076
5704
  }
5705
+ if (mediaKey === void 0) {
5706
+ this.deps.logger.warn("face: crop store failed — dropping face row", {
5707
+ tags: {
5708
+ deviceId,
5709
+ trackId
5710
+ },
5711
+ meta: { faceId }
5712
+ });
5713
+ return;
5714
+ }
5715
+ const keyFrameMediaKey = this.deps.getKeyFrameMediaKey?.(trackId);
5077
5716
  try {
5078
5717
  await this.deps.faceStore.insert({
5079
5718
  id: faceId,
@@ -5081,9 +5720,11 @@ var FaceRecognizer = class {
5081
5720
  trackId,
5082
5721
  timestamp: held.timestamp,
5083
5722
  embedding: held.embedding,
5084
- ...mediaKey !== void 0 ? { mediaKey } : {},
5723
+ mediaKey,
5085
5724
  ...held.recognizedIdentityId !== void 0 ? { recognizedIdentityId: held.recognizedIdentityId } : {},
5086
- assigned: false
5725
+ assigned: false,
5726
+ faceBbox: held.bbox,
5727
+ ...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {}
5087
5728
  });
5088
5729
  this.deps.logger.info("face: buffered to gallery", {
5089
5730
  tags: {
@@ -5092,7 +5733,7 @@ var FaceRecognizer = class {
5092
5733
  },
5093
5734
  meta: {
5094
5735
  faceId,
5095
- hasCrop: mediaKey !== void 0,
5736
+ hasCrop: true,
5096
5737
  recognizedIdentityId: held.recognizedIdentityId ?? null,
5097
5738
  score: held.score
5098
5739
  }
@@ -5424,6 +6065,38 @@ var PlateRecognizer = class {
5424
6065
  }
5425
6066
  };
5426
6067
  //#endregion
6068
+ //#region src/shared/frame/shared-frame-resolver.ts
6069
+ function frameHandleKey(h) {
6070
+ return `${h.nodeId}:${h.shmId}:${h.slot}:${h.seq}`;
6071
+ }
6072
+ function createSharedFrameResolver(resolve) {
6073
+ let cache = null;
6074
+ return (handle) => {
6075
+ const key = frameHandleKey(handle);
6076
+ if (cache === null || cache.key !== key) cache = {
6077
+ key,
6078
+ value: resolve(handle)
6079
+ };
6080
+ return cache.value;
6081
+ };
6082
+ }
6083
+ //#endregion
6084
+ //#region src/shared/frame/encode-crop.ts
6085
+ /**
6086
+ * JPEG-encode an already-cropped raw RGB (24-bit) buffer. Used for the
6087
+ * NATIVE-resolution crop path: the decode worker returns the exact ROI pixels
6088
+ * at native res, so there is nothing left to crop — only to encode to the same
6089
+ * JPEG contract `extractCrop` produces (quality 90). Kept separate from
6090
+ * `extractCrop` (which crops a FULL frame) so the native path never re-crops.
6091
+ */
6092
+ async function encodeRgbCropToJpeg(bytes, width, height) {
6093
+ return sharp(Buffer.from(bytes), { raw: {
6094
+ width,
6095
+ height,
6096
+ channels: 3
6097
+ } }).jpeg({ quality: 90 }).toBuffer();
6098
+ }
6099
+ //#endregion
5427
6100
  //#region src/pipeline-analytics/pipeline/event-child-crops.ts
5428
6101
  /**
5429
6102
  * Compute Intersection-over-Union between two (x,y,w,h) bounding boxes.
@@ -5722,6 +6395,24 @@ function createEventMediaHandler(deps) {
5722
6395
  */
5723
6396
  var TTL_SWEEP_INTERVAL_MS = 5e3;
5724
6397
  var SETTINGS_CACHE_TTL_MS = 5e3;
6398
+ /** §5 best-frame: a track's `thumbnail` is overwritten only when the current
6399
+ * detection confidence beats the held best by at least this margin (hysteresis
6400
+ * so jitter around a plateau doesn't churn the write). */
6401
+ var BEST_FRAME_HYSTERESIS = .05;
6402
+ /** §5 best-frame: at most one best-thumbnail capture per this interval per track. */
6403
+ var BEST_FRAME_MIN_GAP_MS = 2e3;
6404
+ /** Design B: cap (px) on the width of the native KEY FRAME (full-frame native
6405
+ * capture). Native resolution is the point, but a full 4K RGB surface over the
6406
+ * transport per new-best is wasteful for a web detail view — 1920px keeps a
6407
+ * sharp native frame while bounding the copy (a miss falls back to the
6408
+ * detection-res frame, which is already ≤640px). */
6409
+ var KEYFRAME_NATIVE_MAX_WIDTH = 1920;
6410
+ /** getKeyEvents: max completed tracks pulled from a window before importance
6411
+ * ranking. Ordering is by importance (not firstSeen) and legacy rows score on
6412
+ * read, so we over-fetch candidates and trim to `limit` after sorting. */
6413
+ var KEY_EVENT_CANDIDATE_CAP = 500;
6414
+ /** getKeyEvents: default page size when the caller omits `limit`. */
6415
+ var KEY_EVENT_DEFAULT_LIMIT = 50;
5725
6416
  /** Cluster setting key (in the centralized addon store) selecting the SINGLE
5726
6417
  * node that runs post-analysis (event/media/audio/motion generation). All
5727
6418
  * other nodes are fully inert. No multi-node balancing. Default: the hub. */
@@ -5833,6 +6524,31 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
5833
6524
  mediaCacheByDevice = /* @__PURE__ */ new Map();
5834
6525
  /** Consecutive detector-dropout frames skipped per (deviceId, source) key. */
5835
6526
  dropoutSkipsByKey = /* @__PURE__ */ new Map();
6527
+ /** Best (highest-confidence) frame per track — drives the single overwrite
6528
+ * `thumbnail` (§5 best-frame). Shares the ONE best-detection policy with the
6529
+ * face path (`face-recognizer.ts`); rate-limited here since each best-frame
6530
+ * capture re-encodes a full boxed frame. */
6531
+ bestFrameTracker = new BestDetectionTracker({
6532
+ hysteresis: BEST_FRAME_HYSTERESIS,
6533
+ minGapMs: BEST_FRAME_MIN_GAP_MS
6534
+ });
6535
+ /** Best (highest-confidence) CLIP-object detection per track — drives ONE
6536
+ * tight object-crop capture whose media key is written onto the embedding
6537
+ * row (so a semantic-search hit's thumbnail IS the embedded crop). Shares the
6538
+ * unified best-per-track decision (`TrackBestSelector`); the persistent
6539
+ * cross-restart "best" gate stays in `ObjectEmbeddingStore.upsertIfBetter`. */
6540
+ objectEmbeddingBestSelector = new TrackBestSelector();
6541
+ /** Design B: the track's shared native key-frame media key, captured at the
6542
+ * best-detection moment (object-embedding best path). Read by the face path
6543
+ * at track end so a face row links to the SAME single key frame. Cleared on
6544
+ * track end. */
6545
+ keyFrameKeyByTrackId = /* @__PURE__ */ new Map();
6546
+ /** The shared crop extractor (native-res first, detection-frame fallback),
6547
+ * captured in the constructor so `processFrame` can crop object thumbnails in
6548
+ * the same live-frame window as the face/plate/event-media captures. The
6549
+ * optional `maxWidth` caps the native crop width (used for the full-frame key
6550
+ * frame so a 4K native surface never floods the transport). */
6551
+ captureCrop = null;
5836
6552
  shuttingDown = false;
5837
6553
  /** True only on the cluster's designated post-processing node. When false the
5838
6554
  * addon subscribes to NOTHING — fully inert (no event/media generation). */
@@ -5932,23 +6648,62 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
5932
6648
  });
5933
6649
  const ownNodeIdForFaces = ownNodeId;
5934
6650
  const frameReadersForFaces = this.frameReaders;
5935
- const captureCrop = async (frameHandle, bbox, frameWidth, frameHeight, padding) => {
5936
- const decoded = await resolveFrame(frameHandle, {
5937
- ownNodeId: ownNodeIdForFaces,
5938
- readers: frameReadersForFaces,
5939
- getRemoteFrame
5940
- });
5941
- if (!decoded || decoded.format !== "rgb") return null;
5942
- const data = Buffer.isBuffer(decoded.data) ? decoded.data : Buffer.from(decoded.data);
6651
+ const pipelineRunnerApi = api.pipelineRunner;
6652
+ const cropMetricLogger = logger.child("NativeCrop");
6653
+ let nativeHits = 0;
6654
+ let nativeFallbacks = 0;
6655
+ let lastCropMetricAt = 0;
6656
+ const NATIVE_CROP_METRIC_INTERVAL_MS = 3e4;
6657
+ const bumpCropMetric = (hit) => {
6658
+ if (hit) nativeHits += 1;
6659
+ else nativeFallbacks += 1;
6660
+ const now = Date.now();
6661
+ if (now - lastCropMetricAt < NATIVE_CROP_METRIC_INTERVAL_MS) return;
6662
+ lastCropMetricAt = now;
6663
+ cropMetricLogger.info("native-crop window", { meta: {
6664
+ nativeHits,
6665
+ detectionFrameFallbacks: nativeFallbacks
6666
+ } });
6667
+ };
6668
+ const tryNativeCrop = async (frameHandle, paddedNorm, maxWidth) => {
6669
+ if (!pipelineRunnerApi?.getNativeCrop) return null;
6670
+ try {
6671
+ const native = await pipelineRunnerApi.getNativeCrop.query({
6672
+ handle: frameHandle,
6673
+ bbox: paddedNorm,
6674
+ ...maxWidth !== void 0 ? { maxWidth } : {}
6675
+ }, nodePin(frameHandle.nodeId));
6676
+ if (!native || native.width <= 0 || native.height <= 0) return null;
6677
+ return await encodeRgbCropToJpeg(Buffer.from(native.bytes), native.width, native.height);
6678
+ } catch (err) {
6679
+ cropMetricLogger.debug("native crop request failed — falling back", { meta: { error: errMsg(err) } });
6680
+ return null;
6681
+ }
6682
+ };
6683
+ const resolveFrameShared = createSharedFrameResolver((frameHandle) => resolveFrame(frameHandle, {
6684
+ ownNodeId: ownNodeIdForFaces,
6685
+ readers: frameReadersForFaces,
6686
+ getRemoteFrame
6687
+ }));
6688
+ const captureCrop = async (frameHandle, bbox, frameWidth, frameHeight, padding, maxWidth) => {
5943
6689
  const paddedNorm = padBbox({
5944
6690
  x: bbox.x / frameWidth,
5945
6691
  y: bbox.y / frameHeight,
5946
6692
  w: bbox.w / frameWidth,
5947
6693
  h: bbox.h / frameHeight
5948
6694
  }, padding);
5949
- const { crop } = await extractCrop(data, decoded.width, decoded.height, paddedNorm);
6695
+ const nativeCrop = await tryNativeCrop(frameHandle, paddedNorm, maxWidth);
6696
+ if (nativeCrop) {
6697
+ bumpCropMetric(true);
6698
+ return nativeCrop;
6699
+ }
6700
+ bumpCropMetric(false);
6701
+ const decoded = await resolveFrameShared(frameHandle);
6702
+ if (!decoded || decoded.format !== "rgb") return null;
6703
+ const { crop } = await extractCrop(Buffer.isBuffer(decoded.data) ? decoded.data : Buffer.from(decoded.data), decoded.width, decoded.height, paddedNorm);
5950
6704
  return crop;
5951
6705
  };
6706
+ this.captureCrop = captureCrop;
5952
6707
  this.faceRecognizer = new FaceRecognizer({
5953
6708
  identityStore: this.identityStore,
5954
6709
  faceStore: this.faceStore,
@@ -5956,6 +6711,16 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
5956
6711
  trackStore: this.trackStore,
5957
6712
  eventStore: this.eventStore,
5958
6713
  captureCrop,
6714
+ recomputeImportance: (trackId) => {
6715
+ const trackStore = this.trackStore;
6716
+ const eventStore = this.eventStore;
6717
+ if (!trackStore || !eventStore) return Promise.resolve();
6718
+ return recomputeTrackImportance({
6719
+ trackStore,
6720
+ eventStore
6721
+ }, trackId);
6722
+ },
6723
+ getKeyFrameMediaKey: (trackId) => this.keyFrameKeyByTrackId.get(trackId),
5959
6724
  logger: logger.child("FaceRecognizer")
5960
6725
  });
5961
6726
  this.faceRecognizer.refreshGallery();
@@ -5993,7 +6758,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
5993
6758
  try {
5994
6759
  const handler = createEventMediaHandler({ getMedia: async (id) => {
5995
6760
  try {
5996
- return await this.readEventThumbnail(id);
6761
+ return await this.readMediaByEventOrKey(id);
5997
6762
  } catch (err) {
5998
6763
  this.ctx.logger.warn("readEventThumbnail failed", { meta: {
5999
6764
  eventId: id,
@@ -6376,6 +7141,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
6376
7141
  this.processors.clear();
6377
7142
  this.lastActiveTrackIds.clear();
6378
7143
  this.dropoutSkipsByKey.clear();
7144
+ this.bestFrameTracker.clear();
7145
+ this.objectEmbeddingBestSelector.clear();
6379
7146
  this.levelStateByDevice.clear();
6380
7147
  this.settingsCacheByDevice.clear();
6381
7148
  this.sensitivityCacheByDevice.clear();
@@ -6482,12 +7249,15 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
6482
7249
  className: t.className,
6483
7250
  source
6484
7251
  } });
6485
- if (this.eventMediaDispatcher && frameHandle) firstFrameTargets.push({
6486
- trackId: id,
6487
- timestamp: result.timestamp,
6488
- bbox: { ...t.bbox },
6489
- ...t.label ? { label: t.label } : {}
6490
- });
7252
+ if (this.eventMediaDispatcher && frameHandle) {
7253
+ firstFrameTargets.push({
7254
+ trackId: id,
7255
+ timestamp: result.timestamp,
7256
+ bbox: { ...t.bbox },
7257
+ ...t.label ? { label: t.label } : {}
7258
+ });
7259
+ this.trackStore.seedSnapshotClock(id, result.timestamp);
7260
+ }
6491
7261
  this.ctx.eventBus.emit({
6492
7262
  id: `pa-${randomUUID()}`,
6493
7263
  timestamp: new Date(result.timestamp),
@@ -6528,16 +7298,18 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
6528
7298
  } });
6529
7299
  }
6530
7300
  await Promise.all(result.objectEvents.map((e) => this.eventStore.insertObject(e)));
6531
- if (this.objectEmbeddingStore) {
6532
- for (const t of result.tracked) if (t.embedding !== void 0 && t.embeddingModelId !== void 0 && t.embeddingModelId.startsWith("mobileclip-")) this.objectEmbeddingStore.upsertIfBetter({
7301
+ const objectEmbeddingBests = [];
7302
+ if (this.objectEmbeddingStore) for (const t of result.tracked) {
7303
+ if (!isClipObjectEmbedding(t)) continue;
7304
+ if (this.objectEmbeddingBestSelector.observe({
6533
7305
  trackId: t.trackId,
6534
- deviceId,
6535
- timestamp: result.timestamp,
7306
+ confidence: t.confidence,
7307
+ atMs: result.timestamp,
6536
7308
  className: t.className,
7309
+ bbox: t.bbox,
6537
7310
  embedding: t.embedding,
6538
- modelId: t.embeddingModelId,
6539
- confidence: t.confidence
6540
- });
7311
+ embeddingModelId: t.embeddingModelId
7312
+ })) objectEmbeddingBests.push(t);
6541
7313
  }
6542
7314
  const faceSettings = this.faceRecognizer ? await this.resolveDeviceFaceSettings(deviceId) : null;
6543
7315
  const faceGloballyEnabled = this.faceRecognizer ? await this.resolveGlobalFaceEnabled() : false;
@@ -6560,11 +7332,13 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
6560
7332
  let plateCrops = 0;
6561
7333
  for (const crops of childCropsByEvent.values()) for (const c of crops) if (c.kind === "faceCrop") faceCrops += 1;
6562
7334
  else plateCrops += 1;
6563
- if (eventTargets.length > 0 || firstFrameTargets.length > 0) {
7335
+ const snapshotTargets = this.buildSnapshotTargets(result.tracked, result.timestamp, mediaSettings);
7336
+ if (eventTargets.length > 0 || firstFrameTargets.length > 0 || snapshotTargets.length > 0) {
6564
7337
  log.info("media capture", { meta: {
6565
7338
  source,
6566
7339
  events: eventTargets.length,
6567
7340
  trackFrames: firstFrameTargets.length,
7341
+ snapshots: snapshotTargets.length,
6568
7342
  faceCrops,
6569
7343
  plateCrops
6570
7344
  } });
@@ -6573,8 +7347,20 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
6573
7347
  frameHandle,
6574
7348
  events: eventTargets,
6575
7349
  trackFrames: firstFrameTargets,
7350
+ snapshots: snapshotTargets,
6576
7351
  cropPadding: mediaSettings.cropPadding
6577
- });
7352
+ }).then((res) => {
7353
+ for (const s of res.storedSnapshots) this.trackStore?.addSnapshot(s.trackId, {
7354
+ timestamp: s.timestamp,
7355
+ position: {
7356
+ x: s.bbox.x + s.bbox.w / 2,
7357
+ y: s.bbox.y + s.bbox.h / 2,
7358
+ timestamp: s.timestamp,
7359
+ bbox: s.bbox
7360
+ },
7361
+ mediaKey: s.mediaKey
7362
+ });
7363
+ }).catch(() => {});
6578
7364
  }
6579
7365
  }
6580
7366
  if (this.faceRecognizer && faceGloballyEnabled && faceSettings) this.faceRecognizer.processFrame({
@@ -6596,6 +7382,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
6596
7382
  cropPadding: mediaSettings.cropPadding,
6597
7383
  ...frameHandle !== void 0 ? { frameHandle } : {}
6598
7384
  });
7385
+ if (objectEmbeddingBests.length > 0) this.persistObjectEmbeddingBests(deviceId, result.timestamp, objectEmbeddingBests, frameHandle, result.frameWidth, result.frameHeight, mediaSettings.cropPadding);
6599
7386
  for (const e of result.objectEvents) this.ctx.eventBus.emit({
6600
7387
  id: `pa-${e.id}`,
6601
7388
  timestamp: new Date(e.timestamp),
@@ -6707,6 +7494,112 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
6707
7494
  });
6708
7495
  return settings;
6709
7496
  }
7497
+ /**
7498
+ * §5 — decide which active tracks need periodic media THIS frame. Pure over
7499
+ * TrackStore.lastSnapshotAt + the per-track best-confidence map:
7500
+ * • `snapshot` (append) + `lastFrame` (rolling overwrite) fire together on
7501
+ * the snapshotIntervalMs cadence, gated by `saveThumbnails`;
7502
+ * • `thumbnail` (best) fires when confidence beats the held best by the
7503
+ * hysteresis margin, rate-limited to one per BEST_FRAME_MIN_GAP_MS, and is
7504
+ * NOT gated by saveThumbnails (a single best still-frame is always useful).
7505
+ * The per-track best-confidence map is updated here as a side effect.
7506
+ */
7507
+ /**
7508
+ * Capture the tight object crop for each new-best CLIP track (native-res via
7509
+ * the shared extractor) and upsert the embedding row with that crop's media
7510
+ * key. The crop uses `putReplacing` so exactly ONE object crop is kept per
7511
+ * track (the current peak). The embedding is upserted even when no crop was
7512
+ * captured (no frame handle) so semantic search still works — the crop just
7513
+ * enhances the search-hit thumbnail. `upsertIfBetter` remains the durable
7514
+ * cross-restart best gate (R3). Best-effort; issued in the live-frame window.
7515
+ */
7516
+ async persistObjectEmbeddingBests(deviceId, timestamp, bests, frameHandle, frameWidth, frameHeight, cropPadding) {
7517
+ const store = this.objectEmbeddingStore;
7518
+ if (!store) return;
7519
+ await Promise.all(bests.map(async (t) => {
7520
+ if (!isClipObjectEmbedding(t)) return;
7521
+ let mediaKey;
7522
+ let keyFrameMediaKey;
7523
+ if (frameHandle !== void 0 && this.captureCrop && this.mediaStore) {
7524
+ try {
7525
+ const crop = await this.captureCrop(frameHandle, t.bbox, frameWidth, frameHeight, cropPadding);
7526
+ if (crop) mediaKey = await this.mediaStore.putReplacing({
7527
+ deviceId,
7528
+ ownerKind: "track",
7529
+ ownerId: t.trackId,
7530
+ kind: "crop",
7531
+ timestamp,
7532
+ data: crop
7533
+ });
7534
+ } catch (err) {
7535
+ this.ctx.logger.debug("object-embedding crop capture failed", {
7536
+ tags: { deviceId },
7537
+ meta: {
7538
+ trackId: t.trackId,
7539
+ error: errMsg(err)
7540
+ }
7541
+ });
7542
+ }
7543
+ try {
7544
+ const keyFrame = await this.captureCrop(frameHandle, {
7545
+ x: 0,
7546
+ y: 0,
7547
+ w: frameWidth,
7548
+ h: frameHeight
7549
+ }, frameWidth, frameHeight, 0, KEYFRAME_NATIVE_MAX_WIDTH);
7550
+ if (keyFrame) {
7551
+ keyFrameMediaKey = await this.mediaStore.putReplacing({
7552
+ deviceId,
7553
+ ownerKind: "track",
7554
+ ownerId: t.trackId,
7555
+ kind: "keyFrame",
7556
+ timestamp,
7557
+ data: keyFrame
7558
+ });
7559
+ this.keyFrameKeyByTrackId.set(t.trackId, keyFrameMediaKey);
7560
+ }
7561
+ } catch (err) {
7562
+ this.ctx.logger.debug("key-frame capture failed", {
7563
+ tags: { deviceId },
7564
+ meta: {
7565
+ trackId: t.trackId,
7566
+ error: errMsg(err)
7567
+ }
7568
+ });
7569
+ }
7570
+ }
7571
+ await store.upsertIfBetter({
7572
+ trackId: t.trackId,
7573
+ deviceId,
7574
+ timestamp,
7575
+ className: t.className,
7576
+ embedding: t.embedding,
7577
+ modelId: t.embeddingModelId,
7578
+ confidence: t.confidence,
7579
+ ...mediaKey !== void 0 ? { mediaKey } : {},
7580
+ ...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {}
7581
+ });
7582
+ }));
7583
+ }
7584
+ buildSnapshotTargets(tracked, timestamp, media) {
7585
+ const targets = [];
7586
+ for (const t of tracked) {
7587
+ const lastSnap = this.trackStore.lastSnapshotAt(t.trackId);
7588
+ const dueSnapshot = media.saveThumbnails && lastSnap > 0 && timestamp - lastSnap >= media.snapshotIntervalMs;
7589
+ const isNewBest = this.bestFrameTracker.observe(t.trackId, t.confidence, timestamp);
7590
+ if (!dueSnapshot && !isNewBest) continue;
7591
+ targets.push({
7592
+ trackId: t.trackId,
7593
+ timestamp,
7594
+ bbox: { ...t.bbox },
7595
+ ...t.label ? { label: t.label } : {},
7596
+ appendSnapshot: dueSnapshot,
7597
+ rollingLastFrame: dueSnapshot,
7598
+ bestThumbnail: isNewBest
7599
+ });
7600
+ }
7601
+ return targets;
7602
+ }
6710
7603
  async handleAudioResult(data) {
6711
7604
  if (this.shuttingDown) return;
6712
7605
  const { deviceId, frame } = data;
@@ -6959,8 +7852,36 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
6959
7852
  positions: t.positions.length
6960
7853
  }
6961
7854
  });
6962
- this.faceRecognizer?.onTrackEnd(t.deviceId, t.trackId);
7855
+ const faceEnd = this.faceRecognizer?.onTrackEnd(t.deviceId, t.trackId);
7856
+ const dropKeyFrameKey = () => {
7857
+ this.keyFrameKeyByTrackId.delete(t.trackId);
7858
+ };
7859
+ if (faceEnd) faceEnd.finally(dropKeyFrameKey);
7860
+ else dropKeyFrameKey();
6963
7861
  this.plateRecognizer?.onTrackEnd(t.deviceId, t.trackId);
7862
+ try {
7863
+ const peak = await this.eventStore?.peakForTrack(t.trackId);
7864
+ if (peak) {
7865
+ const { importance, reason } = computeImportance({
7866
+ peakConfidence: this.bestFrameTracker.peak(t.trackId)?.confidence ?? peak.peakConfidence,
7867
+ className: t.className,
7868
+ durationMs: duration,
7869
+ peakBboxAreaFrac: peak.peakBboxAreaFrac,
7870
+ totalDistance: t.totalDistance,
7871
+ zonesVisited: t.zonesVisited,
7872
+ ...t.label !== void 0 ? { label: t.label } : {}
7873
+ });
7874
+ await this.trackStore?.setImportance(t.trackId, importance, reason, peak.bestEventId);
7875
+ if (peak.bestEventId !== void 0) await this.eventStore?.setImportanceForTrack(t.trackId, importance);
7876
+ }
7877
+ } catch (err) {
7878
+ this.ctx.logger.debug("importance scoring failed", { meta: {
7879
+ trackId: t.trackId,
7880
+ error: String(err)
7881
+ } });
7882
+ }
7883
+ this.bestFrameTracker.delete(t.trackId);
7884
+ this.objectEmbeddingBestSelector.delete(t.trackId);
6964
7885
  this.ctx.eventBus.emit({
6965
7886
  id: `pa-end-${t.trackId}`,
6966
7887
  timestamp: new Date(t.lastSeen),
@@ -7183,6 +8104,32 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
7183
8104
  if (input.projection !== "slim" || this.eventMediaBaseUrl === null) return events;
7184
8105
  return this.withMediaUrl(events);
7185
8106
  }
8107
+ async getKeyEvents(input) {
8108
+ const trackStore = this.trackStore;
8109
+ if (!trackStore) return [];
8110
+ const eventStore = this.eventStore;
8111
+ try {
8112
+ const candidates = await trackStore.queryHistorical({
8113
+ deviceId: input.deviceId,
8114
+ since: input.since,
8115
+ until: input.until,
8116
+ limit: KEY_EVENT_CANDIDATE_CAP
8117
+ });
8118
+ const peakLookup = (trackId) => eventStore ? eventStore.peakForTrack(trackId) : Promise.resolve({
8119
+ peakConfidence: 0,
8120
+ peakBboxAreaFrac: 0,
8121
+ bestEventId: void 0
8122
+ });
8123
+ return await rankKeyEvents(candidates, {
8124
+ limit: input.limit ?? KEY_EVENT_DEFAULT_LIMIT,
8125
+ ...input.minImportance !== void 0 ? { minImportance: input.minImportance } : {},
8126
+ ...input.classFilter !== void 0 ? { classFilter: input.classFilter } : {}
8127
+ }, peakLookup);
8128
+ } catch (err) {
8129
+ this.ctx.logger.debug("getKeyEvents failed", { meta: { error: String(err) } });
8130
+ return [];
8131
+ }
8132
+ }
7186
8133
  async getAudioEvents(input) {
7187
8134
  const events = await (this.eventStore?.queryAudio(input) ?? Promise.resolve([]));
7188
8135
  if (input.projection !== "slim" || this.eventMediaBaseUrl === null) return events;
@@ -7225,6 +8172,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
7225
8172
  className: input.classFilter
7226
8173
  });
7227
8174
  if (embeddingRows.length === 0) return [];
8175
+ const embeddingMediaKeyByTrackId = /* @__PURE__ */ new Map();
8176
+ const keyFrameKeyByTrackId = /* @__PURE__ */ new Map();
8177
+ for (const row of embeddingRows) {
8178
+ if (row.mediaKey !== void 0) embeddingMediaKeyByTrackId.set(row.trackId, row.mediaKey);
8179
+ if (row.keyFrameMediaKey !== void 0) keyFrameKeyByTrackId.set(row.trackId, row.keyFrameMediaKey);
8180
+ }
7228
8181
  const encoderInfo = await api.embeddingEncoder.getInfo.query().catch((err) => {
7229
8182
  this.ctx.logger.warn("searchObjectEvents: getInfo failed, proceeding without modelId gate", { meta: { error: String(err) } });
7230
8183
  return null;
@@ -7271,10 +8224,20 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
7271
8224
  score
7272
8225
  });
7273
8226
  }
7274
- for (const { event, score } of bestEventByTrackId.values()) scored.push({
7275
- ...event,
7276
- score
7277
- });
8227
+ for (const { event, score } of bestEventByTrackId.values()) {
8228
+ const mediaUrl = resolveSearchThumbnailUrl({
8229
+ baseUrl: this.eventMediaBaseUrl,
8230
+ eventId: event.id,
8231
+ ...event.trackId !== void 0 && embeddingMediaKeyByTrackId.has(event.trackId) ? { embeddingMediaKey: embeddingMediaKeyByTrackId.get(event.trackId) } : {}
8232
+ });
8233
+ const keyFrameMediaKey = event.trackId !== void 0 ? keyFrameKeyByTrackId.get(event.trackId) : void 0;
8234
+ scored.push({
8235
+ ...event,
8236
+ score,
8237
+ ...mediaUrl !== void 0 ? { mediaUrl } : {},
8238
+ ...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {}
8239
+ });
8240
+ }
7278
8241
  }
7279
8242
  scored.sort((a, b) => b.score - a.score);
7280
8243
  return scored.slice(0, input.limit);
@@ -7306,6 +8269,24 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
7306
8269
  * (native-res boxed frame) → any available file. Returns `null` if the
7307
8270
  * event has no media at all.
7308
8271
  */
8272
+ /**
8273
+ * Data-plane resolver: an id is EITHER a bare event id (a UUID → the event's
8274
+ * crop, today's behaviour) OR a MediaStore key (`ownerKind:ownerId:kind:ts`,
8275
+ * contains ':' → served directly by key). The object-embedding search hit
8276
+ * points its thumbnail at the embedding row's crop key (a `track:…:crop:…`
8277
+ * key), so this resolves that crop; event ids stay on the event-crop path.
8278
+ */
8279
+ async readMediaByEventOrKey(id) {
8280
+ if (id.includes(":")) {
8281
+ const file = await (this.mediaStore?.getByKey(id) ?? Promise.resolve(null));
8282
+ if (!file) return null;
8283
+ return {
8284
+ bytes: Buffer.from(file.base64, "base64"),
8285
+ key: file.key
8286
+ };
8287
+ }
8288
+ return this.readEventThumbnail(id);
8289
+ }
7309
8290
  async readEventThumbnail(eventId) {
7310
8291
  const files = await (this.mediaStore?.listByOwner("event", eventId) ?? Promise.resolve([]));
7311
8292
  const chosen = files.find((f) => f.kind === "crop") ?? files.find((f) => f.kind === "fullFrameBoxed") ?? files[0];
@@ -7394,7 +8375,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
7394
8375
  min: 500,
7395
8376
  max: 6e4,
7396
8377
  step: 500,
7397
- default: 2e3,
8378
+ default: 5e3,
7398
8379
  showValue: true,
7399
8380
  unit: "s",
7400
8381
  displayScale: 1e3