@camstack/addon-post-analysis 1.1.30 → 1.1.32

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 { S as EventCategory, _ as hydrateSchema, b as object, c as nodePin, d as videoclipsCapability, f as zoneAnalyticsCapability, g as createEvent, h as DeviceType, i as cosineSimilarity, l as pipelineAnalyticsCapability, m as BaseAddon, n as addonWidgetsSourceCapability, o as faceGalleryCapability, p as errMsg, r as audioMetricsCapability, t as EVENT_PAD_MS, u as plateGalleryCapability, v as boolean, x as string, y as number } from "../dist-yPsKFcJL.mjs";
1
+ import { C as EventCategory, S as string, _ as createEvent, a as cosineSimilarity, b as number, d as plateGalleryCapability, f as videoclipsCapability, g as DeviceType, h as BaseAddon, i as audioMetricsCapability, l as nodePin, m as errMsg, n as EVENT_PAD_MS, p as zoneAnalyticsCapability, r as addonWidgetsSourceCapability, s as faceGalleryCapability, t as EVENT_KIND_BY_CAP, u as pipelineAnalyticsCapability, v as hydrateSchema, x as object, y as boolean } from "../dist-CyyCe4TK.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import sharp from "sharp";
4
4
  //#region src/pipeline-analytics/videoclips-provider.ts
@@ -160,13 +160,13 @@ var CLASS_RANK_VEHICLE = .8;
160
160
  var CLASS_RANK_ANIMAL = .5;
161
161
  var CLASS_RANK_DEFAULT = .25;
162
162
  var PERSON_CLASSES = new Set(["person", "face"]);
163
- var VEHICLE_CLASSES = new Set([
163
+ var VEHICLE_CLASSES$1 = new Set([
164
164
  "vehicle",
165
165
  "car",
166
166
  "truck",
167
167
  "bus"
168
168
  ]);
169
- var ANIMAL_CLASSES = new Set([
169
+ var ANIMAL_CLASSES$1 = new Set([
170
170
  "animal",
171
171
  "dog",
172
172
  "cat"
@@ -182,8 +182,8 @@ function clamp01(x) {
182
182
  function classRank(className) {
183
183
  const c = className.toLowerCase();
184
184
  if (PERSON_CLASSES.has(c)) return 1;
185
- if (VEHICLE_CLASSES.has(c)) return CLASS_RANK_VEHICLE;
186
- if (ANIMAL_CLASSES.has(c)) return CLASS_RANK_ANIMAL;
185
+ if (VEHICLE_CLASSES$1.has(c)) return CLASS_RANK_VEHICLE;
186
+ if (ANIMAL_CLASSES$1.has(c)) return CLASS_RANK_ANIMAL;
187
187
  return CLASS_RANK_DEFAULT;
188
188
  }
189
189
  /**
@@ -1081,7 +1081,7 @@ function resolveDetectionLabel(input) {
1081
1081
  //#endregion
1082
1082
  //#region src/pipeline-analytics/pipeline/zones/geometry.ts
1083
1083
  /** Ray-casting point-in-polygon test */
1084
- function pointInPolygon(point, polygon) {
1084
+ function pointInPolygon$1(point, polygon) {
1085
1085
  if (polygon.length < 3) return false;
1086
1086
  let inside = false;
1087
1087
  for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
@@ -1472,6 +1472,7 @@ var DEFAULT_EVENT_EMITTER_CONFIG = {
1472
1472
  minTrackAge: 3,
1473
1473
  minTrackAgeMs: 0,
1474
1474
  cooldownSec: 5,
1475
+ emitAppearance: true,
1475
1476
  enabledTypes: [
1476
1477
  "object.entering",
1477
1478
  "object.leaving",
@@ -1496,6 +1497,14 @@ var ZONE_TYPE_TO_EVENT = {
1496
1497
  "zone-loiter": "zone.enter",
1497
1498
  "tripwire-cross": "tripwire.cross"
1498
1499
  };
1500
+ /**
1501
+ * DetectionEventType used for the synthetic per-track APPEARANCE event
1502
+ * (Requirement 1). `object.detected` is otherwise unused by the emitter (it maps
1503
+ * from no state and no zone type), so repurposing it as the appearance marker
1504
+ * needs no new event-type vocabulary. The addon routes these into their own
1505
+ * media/persistence path and keeps them OFF the notification bus.
1506
+ */
1507
+ var APPEARANCE_EVENT_TYPE = "object.detected";
1499
1508
  var eventIdCounter = 0;
1500
1509
  var DetectionEventEmitter = class {
1501
1510
  config;
@@ -1508,6 +1517,13 @@ var DetectionEventEmitter = class {
1508
1517
  * fired: the latent "0 `left` events in 24h" bug).
1509
1518
  */
1510
1519
  lastKnownTracks = /* @__PURE__ */ new Map();
1520
+ /**
1521
+ * Track ids that have already been VALUED — either by a crop-bearing
1522
+ * transition/zone event or by a synthetic appearance event. Ensures a track
1523
+ * gets at most ONE appearance and never a redundant appearance on top of a
1524
+ * real crop-bearing event (Requirement 1).
1525
+ */
1526
+ valuedTracks = /* @__PURE__ */ new Set();
1511
1527
  constructor(config = {}) {
1512
1528
  this.config = {
1513
1529
  ...DEFAULT_EVENT_EMITTER_CONFIG,
@@ -1533,6 +1549,7 @@ var DetectionEventEmitter = class {
1533
1549
  if ((now - (this.lastEmitted.get(cooldownKey) ?? 0)) / 1e3 < this.config.cooldownSec) continue;
1534
1550
  this.previousStates.set(state.trackId, state.state);
1535
1551
  this.lastEmitted.set(cooldownKey, now);
1552
+ if (trackMap.has(state.trackId)) this.valuedTracks.add(state.trackId);
1536
1553
  events.push({
1537
1554
  id: `evt-${++eventIdCounter}`,
1538
1555
  type: eventType,
@@ -1552,6 +1569,7 @@ var DetectionEventEmitter = class {
1552
1569
  const track = trackMap.get(ze.trackId);
1553
1570
  if (!track || track.trackAge < this.config.minTrackAge) continue;
1554
1571
  const state = stateMap.get(ze.trackId);
1572
+ this.valuedTracks.add(ze.trackId);
1555
1573
  events.push({
1556
1574
  id: `evt-${++eventIdCounter}`,
1557
1575
  type: eventType,
@@ -1570,9 +1588,29 @@ var DetectionEventEmitter = class {
1570
1588
  trackPath: [...track.path]
1571
1589
  });
1572
1590
  }
1591
+ if (this.config.emitAppearance) for (const track of tracks) {
1592
+ if (this.valuedTracks.has(track.trackId)) continue;
1593
+ if (track.trackAge < this.config.minTrackAge) continue;
1594
+ const state = stateMap.get(track.trackId);
1595
+ if (!state) continue;
1596
+ if (state.dwellTimeMs < this.config.minTrackAgeMs) continue;
1597
+ this.valuedTracks.add(track.trackId);
1598
+ events.push({
1599
+ id: `evt-${++eventIdCounter}`,
1600
+ type: APPEARANCE_EVENT_TYPE,
1601
+ timestamp: now,
1602
+ deviceId,
1603
+ detection: track,
1604
+ classifications,
1605
+ objectState: state,
1606
+ zoneEvents: [],
1607
+ trackPath: [...track.path]
1608
+ });
1609
+ }
1573
1610
  for (const state of states) if (state.state === "leaving") {
1574
1611
  this.previousStates.delete(state.trackId);
1575
1612
  this.lastKnownTracks.delete(state.trackId);
1613
+ this.valuedTracks.delete(state.trackId);
1576
1614
  }
1577
1615
  return events;
1578
1616
  }
@@ -1580,6 +1618,7 @@ var DetectionEventEmitter = class {
1580
1618
  this.previousStates.clear();
1581
1619
  this.lastEmitted.clear();
1582
1620
  this.lastKnownTracks.clear();
1621
+ this.valuedTracks.clear();
1583
1622
  }
1584
1623
  };
1585
1624
  //#endregion
@@ -1594,7 +1633,7 @@ function bboxPolygonOverlap(bbox, polygon) {
1594
1633
  const gridSize = 8;
1595
1634
  let inside = 0;
1596
1635
  const total = gridSize * gridSize;
1597
- for (let row = 0; row < gridSize; row++) for (let col = 0; col < gridSize; col++) if (pointInPolygon({
1636
+ for (let row = 0; row < gridSize; row++) for (let col = 0; col < gridSize; col++) if (pointInPolygon$1({
1598
1637
  x: bbox.x + (col + .5) * (bbox.w / gridSize),
1599
1638
  y: bbox.y + (row + .5) * (bbox.h / gridSize)
1600
1639
  }, polygon)) inside++;
@@ -1613,7 +1652,7 @@ function maskPolygonOverlap(mask, maskWidth, maskHeight, bbox, polygon, _frameWi
1613
1652
  for (let my = 0; my < maskHeight; my++) for (let mx = 0; mx < maskWidth; mx++) {
1614
1653
  if (mask[my * maskWidth + mx] === 0) continue;
1615
1654
  totalMaskPixels++;
1616
- if (pointInPolygon({
1655
+ if (pointInPolygon$1({
1617
1656
  x: bbox.x + mx / maskWidth * bbox.w,
1618
1657
  y: bbox.y + my / maskHeight * bbox.h
1619
1658
  }, polygon)) insidePolygon++;
@@ -1942,9 +1981,9 @@ var FrameProcessor = class {
1942
1981
  } : {}
1943
1982
  };
1944
1983
  });
1945
- const objectEvents = rawEvents.filter((e) => e.detection.trackId).map((e) => {
1984
+ const toObjectEvent = (e, forcedState) => {
1946
1985
  const td = trackedDetections.find((t) => t.trackId === e.detection.trackId);
1947
- const state = mapObjectStateToTrackState(objectStates.find((o) => o.trackId === e.detection.trackId)?.state);
1986
+ const state = forcedState ?? mapObjectStateToTrackState(objectStates.find((o) => o.trackId === e.detection.trackId)?.state);
1948
1987
  const zones = zonesByTrack.get(e.detection.trackId) ?? [];
1949
1988
  const label = td ? resolveDetectionLabel({
1950
1989
  className: td.class,
@@ -1973,7 +2012,10 @@ var FrameProcessor = class {
1973
2012
  frameWidth,
1974
2013
  frameHeight
1975
2014
  };
1976
- });
2015
+ };
2016
+ const withTrackId = rawEvents.filter((e) => e.detection.trackId);
2017
+ const objectEvents = withTrackId.filter((e) => e.type !== "object.detected").map((e) => toObjectEvent(e));
2018
+ const appearanceEvents = withTrackId.filter((e) => e.type === "object.detected").map((e) => toObjectEvent(e, "entered"));
1977
2019
  return {
1978
2020
  deviceId: this.deviceId,
1979
2021
  timestamp,
@@ -1981,6 +2023,7 @@ var FrameProcessor = class {
1981
2023
  frameHeight,
1982
2024
  tracked,
1983
2025
  objectEvents,
2026
+ appearanceEvents,
1984
2027
  rawTrackedDetections: trackedDetections,
1985
2028
  stationaryConfirmed: gate.confirmed,
1986
2029
  stationaryWoken: gate.wokenEntryIds
@@ -2066,29 +2109,120 @@ function buildTrackLifecyclePayload(input) {
2066
2109
  ...hasMedia ? { media } : {}
2067
2110
  };
2068
2111
  }
2112
+ /**
2113
+ * True when `bbox` sits fully inside the frame — no side within the tolerance
2114
+ * band of any border. Degenerate/unknown frame dims (≤ 0) return true so the
2115
+ * gate stays neutral (falls back to pure confidence) rather than demoting every
2116
+ * frame of a dims-less source.
2117
+ */
2118
+ function isEdgeClear(input) {
2119
+ const { bbox, frameWidth, frameHeight } = input;
2120
+ if (frameWidth <= 0 || frameHeight <= 0) return true;
2121
+ const tolerance = input.tolerance ?? .01;
2122
+ const tolX = tolerance * frameWidth;
2123
+ const tolY = tolerance * frameHeight;
2124
+ const left = bbox.x;
2125
+ const top = bbox.y;
2126
+ const right = bbox.x + bbox.w;
2127
+ const bottom = bbox.y + bbox.h;
2128
+ if (left <= tolX) return false;
2129
+ if (top <= tolY) return false;
2130
+ if (right >= frameWidth - tolX) return false;
2131
+ if (bottom >= frameHeight - tolY) return false;
2132
+ return true;
2133
+ }
2134
+ /**
2135
+ * Edge-aware "is `candidate` a new best over `current`?" comparator.
2136
+ *
2137
+ * Tier order: edge-clear ALWAYS outranks edge-touching (a whole subject beats a
2138
+ * clipped one regardless of confidence). WITHIN the same tier, a strictly-higher
2139
+ * confidence past the `hysteresis` margin wins. The tier upgrade
2140
+ * (touching → clear) bypasses hysteresis — the first clear frame is always taken.
2141
+ *
2142
+ * Time gating (`minGapMs`) is applied by the caller (`BestDetectionTracker`),
2143
+ * not here, so this stays a pure value comparison.
2144
+ */
2145
+ function isEdgeAwareNewBest(current, candidate, hysteresis) {
2146
+ if (candidate.edgeClear && !current.edgeClear) return true;
2147
+ if (!candidate.edgeClear && current.edgeClear) return false;
2148
+ return candidate.confidence > current.confidence + hysteresis;
2149
+ }
2069
2150
  //#endregion
2070
2151
  //#region src/pipeline-analytics/pipeline/best-detection-tracker.ts
2152
+ /**
2153
+ * `BestDetectionTracker` — the SINGLE canonical policy for "the best detection
2154
+ * per track" (highest detector confidence per `trackId`).
2155
+ *
2156
+ * Post-analysis derives three per-track "best" artefacts, all keyed by
2157
+ * trackId + confidence, that used to each re-implement the same ranking inline:
2158
+ * 1. the best FRAME (a boxed `thumbnail`) — `index.ts`,
2159
+ * 2. the best FACE crop + its arcface embedding — `face-recognizer.ts`,
2160
+ * 3. the best CLIP object embedding — `store/object-embedding-store.ts`.
2161
+ *
2162
+ * Consumers 1 & 2 now share THIS one implementation so a track's best frame and
2163
+ * its best face crop derive from the same confidence ranking (and, when the peak
2164
+ * frame carries a detected face, the same frame). Consumer 3 (CLIP) is a
2165
+ * DOCUMENTED SEAM: its `upsertIfBetter` is persistence-backed (its best survives
2166
+ * an addon restart, which this in-memory tracker deliberately does not), so it
2167
+ * keeps its own store-side "if better" gate rather than reading this tracker.
2168
+ *
2169
+ * The FACE seam: the best FRAME is ranked over ALL tracked detections, while the
2170
+ * best FACE can only be captured on a frame that actually produced a face
2171
+ * embedding — so the two legitimately diverge when the peak-confidence frame has
2172
+ * no detected face. Each consumer therefore keeps its OWN payload (crop /
2173
+ * embedding / boxed frame); only the ranking DECISION is unified here.
2174
+ */
2071
2175
  var BestDetectionTracker = class {
2072
2176
  hysteresis;
2073
2177
  minGapMs;
2074
2178
  best = /* @__PURE__ */ new Map();
2179
+ /** Held peak's edge-clear tier, kept PARALLEL to `best` so `peak()`'s
2180
+ * `{ confidence, atMs }` shape (a public contract) stays unchanged. Absent =
2181
+ * the edge tier is not in play for the track (treated as clear → the legacy
2182
+ * pure-confidence policy). */
2183
+ edgeClear = /* @__PURE__ */ new Map();
2075
2184
  constructor(options = {}) {
2076
2185
  this.hysteresis = options.hysteresis ?? 0;
2077
2186
  this.minGapMs = options.minGapMs ?? 0;
2078
2187
  }
2079
2188
  /**
2080
2189
  * Record a detection's `confidence` (at wall-clock `timestamp`) for `trackId`.
2081
- * Returns true when it becomes the track's new best — the first sighting, or a
2082
- * confidence that beats the held peak by more than `hysteresis` AND respects
2083
- * `minGapMs`. On acceptance the held peak is advanced to this observation.
2190
+ * Returns true when it becomes the track's new best.
2191
+ *
2192
+ * When `edgeClear` is supplied, an EDGE-AWARE policy applies: an edge-clear
2193
+ * frame (whole subject in view) ALWAYS outranks an edge-touching one (a
2194
+ * clipped, partial subject); the tier upgrade bypasses hysteresis + `minGapMs`
2195
+ * so the first clear frame is always taken. WITHIN the same tier — and when
2196
+ * `edgeClear` is omitted (legacy callers: the face + object-embedding paths) —
2197
+ * the classic policy holds: a confidence past the `hysteresis` margin that also
2198
+ * respects `minGapMs` wins. On acceptance the held peak advances.
2084
2199
  */
2085
- observe(trackId, confidence, timestamp) {
2200
+ observe(trackId, confidence, timestamp, edgeClear) {
2086
2201
  const cur = this.best.get(trackId);
2087
- const isNewBest = cur === void 0 || confidence > cur.confidence + this.hysteresis && timestamp - cur.atMs >= this.minGapMs;
2088
- if (isNewBest) this.best.set(trackId, {
2202
+ if (cur === void 0) {
2203
+ this.best.set(trackId, {
2204
+ confidence,
2205
+ atMs: timestamp
2206
+ });
2207
+ if (edgeClear !== void 0) this.edgeClear.set(trackId, edgeClear);
2208
+ return true;
2209
+ }
2210
+ const curClear = this.edgeClear.get(trackId) ?? true;
2211
+ const candClear = edgeClear ?? true;
2212
+ const isNewBest = candClear && !curClear ? true : isEdgeAwareNewBest({
2213
+ confidence: cur.confidence,
2214
+ edgeClear: curClear
2215
+ }, {
2089
2216
  confidence,
2090
- atMs: timestamp
2091
- });
2217
+ edgeClear: candClear
2218
+ }, this.hysteresis) && timestamp - cur.atMs >= this.minGapMs;
2219
+ if (isNewBest) {
2220
+ this.best.set(trackId, {
2221
+ confidence,
2222
+ atMs: timestamp
2223
+ });
2224
+ if (edgeClear !== void 0) this.edgeClear.set(trackId, edgeClear);
2225
+ }
2092
2226
  return isNewBest;
2093
2227
  }
2094
2228
  /** The held peak for a track (undefined if never observed). */
@@ -2098,9 +2232,11 @@ var BestDetectionTracker = class {
2098
2232
  /** Drop a track's peak (call at track end). */
2099
2233
  delete(trackId) {
2100
2234
  this.best.delete(trackId);
2235
+ this.edgeClear.delete(trackId);
2101
2236
  }
2102
2237
  clear() {
2103
2238
  this.best.clear();
2239
+ this.edgeClear.clear();
2104
2240
  }
2105
2241
  };
2106
2242
  //#endregion
@@ -2228,16 +2364,23 @@ function entryToView(e) {
2228
2364
  ...e.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: e.keyFrameMediaKey } : {}
2229
2365
  };
2230
2366
  }
2231
- /** IoU at/above which a detection is "the same parked object" → suppress its
2232
- * spawn and refresh the entry's `lastConfirmedAt`. Matches decision #2 (0.6). */
2367
+ /** IoU at/above which a detection is unambiguously "the same parked object,
2368
+ * unmoved" → suppress its spawn and refresh `lastConfirmedAt`. Decision #2. */
2233
2369
  var SUPPRESS_IOU = .6;
2234
- /** Centroid move (fraction of the frame diagonal) beyond which a near
2235
- * same-class detection means the parked object actually MOVED wake. */
2236
- var WAKE_MOVE_FRAC = .08;
2237
- /** How near (fraction of frame diagonal) a same-class detection's centroid must
2238
- * be to an entry to be considered "this entry's object" when testing for a
2239
- * wake. Keeps an unrelated object elsewhere in the frame from waking it. */
2240
- var WAKE_SEARCH_FRAC = .5;
2370
+ /**
2371
+ * Minimum IoU for a same-class detection to be ASSOCIATED with an entry i.e.
2372
+ * to be considered THIS parked object's own (possibly jittered/shifted) box
2373
+ * rather than a DIFFERENT vehicle. Below this floor a detection can neither
2374
+ * confirm nor wake the entry (it is a different object).
2375
+ *
2376
+ * This overlap gate is the fix for the 617 parking-lot flood: the previous
2377
+ * wake test used a centroid-distance radius of half the frame diagonal, so any
2378
+ * other vehicle merely PRESENT in the lot (a car driving through, or a second
2379
+ * car parked ~120px away) satisfied "near + moved" and retired the parked
2380
+ * entry — the parked car then re-spawned a fresh track and re-flooded, over and
2381
+ * over. Requiring real box overlap means only the entry's OWN box can wake it.
2382
+ */
2383
+ var WAKE_ASSOC_IOU = .1;
2241
2384
  /**
2242
2385
  * Look-back window over which a track must have stayed put to be PROMOTED. A
2243
2386
  * car that drives in then parks has a large whole-life displacement but a tiny
@@ -2248,8 +2391,7 @@ var WAKE_SEARCH_FRAC = .5;
2248
2391
  var PROMOTION_WINDOW_MS = 3e4;
2249
2392
  var DEFAULT_MATCH_CONFIG = {
2250
2393
  suppressIou: SUPPRESS_IOU,
2251
- wakeMoveFrac: WAKE_MOVE_FRAC,
2252
- wakeSearchFrac: WAKE_SEARCH_FRAC
2394
+ wakeAssocIou: WAKE_ASSOC_IOU
2253
2395
  };
2254
2396
  //#endregion
2255
2397
  //#region src/pipeline-analytics/pipeline/stationary/stationary-match.ts
@@ -2296,36 +2438,45 @@ function centroid(b) {
2296
2438
  y: b.y + b.h / 2
2297
2439
  };
2298
2440
  }
2299
- function diagonalOf(width, height) {
2300
- return Math.hypot(width, height);
2441
+ /** Is point `p` inside box `b`? Used to distinguish box JITTER (centre stays
2442
+ * inside the parked box → suppress) from the object DEPARTING (centre slides
2443
+ * out while the box still overlaps → wake). */
2444
+ function centroidInside(b, p) {
2445
+ return p.x >= b.x && p.x <= b.x + b.w && p.y >= b.y && p.y <= b.y + b.h;
2301
2446
  }
2302
2447
  /**
2303
2448
  * Decide, per stationary entry, whether the current frame confirms it, wakes
2304
2449
  * it, or misses it (no matching detection — leave it for the TTL sweep).
2305
2450
  *
2306
- * Per entry, over same-class detections:
2307
- * - best IoU `suppressIou` SUPPRESS the best-overlap detection (it is the
2308
- * parked object, unmoved) and mark the entry confirmed.
2309
- * - else if a same-class detection sits within `wakeSearchFrac × diag` of the
2310
- * entry centroid but has moved > `wakeMoveFrac × diag` → WAKE the entry (the
2311
- * object slid out of its parked box). The detection is NOT suppressed, so it
2312
- * spawns a fresh moving track.
2313
- * - else MISS (occlusion / brief absence): neither suppress nor wake.
2451
+ * Matching is ASSOCIATION-GATED by box overlap so an entry can only ever be
2452
+ * confirmed or woken by ITS OWN object never by a different same-class
2453
+ * vehicle merely present elsewhere in the frame (the 617 parking-lot flood:
2454
+ * passing/neighbouring cars used to retire parked entries, forcing endless
2455
+ * re-spawns). Per entry, over the best-overlapping same-class detection:
2456
+ *
2457
+ * - best IoU `suppressIou` → SUPPRESS (the parked object, near-identical box)
2458
+ * and mark the entry confirmed.
2459
+ * - `wakeAssocIou` ≤ best IoU < `suppressIou`, centroid still INSIDE the parked
2460
+ * box → SUPPRESS (detector box jitter — the box shrank/grew around the same
2461
+ * centre; still the parked object, must not spawn a duplicate track).
2462
+ * - `wakeAssocIou` ≤ best IoU < `suppressIou`, centroid OUTSIDE the parked box
2463
+ * → WAKE (the object's box slid off its spot → it is departing). Not
2464
+ * suppressed, so it spawns a fresh moving track.
2465
+ * - best IoU < `wakeAssocIou` → MISS: no detection overlaps this entry (a
2466
+ * different object, or the parked object is momentarily undetected). Neither
2467
+ * suppress nor wake; the TTL sweep retires it only if the absence persists.
2314
2468
  *
2315
2469
  * A detection can suppress at most one spawn even if it overlaps two entries
2316
2470
  * (`suppressedIndices` is a set).
2317
2471
  */
2318
2472
  function partitionDetectionsAgainstRegistry(input) {
2319
- const { entries, detections, referenceDiagonalPx, config } = input;
2473
+ const { entries, detections, config } = input;
2320
2474
  const suppressed = /* @__PURE__ */ new Set();
2321
2475
  const confirmed = [];
2322
2476
  const woken = [];
2323
- const diag = referenceDiagonalPx;
2324
2477
  for (const entry of entries) {
2325
- const ec = centroid(entry.bbox);
2326
2478
  let bestIou = 0;
2327
2479
  let bestIdx = -1;
2328
- let wakeCandidate = false;
2329
2480
  for (let di = 0; di < detections.length; di++) {
2330
2481
  const det = detections[di];
2331
2482
  if (det.className !== entry.className) continue;
@@ -2334,20 +2485,17 @@ function partitionDetectionsAgainstRegistry(input) {
2334
2485
  bestIou = o;
2335
2486
  bestIdx = di;
2336
2487
  }
2337
- if (diag > 0) {
2338
- const dc = centroid(det.bbox);
2339
- const dist = Math.hypot(dc.x - ec.x, dc.y - ec.y);
2340
- if (dist <= config.wakeSearchFrac * diag && dist > config.wakeMoveFrac * diag) wakeCandidate = true;
2341
- }
2342
2488
  }
2343
- if (bestIou >= config.suppressIou && bestIdx >= 0) {
2489
+ if (bestIdx < 0 || bestIou < config.wakeAssocIou) continue;
2490
+ const detCentroid = centroid(detections[bestIdx].bbox);
2491
+ if (bestIou >= config.suppressIou || centroidInside(entry.bbox, detCentroid)) {
2344
2492
  suppressed.add(bestIdx);
2345
2493
  confirmed.push({
2346
2494
  entryId: entry.id,
2347
2495
  className: entry.className,
2348
2496
  bbox: { ...entry.bbox }
2349
2497
  });
2350
- } else if (wakeCandidate) woken.push(entry.id);
2498
+ } else woken.push(entry.id);
2351
2499
  }
2352
2500
  return {
2353
2501
  suppressedIndices: suppressed,
@@ -2524,7 +2672,6 @@ var StationaryObjectRegistry = class {
2524
2672
  return partitionDetectionsAgainstRegistry({
2525
2673
  entries,
2526
2674
  detections: input.detections,
2527
- referenceDiagonalPx: diagonalOf(input.frameWidth, input.frameHeight),
2528
2675
  config: this.matchConfig
2529
2676
  });
2530
2677
  }
@@ -2738,7 +2885,7 @@ function computeStationaryEntryZones(entry, zones) {
2738
2885
  const matched = [];
2739
2886
  for (const zone of zones) {
2740
2887
  if (zone.polygon.length < 3) continue;
2741
- if (pointInPolygon(point, zone.polygon)) matched.push(zone.id);
2888
+ if (pointInPolygon$1(point, zone.polygon)) matched.push(zone.id);
2742
2889
  }
2743
2890
  return matched;
2744
2891
  }
@@ -2955,11 +3102,196 @@ var BindingCache = class {
2955
3102
  }
2956
3103
  };
2957
3104
  //#endregion
3105
+ //#region src/pipeline-analytics/store/recent-cursor.ts
3106
+ function encodeRecentCursor(cursor) {
3107
+ return Buffer.from(JSON.stringify({
3108
+ l: cursor.lastSeen,
3109
+ i: cursor.trackId
3110
+ }), "utf8").toString("base64url");
3111
+ }
3112
+ /**
3113
+ * Decode + validate an opaque cursor. Fails fast with a clear error on any
3114
+ * malformed input (bad base64, bad JSON, wrong field types) — a garbage
3115
+ * cursor must never silently degrade into a full-history first page.
3116
+ */
3117
+ function decodeRecentCursor(raw) {
3118
+ let parsed;
3119
+ try {
3120
+ parsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
3121
+ } catch {
3122
+ throw new Error("listRecentTracks: malformed cursor");
3123
+ }
3124
+ if (parsed === null || typeof parsed !== "object" || !("l" in parsed) || !("i" in parsed) || typeof parsed.l !== "number" || !Number.isFinite(parsed.l) || typeof parsed.i !== "string" || parsed.i.length === 0) throw new Error("listRecentTracks: malformed cursor");
3125
+ return {
3126
+ lastSeen: parsed.l,
3127
+ trackId: parsed.i
3128
+ };
3129
+ }
3130
+ /** Comparator for the (lastSeen DESC, id DESC) total order. */
3131
+ function compareRecentDesc(a, b) {
3132
+ if (a.lastSeen !== b.lastSeen) return b.lastSeen - a.lastSeen;
3133
+ if (a.id === b.id) return 0;
3134
+ return a.id < b.id ? 1 : -1;
3135
+ }
3136
+ /** True when `row` sits strictly AFTER the cursor position in DESC order
3137
+ * (i.e. belongs to the next page). */
3138
+ function isAfterCursor(row, cursor) {
3139
+ if (row.lastSeen < cursor.lastSeen) return true;
3140
+ return row.lastSeen === cursor.lastSeen && row.id < cursor.trackId;
3141
+ }
3142
+ //#endregion
3143
+ //#region src/pipeline-analytics/store/zone-geometry.ts
3144
+ /**
3145
+ * Normalized min/max envelope over every position's bbox. Returns `null`
3146
+ * when the frame dimensions are unknown/degenerate or there are no
3147
+ * positions — the caller persists NULL envelope columns in that case.
3148
+ */
3149
+ function computeTrackEnvelope(positions, frameWidth, frameHeight) {
3150
+ if (frameWidth === void 0 || frameHeight === void 0 || !(frameWidth > 0) || !(frameHeight > 0) || positions.length === 0) return null;
3151
+ let minX = Number.POSITIVE_INFINITY;
3152
+ let minY = Number.POSITIVE_INFINITY;
3153
+ let maxX = Number.NEGATIVE_INFINITY;
3154
+ let maxY = Number.NEGATIVE_INFINITY;
3155
+ for (const p of positions) {
3156
+ const x0 = p.bbox.x / frameWidth;
3157
+ const y0 = p.bbox.y / frameHeight;
3158
+ const x1 = (p.bbox.x + p.bbox.w) / frameWidth;
3159
+ const y1 = (p.bbox.y + p.bbox.h) / frameHeight;
3160
+ if (x0 < minX) minX = x0;
3161
+ if (y0 < minY) minY = y0;
3162
+ if (x1 > maxX) maxX = x1;
3163
+ if (y1 > maxY) maxY = y1;
3164
+ }
3165
+ return {
3166
+ minX,
3167
+ minY,
3168
+ maxX,
3169
+ maxY
3170
+ };
3171
+ }
3172
+ /**
3173
+ * Axis-aligned bounds of a zone filter (rect: itself; polygon: vertex
3174
+ * min/max). A degenerate polygon (< 3 points) yields the full frame so the
3175
+ * SQL prefilter never silently drops rows the precise test would keep.
3176
+ */
3177
+ function zoneBounds(zone) {
3178
+ if (zone.kind === "rect") return {
3179
+ minX: zone.x,
3180
+ minY: zone.y,
3181
+ maxX: zone.x + zone.width,
3182
+ maxY: zone.y + zone.height
3183
+ };
3184
+ if (zone.points.length < 3) return {
3185
+ minX: 0,
3186
+ minY: 0,
3187
+ maxX: 1,
3188
+ maxY: 1
3189
+ };
3190
+ let minX = Number.POSITIVE_INFINITY;
3191
+ let minY = Number.POSITIVE_INFINITY;
3192
+ let maxX = Number.NEGATIVE_INFINITY;
3193
+ let maxY = Number.NEGATIVE_INFINITY;
3194
+ for (const p of zone.points) {
3195
+ if (p.x < minX) minX = p.x;
3196
+ if (p.y < minY) minY = p.y;
3197
+ if (p.x > maxX) maxX = p.x;
3198
+ if (p.y > maxY) maxY = p.y;
3199
+ }
3200
+ return {
3201
+ minX,
3202
+ minY,
3203
+ maxX,
3204
+ maxY
3205
+ };
3206
+ }
3207
+ /** Whether two axis-aligned envelopes overlap (touching edges count). */
3208
+ function envelopesOverlap(a, b) {
3209
+ return a.minX <= b.maxX && a.maxX >= b.minX && a.minY <= b.maxY && a.maxY >= b.minY;
3210
+ }
3211
+ /**
3212
+ * Ray-casting point-in-polygon (even-odd rule). Points on an edge may
3213
+ * resolve either way — acceptable for zone filtering. A polygon with
3214
+ * fewer than 3 vertices contains nothing.
3215
+ */
3216
+ function pointInPolygon(point, polygon) {
3217
+ if (polygon.length < 3) return false;
3218
+ let inside = false;
3219
+ for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
3220
+ const a = polygon[i];
3221
+ const b = polygon[j];
3222
+ if (a.y > point.y !== b.y > point.y && point.x < (b.x - a.x) * (point.y - a.y) / (b.y - a.y) + a.x) inside = !inside;
3223
+ }
3224
+ return inside;
3225
+ }
3226
+ /**
3227
+ * Precise per-position zone test.
3228
+ *
3229
+ * - rect zone → any position bbox (normalized) intersects the rect.
3230
+ * - polygon zone → any position CENTER (normalized `x`/`y` — positions
3231
+ * store the bbox center) falls inside the polygon.
3232
+ *
3233
+ * Unknown/degenerate frame dims → `true` (the track cannot be tested, so it
3234
+ * PASSES — mirroring the NULL-envelope-matches rule).
3235
+ */
3236
+ function positionsIntersectZone(positions, frameWidth, frameHeight, zone) {
3237
+ if (frameWidth === void 0 || frameHeight === void 0 || !(frameWidth > 0) || !(frameHeight > 0)) return true;
3238
+ if (zone.kind === "rect") {
3239
+ const rect = zoneBounds(zone);
3240
+ for (const p of positions) if (envelopesOverlap({
3241
+ minX: p.bbox.x / frameWidth,
3242
+ minY: p.bbox.y / frameHeight,
3243
+ maxX: (p.bbox.x + p.bbox.w) / frameWidth,
3244
+ maxY: (p.bbox.y + p.bbox.h) / frameHeight
3245
+ }, rect)) return true;
3246
+ return false;
3247
+ }
3248
+ for (const p of positions) if (pointInPolygon({
3249
+ x: p.x / frameWidth,
3250
+ y: p.y / frameHeight
3251
+ }, zone.points)) return true;
3252
+ return false;
3253
+ }
3254
+ //#endregion
2958
3255
  //#region src/pipeline-analytics/store/track-store.ts
2959
3256
  var DEFAULT_CONFIG = {
2960
3257
  ttlMs: 3e4,
2961
3258
  maxPositionHistory: 300
2962
3259
  };
3260
+ /** `queryRecent` page-size defaults (mirrors the cap input's bounds). */
3261
+ var RECENT_DEFAULT_LIMIT = 200;
3262
+ var RECENT_MAX_LIMIT = 1e3;
3263
+ /** Wide inclusive bound for the envelope-overlap `BETWEEN` prefilter.
3264
+ * Envelope values are normalized ~0..1 but a bbox can spill slightly past
3265
+ * the frame edge; ±1e6 keeps every real value inside the range while the
3266
+ * opposing bound does the actual overlap cut. */
3267
+ var ENV_RANGE_SLACK = 1e6;
3268
+ function rowMatchesZone(data, zone) {
3269
+ const fw = data["frameWidth"];
3270
+ const fh = data["frameHeight"];
3271
+ if (typeof fw !== "number" || typeof fh !== "number") return true;
3272
+ const positions = data["positions"];
3273
+ if (!Array.isArray(positions)) return true;
3274
+ const positionRows = [];
3275
+ for (const p of positions) {
3276
+ if (p === null || typeof p !== "object") continue;
3277
+ if (!("x" in p) || typeof p.x !== "number" || !("y" in p) || typeof p.y !== "number") continue;
3278
+ if (!("bbox" in p) || p.bbox === null || typeof p.bbox !== "object") continue;
3279
+ const b = p.bbox;
3280
+ if (!("x" in b) || typeof b.x !== "number" || !("y" in b) || typeof b.y !== "number") continue;
3281
+ if (!("w" in b) || typeof b.w !== "number" || !("h" in b) || typeof b.h !== "number") continue;
3282
+ positionRows.push({
3283
+ x: p.x,
3284
+ y: p.y,
3285
+ bbox: {
3286
+ x: b.x,
3287
+ y: b.y,
3288
+ w: b.w,
3289
+ h: b.h
3290
+ }
3291
+ });
3292
+ }
3293
+ return positionsIntersectZone(positionRows, fw, fh, zone);
3294
+ }
2963
3295
  var TRACKS_COLLECTION = "pipeline-analytics:tracks";
2964
3296
  var TRACKS_COLUMNS = [
2965
3297
  {
@@ -3031,6 +3363,30 @@ var TRACKS_COLUMNS = [
3031
3363
  {
3032
3364
  name: "audioLabels",
3033
3365
  type: "JSON"
3366
+ },
3367
+ {
3368
+ name: "envMinX",
3369
+ type: "REAL"
3370
+ },
3371
+ {
3372
+ name: "envMinY",
3373
+ type: "REAL"
3374
+ },
3375
+ {
3376
+ name: "envMaxX",
3377
+ type: "REAL"
3378
+ },
3379
+ {
3380
+ name: "envMaxY",
3381
+ type: "REAL"
3382
+ },
3383
+ {
3384
+ name: "frameWidth",
3385
+ type: "INTEGER"
3386
+ },
3387
+ {
3388
+ name: "frameHeight",
3389
+ type: "INTEGER"
3034
3390
  }
3035
3391
  ];
3036
3392
  var TRACKS_INDEXES = [{
@@ -3086,6 +3442,7 @@ var TrackStore = class {
3086
3442
  config;
3087
3443
  logger;
3088
3444
  store;
3445
+ frameDims;
3089
3446
  constructor(deps) {
3090
3447
  this.logger = deps.logger;
3091
3448
  this.store = deps.store;
@@ -3093,6 +3450,7 @@ var TrackStore = class {
3093
3450
  ...DEFAULT_CONFIG,
3094
3451
  ...deps.config
3095
3452
  };
3453
+ this.frameDims = deps.frameDims;
3096
3454
  }
3097
3455
  /** One-time collection declaration. Call from addon onInitialize. */
3098
3456
  static async declare(store) {
@@ -3439,21 +3797,200 @@ var TrackStore = class {
3439
3797
  }
3440
3798
  return [...seenDevices];
3441
3799
  }
3442
- /** Historical query — hits the persisted collection. */
3800
+ /** Historical query — hits the persisted collection. With `zone` set,
3801
+ * candidates are SQL-prefiltered on the envelope columns (overlap test via
3802
+ * `whereBetween`), NULL-envelope rows are re-fetched separately (they must
3803
+ * still MATCH — `BETWEEN` excludes NULL), and survivors run the precise
3804
+ * per-position test against the zone. `projection: 'slim'` drops the heavy
3805
+ * `positions[]` / `snapshots[]` JSON from the returned rows (empty arrays);
3806
+ * the zone test still runs on the stored positions before the drop. */
3443
3807
  async queryHistorical(params) {
3444
- const filter = { where: { deviceId: params.deviceId } };
3445
- if (params.since !== void 0 || params.until !== void 0) filter.whereBetween = { firstSeen: [params.since ?? 0, params.until ?? Date.now()] };
3446
- return (await this.store.query.query({
3808
+ const limit = params.limit ?? 50;
3809
+ const timeBetween = params.since !== void 0 || params.until !== void 0 ? { firstSeen: [params.since ?? 0, params.until ?? Date.now()] } : {};
3810
+ if (params.zone === void 0) return (await this.store.query.query({
3811
+ collection: TRACKS_COLLECTION,
3812
+ filter: {
3813
+ where: { deviceId: params.deviceId },
3814
+ ...Object.keys(timeBetween).length > 0 ? { whereBetween: timeBetween } : {},
3815
+ orderBy: {
3816
+ field: "firstSeen",
3817
+ direction: "desc"
3818
+ },
3819
+ limit
3820
+ }
3821
+ })).map((r) => this.rowToTrack(r.id, r.data, params.projection));
3822
+ const zone = params.zone;
3823
+ const bounds = zoneBounds(zone);
3824
+ const overlapQuery = this.store.query.query({
3825
+ collection: TRACKS_COLLECTION,
3826
+ filter: {
3827
+ where: { deviceId: params.deviceId },
3828
+ whereBetween: {
3829
+ ...timeBetween,
3830
+ envMinX: [-1e6, bounds.maxX],
3831
+ envMaxX: [bounds.minX, ENV_RANGE_SLACK],
3832
+ envMinY: [-1e6, bounds.maxY],
3833
+ envMaxY: [bounds.minY, ENV_RANGE_SLACK]
3834
+ },
3835
+ orderBy: {
3836
+ field: "firstSeen",
3837
+ direction: "desc"
3838
+ },
3839
+ limit
3840
+ }
3841
+ });
3842
+ const nullEnvQuery = this.store.query.query({
3447
3843
  collection: TRACKS_COLLECTION,
3448
3844
  filter: {
3449
- ...filter,
3845
+ where: { deviceId: params.deviceId },
3846
+ ...Object.keys(timeBetween).length > 0 ? { whereBetween: timeBetween } : {},
3450
3847
  orderBy: {
3451
3848
  field: "firstSeen",
3452
3849
  direction: "desc"
3453
3850
  },
3454
- limit: params.limit ?? 50
3851
+ limit
3852
+ }
3853
+ });
3854
+ const [overlapRows, windowRows] = await Promise.all([overlapQuery, nullEnvQuery]);
3855
+ const candidates = /* @__PURE__ */ new Map();
3856
+ for (const r of overlapRows) if (typeof r.data["envMinX"] === "number") candidates.set(r.id, r.data);
3857
+ for (const r of windowRows) if (r.data["envMinX"] === null || r.data["envMinX"] === void 0) candidates.set(r.id, r.data);
3858
+ const matched = [];
3859
+ for (const [id, data] of candidates) if (rowMatchesZone(data, zone)) matched.push({
3860
+ id,
3861
+ data
3862
+ });
3863
+ matched.sort((a, b) => Number(b.data["firstSeen"] ?? 0) - Number(a.data["firstSeen"] ?? 0));
3864
+ return matched.slice(0, limit).map((r) => this.rowToTrack(r.id, r.data, params.projection));
3865
+ }
3866
+ /**
3867
+ * Batched multi-device recent-tracks page (`listRecentTracks`): the
3868
+ * persisted completed tracks of every requested device, merged and ordered
3869
+ * by (`lastSeen` DESC, `trackId` DESC) with a stable opaque cursor.
3870
+ *
3871
+ * Approach — per-device indexed page + k-way merge (documented for the
3872
+ * cap): each device is fetched with ONE indexed query on
3873
+ * `idx_tracks_device_lastSeen` (`WHERE deviceId = ? AND lastSeen BETWEEN
3874
+ * ? AND ? ORDER BY lastSeen DESC LIMIT limit+1`), then the pages are
3875
+ * merged in memory and cut to `limit`. At events-page cardinalities
3876
+ * (≤ dozens of devices × ≤ 1000 rows) the in-memory merge is negligible
3877
+ * next to the row I/O, and every row fetched is a candidate (no scan
3878
+ * waste). The +1 overfetch makes `nextCursor` exact: when the merged
3879
+ * candidate set exceeds `limit` more rows are KNOWN to exist; when it
3880
+ * does not, every device returned fewer rows than asked for and is
3881
+ * therefore exhausted — so the final page always ends with
3882
+ * `nextCursor: null` (no empty trailing page).
3883
+ *
3884
+ * Cursor correctness: SQL can only bound `lastSeen`, and rows sharing the
3885
+ * cursor's exact millisecond have no defined SQL order — so a cursor page
3886
+ * runs TWO ranges per device: an exhaustive same-millisecond tie query
3887
+ * (`lastSeen = cursor.lastSeen`, id tie-break applied in memory) plus the
3888
+ * strictly-older indexed page (`lastSeen ≤ cursor.lastSeen - 1`). Tie
3889
+ * clusters are same-ms track expiries on one camera — physically tiny —
3890
+ * so the unbounded tie query stays O(1) in practice. Known accepted edge:
3891
+ * on a NON-cursor page, a same-ms tie cluster straddling a device's
3892
+ * `limit+1` SQL cut could omit a tie row (needs > limit rows sharing one
3893
+ * millisecond on one camera — unreachable at these cardinalities).
3894
+ *
3895
+ * Errors propagate (no partial merges): a failed device query fails the
3896
+ * page rather than silently returning an incomplete window.
3897
+ */
3898
+ async queryRecent(params) {
3899
+ const limit = Math.min(Math.max(params.limit ?? RECENT_DEFAULT_LIMIT, 1), RECENT_MAX_LIMIT);
3900
+ const deviceIds = [...new Set(params.deviceIds)];
3901
+ if (deviceIds.length === 0) return {
3902
+ tracks: [],
3903
+ nextCursor: null
3904
+ };
3905
+ const cursor = params.cursor !== void 0 ? decodeRecentCursor(params.cursor) : null;
3906
+ const lo = params.since ?? 0;
3907
+ const winHi = params.until ?? Number.MAX_SAFE_INTEGER;
3908
+ const hi = cursor !== null ? Math.min(cursor.lastSeen, winHi) : winHi;
3909
+ if (hi < lo) return {
3910
+ tracks: [],
3911
+ nextCursor: null
3912
+ };
3913
+ const fetchRange = async (deviceId, range, pageLimit) => this.store.query.query({
3914
+ collection: TRACKS_COLLECTION,
3915
+ filter: {
3916
+ where: { deviceId },
3917
+ whereBetween: { lastSeen: range },
3918
+ orderBy: {
3919
+ field: "lastSeen",
3920
+ direction: "desc"
3921
+ },
3922
+ ...pageLimit !== void 0 ? { limit: pageLimit } : {}
3923
+ }
3924
+ });
3925
+ const perDevice = await Promise.all(deviceIds.map(async (deviceId) => {
3926
+ if (cursor === null || cursor.lastSeen > hi) return fetchRange(deviceId, [lo, hi], limit + 1);
3927
+ const tieRange = [cursor.lastSeen, cursor.lastSeen];
3928
+ const belowHi = cursor.lastSeen - 1;
3929
+ const [ties, below] = await Promise.all([cursor.lastSeen >= lo ? fetchRange(deviceId, tieRange) : Promise.resolve([]), belowHi >= lo ? fetchRange(deviceId, [lo, belowHi], limit + 1) : Promise.resolve([])]);
3930
+ return [...ties, ...below];
3931
+ }));
3932
+ const candidates = [];
3933
+ for (const rows of perDevice) for (const r of rows) {
3934
+ const lastSeen = Number(r.data["lastSeen"] ?? 0);
3935
+ if (cursor !== null && !isAfterCursor({
3936
+ lastSeen,
3937
+ id: r.id
3938
+ }, cursor)) continue;
3939
+ candidates.push({
3940
+ id: r.id,
3941
+ lastSeen,
3942
+ data: r.data
3943
+ });
3944
+ }
3945
+ candidates.sort(compareRecentDesc);
3946
+ const page = candidates.slice(0, limit);
3947
+ const tracks = page.map((r) => this.rowToTrack(r.id, r.data, params.projection));
3948
+ const last = page[page.length - 1];
3949
+ return {
3950
+ tracks,
3951
+ nextCursor: candidates.length > limit && last !== void 0 ? encodeRecentCursor({
3952
+ lastSeen: last.lastSeen,
3953
+ trackId: last.id
3954
+ }) : null
3955
+ };
3956
+ }
3957
+ /**
3958
+ * Deduplicated detector class names observed on a device's RECENT persisted
3959
+ * tracks (one indexed page, `lastSeen` desc). Feeds `listEventKinds` — a
3960
+ * representative "what has this camera actually seen" set, not an exhaustive
3961
+ * all-time DISTINCT (the query cap has none). Unions the primary `className`
3962
+ * with the accumulated `classes` array. Best-effort: [] on error.
3963
+ */
3964
+ async observedClassNames(deviceId, limit = 500) {
3965
+ try {
3966
+ const rows = await this.store.query.query({
3967
+ collection: TRACKS_COLLECTION,
3968
+ filter: {
3969
+ where: { deviceId },
3970
+ orderBy: {
3971
+ field: "lastSeen",
3972
+ direction: "desc"
3973
+ },
3974
+ limit
3975
+ }
3976
+ });
3977
+ const names = /* @__PURE__ */ new Set();
3978
+ for (const r of rows) {
3979
+ const className = r.data["className"];
3980
+ if (typeof className === "string" && className.length > 0) names.add(className);
3981
+ const classes = r.data["classes"];
3982
+ if (Array.isArray(classes)) {
3983
+ for (const c of classes) if (typeof c === "string" && c.length > 0) names.add(c);
3984
+ }
3455
3985
  }
3456
- })).map((r) => this.rowToTrack(r.id, r.data));
3986
+ return [...names];
3987
+ } catch (err) {
3988
+ this.logger.warn("TrackStore.observedClassNames failed", { meta: {
3989
+ deviceId,
3990
+ error: String(err)
3991
+ } });
3992
+ return [];
3993
+ }
3457
3994
  }
3458
3995
  async getPersistedByTrackId(trackId) {
3459
3996
  const records = await this.store.query.query({
@@ -3468,6 +4005,8 @@ var TrackStore = class {
3468
4005
  return this.rowToTrack(row.id, row.data);
3469
4006
  }
3470
4007
  async persistCompleted(t) {
4008
+ const dims = this.frameDims?.(t.deviceId);
4009
+ const envelope = computeTrackEnvelope(t.positions, dims?.w, dims?.h);
3471
4010
  await this.store.set.mutate({
3472
4011
  collection: TRACKS_COLLECTION,
3473
4012
  key: t.trackId,
@@ -3486,13 +4025,30 @@ var TrackStore = class {
3486
4025
  ...t.importance !== void 0 ? { importance: t.importance } : {},
3487
4026
  ...t.bestEventId !== void 0 ? { bestEventId: t.bestEventId } : {},
3488
4027
  ...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {},
3489
- ...t.audioLabels !== void 0 ? { audioLabels: [...t.audioLabels] } : {}
4028
+ ...t.audioLabels !== void 0 ? { audioLabels: [...t.audioLabels] } : {},
4029
+ ...envelope !== null && dims !== void 0 ? {
4030
+ envMinX: envelope.minX,
4031
+ envMinY: envelope.minY,
4032
+ envMaxX: envelope.maxX,
4033
+ envMaxY: envelope.maxY,
4034
+ frameWidth: dims.w,
4035
+ frameHeight: dims.h
4036
+ } : {}
3490
4037
  }
3491
4038
  });
3492
4039
  }
3493
- rowToTrack(id, data) {
3494
- const positions = data["positions"] ?? [];
3495
- const snapshots = data["snapshots"] ?? [];
4040
+ /**
4041
+ * Map a persisted row onto the cap `Track` shape. `projection: 'slim'`
4042
+ * drops the heavy `positions[]` / `snapshots[]` JSON (returned as empty
4043
+ * arrays — they are required on the schema) while keeping every scalar
4044
+ * the list surfaces render; `full` (default) is byte-compatible with the
4045
+ * pre-projection behaviour. The persisted envelope columns surface as the
4046
+ * optional `envelope` object in BOTH projections (four light numbers).
4047
+ */
4048
+ rowToTrack(id, data, projection) {
4049
+ const slim = projection === "slim";
4050
+ const positions = slim ? [] : data["positions"] ?? [];
4051
+ const snapshots = slim ? [] : data["snapshots"] ?? [];
3496
4052
  const zones = data["zonesVisited"] ?? [];
3497
4053
  const classes = data["classes"];
3498
4054
  const label = data["label"];
@@ -3500,6 +4056,16 @@ var TrackStore = class {
3500
4056
  const bestEventId = data["bestEventId"];
3501
4057
  const importanceReason = data["importanceReason"];
3502
4058
  const audioLabels = data["audioLabels"];
4059
+ const envMinX = data["envMinX"];
4060
+ const envMinY = data["envMinY"];
4061
+ const envMaxX = data["envMaxX"];
4062
+ const envMaxY = data["envMaxY"];
4063
+ const envelope = typeof envMinX === "number" && typeof envMinY === "number" && typeof envMaxX === "number" && typeof envMaxY === "number" ? {
4064
+ minX: envMinX,
4065
+ minY: envMinY,
4066
+ maxX: envMaxX,
4067
+ maxY: envMaxY
4068
+ } : null;
3503
4069
  return {
3504
4070
  trackId: id,
3505
4071
  deviceId: Number(data["deviceId"]),
@@ -3517,7 +4083,8 @@ var TrackStore = class {
3517
4083
  ...typeof importance === "number" ? { importance } : {},
3518
4084
  ...typeof bestEventId === "string" ? { bestEventId } : {},
3519
4085
  ...typeof importanceReason === "string" ? { importanceReason } : {},
3520
- ...Array.isArray(audioLabels) && audioLabels.length > 0 ? { audioLabels } : {}
4086
+ ...Array.isArray(audioLabels) && audioLabels.length > 0 ? { audioLabels } : {},
4087
+ ...envelope !== null ? { envelope } : {}
3521
4088
  };
3522
4089
  }
3523
4090
  };
@@ -4287,10 +4854,21 @@ var EventStore = class {
4287
4854
  }
4288
4855
  /**
4289
4856
  * The track's highest-confidence object event, its bbox area (as a fraction of
4290
- * frame area), and that event's id — the SHARED per-track ranking already used
4291
- * for the best frame, read back from the persisted object events (index
4857
+ * frame area), and its representative event id — the SHARED per-track ranking
4858
+ * used for the best frame, read back from the persisted object events (index
4292
4859
  * `idx_object_track`). Returns zeros + undefined id when the track has none.
4293
4860
  * Used by the importance scorer at expiry and by `getKeyEvents` compute-on-read.
4861
+ *
4862
+ * `peakConfidence` + `peakBboxAreaFrac` always track the TRUE confidence peak
4863
+ * (over every crop-bearing row) — importance reads them.
4864
+ *
4865
+ * `bestEventId` treats the synthetic APPEARANCE / entry event (state
4866
+ * `'entered'`, Requirement 1) as a FLOOR: it represents the track only when no
4867
+ * genuine ACTIVITY event (any other state — a zone / stationary / moving
4868
+ * moment) exists. So a degenerate passer-by whose only crop-bearing event is
4869
+ * its appearance still gets a representative id, while a track that later
4870
+ * produced a real activity event surfaces THAT as its best. Within a tier the
4871
+ * highest confidence wins.
4294
4872
  */
4295
4873
  async peakForTrack(trackId) {
4296
4874
  const rows = await this.store.query.query({
@@ -4298,8 +4876,11 @@ var EventStore = class {
4298
4876
  filter: { where: { trackId } }
4299
4877
  });
4300
4878
  let bestConf = -1;
4301
- let bestEventId;
4302
4879
  let peakBboxAreaFrac = 0;
4880
+ let activityConf = -1;
4881
+ let activityId;
4882
+ let entryConf = -1;
4883
+ let entryId;
4303
4884
  for (const row of rows) {
4304
4885
  const bbox = row.data["bbox"];
4305
4886
  if (bbox !== null && typeof bbox === "object") {
@@ -4308,15 +4889,24 @@ var EventStore = class {
4308
4889
  if (bw <= 0 || bh <= 0) continue;
4309
4890
  }
4310
4891
  const conf = typeof row.data["confidence"] === "number" ? row.data["confidence"] : 0;
4311
- if (conf <= bestConf) continue;
4312
- bestConf = conf;
4313
- bestEventId = row.id;
4314
- peakBboxAreaFrac = bboxAreaFrac(row.data);
4892
+ if (conf > bestConf) {
4893
+ bestConf = conf;
4894
+ peakBboxAreaFrac = bboxAreaFrac(row.data);
4895
+ }
4896
+ if (row.data["state"] === "entered") {
4897
+ if (conf > entryConf) {
4898
+ entryConf = conf;
4899
+ entryId = row.id;
4900
+ }
4901
+ } else if (conf > activityConf) {
4902
+ activityConf = conf;
4903
+ activityId = row.id;
4904
+ }
4315
4905
  }
4316
4906
  return {
4317
4907
  peakConfidence: bestConf < 0 ? 0 : bestConf,
4318
4908
  peakBboxAreaFrac,
4319
- bestEventId
4909
+ bestEventId: activityId ?? entryId
4320
4910
  };
4321
4911
  }
4322
4912
  /**
@@ -4628,6 +5218,384 @@ function stripNulls(data) {
4628
5218
  return out;
4629
5219
  }
4630
5220
  //#endregion
5221
+ //#region src/pipeline-analytics/store/sensor-event-store.ts
5222
+ var SENSOR_EVENTS_COLLECTION = "pipeline-analytics:sensor-events";
5223
+ var SENSOR_EVENT_COLUMNS = [
5224
+ {
5225
+ name: "id",
5226
+ type: "TEXT",
5227
+ primaryKey: true,
5228
+ notNull: true
5229
+ },
5230
+ (
5231
+ /** The CAMERA the event is attributed to. */
5232
+ {
5233
+ name: "deviceId",
5234
+ type: "INTEGER",
5235
+ notNull: true
5236
+ }),
5237
+ (
5238
+ /** The linked sensor device whose state changed. */
5239
+ {
5240
+ name: "sourceDeviceId",
5241
+ type: "INTEGER",
5242
+ notNull: true
5243
+ }),
5244
+ (
5245
+ /** Event kind id (matches an `EventKindDescriptor.kind`). */
5246
+ {
5247
+ name: "kind",
5248
+ type: "TEXT",
5249
+ notNull: true
5250
+ }),
5251
+ (
5252
+ /** Snapshot of the sensor cap's runtime-state slice at the change. */
5253
+ {
5254
+ name: "value",
5255
+ type: "JSON"
5256
+ }),
5257
+ {
5258
+ name: "timestamp",
5259
+ type: "INTEGER",
5260
+ notNull: true
5261
+ }
5262
+ ];
5263
+ var SENSOR_EVENT_INDEXES = [{
5264
+ name: "idx_sensor_events_device_ts",
5265
+ columns: ["deviceId", "timestamp"]
5266
+ }];
5267
+ var DEFAULT_QUERY_LIMIT = 1e3;
5268
+ var SensorEventStore = class {
5269
+ store;
5270
+ logger;
5271
+ constructor(deps) {
5272
+ this.store = deps.store;
5273
+ this.logger = deps.logger;
5274
+ }
5275
+ /** One-time collection declaration. Call from addon onInitialize. */
5276
+ static async declare(store) {
5277
+ await store.declareCollection.mutate({
5278
+ collection: SENSOR_EVENTS_COLLECTION,
5279
+ columns: [...SENSOR_EVENT_COLUMNS],
5280
+ indexes: [...SENSOR_EVENT_INDEXES]
5281
+ });
5282
+ }
5283
+ /** Insert one attributed sensor event. Best-effort (telemetry-lossy). */
5284
+ async insert(ev) {
5285
+ try {
5286
+ await this.store.insert.mutate({
5287
+ collection: SENSOR_EVENTS_COLLECTION,
5288
+ record: {
5289
+ id: ev.id,
5290
+ data: {
5291
+ deviceId: ev.deviceId,
5292
+ sourceDeviceId: ev.sourceDeviceId,
5293
+ kind: ev.kind,
5294
+ value: ev.value,
5295
+ timestamp: ev.timestamp
5296
+ }
5297
+ }
5298
+ });
5299
+ } catch (err) {
5300
+ this.logger.warn("SensorEventStore.insert failed", {
5301
+ tags: { deviceId: ev.deviceId },
5302
+ meta: {
5303
+ eventId: ev.id,
5304
+ error: String(err)
5305
+ }
5306
+ });
5307
+ }
5308
+ }
5309
+ /** Per-camera sensor-event history, newest first. Mirrors the
5310
+ * motion/object/audio query semantics; `kinds` narrows via `whereIn`. */
5311
+ async query(q) {
5312
+ const filter = {
5313
+ where: { deviceId: q.deviceId },
5314
+ orderBy: {
5315
+ field: "timestamp",
5316
+ direction: "desc"
5317
+ },
5318
+ limit: q.limit ?? DEFAULT_QUERY_LIMIT
5319
+ };
5320
+ if (q.since !== void 0 || q.until !== void 0) filter["whereBetween"] = { timestamp: [q.since ?? 0, q.until ?? Date.now()] };
5321
+ if (q.kinds !== void 0 && q.kinds.length > 0) filter["whereIn"] = { kind: [...q.kinds] };
5322
+ return (await this.store.query.query({
5323
+ collection: SENSOR_EVENTS_COLLECTION,
5324
+ filter
5325
+ })).map((r) => rowToSensorEvent(r.id, r.data));
5326
+ }
5327
+ /**
5328
+ * Delete every row with `timestamp ≤ cutoffMs`, draining a page at a time
5329
+ * (mirrors `EventStore.evictBefore`, including the infinite-loop guard).
5330
+ * Returns the number of rows deleted. Rides the analytics retention sweep.
5331
+ */
5332
+ async evictBefore(cutoffMs) {
5333
+ let deleted = 0;
5334
+ for (;;) {
5335
+ const rows = await this.store.query.query({
5336
+ collection: SENSOR_EVENTS_COLLECTION,
5337
+ filter: {
5338
+ whereBetween: { timestamp: [0, cutoffMs] },
5339
+ limit: EVICT_PAGE_SIZE
5340
+ }
5341
+ });
5342
+ if (rows.length === 0) break;
5343
+ let deletedInPage = 0;
5344
+ for (const row of rows) {
5345
+ if (typeof row.id !== "string") continue;
5346
+ try {
5347
+ await this.store.delete.mutate({
5348
+ collection: SENSOR_EVENTS_COLLECTION,
5349
+ key: row.id
5350
+ });
5351
+ deleted++;
5352
+ deletedInPage++;
5353
+ } catch {}
5354
+ }
5355
+ if (deletedInPage === 0) break;
5356
+ }
5357
+ return deleted;
5358
+ }
5359
+ };
5360
+ /** Page size for the eviction drain loop (mirrors EventStore.PRUNE_PAGE_SIZE). */
5361
+ var EVICT_PAGE_SIZE = 500;
5362
+ function rowToSensorEvent(id, data) {
5363
+ const value = data["value"];
5364
+ return {
5365
+ id,
5366
+ deviceId: Number(data["deviceId"]),
5367
+ sourceDeviceId: Number(data["sourceDeviceId"]),
5368
+ kind: String(data["kind"]),
5369
+ value: isRecord(value) ? value : null,
5370
+ timestamp: Number(data["timestamp"])
5371
+ };
5372
+ }
5373
+ function isRecord(x) {
5374
+ return x !== null && typeof x === "object" && !Array.isArray(x);
5375
+ }
5376
+ //#endregion
5377
+ //#region src/pipeline-analytics/services/event-kinds.ts
5378
+ /**
5379
+ * Extensible per-device event kinds (Part B).
5380
+ *
5381
+ * `composeEventKinds` builds the `listEventKinds` payload for a camera:
5382
+ * (a) built-ins — motion + audio, always present;
5383
+ * (b) detection classes actually OBSERVED on the device (track history);
5384
+ * (c) sensor kinds contributed by LINKED devices (device-manager
5385
+ * `getLinkedDevices`), one descriptor per bound sensor cap present in
5386
+ * the static `EVENT_KIND_BY_CAP` map. Binding-driven per linked device
5387
+ * (`getBindings`) — never a global cap enumeration (D12).
5388
+ *
5389
+ * `LinkedCamerasCache` is the ingest-side reverse index (sensor device →
5390
+ * linked camera ids) with a TTL, so the `DeviceStateChanged` handler stays
5391
+ * cheap at bus rate.
5392
+ */
5393
+ var MOTION_COLOR = "#f59e0b";
5394
+ var AUDIO_COLOR = "#06b6d4";
5395
+ var PERSON_COLOR = "#22c55e";
5396
+ var VEHICLE_COLOR = "#3b82f6";
5397
+ var ANIMAL_COLOR = "#f97316";
5398
+ var GENERIC_DETECTION_COLOR = "#64748b";
5399
+ var VEHICLE_CLASSES = new Set([
5400
+ "vehicle",
5401
+ "car",
5402
+ "truck",
5403
+ "bus",
5404
+ "motorcycle",
5405
+ "bicycle",
5406
+ "boat",
5407
+ "train"
5408
+ ]);
5409
+ var ANIMAL_CLASSES = new Set([
5410
+ "animal",
5411
+ "dog",
5412
+ "cat",
5413
+ "bird",
5414
+ "horse",
5415
+ "cow",
5416
+ "sheep"
5417
+ ]);
5418
+ function detectionIcon(className) {
5419
+ if (className === "person") return "person";
5420
+ if (VEHICLE_CLASSES.has(className)) return "vehicle";
5421
+ if (ANIMAL_CLASSES.has(className)) return "animal";
5422
+ return "generic";
5423
+ }
5424
+ function detectionColor(className) {
5425
+ if (className === "person") return PERSON_COLOR;
5426
+ if (VEHICLE_CLASSES.has(className)) return VEHICLE_COLOR;
5427
+ if (ANIMAL_CLASSES.has(className)) return ANIMAL_COLOR;
5428
+ return GENERIC_DETECTION_COLOR;
5429
+ }
5430
+ function titleCase(s) {
5431
+ return s.length > 0 ? s.charAt(0).toUpperCase() + s.slice(1) : s;
5432
+ }
5433
+ /**
5434
+ * Full event-kind list for a camera. Sensor kinds are deduped per
5435
+ * (kind, source deviceId) — two linked contact sensors each contribute
5436
+ * their own entry, distinguishable by `source.deviceId`.
5437
+ */
5438
+ async function composeEventKinds(deps, deviceId) {
5439
+ const out = [{
5440
+ kind: "motion",
5441
+ label: "Motion",
5442
+ color: MOTION_COLOR,
5443
+ icon: "motion",
5444
+ category: "motion",
5445
+ source: {
5446
+ capName: "pipeline-analytics",
5447
+ deviceId
5448
+ }
5449
+ }, {
5450
+ kind: "audio",
5451
+ label: "Audio",
5452
+ color: AUDIO_COLOR,
5453
+ icon: "audio",
5454
+ category: "audio",
5455
+ source: {
5456
+ capName: "pipeline-analytics",
5457
+ deviceId
5458
+ }
5459
+ }];
5460
+ try {
5461
+ const classNames = await deps.observedClassNames(deviceId);
5462
+ for (const className of [...classNames].sort()) out.push({
5463
+ kind: className,
5464
+ label: titleCase(className),
5465
+ color: detectionColor(className),
5466
+ icon: detectionIcon(className),
5467
+ category: "detection",
5468
+ source: {
5469
+ capName: "pipeline-analytics",
5470
+ deviceId
5471
+ }
5472
+ });
5473
+ } catch (err) {
5474
+ deps.onError?.("observedClassNames", err);
5475
+ }
5476
+ try {
5477
+ const { devices } = await deps.linkedDevices.getLinkedDevices({ deviceId });
5478
+ const seen = /* @__PURE__ */ new Set();
5479
+ for (const linked of devices) {
5480
+ let capNames;
5481
+ try {
5482
+ const { entries } = await deps.bindings.getBindings({ deviceId: linked.deviceId });
5483
+ capNames = entries.map((e) => e.capName);
5484
+ } catch (err) {
5485
+ deps.onError?.("getBindings", err);
5486
+ continue;
5487
+ }
5488
+ for (const capName of capNames) {
5489
+ const descriptor = EVENT_KIND_BY_CAP[capName];
5490
+ if (descriptor === void 0) continue;
5491
+ const dedupeKey = `${descriptor.kind}:${linked.deviceId}`;
5492
+ if (seen.has(dedupeKey)) continue;
5493
+ seen.add(dedupeKey);
5494
+ out.push({
5495
+ kind: descriptor.kind,
5496
+ label: descriptor.label,
5497
+ color: descriptor.color,
5498
+ icon: descriptor.icon,
5499
+ category: descriptor.category,
5500
+ source: {
5501
+ capName,
5502
+ deviceId: linked.deviceId
5503
+ }
5504
+ });
5505
+ }
5506
+ }
5507
+ } catch (err) {
5508
+ deps.onError?.("getLinkedDevices", err);
5509
+ }
5510
+ return out;
5511
+ }
5512
+ var DEFAULT_CACHE_TTL_MS = 6e4;
5513
+ /**
5514
+ * TTL-cached reverse index: source deviceId → camera ids it is linked to.
5515
+ * Rebuilds lazily (single-flight) when stale, so the `DeviceStateChanged`
5516
+ * handler pays one map lookup per event in the common case.
5517
+ */
5518
+ var LinkedCamerasCache = class {
5519
+ deps;
5520
+ ttlMs;
5521
+ index = /* @__PURE__ */ new Map();
5522
+ /** Ms timestamp of the last build; null = never built / invalidated. */
5523
+ builtAt = null;
5524
+ building = null;
5525
+ constructor(deps) {
5526
+ this.deps = deps;
5527
+ this.ttlMs = deps.ttlMs ?? DEFAULT_CACHE_TTL_MS;
5528
+ }
5529
+ /** Camera ids linked to `sourceDeviceId` ([] when none). */
5530
+ async camerasFor(sourceDeviceId, nowMs = Date.now()) {
5531
+ if (this.builtAt === null || nowMs - this.builtAt >= this.ttlMs) {
5532
+ this.building ??= this.rebuild(nowMs).finally(() => {
5533
+ this.building = null;
5534
+ });
5535
+ await this.building;
5536
+ }
5537
+ return this.index.get(sourceDeviceId) ?? [];
5538
+ }
5539
+ /** Drop the cached index (e.g. on link-topology change events). */
5540
+ invalidate() {
5541
+ this.builtAt = null;
5542
+ }
5543
+ /** Test/maintenance hook: replace the index directly. */
5544
+ seed(index, builtAt) {
5545
+ this.index = new Map(index);
5546
+ this.builtAt = builtAt;
5547
+ }
5548
+ async rebuild(nowMs) {
5549
+ try {
5550
+ const cameraIds = await this.deps.cameras.listCameraIds();
5551
+ const next = /* @__PURE__ */ new Map();
5552
+ for (const cameraId of cameraIds) try {
5553
+ const { devices } = await this.deps.linkedDevices.getLinkedDevices({ deviceId: cameraId });
5554
+ for (const d of devices) {
5555
+ const list = next.get(d.deviceId);
5556
+ if (list === void 0) next.set(d.deviceId, [cameraId]);
5557
+ else if (!list.includes(cameraId)) list.push(cameraId);
5558
+ }
5559
+ } catch (err) {
5560
+ this.deps.onError?.("getLinkedDevices", err);
5561
+ }
5562
+ this.index = next;
5563
+ this.builtAt = nowMs;
5564
+ } catch (err) {
5565
+ this.deps.onError?.("listCameraIds", err);
5566
+ this.builtAt = nowMs;
5567
+ }
5568
+ }
5569
+ };
5570
+ /**
5571
+ * One `DeviceStateChanged` → N history rows (one per linked camera). The
5572
+ * EVENT_KIND_BY_CAP gate exits first so non-sensor cap churn costs one map
5573
+ * lookup. Returns the number of rows inserted (0 when unmapped/unlinked).
5574
+ * Telemetry-lossy by design (D8) — inserts are best-effort.
5575
+ */
5576
+ async function ingestSensorStateChange(deps, data, timestamp) {
5577
+ const descriptor = EVENT_KIND_BY_CAP[data.capName];
5578
+ if (descriptor === void 0) return 0;
5579
+ const cameraIds = await deps.cache.camerasFor(data.deviceId);
5580
+ if (cameraIds.length === 0) return 0;
5581
+ const slice = data.slice;
5582
+ const value = slice !== null && slice !== void 0 && typeof slice === "object" && !Array.isArray(slice) ? slice : null;
5583
+ const makeId = deps.makeId ?? (() => `pa-sensor-${randomUUID()}`);
5584
+ let inserted = 0;
5585
+ for (const cameraId of cameraIds) {
5586
+ await deps.sink.insert({
5587
+ id: makeId(),
5588
+ deviceId: cameraId,
5589
+ sourceDeviceId: data.deviceId,
5590
+ kind: descriptor.kind,
5591
+ value,
5592
+ timestamp
5593
+ });
5594
+ inserted++;
5595
+ }
5596
+ return inserted;
5597
+ }
5598
+ //#endregion
4631
5599
  //#region src/shared/frame/resolve-frame.ts
4632
5600
  /**
4633
5601
  * Resolve the pixels a `FrameHandle` refers to via the node-routed fetch.
@@ -4861,22 +5829,26 @@ var EventMediaDispatcher = class {
4861
5829
  for (const tf of trackFrames) await this.writeTrackFrame(deviceId, frameData, fw, fh, tf);
4862
5830
  const storedSnapshots = [];
4863
5831
  for (const sn of snapshots) {
4864
- const stored = await this.writeTrackSnapshot(deviceId, frameData, fw, fh, sn);
5832
+ const stored = await this.writeTrackSnapshot(deviceId, frameData, fw, fh, sn, input.cropPadding);
4865
5833
  if (stored) storedSnapshots.push(stored);
4866
5834
  }
4867
5835
  return { storedSnapshots };
4868
5836
  }
4869
5837
  /**
4870
- * Periodic per-track media (§5). Encodes the boxed frame ONCE and fans it out
4871
- * to whichever of the three destinations is requested: an appended `snapshot`
4872
- * (timeline filmstrip), the rolling `lastFrame` (overwrite), and the best
4873
- * `thumbnail` (overwrite). Returns the appended snapshot for TrackStore wiring
4874
- * (null when `appendSnapshot` is false or the encode failed).
5838
+ * Periodic per-track media (§5). The boxed FULL frame is encoded once and
5839
+ * shared by the appended `snapshot` (timeline filmstrip) and the rolling
5840
+ * `lastFrame` (overwrite). The best `thumbnail` is DIFFERENT: a clean
5841
+ * subject-centered crop (same output contract as the object-event `crop`
5842
+ * kind) it is the gallery/reel fallback for tracks that never produced an
5843
+ * object event, and a full frame there shows the scene (e.g. a foreground
5844
+ * parked car), not the track's subject. Returns the appended snapshot for
5845
+ * TrackStore wiring (null when `appendSnapshot` is false or the encode
5846
+ * failed).
4875
5847
  */
4876
- async writeTrackSnapshot(deviceId, frameData, fw, fh, sn) {
5848
+ async writeTrackSnapshot(deviceId, frameData, fw, fh, sn, cropPadding) {
4877
5849
  if (!sn.appendSnapshot && !sn.rollingLastFrame && !sn.bestThumbnail) return null;
4878
- let boxed;
4879
- try {
5850
+ let boxed = null;
5851
+ if (sn.appendSnapshot || sn.rollingLastFrame) try {
4880
5852
  boxed = await drawBoxedFrame(frameData, fw, fh, [{
4881
5853
  ...sn.bbox,
4882
5854
  ...sn.label ? { label: sn.label } : {}
@@ -4890,10 +5862,9 @@ var EventMediaDispatcher = class {
4890
5862
  error: err instanceof Error ? err.message : String(err)
4891
5863
  }
4892
5864
  });
4893
- return null;
4894
5865
  }
4895
5866
  let stored = null;
4896
- if (sn.appendSnapshot) try {
5867
+ if (sn.appendSnapshot && boxed) try {
4897
5868
  const mediaKey = await this.deps.mediaStore.put({
4898
5869
  deviceId,
4899
5870
  ownerKind: "track",
@@ -4909,10 +5880,49 @@ var EventMediaDispatcher = class {
4909
5880
  bbox: sn.bbox
4910
5881
  };
4911
5882
  } catch {}
4912
- if (sn.rollingLastFrame) await this.replaceKind(deviceId, sn.trackId, "lastFrame", sn.timestamp, boxed);
4913
- if (sn.bestThumbnail) await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, boxed);
5883
+ if (sn.rollingLastFrame && boxed) await this.replaceKind(deviceId, sn.trackId, "lastFrame", sn.timestamp, boxed);
5884
+ if (sn.bestThumbnail) try {
5885
+ const crop = await this.cropSubjectRegion(frameData, fw, fh, sn.bbox, cropPadding);
5886
+ await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, crop);
5887
+ } catch (err) {
5888
+ this.deps.logger.warn("event media: track thumbnail crop failed", {
5889
+ tags: { deviceId },
5890
+ meta: {
5891
+ deviceId,
5892
+ trackId: sn.trackId,
5893
+ error: err instanceof Error ? err.message : String(err)
5894
+ }
5895
+ });
5896
+ if (boxed) await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, boxed);
5897
+ }
4914
5898
  return stored;
4915
5899
  }
5900
+ /**
5901
+ * Clean subject-centered crop of `bbox` out of the raw frame — the shared
5902
+ * output contract of the object-event `crop` kind and the track `thumbnail`:
5903
+ * square-safe 16:9 region around the bbox, extracted from the ORIGINAL frame
5904
+ * (no box drawn), resized to 640×360, JPEG q80.
5905
+ */
5906
+ async cropSubjectRegion(frameData, fw, fh, bbox, cropPadding) {
5907
+ const region = squareSafeCropRegion(bbox, {
5908
+ W: fw,
5909
+ H: fh
5910
+ }, cropPadding);
5911
+ const left = Math.max(0, Math.min(region.x, fw - 1));
5912
+ const top = Math.max(0, Math.min(region.y, fh - 1));
5913
+ const width = Math.max(1, Math.min(region.w, fw - left));
5914
+ const height = Math.max(1, Math.min(region.h, fh - top));
5915
+ return await sharp(frameData, { raw: {
5916
+ width: fw,
5917
+ height: fh,
5918
+ channels: 3
5919
+ } }).extract({
5920
+ left,
5921
+ top,
5922
+ width,
5923
+ height
5924
+ }).resize(CROP_WIDTH, CROP_HEIGHT).jpeg({ quality: CROP_QUALITY }).toBuffer();
5925
+ }
4916
5926
  async replaceKind(deviceId, trackId, kind, timestamp, data) {
4917
5927
  try {
4918
5928
  await this.deps.mediaStore.putReplacing({
@@ -4940,24 +5950,7 @@ var EventMediaDispatcher = class {
4940
5950
  label: caption(ev.className, ev.confidence, ev.label)
4941
5951
  };
4942
5952
  try {
4943
- const region = squareSafeCropRegion(ev.bbox, {
4944
- W: fw,
4945
- H: fh
4946
- }, cropPadding);
4947
- const left = Math.max(0, Math.min(region.x, fw - 1));
4948
- const top = Math.max(0, Math.min(region.y, fh - 1));
4949
- const width = Math.max(1, Math.min(region.w, fw - left));
4950
- const height = Math.max(1, Math.min(region.h, fh - top));
4951
- const crop = await sharp(frameData, { raw: {
4952
- width: fw,
4953
- height: fh,
4954
- channels: 3
4955
- } }).extract({
4956
- left,
4957
- top,
4958
- width,
4959
- height
4960
- }).resize(CROP_WIDTH, CROP_HEIGHT).jpeg({ quality: CROP_QUALITY }).toBuffer();
5953
+ const crop = await this.cropSubjectRegion(frameData, fw, fh, ev.bbox, cropPadding);
4961
5954
  await this.deps.mediaStore.put({
4962
5955
  deviceId,
4963
5956
  ownerKind: "event",
@@ -9646,10 +10639,18 @@ var MOTION_EVENT_HEARTBEAT_MS = 5e3;
9646
10639
  * Stored media kinds that carry NO drawn bounding box, in fallback preference
9647
10640
  * order. The reel forces `?kind=crop`; when a track has no crop the endpoint may
9648
10641
  * degrade only to one of these CLEAN frames — never `fullFrameBoxed` /
9649
- * `thumbnail` / `lastFrame` / `firstFrame` / `snapshot` (all server-boxed).
10642
+ * `lastFrame` / `firstFrame` / `snapshot` (all server-boxed).
10643
+ *
10644
+ * `thumbnail` joined the clean set on 2026-07-17: the track best-thumbnail is
10645
+ * now a subject-centered crop (event-media-dispatcher), and for DEGENERATE
10646
+ * tracks (no object event → no `crop`) it is the ONLY subject-centered media —
10647
+ * without it a crop-forced request degraded to the `keyFrame` FULL FRAME,
10648
+ * which is how 65% of the timeline tiles rendered as whole scenes. It sits
10649
+ * right after a real `crop`, before the full-scene `fullFrame`/`keyFrame`.
9650
10650
  */
9651
10651
  var CLEAN_MEDIA_KINDS = [
9652
10652
  "crop",
10653
+ "thumbnail",
9653
10654
  "fullFrame",
9654
10655
  "keyFrame"
9655
10656
  ];
@@ -9727,6 +10728,11 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
9727
10728
  stationaryRegistry = null;
9728
10729
  mediaStore = null;
9729
10730
  eventStore = null;
10731
+ /** Per-camera history of LINKED-device sensor state changes (Part B). */
10732
+ sensorEventStore = null;
10733
+ /** Ingest-side reverse index (sensor device → linked camera ids), TTL-cached
10734
+ * so the DeviceStateChanged handler stays cheap. */
10735
+ linkedCamerasCache = null;
9730
10736
  identityStore = null;
9731
10737
  faceStore = null;
9732
10738
  faceRecognizer = null;
@@ -9777,6 +10783,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
9777
10783
  unsubNativeDetection = null;
9778
10784
  unsubBindings = null;
9779
10785
  unsubDeviceUnreg = null;
10786
+ /** DeviceStateChanged subscription feeding the sensor-event history. */
10787
+ unsubDeviceState = null;
9780
10788
  ttlSweepTimer = null;
9781
10789
  retentionSweepTimer = null;
9782
10790
  /** Handle for the event-media data-plane listener (dispose on shutdown). */
@@ -9872,6 +10880,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
9872
10880
  await TrackStore.declare(api.settingsStore);
9873
10881
  await MediaStore.declare(api.settingsStore);
9874
10882
  await EventStore.declare(api.settingsStore);
10883
+ await SensorEventStore.declare(api.settingsStore);
9875
10884
  await IdentityStore.declare(api.settingsStore);
9876
10885
  await FaceStore.declare(api.settingsStore);
9877
10886
  await PlateStore.declare(api.settingsStore);
@@ -9889,7 +10898,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
9889
10898
  if (!storage) throw new Error("pipeline-analytics requires ctx.kernel.storage");
9890
10899
  this.trackStore = new TrackStore({
9891
10900
  store: api.settingsStore,
9892
- logger: logger.child("TrackStore")
10901
+ logger: logger.child("TrackStore"),
10902
+ frameDims: (deviceId) => this.lastFrameDimsByDevice.get(deviceId)
9893
10903
  });
9894
10904
  this.stationaryRegistry = new StationaryObjectRegistry({
9895
10905
  store: api.settingsStore,
@@ -9925,6 +10935,20 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
9925
10935
  logger: logger.child("EventStore"),
9926
10936
  media: this.mediaStore
9927
10937
  });
10938
+ this.sensorEventStore = new SensorEventStore({
10939
+ store: api.settingsStore,
10940
+ logger: logger.child("SensorEventStore")
10941
+ });
10942
+ this.linkedCamerasCache = new LinkedCamerasCache({
10943
+ cameras: { listCameraIds: async () => {
10944
+ return (await api.deviceManager.listAll.query({})).filter((d) => d.isCamera).map((d) => d.id);
10945
+ } },
10946
+ linkedDevices: { getLinkedDevices: (input) => api.deviceManager.getLinkedDevices.query(input) },
10947
+ onError: (scope, err) => logger.warn("linked-cameras cache refresh failed", { meta: {
10948
+ scope,
10949
+ error: errMsg(err)
10950
+ } })
10951
+ });
9928
10952
  this.identityStore = new IdentityStore({
9929
10953
  store: api.settingsStore,
9930
10954
  logger: logger.child("IdentityStore")
@@ -10151,6 +11175,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
10151
11175
  const data = ev.data;
10152
11176
  this.handleNativeDetection(data);
10153
11177
  });
11178
+ this.unsubDeviceState = this.ctx.eventBus.subscribe({ category: EventCategory.DeviceStateChanged }, (ev) => {
11179
+ const data = ev.data;
11180
+ if (EVENT_KIND_BY_CAP[data.capName] === void 0) return;
11181
+ const timestamp = ev.timestamp instanceof Date ? ev.timestamp.getTime() : Date.now();
11182
+ this.handleSensorStateChanged(data, timestamp);
11183
+ });
10154
11184
  if (await this.embeddingEnabledState.get()) {
10155
11185
  const encoderClient = {
10156
11186
  encode: (crop, width, height) => this.ctx.api.embeddingEncoder.encode.query({
@@ -10516,6 +11546,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
10516
11546
  this.unsubBindings = null;
10517
11547
  this.unsubDeviceUnreg?.();
10518
11548
  this.unsubDeviceUnreg = null;
11549
+ this.unsubDeviceState?.();
11550
+ this.unsubDeviceState = null;
10519
11551
  await this.embeddingDispatcher?.stop();
10520
11552
  this.embeddingDispatcher = null;
10521
11553
  for (const id of this.proxies.keys()) this.releaseProxy(id);
@@ -10779,7 +11811,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
10779
11811
  else dispatcher.onTrackStarted(deviceId, t.trackId, t.className, steps, detailFrame, result.timestamp);
10780
11812
  }
10781
11813
  }
10782
- if (newTrackCount > 0 || lostTrackCount > 0 || result.objectEvents.length > 0) {
11814
+ if (newTrackCount > 0 || lostTrackCount > 0 || result.objectEvents.length > 0 || result.appearanceEvents.length > 0) {
10783
11815
  const byState = {};
10784
11816
  for (const t of result.tracked) byState[t.state] = (byState[t.state] ?? 0) + 1;
10785
11817
  log.info("frame processed", { meta: {
@@ -10789,10 +11821,11 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
10789
11821
  newTracks: newTrackCount,
10790
11822
  lostTracks: lostTrackCount,
10791
11823
  objectEvents: result.objectEvents.length,
11824
+ appearanceEvents: result.appearanceEvents.length,
10792
11825
  byState
10793
11826
  } });
10794
11827
  }
10795
- await Promise.all(result.objectEvents.map((e) => this.eventStore.insertObject(e)));
11828
+ await Promise.all([...result.objectEvents, ...result.appearanceEvents].map((e) => this.eventStore.insertObject(e)));
10796
11829
  const objectEmbeddingBests = [];
10797
11830
  if (this.objectEmbeddingStore) for (const t of result.tracked) {
10798
11831
  if (!isClipObjectEmbedding(t)) continue;
@@ -10810,8 +11843,9 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
10810
11843
  const faceGloballyEnabled = this.faceRecognizer ? await this.resolveGlobalFaceEnabled() : false;
10811
11844
  const mediaSettings = await this.resolveDeviceMediaSettings(deviceId);
10812
11845
  if (this.eventMediaDispatcher && frameHandle) {
10813
- const childCropsByEvent = buildEventChildCrops(result.objectEvents, frame.detections);
10814
- const eventTargets = result.objectEvents.filter((e) => e.bbox !== void 0 && e.bbox.w > 0 && e.bbox.h > 0).map((e) => {
11846
+ const mediaEvents = [...result.objectEvents, ...result.appearanceEvents];
11847
+ const childCropsByEvent = buildEventChildCrops(mediaEvents, frame.detections);
11848
+ const eventTargets = mediaEvents.filter((e) => e.bbox !== void 0 && e.bbox.w > 0 && e.bbox.h > 0).map((e) => {
10815
11849
  const childCrops = childCropsByEvent.get(e.id);
10816
11850
  return {
10817
11851
  eventId: e.id,
@@ -11365,7 +12399,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
11365
12399
  movementThreshold: media.snapshotMovementThreshold,
11366
12400
  maxIdleMs: media.snapshotMaxIdleMs
11367
12401
  }).capture;
11368
- const isNewBest = this.bestFrameTracker.observe(t.trackId, t.confidence, timestamp);
12402
+ const edgeClear = isEdgeClear({
12403
+ bbox: t.bbox,
12404
+ frameWidth,
12405
+ frameHeight
12406
+ });
12407
+ const isNewBest = this.bestFrameTracker.observe(t.trackId, t.confidence, timestamp, edgeClear);
11369
12408
  this.maybeEmitTrackLifecycleUpdate(deviceId, t, timestamp, isNewBest);
11370
12409
  const plan = planPeriodicMedia({
11371
12410
  saveThumbnails: media.saveThumbnails,
@@ -11778,6 +12817,15 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
11778
12817
  } });
11779
12818
  }
11780
12819
  await this.mediaStore.evictBefore(now - 31 * day);
12820
+ if (this.sensorEventStore) try {
12821
+ const sensorDeleted = await this.sensorEventStore.evictBefore(objectCutoffMs);
12822
+ if (sensorDeleted > 0) this.ctx.logger.info("sensor-event retention prune", { meta: {
12823
+ deleted: sensorDeleted,
12824
+ cutoffMs: objectCutoffMs
12825
+ } });
12826
+ } catch (err) {
12827
+ this.ctx.logger.debug("sensor-event prune failed", { meta: { error: String(err) } });
12828
+ }
11781
12829
  if (this.faceStore) try {
11782
12830
  const faceCutoffMs = now - FACE_DEFAULTS.bufferRetentionDays * day;
11783
12831
  const deletedFaceIds = await this.faceStore.pruneAll({
@@ -12083,6 +13131,68 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
12083
13131
  async listTracks(input) {
12084
13132
  return this.trackStore?.queryHistorical(input) ?? [];
12085
13133
  }
13134
+ /**
13135
+ * Batched cluster-wide track listing — one merged (`lastSeen` DESC,
13136
+ * `trackId` DESC) page across the requested devices with a stable opaque
13137
+ * cursor. Replaces the per-camera `listTracks` fan-out for the events page
13138
+ * first paint + the reel. See `TrackStore.queryRecent` for the per-device
13139
+ * indexed page + k-way merge and the cursor encoding.
13140
+ */
13141
+ async listRecentTracks(input) {
13142
+ return this.trackStore?.queryRecent(input) ?? {
13143
+ tracks: [],
13144
+ nextCursor: null
13145
+ };
13146
+ }
13147
+ /**
13148
+ * Every event kind the device can produce: built-ins (motion + audio),
13149
+ * detection classes actually observed on the device, and sensor kinds
13150
+ * from LINKED devices (device-manager `getLinkedDevices`, binding-driven
13151
+ * per linked device). Degrades to the built-ins on error.
13152
+ */
13153
+ async listEventKinds(input) {
13154
+ const api = this.ctx.api;
13155
+ return composeEventKinds({
13156
+ linkedDevices: { getLinkedDevices: (i) => api.deviceManager.getLinkedDevices.query(i) },
13157
+ bindings: { getBindings: (i) => api.deviceManager.getBindings.query(i) },
13158
+ observedClassNames: async (deviceId) => this.trackStore?.observedClassNames(deviceId) ?? [],
13159
+ onError: (scope, err) => this.ctx.logger.warn("listEventKinds: partial compose", {
13160
+ tags: { deviceId: input.deviceId },
13161
+ meta: {
13162
+ scope,
13163
+ error: errMsg(err)
13164
+ }
13165
+ })
13166
+ }, input.deviceId);
13167
+ }
13168
+ /** Per-camera sensor-event history (state changes of linked devices). */
13169
+ async getSensorEvents(input) {
13170
+ return this.sensorEventStore?.query(input) ?? [];
13171
+ }
13172
+ /**
13173
+ * Sensor-event ingest handler — `DeviceStateChanged` of a device exposing a
13174
+ * mapped sensor cap. Resolves the linked-camera set through the TTL cache
13175
+ * and inserts ONE row per linked camera. Best-effort (telemetry-lossy).
13176
+ */
13177
+ async handleSensorStateChanged(data, timestamp) {
13178
+ const store = this.sensorEventStore;
13179
+ const cache = this.linkedCamerasCache;
13180
+ if (store === null || cache === null) return;
13181
+ try {
13182
+ await ingestSensorStateChange({
13183
+ sink: store,
13184
+ cache
13185
+ }, data, timestamp);
13186
+ } catch (err) {
13187
+ this.ctx.logger.warn("sensor-event ingest failed", {
13188
+ tags: { deviceId: data.deviceId },
13189
+ meta: {
13190
+ capName: data.capName,
13191
+ error: errMsg(err)
13192
+ }
13193
+ });
13194
+ }
13195
+ }
12086
13196
  async clearTracks(input) {
12087
13197
  this.trackStore?.clearDevice(input.deviceId);
12088
13198
  this.stationaryRegistry?.clearDevice(input.deviceId);