@camstack/addon-post-analysis 1.1.25 → 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-Csk_yJr_.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;
@@ -1564,6 +1708,115 @@ var BestDetectionTracker = class {
1564
1708
  }
1565
1709
  };
1566
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
1567
1820
  //#region src/pipeline-analytics/pipeline/native-detection.ts
1568
1821
  /**
1569
1822
  * Nominal frame size used to denormalize native `[0,1]` boxes when a
@@ -1733,6 +1986,18 @@ var TRACKS_COLUMNS = [
1733
1986
  {
1734
1987
  name: "state",
1735
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"
1736
2001
  }
1737
2002
  ];
1738
2003
  var TRACKS_INDEXES = [{
@@ -1764,7 +2029,10 @@ function cloneTrack(t) {
1764
2029
  zonesVisited: [...t.zonesVisited],
1765
2030
  totalDistance: t.totalDistance,
1766
2031
  state: t.state,
1767
- 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 } : {}
1768
2036
  };
1769
2037
  }
1770
2038
  var TrackStore = class {
@@ -1898,6 +2166,37 @@ var TrackStore = class {
1898
2166
  }
1899
2167
  }
1900
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
+ /**
1901
2200
  * Clear a track's label. Sets label to null in the persisted row (so
1902
2201
  * rowToTrack's `typeof label === 'string'` guard omits it on read → label
1903
2202
  * is absent/undefined). Also clears the in-memory active entry if present.
@@ -1968,7 +2267,10 @@ var TrackStore = class {
1968
2267
  snapshots: [...t.snapshots],
1969
2268
  zonesVisited: [...t.zonesVisited],
1970
2269
  totalDistance: t.totalDistance,
1971
- 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 } : {}
1972
2274
  }
1973
2275
  });
1974
2276
  }
@@ -1977,6 +2279,9 @@ var TrackStore = class {
1977
2279
  const snapshots = data["snapshots"] ?? [];
1978
2280
  const zones = data["zonesVisited"] ?? [];
1979
2281
  const label = data["label"];
2282
+ const importance = data["importance"];
2283
+ const bestEventId = data["bestEventId"];
2284
+ const importanceReason = data["importanceReason"];
1980
2285
  return {
1981
2286
  trackId: id,
1982
2287
  deviceId: Number(data["deviceId"]),
@@ -1989,7 +2294,10 @@ var TrackStore = class {
1989
2294
  zonesVisited: zones,
1990
2295
  totalDistance: Number(data["totalDistance"] ?? 0),
1991
2296
  state: data["state"] ?? "idle",
1992
- active: false
2297
+ active: false,
2298
+ ...typeof importance === "number" ? { importance } : {},
2299
+ ...typeof bestEventId === "string" ? { bestEventId } : {},
2300
+ ...typeof importanceReason === "string" ? { importanceReason } : {}
1993
2301
  };
1994
2302
  }
1995
2303
  };
@@ -2492,6 +2800,10 @@ var OBJECT_COLUMNS = [
2492
2800
  {
2493
2801
  name: "mediaKey",
2494
2802
  type: "TEXT"
2803
+ },
2804
+ {
2805
+ name: "importance",
2806
+ type: "REAL"
2495
2807
  }
2496
2808
  ];
2497
2809
  var AUDIO_COLUMNS = [
@@ -2689,6 +3001,63 @@ var EventStore = class {
2689
3001
  return updated;
2690
3002
  }
2691
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
+ /**
2692
3061
  * Clear `label` on every already-emitted object event of a track (sets label
2693
3062
  * to null so stripNulls/slimObject omit it on read → label is absent/undefined).
2694
3063
  * Returns the number of events updated. Best-effort per row. Mirrors
@@ -2883,7 +3252,8 @@ function slimObject(id, data) {
2883
3252
  timestamp: data["timestamp"],
2884
3253
  className: data["className"],
2885
3254
  ...typeof data["frameId"] === "string" ? { frameId: data["frameId"] } : {},
2886
- ...typeof data["trackId"] === "string" ? { trackId: data["trackId"] } : {}
3255
+ ...typeof data["trackId"] === "string" ? { trackId: data["trackId"] } : {},
3256
+ ...typeof data["importance"] === "number" ? { importance: data["importance"] } : {}
2887
3257
  };
2888
3258
  if (typeof data["label"] === "string") return {
2889
3259
  ...base,
@@ -2913,6 +3283,17 @@ function slimAudio(id, data) {
2913
3283
  }
2914
3284
  return base;
2915
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
+ }
2916
3297
  function stripNulls(data) {
2917
3298
  const out = {};
2918
3299
  for (const [k, v] of Object.entries(data)) if (v !== null) out[k] = v;
@@ -4532,6 +4913,14 @@ var FACE_COLUMNS = [
4532
4913
  {
4533
4914
  name: "assignedSampleId",
4534
4915
  type: "TEXT"
4916
+ },
4917
+ {
4918
+ name: "keyFrameMediaKey",
4919
+ type: "TEXT"
4920
+ },
4921
+ {
4922
+ name: "faceBbox",
4923
+ type: "JSON"
4535
4924
  }
4536
4925
  ];
4537
4926
  var FACE_INDEXES = [{
@@ -4604,7 +4993,9 @@ var FaceStore = class {
4604
4993
  assigned: Boolean(r.data.assigned),
4605
4994
  mediaKey: data.mediaKey ?? void 0,
4606
4995
  recognizedIdentityId: data.recognizedIdentityId ?? void 0,
4607
- assignedSampleId: data.assignedSampleId ?? void 0
4996
+ assignedSampleId: data.assignedSampleId ?? void 0,
4997
+ keyFrameMediaKey: data.keyFrameMediaKey ?? void 0,
4998
+ faceBbox: data.faceBbox ?? void 0
4608
4999
  };
4609
5000
  }).filter((f) => !f.assigned);
4610
5001
  }
@@ -4768,8 +5159,11 @@ var FaceStore = class {
4768
5159
  id: faceId,
4769
5160
  ...data,
4770
5161
  assigned: Boolean(raw.assigned),
5162
+ mediaKey: data.mediaKey ?? void 0,
4771
5163
  recognizedIdentityId: data.recognizedIdentityId ?? void 0,
4772
- assignedSampleId: data.assignedSampleId ?? void 0
5164
+ assignedSampleId: data.assignedSampleId ?? void 0,
5165
+ keyFrameMediaKey: data.keyFrameMediaKey ?? void 0,
5166
+ faceBbox: data.faceBbox ?? void 0
4773
5167
  };
4774
5168
  }
4775
5169
  /**
@@ -4813,7 +5207,9 @@ var FaceStore = class {
4813
5207
  assigned: Boolean(r.data.assigned),
4814
5208
  mediaKey: data.mediaKey ?? void 0,
4815
5209
  recognizedIdentityId: data.recognizedIdentityId ?? void 0,
4816
- assignedSampleId: data.assignedSampleId ?? void 0
5210
+ assignedSampleId: data.assignedSampleId ?? void 0,
5211
+ keyFrameMediaKey: data.keyFrameMediaKey ?? void 0,
5212
+ faceBbox: data.faceBbox ?? void 0
4817
5213
  };
4818
5214
  });
4819
5215
  const filterMode = input.filter ?? "all";
@@ -4878,6 +5274,10 @@ var OBJECT_EMBEDDING_COLUMNS = [
4878
5274
  {
4879
5275
  name: "mediaKey",
4880
5276
  type: "TEXT"
5277
+ },
5278
+ {
5279
+ name: "keyFrameMediaKey",
5280
+ type: "TEXT"
4881
5281
  }
4882
5282
  ];
4883
5283
  var ObjectEmbeddingStore = class {
@@ -4924,7 +5324,8 @@ var ObjectEmbeddingStore = class {
4924
5324
  modelId: input.modelId,
4925
5325
  dim: input.embedding.length,
4926
5326
  confidence: input.confidence,
4927
- ...input.mediaKey !== void 0 ? { mediaKey: input.mediaKey } : {}
5327
+ ...input.mediaKey !== void 0 ? { mediaKey: input.mediaKey } : {},
5328
+ ...input.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: input.keyFrameMediaKey } : {}
4928
5329
  };
4929
5330
  try {
4930
5331
  await this.store.set.mutate({
@@ -4962,7 +5363,8 @@ var ObjectEmbeddingStore = class {
4962
5363
  return {
4963
5364
  id: r.id,
4964
5365
  ...data,
4965
- mediaKey: data.mediaKey ?? void 0
5366
+ mediaKey: data.mediaKey ?? void 0,
5367
+ keyFrameMediaKey: data.keyFrameMediaKey ?? void 0
4966
5368
  };
4967
5369
  });
4968
5370
  }
@@ -5091,6 +5493,8 @@ function updateTrackAggregate(prev, match, opts) {
5091
5493
  }
5092
5494
  //#endregion
5093
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;
5094
5498
  var FaceRecognizer = class {
5095
5499
  deps;
5096
5500
  gallery = [];
@@ -5102,6 +5506,9 @@ var FaceRecognizer = class {
5102
5506
  * true highest-confidence face (holding a buffer in memory is cheap, and a
5103
5507
  * track may last well under the best-frame rate-limit window). */
5104
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;
5105
5512
  constructor(deps) {
5106
5513
  this.deps = deps;
5107
5514
  }
@@ -5231,6 +5638,17 @@ var FaceRecognizer = class {
5231
5638
  }
5232
5639
  });
5233
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
+ }
5234
5652
  }
5235
5653
  }
5236
5654
  /**
@@ -5250,9 +5668,23 @@ var FaceRecognizer = class {
5250
5668
  } });
5251
5669
  return;
5252
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
+ }
5253
5685
  const faceId = `face-${trackId}`;
5254
5686
  let mediaKey;
5255
- if (held.crop !== void 0) try {
5687
+ try {
5256
5688
  mediaKey = await this.deps.mediaStore.put({
5257
5689
  deviceId,
5258
5690
  ownerKind: "face",
@@ -5270,6 +5702,17 @@ var FaceRecognizer = class {
5270
5702
  }
5271
5703
  });
5272
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);
5273
5716
  try {
5274
5717
  await this.deps.faceStore.insert({
5275
5718
  id: faceId,
@@ -5277,9 +5720,11 @@ var FaceRecognizer = class {
5277
5720
  trackId,
5278
5721
  timestamp: held.timestamp,
5279
5722
  embedding: held.embedding,
5280
- ...mediaKey !== void 0 ? { mediaKey } : {},
5723
+ mediaKey,
5281
5724
  ...held.recognizedIdentityId !== void 0 ? { recognizedIdentityId: held.recognizedIdentityId } : {},
5282
- assigned: false
5725
+ assigned: false,
5726
+ faceBbox: held.bbox,
5727
+ ...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {}
5283
5728
  });
5284
5729
  this.deps.logger.info("face: buffered to gallery", {
5285
5730
  tags: {
@@ -5288,7 +5733,7 @@ var FaceRecognizer = class {
5288
5733
  },
5289
5734
  meta: {
5290
5735
  faceId,
5291
- hasCrop: mediaKey !== void 0,
5736
+ hasCrop: true,
5292
5737
  recognizedIdentityId: held.recognizedIdentityId ?? null,
5293
5738
  score: held.score
5294
5739
  }
@@ -5620,6 +6065,38 @@ var PlateRecognizer = class {
5620
6065
  }
5621
6066
  };
5622
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
5623
6100
  //#region src/pipeline-analytics/pipeline/event-child-crops.ts
5624
6101
  /**
5625
6102
  * Compute Intersection-over-Union between two (x,y,w,h) bounding boxes.
@@ -5924,6 +6401,18 @@ var SETTINGS_CACHE_TTL_MS = 5e3;
5924
6401
  var BEST_FRAME_HYSTERESIS = .05;
5925
6402
  /** §5 best-frame: at most one best-thumbnail capture per this interval per track. */
5926
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;
5927
6416
  /** Cluster setting key (in the centralized addon store) selecting the SINGLE
5928
6417
  * node that runs post-analysis (event/media/audio/motion generation). All
5929
6418
  * other nodes are fully inert. No multi-node balancing. Default: the hub. */
@@ -6043,6 +6532,23 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
6043
6532
  hysteresis: BEST_FRAME_HYSTERESIS,
6044
6533
  minGapMs: BEST_FRAME_MIN_GAP_MS
6045
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;
6046
6552
  shuttingDown = false;
6047
6553
  /** True only on the cluster's designated post-processing node. When false the
6048
6554
  * addon subscribes to NOTHING — fully inert (no event/media generation). */
@@ -6142,23 +6648,62 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
6142
6648
  });
6143
6649
  const ownNodeIdForFaces = ownNodeId;
6144
6650
  const frameReadersForFaces = this.frameReaders;
6145
- const captureCrop = async (frameHandle, bbox, frameWidth, frameHeight, padding) => {
6146
- const decoded = await resolveFrame(frameHandle, {
6147
- ownNodeId: ownNodeIdForFaces,
6148
- readers: frameReadersForFaces,
6149
- getRemoteFrame
6150
- });
6151
- if (!decoded || decoded.format !== "rgb") return null;
6152
- 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) => {
6153
6689
  const paddedNorm = padBbox({
6154
6690
  x: bbox.x / frameWidth,
6155
6691
  y: bbox.y / frameHeight,
6156
6692
  w: bbox.w / frameWidth,
6157
6693
  h: bbox.h / frameHeight
6158
6694
  }, padding);
6159
- 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);
6160
6704
  return crop;
6161
6705
  };
6706
+ this.captureCrop = captureCrop;
6162
6707
  this.faceRecognizer = new FaceRecognizer({
6163
6708
  identityStore: this.identityStore,
6164
6709
  faceStore: this.faceStore,
@@ -6166,6 +6711,16 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
6166
6711
  trackStore: this.trackStore,
6167
6712
  eventStore: this.eventStore,
6168
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),
6169
6724
  logger: logger.child("FaceRecognizer")
6170
6725
  });
6171
6726
  this.faceRecognizer.refreshGallery();
@@ -6203,7 +6758,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
6203
6758
  try {
6204
6759
  const handler = createEventMediaHandler({ getMedia: async (id) => {
6205
6760
  try {
6206
- return await this.readEventThumbnail(id);
6761
+ return await this.readMediaByEventOrKey(id);
6207
6762
  } catch (err) {
6208
6763
  this.ctx.logger.warn("readEventThumbnail failed", { meta: {
6209
6764
  eventId: id,
@@ -6587,6 +7142,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
6587
7142
  this.lastActiveTrackIds.clear();
6588
7143
  this.dropoutSkipsByKey.clear();
6589
7144
  this.bestFrameTracker.clear();
7145
+ this.objectEmbeddingBestSelector.clear();
6590
7146
  this.levelStateByDevice.clear();
6591
7147
  this.settingsCacheByDevice.clear();
6592
7148
  this.sensitivityCacheByDevice.clear();
@@ -6742,16 +7298,18 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
6742
7298
  } });
6743
7299
  }
6744
7300
  await Promise.all(result.objectEvents.map((e) => this.eventStore.insertObject(e)));
6745
- if (this.objectEmbeddingStore) {
6746
- 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({
6747
7305
  trackId: t.trackId,
6748
- deviceId,
6749
- timestamp: result.timestamp,
7306
+ confidence: t.confidence,
7307
+ atMs: result.timestamp,
6750
7308
  className: t.className,
7309
+ bbox: t.bbox,
6751
7310
  embedding: t.embedding,
6752
- modelId: t.embeddingModelId,
6753
- confidence: t.confidence
6754
- });
7311
+ embeddingModelId: t.embeddingModelId
7312
+ })) objectEmbeddingBests.push(t);
6755
7313
  }
6756
7314
  const faceSettings = this.faceRecognizer ? await this.resolveDeviceFaceSettings(deviceId) : null;
6757
7315
  const faceGloballyEnabled = this.faceRecognizer ? await this.resolveGlobalFaceEnabled() : false;
@@ -6824,6 +7382,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
6824
7382
  cropPadding: mediaSettings.cropPadding,
6825
7383
  ...frameHandle !== void 0 ? { frameHandle } : {}
6826
7384
  });
7385
+ if (objectEmbeddingBests.length > 0) this.persistObjectEmbeddingBests(deviceId, result.timestamp, objectEmbeddingBests, frameHandle, result.frameWidth, result.frameHeight, mediaSettings.cropPadding);
6827
7386
  for (const e of result.objectEvents) this.ctx.eventBus.emit({
6828
7387
  id: `pa-${e.id}`,
6829
7388
  timestamp: new Date(e.timestamp),
@@ -6945,6 +7504,83 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
6945
7504
  * NOT gated by saveThumbnails (a single best still-frame is always useful).
6946
7505
  * The per-track best-confidence map is updated here as a side effect.
6947
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
+ }
6948
7584
  buildSnapshotTargets(tracked, timestamp, media) {
6949
7585
  const targets = [];
6950
7586
  for (const t of tracked) {
@@ -7216,9 +7852,36 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
7216
7852
  positions: t.positions.length
7217
7853
  }
7218
7854
  });
7219
- 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();
7220
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
+ }
7221
7883
  this.bestFrameTracker.delete(t.trackId);
7884
+ this.objectEmbeddingBestSelector.delete(t.trackId);
7222
7885
  this.ctx.eventBus.emit({
7223
7886
  id: `pa-end-${t.trackId}`,
7224
7887
  timestamp: new Date(t.lastSeen),
@@ -7441,6 +8104,32 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
7441
8104
  if (input.projection !== "slim" || this.eventMediaBaseUrl === null) return events;
7442
8105
  return this.withMediaUrl(events);
7443
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
+ }
7444
8133
  async getAudioEvents(input) {
7445
8134
  const events = await (this.eventStore?.queryAudio(input) ?? Promise.resolve([]));
7446
8135
  if (input.projection !== "slim" || this.eventMediaBaseUrl === null) return events;
@@ -7483,6 +8172,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
7483
8172
  className: input.classFilter
7484
8173
  });
7485
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
+ }
7486
8181
  const encoderInfo = await api.embeddingEncoder.getInfo.query().catch((err) => {
7487
8182
  this.ctx.logger.warn("searchObjectEvents: getInfo failed, proceeding without modelId gate", { meta: { error: String(err) } });
7488
8183
  return null;
@@ -7529,10 +8224,20 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
7529
8224
  score
7530
8225
  });
7531
8226
  }
7532
- for (const { event, score } of bestEventByTrackId.values()) scored.push({
7533
- ...event,
7534
- score
7535
- });
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
+ }
7536
8241
  }
7537
8242
  scored.sort((a, b) => b.score - a.score);
7538
8243
  return scored.slice(0, input.limit);
@@ -7564,6 +8269,24 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
7564
8269
  * (native-res boxed frame) → any available file. Returns `null` if the
7565
8270
  * event has no media at all.
7566
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
+ }
7567
8290
  async readEventThumbnail(eventId) {
7568
8291
  const files = await (this.mediaStore?.listByOwner("event", eventId) ?? Promise.resolve([]));
7569
8292
  const chosen = files.find((f) => f.kind === "crop") ?? files.find((f) => f.kind === "fullFrameBoxed") ?? files[0];