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