@camstack/addon-post-analysis 1.1.29 → 1.1.31

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 string, _ as createEvent, b as number, c as nodePin, d as videoclipsCapability, f as zoneAnalyticsCapability, g as EventCategory, h as DeviceType, i as cosineSimilarity, l as pipelineAnalyticsCapability, m as BaseAddon, n as addonWidgetsSourceCapability, o as faceGalleryCapability, p as errMsg, r as audioMetricsCapability, t as EVENT_PAD_MS, u as plateGalleryCapability, v as hydrateSchema, x as object, y as boolean } from "../dist-CFjLqX2m.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-D1cY_vlY.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++) {
@@ -1594,7 +1594,7 @@ function bboxPolygonOverlap(bbox, polygon) {
1594
1594
  const gridSize = 8;
1595
1595
  let inside = 0;
1596
1596
  const total = gridSize * gridSize;
1597
- for (let row = 0; row < gridSize; row++) for (let col = 0; col < gridSize; col++) if (pointInPolygon({
1597
+ for (let row = 0; row < gridSize; row++) for (let col = 0; col < gridSize; col++) if (pointInPolygon$1({
1598
1598
  x: bbox.x + (col + .5) * (bbox.w / gridSize),
1599
1599
  y: bbox.y + (row + .5) * (bbox.h / gridSize)
1600
1600
  }, polygon)) inside++;
@@ -1613,7 +1613,7 @@ function maskPolygonOverlap(mask, maskWidth, maskHeight, bbox, polygon, _frameWi
1613
1613
  for (let my = 0; my < maskHeight; my++) for (let mx = 0; mx < maskWidth; mx++) {
1614
1614
  if (mask[my * maskWidth + mx] === 0) continue;
1615
1615
  totalMaskPixels++;
1616
- if (pointInPolygon({
1616
+ if (pointInPolygon$1({
1617
1617
  x: bbox.x + mx / maskWidth * bbox.w,
1618
1618
  y: bbox.y + my / maskHeight * bbox.h
1619
1619
  }, polygon)) insidePolygon++;
@@ -2496,6 +2496,14 @@ var StationaryObjectRegistry = class {
2496
2496
  count(deviceId) {
2497
2497
  return this.byDevice.get(deviceId)?.size ?? 0;
2498
2498
  }
2499
+ /** Device ids that currently hold at least one parked entry — drives the
2500
+ * occupancy baseline sampler (a detached camera with parked cars still
2501
+ * gets a flat history baseline). */
2502
+ deviceIds() {
2503
+ const ids = [];
2504
+ for (const [deviceId, m] of this.byDevice) if (m.size > 0) ids.push(deviceId);
2505
+ return ids;
2506
+ }
2499
2507
  /** Record that a frame was processed for a device — advances the OBSERVED
2500
2508
  * clock that drives entry expiry in {@link sweep}. */
2501
2509
  noteFrame(deviceId, timestamp) {
@@ -2715,6 +2723,26 @@ function rowToEntry(id, data) {
2715
2723
  };
2716
2724
  }
2717
2725
  //#endregion
2726
+ //#region src/pipeline-analytics/pipeline/stationary/stationary-zones.ts
2727
+ /**
2728
+ * Zone ids whose 0–1 polygon contains the entry's normalised bbox centroid.
2729
+ * Empty when the entry has no frame dims (can't normalise) or no zone matches.
2730
+ */
2731
+ function computeStationaryEntryZones(entry, zones) {
2732
+ if (entry.frameWidth <= 0 || entry.frameHeight <= 0 || zones.length === 0) return [];
2733
+ const centroidPx = bboxCentroid(entry.bbox);
2734
+ const point = {
2735
+ x: centroidPx.x / entry.frameWidth,
2736
+ y: centroidPx.y / entry.frameHeight
2737
+ };
2738
+ const matched = [];
2739
+ for (const zone of zones) {
2740
+ if (zone.polygon.length < 3) continue;
2741
+ if (pointInPolygon$1(point, zone.polygon)) matched.push(zone.id);
2742
+ }
2743
+ return matched;
2744
+ }
2745
+ //#endregion
2718
2746
  //#region src/pipeline-analytics/pipeline/track-appearance.ts
2719
2747
  /**
2720
2748
  * Pure: no side effects. `continuing` = still active from last frame;
@@ -2927,11 +2955,196 @@ var BindingCache = class {
2927
2955
  }
2928
2956
  };
2929
2957
  //#endregion
2958
+ //#region src/pipeline-analytics/store/recent-cursor.ts
2959
+ function encodeRecentCursor(cursor) {
2960
+ return Buffer.from(JSON.stringify({
2961
+ l: cursor.lastSeen,
2962
+ i: cursor.trackId
2963
+ }), "utf8").toString("base64url");
2964
+ }
2965
+ /**
2966
+ * Decode + validate an opaque cursor. Fails fast with a clear error on any
2967
+ * malformed input (bad base64, bad JSON, wrong field types) — a garbage
2968
+ * cursor must never silently degrade into a full-history first page.
2969
+ */
2970
+ function decodeRecentCursor(raw) {
2971
+ let parsed;
2972
+ try {
2973
+ parsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
2974
+ } catch {
2975
+ throw new Error("listRecentTracks: malformed cursor");
2976
+ }
2977
+ 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");
2978
+ return {
2979
+ lastSeen: parsed.l,
2980
+ trackId: parsed.i
2981
+ };
2982
+ }
2983
+ /** Comparator for the (lastSeen DESC, id DESC) total order. */
2984
+ function compareRecentDesc(a, b) {
2985
+ if (a.lastSeen !== b.lastSeen) return b.lastSeen - a.lastSeen;
2986
+ if (a.id === b.id) return 0;
2987
+ return a.id < b.id ? 1 : -1;
2988
+ }
2989
+ /** True when `row` sits strictly AFTER the cursor position in DESC order
2990
+ * (i.e. belongs to the next page). */
2991
+ function isAfterCursor(row, cursor) {
2992
+ if (row.lastSeen < cursor.lastSeen) return true;
2993
+ return row.lastSeen === cursor.lastSeen && row.id < cursor.trackId;
2994
+ }
2995
+ //#endregion
2996
+ //#region src/pipeline-analytics/store/zone-geometry.ts
2997
+ /**
2998
+ * Normalized min/max envelope over every position's bbox. Returns `null`
2999
+ * when the frame dimensions are unknown/degenerate or there are no
3000
+ * positions — the caller persists NULL envelope columns in that case.
3001
+ */
3002
+ function computeTrackEnvelope(positions, frameWidth, frameHeight) {
3003
+ if (frameWidth === void 0 || frameHeight === void 0 || !(frameWidth > 0) || !(frameHeight > 0) || positions.length === 0) return null;
3004
+ let minX = Number.POSITIVE_INFINITY;
3005
+ let minY = Number.POSITIVE_INFINITY;
3006
+ let maxX = Number.NEGATIVE_INFINITY;
3007
+ let maxY = Number.NEGATIVE_INFINITY;
3008
+ for (const p of positions) {
3009
+ const x0 = p.bbox.x / frameWidth;
3010
+ const y0 = p.bbox.y / frameHeight;
3011
+ const x1 = (p.bbox.x + p.bbox.w) / frameWidth;
3012
+ const y1 = (p.bbox.y + p.bbox.h) / frameHeight;
3013
+ if (x0 < minX) minX = x0;
3014
+ if (y0 < minY) minY = y0;
3015
+ if (x1 > maxX) maxX = x1;
3016
+ if (y1 > maxY) maxY = y1;
3017
+ }
3018
+ return {
3019
+ minX,
3020
+ minY,
3021
+ maxX,
3022
+ maxY
3023
+ };
3024
+ }
3025
+ /**
3026
+ * Axis-aligned bounds of a zone filter (rect: itself; polygon: vertex
3027
+ * min/max). A degenerate polygon (< 3 points) yields the full frame so the
3028
+ * SQL prefilter never silently drops rows the precise test would keep.
3029
+ */
3030
+ function zoneBounds(zone) {
3031
+ if (zone.kind === "rect") return {
3032
+ minX: zone.x,
3033
+ minY: zone.y,
3034
+ maxX: zone.x + zone.width,
3035
+ maxY: zone.y + zone.height
3036
+ };
3037
+ if (zone.points.length < 3) return {
3038
+ minX: 0,
3039
+ minY: 0,
3040
+ maxX: 1,
3041
+ maxY: 1
3042
+ };
3043
+ let minX = Number.POSITIVE_INFINITY;
3044
+ let minY = Number.POSITIVE_INFINITY;
3045
+ let maxX = Number.NEGATIVE_INFINITY;
3046
+ let maxY = Number.NEGATIVE_INFINITY;
3047
+ for (const p of zone.points) {
3048
+ if (p.x < minX) minX = p.x;
3049
+ if (p.y < minY) minY = p.y;
3050
+ if (p.x > maxX) maxX = p.x;
3051
+ if (p.y > maxY) maxY = p.y;
3052
+ }
3053
+ return {
3054
+ minX,
3055
+ minY,
3056
+ maxX,
3057
+ maxY
3058
+ };
3059
+ }
3060
+ /** Whether two axis-aligned envelopes overlap (touching edges count). */
3061
+ function envelopesOverlap(a, b) {
3062
+ return a.minX <= b.maxX && a.maxX >= b.minX && a.minY <= b.maxY && a.maxY >= b.minY;
3063
+ }
3064
+ /**
3065
+ * Ray-casting point-in-polygon (even-odd rule). Points on an edge may
3066
+ * resolve either way — acceptable for zone filtering. A polygon with
3067
+ * fewer than 3 vertices contains nothing.
3068
+ */
3069
+ function pointInPolygon(point, polygon) {
3070
+ if (polygon.length < 3) return false;
3071
+ let inside = false;
3072
+ for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
3073
+ const a = polygon[i];
3074
+ const b = polygon[j];
3075
+ 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;
3076
+ }
3077
+ return inside;
3078
+ }
3079
+ /**
3080
+ * Precise per-position zone test.
3081
+ *
3082
+ * - rect zone → any position bbox (normalized) intersects the rect.
3083
+ * - polygon zone → any position CENTER (normalized `x`/`y` — positions
3084
+ * store the bbox center) falls inside the polygon.
3085
+ *
3086
+ * Unknown/degenerate frame dims → `true` (the track cannot be tested, so it
3087
+ * PASSES — mirroring the NULL-envelope-matches rule).
3088
+ */
3089
+ function positionsIntersectZone(positions, frameWidth, frameHeight, zone) {
3090
+ if (frameWidth === void 0 || frameHeight === void 0 || !(frameWidth > 0) || !(frameHeight > 0)) return true;
3091
+ if (zone.kind === "rect") {
3092
+ const rect = zoneBounds(zone);
3093
+ for (const p of positions) if (envelopesOverlap({
3094
+ minX: p.bbox.x / frameWidth,
3095
+ minY: p.bbox.y / frameHeight,
3096
+ maxX: (p.bbox.x + p.bbox.w) / frameWidth,
3097
+ maxY: (p.bbox.y + p.bbox.h) / frameHeight
3098
+ }, rect)) return true;
3099
+ return false;
3100
+ }
3101
+ for (const p of positions) if (pointInPolygon({
3102
+ x: p.x / frameWidth,
3103
+ y: p.y / frameHeight
3104
+ }, zone.points)) return true;
3105
+ return false;
3106
+ }
3107
+ //#endregion
2930
3108
  //#region src/pipeline-analytics/store/track-store.ts
2931
3109
  var DEFAULT_CONFIG = {
2932
3110
  ttlMs: 3e4,
2933
3111
  maxPositionHistory: 300
2934
3112
  };
3113
+ /** `queryRecent` page-size defaults (mirrors the cap input's bounds). */
3114
+ var RECENT_DEFAULT_LIMIT = 200;
3115
+ var RECENT_MAX_LIMIT = 1e3;
3116
+ /** Wide inclusive bound for the envelope-overlap `BETWEEN` prefilter.
3117
+ * Envelope values are normalized ~0..1 but a bbox can spill slightly past
3118
+ * the frame edge; ±1e6 keeps every real value inside the range while the
3119
+ * opposing bound does the actual overlap cut. */
3120
+ var ENV_RANGE_SLACK = 1e6;
3121
+ function rowMatchesZone(data, zone) {
3122
+ const fw = data["frameWidth"];
3123
+ const fh = data["frameHeight"];
3124
+ if (typeof fw !== "number" || typeof fh !== "number") return true;
3125
+ const positions = data["positions"];
3126
+ if (!Array.isArray(positions)) return true;
3127
+ const positionRows = [];
3128
+ for (const p of positions) {
3129
+ if (p === null || typeof p !== "object") continue;
3130
+ if (!("x" in p) || typeof p.x !== "number" || !("y" in p) || typeof p.y !== "number") continue;
3131
+ if (!("bbox" in p) || p.bbox === null || typeof p.bbox !== "object") continue;
3132
+ const b = p.bbox;
3133
+ if (!("x" in b) || typeof b.x !== "number" || !("y" in b) || typeof b.y !== "number") continue;
3134
+ if (!("w" in b) || typeof b.w !== "number" || !("h" in b) || typeof b.h !== "number") continue;
3135
+ positionRows.push({
3136
+ x: p.x,
3137
+ y: p.y,
3138
+ bbox: {
3139
+ x: b.x,
3140
+ y: b.y,
3141
+ w: b.w,
3142
+ h: b.h
3143
+ }
3144
+ });
3145
+ }
3146
+ return positionsIntersectZone(positionRows, fw, fh, zone);
3147
+ }
2935
3148
  var TRACKS_COLLECTION = "pipeline-analytics:tracks";
2936
3149
  var TRACKS_COLUMNS = [
2937
3150
  {
@@ -3003,6 +3216,30 @@ var TRACKS_COLUMNS = [
3003
3216
  {
3004
3217
  name: "audioLabels",
3005
3218
  type: "JSON"
3219
+ },
3220
+ {
3221
+ name: "envMinX",
3222
+ type: "REAL"
3223
+ },
3224
+ {
3225
+ name: "envMinY",
3226
+ type: "REAL"
3227
+ },
3228
+ {
3229
+ name: "envMaxX",
3230
+ type: "REAL"
3231
+ },
3232
+ {
3233
+ name: "envMaxY",
3234
+ type: "REAL"
3235
+ },
3236
+ {
3237
+ name: "frameWidth",
3238
+ type: "INTEGER"
3239
+ },
3240
+ {
3241
+ name: "frameHeight",
3242
+ type: "INTEGER"
3006
3243
  }
3007
3244
  ];
3008
3245
  var TRACKS_INDEXES = [{
@@ -3058,6 +3295,7 @@ var TrackStore = class {
3058
3295
  config;
3059
3296
  logger;
3060
3297
  store;
3298
+ frameDims;
3061
3299
  constructor(deps) {
3062
3300
  this.logger = deps.logger;
3063
3301
  this.store = deps.store;
@@ -3065,6 +3303,7 @@ var TrackStore = class {
3065
3303
  ...DEFAULT_CONFIG,
3066
3304
  ...deps.config
3067
3305
  };
3306
+ this.frameDims = deps.frameDims;
3068
3307
  }
3069
3308
  /** One-time collection declaration. Call from addon onInitialize. */
3070
3309
  static async declare(store) {
@@ -3411,21 +3650,200 @@ var TrackStore = class {
3411
3650
  }
3412
3651
  return [...seenDevices];
3413
3652
  }
3414
- /** Historical query — hits the persisted collection. */
3653
+ /** Historical query — hits the persisted collection. With `zone` set,
3654
+ * candidates are SQL-prefiltered on the envelope columns (overlap test via
3655
+ * `whereBetween`), NULL-envelope rows are re-fetched separately (they must
3656
+ * still MATCH — `BETWEEN` excludes NULL), and survivors run the precise
3657
+ * per-position test against the zone. `projection: 'slim'` drops the heavy
3658
+ * `positions[]` / `snapshots[]` JSON from the returned rows (empty arrays);
3659
+ * the zone test still runs on the stored positions before the drop. */
3415
3660
  async queryHistorical(params) {
3416
- const filter = { where: { deviceId: params.deviceId } };
3417
- if (params.since !== void 0 || params.until !== void 0) filter.whereBetween = { firstSeen: [params.since ?? 0, params.until ?? Date.now()] };
3418
- return (await this.store.query.query({
3661
+ const limit = params.limit ?? 50;
3662
+ const timeBetween = params.since !== void 0 || params.until !== void 0 ? { firstSeen: [params.since ?? 0, params.until ?? Date.now()] } : {};
3663
+ if (params.zone === void 0) return (await this.store.query.query({
3664
+ collection: TRACKS_COLLECTION,
3665
+ filter: {
3666
+ where: { deviceId: params.deviceId },
3667
+ ...Object.keys(timeBetween).length > 0 ? { whereBetween: timeBetween } : {},
3668
+ orderBy: {
3669
+ field: "firstSeen",
3670
+ direction: "desc"
3671
+ },
3672
+ limit
3673
+ }
3674
+ })).map((r) => this.rowToTrack(r.id, r.data, params.projection));
3675
+ const zone = params.zone;
3676
+ const bounds = zoneBounds(zone);
3677
+ const overlapQuery = this.store.query.query({
3678
+ collection: TRACKS_COLLECTION,
3679
+ filter: {
3680
+ where: { deviceId: params.deviceId },
3681
+ whereBetween: {
3682
+ ...timeBetween,
3683
+ envMinX: [-1e6, bounds.maxX],
3684
+ envMaxX: [bounds.minX, ENV_RANGE_SLACK],
3685
+ envMinY: [-1e6, bounds.maxY],
3686
+ envMaxY: [bounds.minY, ENV_RANGE_SLACK]
3687
+ },
3688
+ orderBy: {
3689
+ field: "firstSeen",
3690
+ direction: "desc"
3691
+ },
3692
+ limit
3693
+ }
3694
+ });
3695
+ const nullEnvQuery = this.store.query.query({
3419
3696
  collection: TRACKS_COLLECTION,
3420
3697
  filter: {
3421
- ...filter,
3698
+ where: { deviceId: params.deviceId },
3699
+ ...Object.keys(timeBetween).length > 0 ? { whereBetween: timeBetween } : {},
3422
3700
  orderBy: {
3423
3701
  field: "firstSeen",
3424
3702
  direction: "desc"
3425
3703
  },
3426
- limit: params.limit ?? 50
3704
+ limit
3705
+ }
3706
+ });
3707
+ const [overlapRows, windowRows] = await Promise.all([overlapQuery, nullEnvQuery]);
3708
+ const candidates = /* @__PURE__ */ new Map();
3709
+ for (const r of overlapRows) if (typeof r.data["envMinX"] === "number") candidates.set(r.id, r.data);
3710
+ for (const r of windowRows) if (r.data["envMinX"] === null || r.data["envMinX"] === void 0) candidates.set(r.id, r.data);
3711
+ const matched = [];
3712
+ for (const [id, data] of candidates) if (rowMatchesZone(data, zone)) matched.push({
3713
+ id,
3714
+ data
3715
+ });
3716
+ matched.sort((a, b) => Number(b.data["firstSeen"] ?? 0) - Number(a.data["firstSeen"] ?? 0));
3717
+ return matched.slice(0, limit).map((r) => this.rowToTrack(r.id, r.data, params.projection));
3718
+ }
3719
+ /**
3720
+ * Batched multi-device recent-tracks page (`listRecentTracks`): the
3721
+ * persisted completed tracks of every requested device, merged and ordered
3722
+ * by (`lastSeen` DESC, `trackId` DESC) with a stable opaque cursor.
3723
+ *
3724
+ * Approach — per-device indexed page + k-way merge (documented for the
3725
+ * cap): each device is fetched with ONE indexed query on
3726
+ * `idx_tracks_device_lastSeen` (`WHERE deviceId = ? AND lastSeen BETWEEN
3727
+ * ? AND ? ORDER BY lastSeen DESC LIMIT limit+1`), then the pages are
3728
+ * merged in memory and cut to `limit`. At events-page cardinalities
3729
+ * (≤ dozens of devices × ≤ 1000 rows) the in-memory merge is negligible
3730
+ * next to the row I/O, and every row fetched is a candidate (no scan
3731
+ * waste). The +1 overfetch makes `nextCursor` exact: when the merged
3732
+ * candidate set exceeds `limit` more rows are KNOWN to exist; when it
3733
+ * does not, every device returned fewer rows than asked for and is
3734
+ * therefore exhausted — so the final page always ends with
3735
+ * `nextCursor: null` (no empty trailing page).
3736
+ *
3737
+ * Cursor correctness: SQL can only bound `lastSeen`, and rows sharing the
3738
+ * cursor's exact millisecond have no defined SQL order — so a cursor page
3739
+ * runs TWO ranges per device: an exhaustive same-millisecond tie query
3740
+ * (`lastSeen = cursor.lastSeen`, id tie-break applied in memory) plus the
3741
+ * strictly-older indexed page (`lastSeen ≤ cursor.lastSeen - 1`). Tie
3742
+ * clusters are same-ms track expiries on one camera — physically tiny —
3743
+ * so the unbounded tie query stays O(1) in practice. Known accepted edge:
3744
+ * on a NON-cursor page, a same-ms tie cluster straddling a device's
3745
+ * `limit+1` SQL cut could omit a tie row (needs > limit rows sharing one
3746
+ * millisecond on one camera — unreachable at these cardinalities).
3747
+ *
3748
+ * Errors propagate (no partial merges): a failed device query fails the
3749
+ * page rather than silently returning an incomplete window.
3750
+ */
3751
+ async queryRecent(params) {
3752
+ const limit = Math.min(Math.max(params.limit ?? RECENT_DEFAULT_LIMIT, 1), RECENT_MAX_LIMIT);
3753
+ const deviceIds = [...new Set(params.deviceIds)];
3754
+ if (deviceIds.length === 0) return {
3755
+ tracks: [],
3756
+ nextCursor: null
3757
+ };
3758
+ const cursor = params.cursor !== void 0 ? decodeRecentCursor(params.cursor) : null;
3759
+ const lo = params.since ?? 0;
3760
+ const winHi = params.until ?? Number.MAX_SAFE_INTEGER;
3761
+ const hi = cursor !== null ? Math.min(cursor.lastSeen, winHi) : winHi;
3762
+ if (hi < lo) return {
3763
+ tracks: [],
3764
+ nextCursor: null
3765
+ };
3766
+ const fetchRange = async (deviceId, range, pageLimit) => this.store.query.query({
3767
+ collection: TRACKS_COLLECTION,
3768
+ filter: {
3769
+ where: { deviceId },
3770
+ whereBetween: { lastSeen: range },
3771
+ orderBy: {
3772
+ field: "lastSeen",
3773
+ direction: "desc"
3774
+ },
3775
+ ...pageLimit !== void 0 ? { limit: pageLimit } : {}
3427
3776
  }
3428
- })).map((r) => this.rowToTrack(r.id, r.data));
3777
+ });
3778
+ const perDevice = await Promise.all(deviceIds.map(async (deviceId) => {
3779
+ if (cursor === null || cursor.lastSeen > hi) return fetchRange(deviceId, [lo, hi], limit + 1);
3780
+ const tieRange = [cursor.lastSeen, cursor.lastSeen];
3781
+ const belowHi = cursor.lastSeen - 1;
3782
+ const [ties, below] = await Promise.all([cursor.lastSeen >= lo ? fetchRange(deviceId, tieRange) : Promise.resolve([]), belowHi >= lo ? fetchRange(deviceId, [lo, belowHi], limit + 1) : Promise.resolve([])]);
3783
+ return [...ties, ...below];
3784
+ }));
3785
+ const candidates = [];
3786
+ for (const rows of perDevice) for (const r of rows) {
3787
+ const lastSeen = Number(r.data["lastSeen"] ?? 0);
3788
+ if (cursor !== null && !isAfterCursor({
3789
+ lastSeen,
3790
+ id: r.id
3791
+ }, cursor)) continue;
3792
+ candidates.push({
3793
+ id: r.id,
3794
+ lastSeen,
3795
+ data: r.data
3796
+ });
3797
+ }
3798
+ candidates.sort(compareRecentDesc);
3799
+ const page = candidates.slice(0, limit);
3800
+ const tracks = page.map((r) => this.rowToTrack(r.id, r.data, params.projection));
3801
+ const last = page[page.length - 1];
3802
+ return {
3803
+ tracks,
3804
+ nextCursor: candidates.length > limit && last !== void 0 ? encodeRecentCursor({
3805
+ lastSeen: last.lastSeen,
3806
+ trackId: last.id
3807
+ }) : null
3808
+ };
3809
+ }
3810
+ /**
3811
+ * Deduplicated detector class names observed on a device's RECENT persisted
3812
+ * tracks (one indexed page, `lastSeen` desc). Feeds `listEventKinds` — a
3813
+ * representative "what has this camera actually seen" set, not an exhaustive
3814
+ * all-time DISTINCT (the query cap has none). Unions the primary `className`
3815
+ * with the accumulated `classes` array. Best-effort: [] on error.
3816
+ */
3817
+ async observedClassNames(deviceId, limit = 500) {
3818
+ try {
3819
+ const rows = await this.store.query.query({
3820
+ collection: TRACKS_COLLECTION,
3821
+ filter: {
3822
+ where: { deviceId },
3823
+ orderBy: {
3824
+ field: "lastSeen",
3825
+ direction: "desc"
3826
+ },
3827
+ limit
3828
+ }
3829
+ });
3830
+ const names = /* @__PURE__ */ new Set();
3831
+ for (const r of rows) {
3832
+ const className = r.data["className"];
3833
+ if (typeof className === "string" && className.length > 0) names.add(className);
3834
+ const classes = r.data["classes"];
3835
+ if (Array.isArray(classes)) {
3836
+ for (const c of classes) if (typeof c === "string" && c.length > 0) names.add(c);
3837
+ }
3838
+ }
3839
+ return [...names];
3840
+ } catch (err) {
3841
+ this.logger.warn("TrackStore.observedClassNames failed", { meta: {
3842
+ deviceId,
3843
+ error: String(err)
3844
+ } });
3845
+ return [];
3846
+ }
3429
3847
  }
3430
3848
  async getPersistedByTrackId(trackId) {
3431
3849
  const records = await this.store.query.query({
@@ -3440,6 +3858,8 @@ var TrackStore = class {
3440
3858
  return this.rowToTrack(row.id, row.data);
3441
3859
  }
3442
3860
  async persistCompleted(t) {
3861
+ const dims = this.frameDims?.(t.deviceId);
3862
+ const envelope = computeTrackEnvelope(t.positions, dims?.w, dims?.h);
3443
3863
  await this.store.set.mutate({
3444
3864
  collection: TRACKS_COLLECTION,
3445
3865
  key: t.trackId,
@@ -3458,13 +3878,30 @@ var TrackStore = class {
3458
3878
  ...t.importance !== void 0 ? { importance: t.importance } : {},
3459
3879
  ...t.bestEventId !== void 0 ? { bestEventId: t.bestEventId } : {},
3460
3880
  ...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {},
3461
- ...t.audioLabels !== void 0 ? { audioLabels: [...t.audioLabels] } : {}
3881
+ ...t.audioLabels !== void 0 ? { audioLabels: [...t.audioLabels] } : {},
3882
+ ...envelope !== null && dims !== void 0 ? {
3883
+ envMinX: envelope.minX,
3884
+ envMinY: envelope.minY,
3885
+ envMaxX: envelope.maxX,
3886
+ envMaxY: envelope.maxY,
3887
+ frameWidth: dims.w,
3888
+ frameHeight: dims.h
3889
+ } : {}
3462
3890
  }
3463
3891
  });
3464
3892
  }
3465
- rowToTrack(id, data) {
3466
- const positions = data["positions"] ?? [];
3467
- const snapshots = data["snapshots"] ?? [];
3893
+ /**
3894
+ * Map a persisted row onto the cap `Track` shape. `projection: 'slim'`
3895
+ * drops the heavy `positions[]` / `snapshots[]` JSON (returned as empty
3896
+ * arrays — they are required on the schema) while keeping every scalar
3897
+ * the list surfaces render; `full` (default) is byte-compatible with the
3898
+ * pre-projection behaviour. The persisted envelope columns surface as the
3899
+ * optional `envelope` object in BOTH projections (four light numbers).
3900
+ */
3901
+ rowToTrack(id, data, projection) {
3902
+ const slim = projection === "slim";
3903
+ const positions = slim ? [] : data["positions"] ?? [];
3904
+ const snapshots = slim ? [] : data["snapshots"] ?? [];
3468
3905
  const zones = data["zonesVisited"] ?? [];
3469
3906
  const classes = data["classes"];
3470
3907
  const label = data["label"];
@@ -3472,6 +3909,16 @@ var TrackStore = class {
3472
3909
  const bestEventId = data["bestEventId"];
3473
3910
  const importanceReason = data["importanceReason"];
3474
3911
  const audioLabels = data["audioLabels"];
3912
+ const envMinX = data["envMinX"];
3913
+ const envMinY = data["envMinY"];
3914
+ const envMaxX = data["envMaxX"];
3915
+ const envMaxY = data["envMaxY"];
3916
+ const envelope = typeof envMinX === "number" && typeof envMinY === "number" && typeof envMaxX === "number" && typeof envMaxY === "number" ? {
3917
+ minX: envMinX,
3918
+ minY: envMinY,
3919
+ maxX: envMaxX,
3920
+ maxY: envMaxY
3921
+ } : null;
3475
3922
  return {
3476
3923
  trackId: id,
3477
3924
  deviceId: Number(data["deviceId"]),
@@ -3489,7 +3936,8 @@ var TrackStore = class {
3489
3936
  ...typeof importance === "number" ? { importance } : {},
3490
3937
  ...typeof bestEventId === "string" ? { bestEventId } : {},
3491
3938
  ...typeof importanceReason === "string" ? { importanceReason } : {},
3492
- ...Array.isArray(audioLabels) && audioLabels.length > 0 ? { audioLabels } : {}
3939
+ ...Array.isArray(audioLabels) && audioLabels.length > 0 ? { audioLabels } : {},
3940
+ ...envelope !== null ? { envelope } : {}
3493
3941
  };
3494
3942
  }
3495
3943
  };
@@ -4600,6 +5048,384 @@ function stripNulls(data) {
4600
5048
  return out;
4601
5049
  }
4602
5050
  //#endregion
5051
+ //#region src/pipeline-analytics/store/sensor-event-store.ts
5052
+ var SENSOR_EVENTS_COLLECTION = "pipeline-analytics:sensor-events";
5053
+ var SENSOR_EVENT_COLUMNS = [
5054
+ {
5055
+ name: "id",
5056
+ type: "TEXT",
5057
+ primaryKey: true,
5058
+ notNull: true
5059
+ },
5060
+ (
5061
+ /** The CAMERA the event is attributed to. */
5062
+ {
5063
+ name: "deviceId",
5064
+ type: "INTEGER",
5065
+ notNull: true
5066
+ }),
5067
+ (
5068
+ /** The linked sensor device whose state changed. */
5069
+ {
5070
+ name: "sourceDeviceId",
5071
+ type: "INTEGER",
5072
+ notNull: true
5073
+ }),
5074
+ (
5075
+ /** Event kind id (matches an `EventKindDescriptor.kind`). */
5076
+ {
5077
+ name: "kind",
5078
+ type: "TEXT",
5079
+ notNull: true
5080
+ }),
5081
+ (
5082
+ /** Snapshot of the sensor cap's runtime-state slice at the change. */
5083
+ {
5084
+ name: "value",
5085
+ type: "JSON"
5086
+ }),
5087
+ {
5088
+ name: "timestamp",
5089
+ type: "INTEGER",
5090
+ notNull: true
5091
+ }
5092
+ ];
5093
+ var SENSOR_EVENT_INDEXES = [{
5094
+ name: "idx_sensor_events_device_ts",
5095
+ columns: ["deviceId", "timestamp"]
5096
+ }];
5097
+ var DEFAULT_QUERY_LIMIT = 1e3;
5098
+ var SensorEventStore = class {
5099
+ store;
5100
+ logger;
5101
+ constructor(deps) {
5102
+ this.store = deps.store;
5103
+ this.logger = deps.logger;
5104
+ }
5105
+ /** One-time collection declaration. Call from addon onInitialize. */
5106
+ static async declare(store) {
5107
+ await store.declareCollection.mutate({
5108
+ collection: SENSOR_EVENTS_COLLECTION,
5109
+ columns: [...SENSOR_EVENT_COLUMNS],
5110
+ indexes: [...SENSOR_EVENT_INDEXES]
5111
+ });
5112
+ }
5113
+ /** Insert one attributed sensor event. Best-effort (telemetry-lossy). */
5114
+ async insert(ev) {
5115
+ try {
5116
+ await this.store.insert.mutate({
5117
+ collection: SENSOR_EVENTS_COLLECTION,
5118
+ record: {
5119
+ id: ev.id,
5120
+ data: {
5121
+ deviceId: ev.deviceId,
5122
+ sourceDeviceId: ev.sourceDeviceId,
5123
+ kind: ev.kind,
5124
+ value: ev.value,
5125
+ timestamp: ev.timestamp
5126
+ }
5127
+ }
5128
+ });
5129
+ } catch (err) {
5130
+ this.logger.warn("SensorEventStore.insert failed", {
5131
+ tags: { deviceId: ev.deviceId },
5132
+ meta: {
5133
+ eventId: ev.id,
5134
+ error: String(err)
5135
+ }
5136
+ });
5137
+ }
5138
+ }
5139
+ /** Per-camera sensor-event history, newest first. Mirrors the
5140
+ * motion/object/audio query semantics; `kinds` narrows via `whereIn`. */
5141
+ async query(q) {
5142
+ const filter = {
5143
+ where: { deviceId: q.deviceId },
5144
+ orderBy: {
5145
+ field: "timestamp",
5146
+ direction: "desc"
5147
+ },
5148
+ limit: q.limit ?? DEFAULT_QUERY_LIMIT
5149
+ };
5150
+ if (q.since !== void 0 || q.until !== void 0) filter["whereBetween"] = { timestamp: [q.since ?? 0, q.until ?? Date.now()] };
5151
+ if (q.kinds !== void 0 && q.kinds.length > 0) filter["whereIn"] = { kind: [...q.kinds] };
5152
+ return (await this.store.query.query({
5153
+ collection: SENSOR_EVENTS_COLLECTION,
5154
+ filter
5155
+ })).map((r) => rowToSensorEvent(r.id, r.data));
5156
+ }
5157
+ /**
5158
+ * Delete every row with `timestamp ≤ cutoffMs`, draining a page at a time
5159
+ * (mirrors `EventStore.evictBefore`, including the infinite-loop guard).
5160
+ * Returns the number of rows deleted. Rides the analytics retention sweep.
5161
+ */
5162
+ async evictBefore(cutoffMs) {
5163
+ let deleted = 0;
5164
+ for (;;) {
5165
+ const rows = await this.store.query.query({
5166
+ collection: SENSOR_EVENTS_COLLECTION,
5167
+ filter: {
5168
+ whereBetween: { timestamp: [0, cutoffMs] },
5169
+ limit: EVICT_PAGE_SIZE
5170
+ }
5171
+ });
5172
+ if (rows.length === 0) break;
5173
+ let deletedInPage = 0;
5174
+ for (const row of rows) {
5175
+ if (typeof row.id !== "string") continue;
5176
+ try {
5177
+ await this.store.delete.mutate({
5178
+ collection: SENSOR_EVENTS_COLLECTION,
5179
+ key: row.id
5180
+ });
5181
+ deleted++;
5182
+ deletedInPage++;
5183
+ } catch {}
5184
+ }
5185
+ if (deletedInPage === 0) break;
5186
+ }
5187
+ return deleted;
5188
+ }
5189
+ };
5190
+ /** Page size for the eviction drain loop (mirrors EventStore.PRUNE_PAGE_SIZE). */
5191
+ var EVICT_PAGE_SIZE = 500;
5192
+ function rowToSensorEvent(id, data) {
5193
+ const value = data["value"];
5194
+ return {
5195
+ id,
5196
+ deviceId: Number(data["deviceId"]),
5197
+ sourceDeviceId: Number(data["sourceDeviceId"]),
5198
+ kind: String(data["kind"]),
5199
+ value: isRecord(value) ? value : null,
5200
+ timestamp: Number(data["timestamp"])
5201
+ };
5202
+ }
5203
+ function isRecord(x) {
5204
+ return x !== null && typeof x === "object" && !Array.isArray(x);
5205
+ }
5206
+ //#endregion
5207
+ //#region src/pipeline-analytics/services/event-kinds.ts
5208
+ /**
5209
+ * Extensible per-device event kinds (Part B).
5210
+ *
5211
+ * `composeEventKinds` builds the `listEventKinds` payload for a camera:
5212
+ * (a) built-ins — motion + audio, always present;
5213
+ * (b) detection classes actually OBSERVED on the device (track history);
5214
+ * (c) sensor kinds contributed by LINKED devices (device-manager
5215
+ * `getLinkedDevices`), one descriptor per bound sensor cap present in
5216
+ * the static `EVENT_KIND_BY_CAP` map. Binding-driven per linked device
5217
+ * (`getBindings`) — never a global cap enumeration (D12).
5218
+ *
5219
+ * `LinkedCamerasCache` is the ingest-side reverse index (sensor device →
5220
+ * linked camera ids) with a TTL, so the `DeviceStateChanged` handler stays
5221
+ * cheap at bus rate.
5222
+ */
5223
+ var MOTION_COLOR = "#f59e0b";
5224
+ var AUDIO_COLOR = "#06b6d4";
5225
+ var PERSON_COLOR = "#22c55e";
5226
+ var VEHICLE_COLOR = "#3b82f6";
5227
+ var ANIMAL_COLOR = "#f97316";
5228
+ var GENERIC_DETECTION_COLOR = "#64748b";
5229
+ var VEHICLE_CLASSES = new Set([
5230
+ "vehicle",
5231
+ "car",
5232
+ "truck",
5233
+ "bus",
5234
+ "motorcycle",
5235
+ "bicycle",
5236
+ "boat",
5237
+ "train"
5238
+ ]);
5239
+ var ANIMAL_CLASSES = new Set([
5240
+ "animal",
5241
+ "dog",
5242
+ "cat",
5243
+ "bird",
5244
+ "horse",
5245
+ "cow",
5246
+ "sheep"
5247
+ ]);
5248
+ function detectionIcon(className) {
5249
+ if (className === "person") return "person";
5250
+ if (VEHICLE_CLASSES.has(className)) return "vehicle";
5251
+ if (ANIMAL_CLASSES.has(className)) return "animal";
5252
+ return "generic";
5253
+ }
5254
+ function detectionColor(className) {
5255
+ if (className === "person") return PERSON_COLOR;
5256
+ if (VEHICLE_CLASSES.has(className)) return VEHICLE_COLOR;
5257
+ if (ANIMAL_CLASSES.has(className)) return ANIMAL_COLOR;
5258
+ return GENERIC_DETECTION_COLOR;
5259
+ }
5260
+ function titleCase(s) {
5261
+ return s.length > 0 ? s.charAt(0).toUpperCase() + s.slice(1) : s;
5262
+ }
5263
+ /**
5264
+ * Full event-kind list for a camera. Sensor kinds are deduped per
5265
+ * (kind, source deviceId) — two linked contact sensors each contribute
5266
+ * their own entry, distinguishable by `source.deviceId`.
5267
+ */
5268
+ async function composeEventKinds(deps, deviceId) {
5269
+ const out = [{
5270
+ kind: "motion",
5271
+ label: "Motion",
5272
+ color: MOTION_COLOR,
5273
+ icon: "motion",
5274
+ category: "motion",
5275
+ source: {
5276
+ capName: "pipeline-analytics",
5277
+ deviceId
5278
+ }
5279
+ }, {
5280
+ kind: "audio",
5281
+ label: "Audio",
5282
+ color: AUDIO_COLOR,
5283
+ icon: "audio",
5284
+ category: "audio",
5285
+ source: {
5286
+ capName: "pipeline-analytics",
5287
+ deviceId
5288
+ }
5289
+ }];
5290
+ try {
5291
+ const classNames = await deps.observedClassNames(deviceId);
5292
+ for (const className of [...classNames].sort()) out.push({
5293
+ kind: className,
5294
+ label: titleCase(className),
5295
+ color: detectionColor(className),
5296
+ icon: detectionIcon(className),
5297
+ category: "detection",
5298
+ source: {
5299
+ capName: "pipeline-analytics",
5300
+ deviceId
5301
+ }
5302
+ });
5303
+ } catch (err) {
5304
+ deps.onError?.("observedClassNames", err);
5305
+ }
5306
+ try {
5307
+ const { devices } = await deps.linkedDevices.getLinkedDevices({ deviceId });
5308
+ const seen = /* @__PURE__ */ new Set();
5309
+ for (const linked of devices) {
5310
+ let capNames;
5311
+ try {
5312
+ const { entries } = await deps.bindings.getBindings({ deviceId: linked.deviceId });
5313
+ capNames = entries.map((e) => e.capName);
5314
+ } catch (err) {
5315
+ deps.onError?.("getBindings", err);
5316
+ continue;
5317
+ }
5318
+ for (const capName of capNames) {
5319
+ const descriptor = EVENT_KIND_BY_CAP[capName];
5320
+ if (descriptor === void 0) continue;
5321
+ const dedupeKey = `${descriptor.kind}:${linked.deviceId}`;
5322
+ if (seen.has(dedupeKey)) continue;
5323
+ seen.add(dedupeKey);
5324
+ out.push({
5325
+ kind: descriptor.kind,
5326
+ label: descriptor.label,
5327
+ color: descriptor.color,
5328
+ icon: descriptor.icon,
5329
+ category: descriptor.category,
5330
+ source: {
5331
+ capName,
5332
+ deviceId: linked.deviceId
5333
+ }
5334
+ });
5335
+ }
5336
+ }
5337
+ } catch (err) {
5338
+ deps.onError?.("getLinkedDevices", err);
5339
+ }
5340
+ return out;
5341
+ }
5342
+ var DEFAULT_CACHE_TTL_MS = 6e4;
5343
+ /**
5344
+ * TTL-cached reverse index: source deviceId → camera ids it is linked to.
5345
+ * Rebuilds lazily (single-flight) when stale, so the `DeviceStateChanged`
5346
+ * handler pays one map lookup per event in the common case.
5347
+ */
5348
+ var LinkedCamerasCache = class {
5349
+ deps;
5350
+ ttlMs;
5351
+ index = /* @__PURE__ */ new Map();
5352
+ /** Ms timestamp of the last build; null = never built / invalidated. */
5353
+ builtAt = null;
5354
+ building = null;
5355
+ constructor(deps) {
5356
+ this.deps = deps;
5357
+ this.ttlMs = deps.ttlMs ?? DEFAULT_CACHE_TTL_MS;
5358
+ }
5359
+ /** Camera ids linked to `sourceDeviceId` ([] when none). */
5360
+ async camerasFor(sourceDeviceId, nowMs = Date.now()) {
5361
+ if (this.builtAt === null || nowMs - this.builtAt >= this.ttlMs) {
5362
+ this.building ??= this.rebuild(nowMs).finally(() => {
5363
+ this.building = null;
5364
+ });
5365
+ await this.building;
5366
+ }
5367
+ return this.index.get(sourceDeviceId) ?? [];
5368
+ }
5369
+ /** Drop the cached index (e.g. on link-topology change events). */
5370
+ invalidate() {
5371
+ this.builtAt = null;
5372
+ }
5373
+ /** Test/maintenance hook: replace the index directly. */
5374
+ seed(index, builtAt) {
5375
+ this.index = new Map(index);
5376
+ this.builtAt = builtAt;
5377
+ }
5378
+ async rebuild(nowMs) {
5379
+ try {
5380
+ const cameraIds = await this.deps.cameras.listCameraIds();
5381
+ const next = /* @__PURE__ */ new Map();
5382
+ for (const cameraId of cameraIds) try {
5383
+ const { devices } = await this.deps.linkedDevices.getLinkedDevices({ deviceId: cameraId });
5384
+ for (const d of devices) {
5385
+ const list = next.get(d.deviceId);
5386
+ if (list === void 0) next.set(d.deviceId, [cameraId]);
5387
+ else if (!list.includes(cameraId)) list.push(cameraId);
5388
+ }
5389
+ } catch (err) {
5390
+ this.deps.onError?.("getLinkedDevices", err);
5391
+ }
5392
+ this.index = next;
5393
+ this.builtAt = nowMs;
5394
+ } catch (err) {
5395
+ this.deps.onError?.("listCameraIds", err);
5396
+ this.builtAt = nowMs;
5397
+ }
5398
+ }
5399
+ };
5400
+ /**
5401
+ * One `DeviceStateChanged` → N history rows (one per linked camera). The
5402
+ * EVENT_KIND_BY_CAP gate exits first so non-sensor cap churn costs one map
5403
+ * lookup. Returns the number of rows inserted (0 when unmapped/unlinked).
5404
+ * Telemetry-lossy by design (D8) — inserts are best-effort.
5405
+ */
5406
+ async function ingestSensorStateChange(deps, data, timestamp) {
5407
+ const descriptor = EVENT_KIND_BY_CAP[data.capName];
5408
+ if (descriptor === void 0) return 0;
5409
+ const cameraIds = await deps.cache.camerasFor(data.deviceId);
5410
+ if (cameraIds.length === 0) return 0;
5411
+ const slice = data.slice;
5412
+ const value = slice !== null && slice !== void 0 && typeof slice === "object" && !Array.isArray(slice) ? slice : null;
5413
+ const makeId = deps.makeId ?? (() => `pa-sensor-${randomUUID()}`);
5414
+ let inserted = 0;
5415
+ for (const cameraId of cameraIds) {
5416
+ await deps.sink.insert({
5417
+ id: makeId(),
5418
+ deviceId: cameraId,
5419
+ sourceDeviceId: data.deviceId,
5420
+ kind: descriptor.kind,
5421
+ value,
5422
+ timestamp
5423
+ });
5424
+ inserted++;
5425
+ }
5426
+ return inserted;
5427
+ }
5428
+ //#endregion
4603
5429
  //#region src/shared/frame/resolve-frame.ts
4604
5430
  /**
4605
5431
  * Resolve the pixels a `FrameHandle` refers to via the node-routed fetch.
@@ -4833,22 +5659,26 @@ var EventMediaDispatcher = class {
4833
5659
  for (const tf of trackFrames) await this.writeTrackFrame(deviceId, frameData, fw, fh, tf);
4834
5660
  const storedSnapshots = [];
4835
5661
  for (const sn of snapshots) {
4836
- const stored = await this.writeTrackSnapshot(deviceId, frameData, fw, fh, sn);
5662
+ const stored = await this.writeTrackSnapshot(deviceId, frameData, fw, fh, sn, input.cropPadding);
4837
5663
  if (stored) storedSnapshots.push(stored);
4838
5664
  }
4839
5665
  return { storedSnapshots };
4840
5666
  }
4841
5667
  /**
4842
- * Periodic per-track media (§5). Encodes the boxed frame ONCE and fans it out
4843
- * to whichever of the three destinations is requested: an appended `snapshot`
4844
- * (timeline filmstrip), the rolling `lastFrame` (overwrite), and the best
4845
- * `thumbnail` (overwrite). Returns the appended snapshot for TrackStore wiring
4846
- * (null when `appendSnapshot` is false or the encode failed).
5668
+ * Periodic per-track media (§5). The boxed FULL frame is encoded once and
5669
+ * shared by the appended `snapshot` (timeline filmstrip) and the rolling
5670
+ * `lastFrame` (overwrite). The best `thumbnail` is DIFFERENT: a clean
5671
+ * subject-centered crop (same output contract as the object-event `crop`
5672
+ * kind) it is the gallery/reel fallback for tracks that never produced an
5673
+ * object event, and a full frame there shows the scene (e.g. a foreground
5674
+ * parked car), not the track's subject. Returns the appended snapshot for
5675
+ * TrackStore wiring (null when `appendSnapshot` is false or the encode
5676
+ * failed).
4847
5677
  */
4848
- async writeTrackSnapshot(deviceId, frameData, fw, fh, sn) {
5678
+ async writeTrackSnapshot(deviceId, frameData, fw, fh, sn, cropPadding) {
4849
5679
  if (!sn.appendSnapshot && !sn.rollingLastFrame && !sn.bestThumbnail) return null;
4850
- let boxed;
4851
- try {
5680
+ let boxed = null;
5681
+ if (sn.appendSnapshot || sn.rollingLastFrame) try {
4852
5682
  boxed = await drawBoxedFrame(frameData, fw, fh, [{
4853
5683
  ...sn.bbox,
4854
5684
  ...sn.label ? { label: sn.label } : {}
@@ -4862,10 +5692,9 @@ var EventMediaDispatcher = class {
4862
5692
  error: err instanceof Error ? err.message : String(err)
4863
5693
  }
4864
5694
  });
4865
- return null;
4866
5695
  }
4867
5696
  let stored = null;
4868
- if (sn.appendSnapshot) try {
5697
+ if (sn.appendSnapshot && boxed) try {
4869
5698
  const mediaKey = await this.deps.mediaStore.put({
4870
5699
  deviceId,
4871
5700
  ownerKind: "track",
@@ -4881,10 +5710,49 @@ var EventMediaDispatcher = class {
4881
5710
  bbox: sn.bbox
4882
5711
  };
4883
5712
  } catch {}
4884
- if (sn.rollingLastFrame) await this.replaceKind(deviceId, sn.trackId, "lastFrame", sn.timestamp, boxed);
4885
- if (sn.bestThumbnail) await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, boxed);
5713
+ if (sn.rollingLastFrame && boxed) await this.replaceKind(deviceId, sn.trackId, "lastFrame", sn.timestamp, boxed);
5714
+ if (sn.bestThumbnail) try {
5715
+ const crop = await this.cropSubjectRegion(frameData, fw, fh, sn.bbox, cropPadding);
5716
+ await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, crop);
5717
+ } catch (err) {
5718
+ this.deps.logger.warn("event media: track thumbnail crop failed", {
5719
+ tags: { deviceId },
5720
+ meta: {
5721
+ deviceId,
5722
+ trackId: sn.trackId,
5723
+ error: err instanceof Error ? err.message : String(err)
5724
+ }
5725
+ });
5726
+ if (boxed) await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, boxed);
5727
+ }
4886
5728
  return stored;
4887
5729
  }
5730
+ /**
5731
+ * Clean subject-centered crop of `bbox` out of the raw frame — the shared
5732
+ * output contract of the object-event `crop` kind and the track `thumbnail`:
5733
+ * square-safe 16:9 region around the bbox, extracted from the ORIGINAL frame
5734
+ * (no box drawn), resized to 640×360, JPEG q80.
5735
+ */
5736
+ async cropSubjectRegion(frameData, fw, fh, bbox, cropPadding) {
5737
+ const region = squareSafeCropRegion(bbox, {
5738
+ W: fw,
5739
+ H: fh
5740
+ }, cropPadding);
5741
+ const left = Math.max(0, Math.min(region.x, fw - 1));
5742
+ const top = Math.max(0, Math.min(region.y, fh - 1));
5743
+ const width = Math.max(1, Math.min(region.w, fw - left));
5744
+ const height = Math.max(1, Math.min(region.h, fh - top));
5745
+ return await sharp(frameData, { raw: {
5746
+ width: fw,
5747
+ height: fh,
5748
+ channels: 3
5749
+ } }).extract({
5750
+ left,
5751
+ top,
5752
+ width,
5753
+ height
5754
+ }).resize(CROP_WIDTH, CROP_HEIGHT).jpeg({ quality: CROP_QUALITY }).toBuffer();
5755
+ }
4888
5756
  async replaceKind(deviceId, trackId, kind, timestamp, data) {
4889
5757
  try {
4890
5758
  await this.deps.mediaStore.putReplacing({
@@ -4912,24 +5780,7 @@ var EventMediaDispatcher = class {
4912
5780
  label: caption(ev.className, ev.confidence, ev.label)
4913
5781
  };
4914
5782
  try {
4915
- const region = squareSafeCropRegion(ev.bbox, {
4916
- W: fw,
4917
- H: fh
4918
- }, cropPadding);
4919
- const left = Math.max(0, Math.min(region.x, fw - 1));
4920
- const top = Math.max(0, Math.min(region.y, fh - 1));
4921
- const width = Math.max(1, Math.min(region.w, fw - left));
4922
- const height = Math.max(1, Math.min(region.h, fh - top));
4923
- const crop = await sharp(frameData, { raw: {
4924
- width: fw,
4925
- height: fh,
4926
- channels: 3
4927
- } }).extract({
4928
- left,
4929
- top,
4930
- width,
4931
- height
4932
- }).resize(CROP_WIDTH, CROP_HEIGHT).jpeg({ quality: CROP_QUALITY }).toBuffer();
5783
+ const crop = await this.cropSubjectRegion(frameData, fw, fh, ev.bbox, cropPadding);
4933
5784
  await this.deps.mediaStore.put({
4934
5785
  deviceId,
4935
5786
  ownerKind: "event",
@@ -5403,6 +6254,17 @@ var RESOLUTION_MS = {
5403
6254
  * latest state is never lost.
5404
6255
  */
5405
6256
  var SLICE_WRITE_INTERVAL_MS$1 = 1e3;
6257
+ /**
6258
+ * Cadence of the synthetic occupancy baseline. When a camera is detached (no
6259
+ * inference frames) but has persisted parked objects, the history ring would
6260
+ * otherwise stay empty and the chart would read "No occupancy history yet". A
6261
+ * device WITH parked entries gets one hydrated sample per this interval — a
6262
+ * flat baseline of the parked count — so the graph shows the parking lot's
6263
+ * standing occupancy instead of a gap. No sample is emitted for a device
6264
+ * without entries, and a real `recordFrame` in the same window suppresses the
6265
+ * baseline (it already appended a richer sample).
6266
+ */
6267
+ var BASELINE_SAMPLE_INTERVAL_MS = 6e4;
5406
6268
  var ZoneAnalyticsProvider = class {
5407
6269
  ctx;
5408
6270
  snapshots = /* @__PURE__ */ new Map();
@@ -5419,8 +6281,17 @@ var ZoneAnalyticsProvider = class {
5419
6281
  /** Last logged frame-wide occupancy total per device — so the occupancy log
5420
6282
  * fires only when the count actually changes, not every inference frame. */
5421
6283
  lastOccupancyTotal = /* @__PURE__ */ new Map();
6284
+ /** Low-cadence baseline sampler — appends a hydrated occupancy sample for any
6285
+ * device with parked objects but no live frames. `null` when disabled. */
6286
+ baselineTimer = null;
5422
6287
  constructor(ctx) {
5423
6288
  this.ctx = ctx;
6289
+ if (ctx.listStationaryDeviceIds && ctx.listStationaryObjects) {
6290
+ this.baselineTimer = setInterval(() => {
6291
+ this.appendBaselineSamples();
6292
+ }, BASELINE_SAMPLE_INTERVAL_MS);
6293
+ this.baselineTimer.unref?.();
6294
+ }
5424
6295
  this.sliceThrottle = new SliceThrottler({
5425
6296
  intervalMs: SLICE_WRITE_INTERVAL_MS$1,
5426
6297
  equalsIgnoringTs: snapshotEqualsIgnoringTs,
@@ -5442,9 +6313,10 @@ var ZoneAnalyticsProvider = class {
5442
6313
  /** Stop pending throttle timers — called from addon shutdown. */
5443
6314
  destroy() {
5444
6315
  this.sliceThrottle.destroy();
6316
+ if (this.baselineTimer) clearInterval(this.baselineTimer);
5445
6317
  }
5446
6318
  async getCurrentSnapshot({ deviceId }) {
5447
- return this.snapshots.get(deviceId) ?? null;
6319
+ return this.snapshots.get(deviceId) ?? await this.hydrateFromRegistry(deviceId);
5448
6320
  }
5449
6321
  async getZoneHistory(input) {
5450
6322
  return this.bucketize(input.deviceId, input.from, input.to, input.resolution, (snap) => {
@@ -5497,6 +6369,65 @@ var ZoneAnalyticsProvider = class {
5497
6369
  this.lastOccupancyTotal.delete(deviceId);
5498
6370
  this.sliceThrottle.forgetDevice(deviceId);
5499
6371
  }
6372
+ /**
6373
+ * Build an occupancy snapshot for a device purely from its parked-object
6374
+ * registry (no live frame). Returns `null` when hydration is unavailable or
6375
+ * the device has no parked objects — a device with neither frames nor entries
6376
+ * legitimately reports `null`. The snapshot's `ts` is the most recent
6377
+ * `lastConfirmedAt` across entries, falling back to the current tick.
6378
+ */
6379
+ async hydrateFromRegistry(deviceId) {
6380
+ const listStationary = this.ctx.listStationaryObjects;
6381
+ if (!listStationary) return null;
6382
+ const entries = listStationary(deviceId);
6383
+ if (entries.length === 0) return null;
6384
+ let zones = [];
6385
+ try {
6386
+ zones = await this.ctx.resolveZones?.(deviceId) ?? [];
6387
+ } catch (err) {
6388
+ this.ctx.logger.debug("zone-analytics hydrate zone resolve failed", {
6389
+ tags: { deviceId },
6390
+ meta: { error: err instanceof Error ? err.message : String(err) }
6391
+ });
6392
+ }
6393
+ const ts = mostRecentStationaryConfirmedAt(entries) || Date.now();
6394
+ return buildStationarySnapshot({
6395
+ deviceId,
6396
+ entries,
6397
+ zones,
6398
+ timestamp: ts
6399
+ });
6400
+ }
6401
+ /**
6402
+ * Baseline sampler tick: for every device with parked objects, append a
6403
+ * hydrated sample to the history ring at the CURRENT time — but only when a
6404
+ * real frame hasn't already appended a sample within this interval (frames
6405
+ * flowing = richer samples, no synthetic baseline needed).
6406
+ */
6407
+ async appendBaselineSamples() {
6408
+ const deviceIds = this.ctx.listStationaryDeviceIds?.() ?? [];
6409
+ const now = Date.now();
6410
+ for (const deviceId of deviceIds) {
6411
+ const ring = this.history.get(deviceId);
6412
+ if (now - (ring && ring.length > 0 ? ring[ring.length - 1].ts : 0) < BASELINE_SAMPLE_INTERVAL_MS) continue;
6413
+ const entries = this.ctx.listStationaryObjects?.(deviceId) ?? [];
6414
+ if (entries.length === 0) continue;
6415
+ let zones = [];
6416
+ try {
6417
+ zones = await this.ctx.resolveZones?.(deviceId) ?? [];
6418
+ } catch {}
6419
+ const snapshot = buildStationarySnapshot({
6420
+ deviceId,
6421
+ entries,
6422
+ zones,
6423
+ timestamp: now
6424
+ });
6425
+ if (snapshot) {
6426
+ this.appendHistory(deviceId, snapshot);
6427
+ this.sliceThrottle.push(deviceId, snapshot);
6428
+ }
6429
+ }
6430
+ }
5500
6431
  appendHistory(deviceId, snapshot) {
5501
6432
  const ring = this.history.get(deviceId) ?? [];
5502
6433
  const cutoff = snapshot.ts - HISTORY_WINDOW_MS;
@@ -5587,6 +6518,39 @@ function computeSnapshot(input) {
5587
6518
  ...input.stationaryObjects !== void 0 && input.stationaryObjects.length > 0 ? { stationaryObjects: input.stationaryObjects } : {}
5588
6519
  };
5589
6520
  }
6521
+ /** Most recent `lastConfirmedAt` across parked entries (0 when none). */
6522
+ function mostRecentStationaryConfirmedAt(entries) {
6523
+ let max = 0;
6524
+ for (const e of entries) if (e.lastConfirmedAt > max) max = e.lastConfirmedAt;
6525
+ return max;
6526
+ }
6527
+ /**
6528
+ * Build an occupancy snapshot from parked-object registry entries alone —
6529
+ * used when no live inference frame is available (fresh respawn, camera
6530
+ * detached). Each entry is folded into the frame aggregate AND attributed to
6531
+ * the zones its normalised bbox centroid falls inside (via
6532
+ * {@link computeStationaryEntryZones}), so a zone drawn over a parked car
6533
+ * reports a count of 1. Returns `null` for an empty entry list. Reuses
6534
+ * {@link computeSnapshot} — the SAME aggregation the live frame path runs.
6535
+ */
6536
+ function buildStationarySnapshot(input) {
6537
+ if (input.entries.length === 0) return null;
6538
+ const tracked = input.entries.map((e) => ({
6539
+ trackId: `stationary:${e.id}`,
6540
+ className: e.className,
6541
+ zones: computeStationaryEntryZones(e, input.zones)
6542
+ }));
6543
+ const first = input.entries[0];
6544
+ return computeSnapshot({
6545
+ deviceId: input.deviceId,
6546
+ timestamp: input.timestamp,
6547
+ frameWidth: first.frameWidth,
6548
+ frameHeight: first.frameHeight,
6549
+ tracked,
6550
+ zones: input.zones,
6551
+ stationaryObjects: input.entries
6552
+ });
6553
+ }
5590
6554
  //#endregion
5591
6555
  //#region src/pipeline-analytics/audio-metrics-provider.ts
5592
6556
  var AUDIO_METRICS_CAP_NAME = "audio-metrics";
@@ -7664,6 +8628,17 @@ var DEFAULT_MIN_INTERVAL_MS = 1e3;
7664
8628
  /** Default fire cap for a `once` step when its announce omits `maxPerTrack`. */
7665
8629
  var DEFAULT_ONCE_MAX_PER_TRACK = 3;
7666
8630
  /**
8631
+ * Consecutive frame-plane misses ("frame + crop both missed") after which a step
8632
+ * is ABANDONED for the track. The decode worker serves native crops from a RAM
8633
+ * lease store with a ~500ms TTL, so a retry that arrives seconds later re-cuts
8634
+ * from an evicted handle and is a guaranteed miss forever. Retrying a
8635
+ * permanently-gone frame just burns cross-process RPC + CPU + log lines. Three
8636
+ * consecutive misses (each ≥ one tick apart) confidently means the frame is gone
8637
+ * for good, while still tolerating a single transient decode-worker hiccup /
8638
+ * respawn on a genuinely live track (the counter resets on any resolved result).
8639
+ */
8640
+ var MAX_CONSECUTIVE_FRAME_MISSES = 3;
8641
+ /**
7667
8642
  * Pure per-(track, step) scheduling state machine for detail-subtree
7668
8643
  * dispatch. Given a camera's announced child steps (`DetailStepAnnounce[]`,
7669
8644
  * read off `PipelineInferenceResultPayload.detailSteps`), decides WHEN each
@@ -7684,7 +8659,9 @@ var DetailScheduler = class {
7684
8659
  firedCount: 1,
7685
8660
  lastFiredAt: nowMs,
7686
8661
  sticky: false,
7687
- retryPending: false
8662
+ retryPending: false,
8663
+ consecutiveFrameMisses: 0,
8664
+ abandoned: false
7688
8665
  };
7689
8666
  steps.set(stepAnnounce.stepId, state);
7690
8667
  requests.push({
@@ -7717,7 +8694,7 @@ var DetailScheduler = class {
7717
8694
  tick(nowMs) {
7718
8695
  const requests = [];
7719
8696
  for (const [trackId, steps] of this.tracks) for (const state of steps.values()) {
7720
- if (state.sticky) continue;
8697
+ if (state.sticky || state.abandoned) continue;
7721
8698
  if (state.retryPending) {
7722
8699
  if (!this.intervalElapsed(state, nowMs)) continue;
7723
8700
  if (!this.underMaxPerTrack(state)) {
@@ -7755,7 +8732,8 @@ var DetailScheduler = class {
7755
8732
  if (!steps) return;
7756
8733
  const state = steps.get(stepId);
7757
8734
  if (!state) return;
7758
- if (state.sticky) return;
8735
+ if (state.sticky || state.abandoned) return;
8736
+ state.consecutiveFrameMisses = 0;
7759
8737
  const { stickyOnConfidence } = state.announce.cadence;
7760
8738
  if (confidence !== null && stickyOnConfidence !== void 0 && confidence >= stickyOnConfidence) {
7761
8739
  state.sticky = true;
@@ -7770,11 +8748,37 @@ var DetailScheduler = class {
7770
8748
  if (this.underMaxPerTrack(state)) state.retryPending = true;
7771
8749
  }
7772
8750
  }
8751
+ /**
8752
+ * A dispatched request could not resolve a frame AT ALL — the frame handle
8753
+ * lease was evicted AND the crop fallback was unavailable (the "frame + crop
8754
+ * both missed" outcome). This is fundamentally different from `onResult(null)`:
8755
+ * there the frame plane WORKED and the model merely returned nothing (worth a
8756
+ * retry on a fresh frame). A frame-plane miss re-cuts from the SAME evicted
8757
+ * handle every time, so it can never recover from this request. It is
8758
+ * retry-eligible only for a bounded number of CONSECUTIVE attempts; after
8759
+ * {@link MAX_CONSECUTIVE_FRAME_MISSES} in a row the step is abandoned for the
8760
+ * track — this is the give-up that breaks the permanent-retry loop. `_nowMs`
8761
+ * is accepted for signature symmetry (backoff is anchored to `lastFiredAt`).
8762
+ */
8763
+ onFrameMiss(trackId, stepId, _nowMs) {
8764
+ const steps = this.tracks.get(trackId);
8765
+ if (!steps) return;
8766
+ const state = steps.get(stepId);
8767
+ if (!state) return;
8768
+ if (state.sticky || state.abandoned) return;
8769
+ state.consecutiveFrameMisses += 1;
8770
+ if (state.consecutiveFrameMisses >= MAX_CONSECUTIVE_FRAME_MISSES) {
8771
+ state.abandoned = true;
8772
+ state.retryPending = false;
8773
+ return;
8774
+ }
8775
+ if (this.underMaxPerTrack(state)) state.retryPending = true;
8776
+ }
7773
8777
  onTrackEnded(trackId) {
7774
8778
  this.tracks.delete(trackId);
7775
8779
  }
7776
8780
  canFire(state, nowMs) {
7777
- if (state.sticky) return false;
8781
+ if (state.sticky || state.abandoned) return false;
7778
8782
  if (!this.underMaxPerTrack(state)) return false;
7779
8783
  return this.intervalElapsed(state, nowMs);
7780
8784
  }
@@ -7955,8 +8959,12 @@ var TrackDetailDispatcher = class {
7955
8959
  }
7956
8960
  async dispatch(deviceId, dev, req, frame) {
7957
8961
  const details = await this.runOnce(deviceId, dev, req, frame);
8962
+ if (details === null) {
8963
+ dev.scheduler.onFrameMiss(req.trackId, req.stepId, Date.now());
8964
+ return;
8965
+ }
7958
8966
  let topScore = null;
7959
- if (details !== null && details.length > 0) {
8967
+ if (details.length > 0) {
7960
8968
  topScore = details.reduce((max, d) => d.score > max ? d.score : max, Number.NEGATIVE_INFINITY);
7961
8969
  const steps = composeDetailSteps(req, (id) => this.deps.hasTrackLabel?.(id) ?? false);
7962
8970
  if (isEnrichmentChain(steps) && !detailsCarryEnrichment(details)) this.warnEnrichmentMissThrottled(deviceId, dev, req, steps);
@@ -9289,6 +10297,40 @@ function classifyAudioFrame(top, cfg) {
9289
10297
  //#endregion
9290
10298
  //#region src/pipeline-analytics/event-media-handler.ts
9291
10299
  var CACHE_CONTROL = "public, max-age=31536000, immutable";
10300
+ /** Default / clamp bounds for the `thumb` variant edge (px). Mirrors
10301
+ * `shared/frame/square-thumb.ts`; kept here so query parsing stays pure. */
10302
+ var THUMB_DEFAULT_SIZE = 160;
10303
+ var THUMB_MIN_SIZE$1 = 64;
10304
+ var THUMB_MAX_SIZE$1 = 320;
10305
+ /**
10306
+ * Parse the `?kind=…` query into a preferred stored media kind. Returns null
10307
+ * when unset. The value is a free-form kind token (e.g. `crop`); the resolver
10308
+ * validates it against the known kinds.
10309
+ */
10310
+ function parseEventMediaKind(query) {
10311
+ const kind = new URLSearchParams(query).get("kind");
10312
+ return kind !== null && kind.length > 0 ? kind : null;
10313
+ }
10314
+ /**
10315
+ * Parse the `?variant=…` query into an {@link EventMediaVariant}. Returns null
10316
+ * when no small-square rendering was requested (the caller then serves the
10317
+ * stored blob). Accepts `variant=thumb` or `square=1`; the edge comes from
10318
+ * `size` / `w` / `h` (clamped to [64, 320], default 160).
10319
+ */
10320
+ function parseEventMediaVariant(query) {
10321
+ const params = new URLSearchParams(query);
10322
+ if (!(params.get("variant") === "thumb" || params.get("square") === "1")) return null;
10323
+ const sizeRaw = params.get("size") ?? params.get("w") ?? params.get("h");
10324
+ let size = THUMB_DEFAULT_SIZE;
10325
+ if (sizeRaw !== null) {
10326
+ const n = Number.parseInt(sizeRaw, 10);
10327
+ if (Number.isFinite(n)) size = Math.max(THUMB_MIN_SIZE$1, Math.min(THUMB_MAX_SIZE$1, n));
10328
+ }
10329
+ return {
10330
+ kind: "thumb",
10331
+ size
10332
+ };
10333
+ }
9292
10334
  /**
9293
10335
  * Create a data-plane handler that serves event thumbnails as JPEG images.
9294
10336
  *
@@ -9302,14 +10344,20 @@ function createEventMediaHandler(deps) {
9302
10344
  res.writeHead(405, { allow: "GET, HEAD" }).end();
9303
10345
  return;
9304
10346
  }
9305
- const eventId = ((req.url ?? "/").split("?")[0] ?? "/").replace(/^\/+/, "");
10347
+ const url = req.url ?? "/";
10348
+ const qIdx = url.indexOf("?");
10349
+ const rawPath = qIdx === -1 ? url : url.slice(0, qIdx);
10350
+ const query = qIdx === -1 ? "" : url.slice(qIdx + 1);
10351
+ const eventId = rawPath.replace(/^\/+/, "");
9306
10352
  if (!eventId || eventId.includes("/")) {
9307
10353
  res.writeHead(404).end();
9308
10354
  return;
9309
10355
  }
10356
+ const variant = parseEventMediaVariant(query);
10357
+ const preferKind = parseEventMediaKind(query);
9310
10358
  let media = null;
9311
10359
  try {
9312
- media = await deps.getMedia(eventId);
10360
+ media = await deps.getMedia(eventId, variant ?? void 0, preferKind ?? void 0);
9313
10361
  } catch {
9314
10362
  const body = "Internal server error";
9315
10363
  res.writeHead(500, {
@@ -9342,6 +10390,27 @@ function createEventMediaHandler(deps) {
9342
10390
  else res.end(Buffer.from(media.bytes));
9343
10391
  };
9344
10392
  }
10393
+ /** JPEG quality for the small square thumbnail (visibly fine at ≤192px, tiny). */
10394
+ var THUMB_QUALITY = 70;
10395
+ /**
10396
+ * Produce a SMALL SQUARE JPEG from an already-encoded image (typically the
10397
+ * stored 640×360 `crop`). Center-crop cover to a square then downscale to
10398
+ * `size`×`size` at JPEG q70 — the reel/list surfaces want a compact square tile
10399
+ * showing the object, not the full 16:9 crop. Output is a fraction of the source
10400
+ * (~3–8 KB at 144–192 px vs ~65 KB for the crop), so a fleet-wide reel renders
10401
+ * from tiny HTTP-cached tiles instead of full base64 payloads.
10402
+ *
10403
+ * `fit: 'cover'` + `position: 'centre'` scales the shorter side to `size` and
10404
+ * crops the overflow symmetrically — the center square of a square-safe crop
10405
+ * fully contains the detector bbox, so the object stays framed.
10406
+ */
10407
+ async function makeSquareThumb(bytes, size) {
10408
+ const edge = Math.max(64, Math.min(320, Math.round(size)));
10409
+ return sharp(Buffer.from(bytes)).resize(edge, edge, {
10410
+ fit: "cover",
10411
+ position: "centre"
10412
+ }).jpeg({ quality: THUMB_QUALITY }).toBuffer();
10413
+ }
9345
10414
  //#endregion
9346
10415
  //#region src/pipeline-analytics/index.ts
9347
10416
  /**
@@ -9397,6 +10466,34 @@ var OVERLAY_SYNTHESIS_WARN_THROTTLE_MS = 6e4;
9397
10466
  var AUDIO_EVENT_HEARTBEAT_MS = 5e3;
9398
10467
  var MOTION_EVENT_HEARTBEAT_MS = 5e3;
9399
10468
  /**
10469
+ * Stored media kinds that carry NO drawn bounding box, in fallback preference
10470
+ * order. The reel forces `?kind=crop`; when a track has no crop the endpoint may
10471
+ * degrade only to one of these CLEAN frames — never `fullFrameBoxed` /
10472
+ * `thumbnail` / `lastFrame` / `firstFrame` / `snapshot` (all server-boxed).
10473
+ */
10474
+ var CLEAN_MEDIA_KINDS = [
10475
+ "crop",
10476
+ "fullFrame",
10477
+ "keyFrame"
10478
+ ];
10479
+ /**
10480
+ * Pick a CLEAN (never boxed) media file for a crop-forced request: the exact
10481
+ * `preferKind` if it is itself clean and present, else the first available
10482
+ * {@link CLEAN_MEDIA_KINDS} frame. Returns undefined when only boxed / no media
10483
+ * exists (caller 404s → the viewer shows an icon).
10484
+ */
10485
+ function pickCleanMedia(files, preferKind) {
10486
+ const isClean = (k) => CLEAN_MEDIA_KINDS.includes(k);
10487
+ if (isClean(preferKind)) {
10488
+ const exact = files.find((f) => f.kind === preferKind);
10489
+ if (exact) return exact;
10490
+ }
10491
+ for (const kind of CLEAN_MEDIA_KINDS) {
10492
+ const found = files.find((f) => f.kind === kind);
10493
+ if (found) return found;
10494
+ }
10495
+ }
10496
+ /**
9400
10497
  * Decode a base64 little-endian float32 vector (the `DetailResult.embedding`
9401
10498
  * wire encoding produced by `runDetailSubtree`) back into a plain number[].
9402
10499
  */
@@ -9453,6 +10550,11 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
9453
10550
  stationaryRegistry = null;
9454
10551
  mediaStore = null;
9455
10552
  eventStore = null;
10553
+ /** Per-camera history of LINKED-device sensor state changes (Part B). */
10554
+ sensorEventStore = null;
10555
+ /** Ingest-side reverse index (sensor device → linked camera ids), TTL-cached
10556
+ * so the DeviceStateChanged handler stays cheap. */
10557
+ linkedCamerasCache = null;
9456
10558
  identityStore = null;
9457
10559
  faceStore = null;
9458
10560
  faceRecognizer = null;
@@ -9503,6 +10605,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
9503
10605
  unsubNativeDetection = null;
9504
10606
  unsubBindings = null;
9505
10607
  unsubDeviceUnreg = null;
10608
+ /** DeviceStateChanged subscription feeding the sensor-event history. */
10609
+ unsubDeviceState = null;
9506
10610
  ttlSweepTimer = null;
9507
10611
  retentionSweepTimer = null;
9508
10612
  /** Handle for the event-media data-plane listener (dispose on shutdown). */
@@ -9598,6 +10702,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
9598
10702
  await TrackStore.declare(api.settingsStore);
9599
10703
  await MediaStore.declare(api.settingsStore);
9600
10704
  await EventStore.declare(api.settingsStore);
10705
+ await SensorEventStore.declare(api.settingsStore);
9601
10706
  await IdentityStore.declare(api.settingsStore);
9602
10707
  await FaceStore.declare(api.settingsStore);
9603
10708
  await PlateStore.declare(api.settingsStore);
@@ -9615,7 +10720,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
9615
10720
  if (!storage) throw new Error("pipeline-analytics requires ctx.kernel.storage");
9616
10721
  this.trackStore = new TrackStore({
9617
10722
  store: api.settingsStore,
9618
- logger: logger.child("TrackStore")
10723
+ logger: logger.child("TrackStore"),
10724
+ frameDims: (deviceId) => this.lastFrameDimsByDevice.get(deviceId)
9619
10725
  });
9620
10726
  this.stationaryRegistry = new StationaryObjectRegistry({
9621
10727
  store: api.settingsStore,
@@ -9651,6 +10757,20 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
9651
10757
  logger: logger.child("EventStore"),
9652
10758
  media: this.mediaStore
9653
10759
  });
10760
+ this.sensorEventStore = new SensorEventStore({
10761
+ store: api.settingsStore,
10762
+ logger: logger.child("SensorEventStore")
10763
+ });
10764
+ this.linkedCamerasCache = new LinkedCamerasCache({
10765
+ cameras: { listCameraIds: async () => {
10766
+ return (await api.deviceManager.listAll.query({})).filter((d) => d.isCamera).map((d) => d.id);
10767
+ } },
10768
+ linkedDevices: { getLinkedDevices: (input) => api.deviceManager.getLinkedDevices.query(input) },
10769
+ onError: (scope, err) => logger.warn("linked-cameras cache refresh failed", { meta: {
10770
+ scope,
10771
+ error: errMsg(err)
10772
+ } })
10773
+ });
9654
10774
  this.identityStore = new IdentityStore({
9655
10775
  store: api.settingsStore,
9656
10776
  logger: logger.child("IdentityStore")
@@ -9815,7 +10935,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
9815
10935
  });
9816
10936
  this.zoneAnalytics = new ZoneAnalyticsProvider({
9817
10937
  logger: logger.child("ZoneAnalytics"),
9818
- fetchDevice: (deviceId) => this.ctx.fetchDevice(deviceId)
10938
+ fetchDevice: (deviceId) => this.ctx.fetchDevice(deviceId),
10939
+ listStationaryObjects: (deviceId) => this.stationaryRegistry?.listViews(deviceId) ?? [],
10940
+ listStationaryDeviceIds: () => this.stationaryRegistry?.deviceIds() ?? [],
10941
+ resolveZones: (deviceId) => this.resolveDeviceZones(deviceId)
9819
10942
  });
9820
10943
  this.audioMetrics = new AudioMetricsProvider({
9821
10944
  logger: logger.child("AudioMetrics"),
@@ -9831,9 +10954,9 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
9831
10954
  }
9832
10955
  });
9833
10956
  try {
9834
- const handler = createEventMediaHandler({ getMedia: async (id) => {
10957
+ const handler = createEventMediaHandler({ getMedia: async (id, variant, preferKind) => {
9835
10958
  try {
9836
- return await this.readMediaByEventOrKey(id);
10959
+ return await this.readMediaByEventOrKey(id, variant, preferKind);
9837
10960
  } catch (err) {
9838
10961
  this.ctx.logger.warn("readEventThumbnail failed", { meta: {
9839
10962
  eventId: id,
@@ -9874,6 +10997,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
9874
10997
  const data = ev.data;
9875
10998
  this.handleNativeDetection(data);
9876
10999
  });
11000
+ this.unsubDeviceState = this.ctx.eventBus.subscribe({ category: EventCategory.DeviceStateChanged }, (ev) => {
11001
+ const data = ev.data;
11002
+ if (EVENT_KIND_BY_CAP[data.capName] === void 0) return;
11003
+ const timestamp = ev.timestamp instanceof Date ? ev.timestamp.getTime() : Date.now();
11004
+ this.handleSensorStateChanged(data, timestamp);
11005
+ });
9877
11006
  if (await this.embeddingEnabledState.get()) {
9878
11007
  const encoderClient = {
9879
11008
  encode: (crop, width, height) => this.ctx.api.embeddingEncoder.encode.query({
@@ -10239,6 +11368,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
10239
11368
  this.unsubBindings = null;
10240
11369
  this.unsubDeviceUnreg?.();
10241
11370
  this.unsubDeviceUnreg = null;
11371
+ this.unsubDeviceState?.();
11372
+ this.unsubDeviceState = null;
10242
11373
  await this.embeddingDispatcher?.stop();
10243
11374
  this.embeddingDispatcher = null;
10244
11375
  for (const id of this.proxies.keys()) this.releaseProxy(id);
@@ -10336,7 +11467,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
10336
11467
  const stationaryAsTracked = stationaryViews.map((v) => ({
10337
11468
  trackId: `stationary:${v.id}`,
10338
11469
  className: v.className,
10339
- zones: []
11470
+ zones: computeStationaryEntryZones(v, liveZones)
10340
11471
  }));
10341
11472
  this.zoneAnalytics?.recordFrame({
10342
11473
  deviceId,
@@ -11501,6 +12632,15 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
11501
12632
  } });
11502
12633
  }
11503
12634
  await this.mediaStore.evictBefore(now - 31 * day);
12635
+ if (this.sensorEventStore) try {
12636
+ const sensorDeleted = await this.sensorEventStore.evictBefore(objectCutoffMs);
12637
+ if (sensorDeleted > 0) this.ctx.logger.info("sensor-event retention prune", { meta: {
12638
+ deleted: sensorDeleted,
12639
+ cutoffMs: objectCutoffMs
12640
+ } });
12641
+ } catch (err) {
12642
+ this.ctx.logger.debug("sensor-event prune failed", { meta: { error: String(err) } });
12643
+ }
11504
12644
  if (this.faceStore) try {
11505
12645
  const faceCutoffMs = now - FACE_DEFAULTS.bufferRetentionDays * day;
11506
12646
  const deletedFaceIds = await this.faceStore.pruneAll({
@@ -11772,6 +12912,21 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
11772
12912
  return null;
11773
12913
  }
11774
12914
  }
12915
+ /**
12916
+ * Resolve a device's current 0–1 zone catalogue independent of the live
12917
+ * frame path — used by zone-analytics snapshot hydration + the occupancy
12918
+ * baseline sampler when the camera is detached (no frames). Warms the proxy
12919
+ * (cold read via `fetchDevice`) and, when the cached slice is empty, forces
12920
+ * one `refresh()` round-trip so a just-created proxy returns real zones.
12921
+ */
12922
+ async resolveDeviceZones(deviceId) {
12923
+ const proxy = await this.ensureProxy(deviceId);
12924
+ if (!proxy) return [];
12925
+ const cached = proxy.state.zones.value?.zones;
12926
+ if (cached && cached.length > 0) return cached;
12927
+ await proxy.state.zones.refresh().catch(() => void 0);
12928
+ return proxy.state.zones.value?.zones ?? [];
12929
+ }
11775
12930
  releaseProxy(deviceId) {
11776
12931
  const unsubs = this.proxyUnsubs.get(deviceId);
11777
12932
  if (unsubs) for (const u of unsubs) try {
@@ -11791,6 +12946,68 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
11791
12946
  async listTracks(input) {
11792
12947
  return this.trackStore?.queryHistorical(input) ?? [];
11793
12948
  }
12949
+ /**
12950
+ * Batched cluster-wide track listing — one merged (`lastSeen` DESC,
12951
+ * `trackId` DESC) page across the requested devices with a stable opaque
12952
+ * cursor. Replaces the per-camera `listTracks` fan-out for the events page
12953
+ * first paint + the reel. See `TrackStore.queryRecent` for the per-device
12954
+ * indexed page + k-way merge and the cursor encoding.
12955
+ */
12956
+ async listRecentTracks(input) {
12957
+ return this.trackStore?.queryRecent(input) ?? {
12958
+ tracks: [],
12959
+ nextCursor: null
12960
+ };
12961
+ }
12962
+ /**
12963
+ * Every event kind the device can produce: built-ins (motion + audio),
12964
+ * detection classes actually observed on the device, and sensor kinds
12965
+ * from LINKED devices (device-manager `getLinkedDevices`, binding-driven
12966
+ * per linked device). Degrades to the built-ins on error.
12967
+ */
12968
+ async listEventKinds(input) {
12969
+ const api = this.ctx.api;
12970
+ return composeEventKinds({
12971
+ linkedDevices: { getLinkedDevices: (i) => api.deviceManager.getLinkedDevices.query(i) },
12972
+ bindings: { getBindings: (i) => api.deviceManager.getBindings.query(i) },
12973
+ observedClassNames: async (deviceId) => this.trackStore?.observedClassNames(deviceId) ?? [],
12974
+ onError: (scope, err) => this.ctx.logger.warn("listEventKinds: partial compose", {
12975
+ tags: { deviceId: input.deviceId },
12976
+ meta: {
12977
+ scope,
12978
+ error: errMsg(err)
12979
+ }
12980
+ })
12981
+ }, input.deviceId);
12982
+ }
12983
+ /** Per-camera sensor-event history (state changes of linked devices). */
12984
+ async getSensorEvents(input) {
12985
+ return this.sensorEventStore?.query(input) ?? [];
12986
+ }
12987
+ /**
12988
+ * Sensor-event ingest handler — `DeviceStateChanged` of a device exposing a
12989
+ * mapped sensor cap. Resolves the linked-camera set through the TTL cache
12990
+ * and inserts ONE row per linked camera. Best-effort (telemetry-lossy).
12991
+ */
12992
+ async handleSensorStateChanged(data, timestamp) {
12993
+ const store = this.sensorEventStore;
12994
+ const cache = this.linkedCamerasCache;
12995
+ if (store === null || cache === null) return;
12996
+ try {
12997
+ await ingestSensorStateChange({
12998
+ sink: store,
12999
+ cache
13000
+ }, data, timestamp);
13001
+ } catch (err) {
13002
+ this.ctx.logger.warn("sensor-event ingest failed", {
13003
+ tags: { deviceId: data.deviceId },
13004
+ meta: {
13005
+ capName: data.capName,
13006
+ error: errMsg(err)
13007
+ }
13008
+ });
13009
+ }
13010
+ }
11794
13011
  async clearTracks(input) {
11795
13012
  this.trackStore?.clearDevice(input.deviceId);
11796
13013
  this.stationaryRegistry?.clearDevice(input.deviceId);
@@ -12147,19 +13364,51 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
12147
13364
  * points its thumbnail at the embedding row's crop key (a `track:…:crop:…`
12148
13365
  * key), so this resolves that crop; event ids stay on the event-crop path.
12149
13366
  */
12150
- async readMediaByEventOrKey(id) {
12151
- if (id.includes(":")) {
12152
- const file = await (this.mediaStore?.getByKey(id) ?? Promise.resolve(null));
12153
- if (!file) return null;
13367
+ async readMediaByEventOrKey(id, variant, preferKind) {
13368
+ const base = id.includes(":") ? await this.readMediaByKey(id) : await this.readEventThumbnail(id, preferKind);
13369
+ if (base === null || variant === void 0) return base;
13370
+ return this.applyThumbVariant(base, variant);
13371
+ }
13372
+ async readMediaByKey(id) {
13373
+ const file = await (this.mediaStore?.getByKey(id) ?? Promise.resolve(null));
13374
+ if (!file) return null;
13375
+ return {
13376
+ bytes: Buffer.from(file.base64, "base64"),
13377
+ key: file.key
13378
+ };
13379
+ }
13380
+ /**
13381
+ * Render a small center-cropped square from a resolved event media blob for
13382
+ * the reel / list surfaces. The returned `key` is variant-distinct so the
13383
+ * data-plane ETag never collides with the full-size blob's. On any encode
13384
+ * failure the full blob is served (a thumb must never 500 / blank a tile).
13385
+ */
13386
+ async applyThumbVariant(media, variant) {
13387
+ try {
12154
13388
  return {
12155
- bytes: Buffer.from(file.base64, "base64"),
12156
- key: file.key
13389
+ bytes: await makeSquareThumb(media.bytes, variant.size),
13390
+ key: `${media.key}|t${variant.size}`
12157
13391
  };
13392
+ } catch (err) {
13393
+ this.ctx.logger.debug("event media: thumb variant failed — serving full", { meta: {
13394
+ key: media.key,
13395
+ size: variant.size,
13396
+ error: errMsg(err)
13397
+ } });
13398
+ return media;
12158
13399
  }
12159
- return this.readEventThumbnail(id);
12160
13400
  }
12161
- async readEventThumbnail(id) {
13401
+ async readEventThumbnail(id, preferKind) {
12162
13402
  const eventFiles = await (this.mediaStore?.listByOwner("event", id) ?? Promise.resolve([]));
13403
+ if (preferKind !== void 0 && preferKind.length > 0) {
13404
+ const trackFiles = await (this.mediaStore?.listByOwner("track", id) ?? Promise.resolve([]));
13405
+ const clean = pickCleanMedia([...eventFiles, ...trackFiles], preferKind);
13406
+ if (!clean) return null;
13407
+ return {
13408
+ bytes: Buffer.from(clean.base64, "base64"),
13409
+ key: clean.key
13410
+ };
13411
+ }
12163
13412
  const chosenEvent = eventFiles.find((f) => f.kind === "crop") ?? eventFiles.find((f) => f.kind === "fullFrameBoxed") ?? eventFiles[0];
12164
13413
  if (chosenEvent) return {
12165
13414
  bytes: Buffer.from(chosenEvent.base64, "base64"),
@@ -12744,4 +13993,4 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
12744
13993
  }
12745
13994
  };
12746
13995
  //#endregion
12747
- export { PipelineAnalyticsAddon as default, stripGlobalOnlyFields, toAnalyticsDeviceSections };
13996
+ export { PipelineAnalyticsAddon as default, pickCleanMedia, stripGlobalOnlyFields, toAnalyticsDeviceSections };