@camstack/addon-post-analysis 1.2.7 → 1.2.8

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 { A as object, C as BaseAddon, D as array, E as hydrateSchema, M as EventCategory, O as boolean, S as errMsg, T as createEvent, _ as pipelineAnalyticsCapability, a as NC_CONDITION_CATALOG, b as videoclipsCapability, c as addonWidgetsSourceCapability, d as cosineSimilarity, g as notificationRulesCapability, h as nodePin, i as MACRO_LABELS, j as string, k as number, l as audioMetricsCapability, n as EVENT_KIND_BY_CAP, o as NcRuleSchema, p as faceGalleryCapability, r as EVENT_PAD_MS, s as OpsLogEntrySchema, t as DEFAULT_EVENT_COLOR, u as buildEventKindDescriptor, v as plateGalleryCapability, w as DeviceType, x as zoneAnalyticsCapability, y as subKindsOf } from "../dist-D5fcvkof.mjs";
1
+ import { A as BaseAddon, B as EventCategory, C as notificationRulesCapability, D as videoclipsCapability, E as subKindsOf, F as boolean, I as literal, L as number, M as createEvent, N as hydrateSchema, O as zoneAnalyticsCapability, P as array, R as object, S as nodePin, T as plateGalleryCapability, _ as customAction, a as NC_CONDITION_CATALOG, b as faceGalleryCapability, c as NcRuleInputSchema, d as NcTaxonomySchema, f as OpsLogEntrySchema, g as cosineSimilarity, h as buildEventKindDescriptor, i as MACRO_LABELS, j as DeviceType, k as errMsg, l as NcRulePatchSchema, m as audioMetricsCapability, n as EVENT_KIND_BY_CAP, o as NC_TAXONOMY, p as addonWidgetsSourceCapability, r as EVENT_PAD_MS, s as NcConditionDescriptorSchema, t as DEFAULT_EVENT_COLOR, u as NcRuleSchema, v as defineCustomActions, w as pipelineAnalyticsCapability, z as string } from "../dist-Dm1I_IVp.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import sharp from "sharp";
4
4
  //#region src/pipeline-analytics/videoclips-provider.ts
@@ -395,7 +395,11 @@ var FaceGalleryProvider = class {
395
395
  assigned: face.assigned,
396
396
  ...base64 !== void 0 ? { base64 } : {},
397
397
  ...face.faceBbox !== void 0 ? { faceBbox: face.faceBbox } : {},
398
- ...face.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: face.keyFrameMediaKey } : {}
398
+ ...face.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: face.keyFrameMediaKey } : {},
399
+ ...face.bestMatchScore != null ? { bestMatchScore: face.bestMatchScore } : {},
400
+ ...face.nativeFaceShortSidePx != null ? { nativeFaceShortSidePx: face.nativeFaceShortSidePx } : {},
401
+ ...face.suggestedIdentityId != null ? { suggestedIdentityId: face.suggestedIdentityId } : {},
402
+ ...face.suggestedMatchScore != null ? { suggestedMatchScore: face.suggestedMatchScore } : {}
399
403
  });
400
404
  }
401
405
  return result;
@@ -425,7 +429,11 @@ var FaceGalleryProvider = class {
425
429
  assigned: face.assigned,
426
430
  ...base64 !== void 0 ? { base64 } : {},
427
431
  ...face.faceBbox !== void 0 ? { faceBbox: face.faceBbox } : {},
428
- ...face.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: face.keyFrameMediaKey } : {}
432
+ ...face.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: face.keyFrameMediaKey } : {},
433
+ ...face.bestMatchScore != null ? { bestMatchScore: face.bestMatchScore } : {},
434
+ ...face.nativeFaceShortSidePx != null ? { nativeFaceShortSidePx: face.nativeFaceShortSidePx } : {},
435
+ ...face.suggestedIdentityId != null ? { suggestedIdentityId: face.suggestedIdentityId } : {},
436
+ ...face.suggestedMatchScore != null ? { suggestedMatchScore: face.suggestedMatchScore } : {}
429
437
  };
430
438
  }
431
439
  /**
@@ -627,16 +635,25 @@ var FaceGalleryProvider = class {
627
635
  function normalizePlate$1(text) {
628
636
  return text.toUpperCase().replace(/[^A-Z0-9]/g, "");
629
637
  }
638
+ /** Raw-read glyphs a real plate can never contain: anything outside letters,
639
+ * digits, whitespace and the hyphen (display separator). The OCR charset
640
+ * includes bracket/punctuation glyphs, so a blurred crop can hallucinate
641
+ * reads like "[miv4o)" / "6ou.2" (live-observed 2026-07-22) that a
642
+ * length-only gate lets through. */
643
+ var PLATE_SYMBOL_GLYPH = /[^A-Za-z0-9\s-]/;
630
644
  /**
631
645
  * Quality gate for a raw OCR plate read BEFORE it becomes a track label or a
632
646
  * gallery row. Distant/oblique parked plates produce junk reads ("N", "Idag",
633
647
  * "@em") that otherwise flood track labels (live-observed on the parking
634
648
  * camera, 2026-07-16). A plausible European plate is ≥{@link PLATE_MIN_LENGTH}
635
- * alphanumerics and mixes letters AND digits; anything else or a read below
649
+ * alphanumerics, mixes letters AND digits, and carries NO symbol glyph in the
650
+ * RAW read (spaces/hyphens allowed — normalization would silently strip a
651
+ * hallucinated "[" and launder the junk); anything else — or a read below
636
652
  * {@link PLATE_MIN_SCORE} — is discarded, not stored.
637
653
  */
638
654
  function isPlausiblePlateRead(text, score) {
639
655
  if (score < .4) return false;
656
+ if (PLATE_SYMBOL_GLYPH.test(text)) return false;
640
657
  const norm = normalizePlate$1(text);
641
658
  if (norm.length < 4) return false;
642
659
  return /[0-9]/.test(norm) && /[A-Z]/.test(norm);
@@ -2867,6 +2884,7 @@ var TrackResidentState = class {
2867
2884
  const created = {
2868
2885
  deviceId,
2869
2886
  firstFramePending: false,
2887
+ firstFrameLanded: false,
2870
2888
  thumbnailLanded: false,
2871
2889
  confirmed: false
2872
2890
  };
@@ -2924,6 +2942,17 @@ var TrackResidentState = class {
2924
2942
  bestFramePeak(trackId) {
2925
2943
  return this.bestFrameTracker.peak(trackId);
2926
2944
  }
2945
+ /** #27-A: record the track's best-seen subject bbox. Called by the frame
2946
+ * loop on every ACCEPTED (new-best AND plausible-box) observe, so the value
2947
+ * always mirrors the frame the latest keyFrame re-shot was seeded from. */
2948
+ recordBestSeenBbox(deviceId, trackId, bbox) {
2949
+ this.ensure(deviceId, trackId).bestBbox = bbox;
2950
+ }
2951
+ /** The track's best-seen subject bbox (undefined until a plausible new-best
2952
+ * was observed). Backs the retry gate + the close-time keyFrame crop. */
2953
+ bestSeenBbox(trackId) {
2954
+ return this.residents.get(trackId)?.bestBbox;
2955
+ }
2927
2956
  /** Record a CLIP-object observation for the track's best-EMBEDDING ranking
2928
2957
  * (confidence-only policy). Returns true on a new best. */
2929
2958
  observeObjectEmbeddingBest(deviceId, obs) {
@@ -2967,6 +2996,13 @@ var TrackResidentState = class {
2967
2996
  const resident = this.residents.get(trackId);
2968
2997
  if (resident) resident.firstFramePending = false;
2969
2998
  }
2999
+ /** A `firstFrame` write landed for this track (see {@link TrackResident.firstFrameLanded}). */
3000
+ markFirstFrameLanded(deviceId, trackId) {
3001
+ this.ensure(deviceId, trackId).firstFrameLanded = true;
3002
+ }
3003
+ isFirstFrameLanded(trackId) {
3004
+ return this.residents.get(trackId)?.firstFrameLanded ?? false;
3005
+ }
2970
3006
  isThumbnailLanded(trackId) {
2971
3007
  return this.residents.get(trackId)?.thumbnailLanded ?? false;
2972
3008
  }
@@ -3001,81 +3037,131 @@ var TrackResidentState = class {
3001
3037
  if (resident.rasterFallback === void 0) resident.rasterFallback = fallback;
3002
3038
  }
3003
3039
  };
3004
- var DEFAULT_ZERO_MEDIA_POLICY_CONFIG = {
3005
- suppressMaxDurationMs: 1e3,
3006
- nothingToShowMaxDurationMs: 1500
3007
- };
3008
- /**
3009
- * Classify a closing track's persistence outcome. Pure — see the module header
3010
- * for the full contract.
3011
- */
3012
- function decideZeroMediaPolicy(input, config = DEFAULT_ZERO_MEDIA_POLICY_CONFIG) {
3013
- if (input.hasMedia) return "persist";
3014
- if (input.durationMs < config.suppressMaxDurationMs && !input.confirmed) return "suppress";
3015
- if (!input.hasRasterFallback && input.durationMs < config.nothingToShowMaxDurationMs) return "suppress";
3016
- return input.hasRasterFallback ? "raster-fallback" : "persist";
3017
- }
3018
3040
  //#endregion
3019
- //#region src/pipeline-analytics/pipeline/last-frame-promotion.ts
3041
+ //#region src/pipeline-analytics/store/zone-geometry.ts
3020
3042
  /**
3021
- * Decide whether a closing track's newest `snapshot` should be promoted to be
3022
- * its `lastFrame`. Pure see the module header for the contract.
3023
- *
3024
- * Promote when there is at least one `snapshot` AND either there is no
3025
- * `lastFrame` yet, or the newest snapshot is strictly newer than the held
3026
- * `lastFrame`. Otherwise keep the current behaviour (no promotion).
3043
+ * Normalized min/max envelope over every position's bbox. Returns `null`
3044
+ * when the frame dimensions are unknown/degenerate or there are no
3045
+ * positions — the caller persists NULL envelope columns in that case.
3027
3046
  */
3028
- function decideLastFramePromotion(media) {
3029
- let newestSnapshot;
3030
- let lastFrame;
3031
- for (const m of media) if (m.kind === "snapshot") {
3032
- if (newestSnapshot === void 0 || m.timestamp > newestSnapshot.timestamp) newestSnapshot = m;
3033
- } else if (m.kind === "lastFrame") {
3034
- if (lastFrame === void 0 || m.timestamp > lastFrame.timestamp) lastFrame = m;
3047
+ function computeTrackEnvelope(positions, frameWidth, frameHeight) {
3048
+ if (frameWidth === void 0 || frameHeight === void 0 || !(frameWidth > 0) || !(frameHeight > 0) || positions.length === 0) return null;
3049
+ let minX = Number.POSITIVE_INFINITY;
3050
+ let minY = Number.POSITIVE_INFINITY;
3051
+ let maxX = Number.NEGATIVE_INFINITY;
3052
+ let maxY = Number.NEGATIVE_INFINITY;
3053
+ for (const p of positions) {
3054
+ const x0 = p.bbox.x / frameWidth;
3055
+ const y0 = p.bbox.y / frameHeight;
3056
+ const x1 = (p.bbox.x + p.bbox.w) / frameWidth;
3057
+ const y1 = (p.bbox.y + p.bbox.h) / frameHeight;
3058
+ if (x0 < minX) minX = x0;
3059
+ if (y0 < minY) minY = y0;
3060
+ if (x1 > maxX) maxX = x1;
3061
+ if (y1 > maxY) maxY = y1;
3035
3062
  }
3036
- if (newestSnapshot === void 0) return { promote: false };
3037
- if (lastFrame !== void 0 && newestSnapshot.timestamp <= lastFrame.timestamp) return { promote: false };
3038
3063
  return {
3039
- promote: true,
3040
- snapshotKey: newestSnapshot.key,
3041
- snapshotTimestamp: newestSnapshot.timestamp
3064
+ minX,
3065
+ minY,
3066
+ maxX,
3067
+ maxY
3042
3068
  };
3043
3069
  }
3044
- //#endregion
3045
- //#region src/pipeline-analytics/pipeline/static-track-gate.ts
3046
3070
  /**
3047
- * Net displacement + path span for a track's centroid path, normalized to
3048
- * `referenceDiagonalPx`. Returns undefined when there is nothing meaningful to
3049
- * measure (fewer than two points, or a degenerate ≤0 reference) so the caller
3050
- * leaves the importance score untouched.
3071
+ * Axis-aligned bounds of a zone filter (rect: itself; polygon: vertex
3072
+ * min/max). A degenerate polygon (< 3 points) yields the full frame so the
3073
+ * SQL prefilter never silently drops rows the precise test would keep.
3051
3074
  */
3052
- function computeStaticTrackMetrics(centroids, referenceDiagonalPx) {
3053
- if (!(referenceDiagonalPx > 0) || centroids.length < 2) return void 0;
3054
- const first = centroids[0];
3055
- const last = centroids[centroids.length - 1];
3056
- const netDisplacementFrac = Math.hypot(last.x - first.x, last.y - first.y) / referenceDiagonalPx;
3057
- let minX = Infinity;
3058
- let minY = Infinity;
3059
- let maxX = -Infinity;
3060
- let maxY = -Infinity;
3061
- for (const c of centroids) {
3062
- if (c.x < minX) minX = c.x;
3063
- if (c.x > maxX) maxX = c.x;
3064
- if (c.y < minY) minY = c.y;
3065
- if (c.y > maxY) maxY = c.y;
3075
+ function zoneBounds(zone) {
3076
+ if (zone.kind === "rect") return {
3077
+ minX: zone.x,
3078
+ minY: zone.y,
3079
+ maxX: zone.x + zone.width,
3080
+ maxY: zone.y + zone.height
3081
+ };
3082
+ if (zone.points.length < 3) return {
3083
+ minX: 0,
3084
+ minY: 0,
3085
+ maxX: 1,
3086
+ maxY: 1
3087
+ };
3088
+ let minX = Number.POSITIVE_INFINITY;
3089
+ let minY = Number.POSITIVE_INFINITY;
3090
+ let maxX = Number.NEGATIVE_INFINITY;
3091
+ let maxY = Number.NEGATIVE_INFINITY;
3092
+ for (const p of zone.points) {
3093
+ if (p.x < minX) minX = p.x;
3094
+ if (p.y < minY) minY = p.y;
3095
+ if (p.x > maxX) maxX = p.x;
3096
+ if (p.y > maxY) maxY = p.y;
3066
3097
  }
3067
3098
  return {
3068
- netDisplacementFrac,
3069
- pathSpanFrac: Math.hypot(maxX - minX, maxY - minY) / referenceDiagonalPx
3099
+ minX,
3100
+ minY,
3101
+ maxX,
3102
+ maxY
3070
3103
  };
3071
3104
  }
3072
- /** Average bbox diagonal (px) across a track's positions — the scale reference
3073
- * when frame dimensions aren't available. Returns 0 for an empty list. */
3074
- function averageBboxDiagonal(boxes) {
3075
- if (boxes.length === 0) return 0;
3076
- let sum = 0;
3077
- for (const b of boxes) sum += Math.hypot(b.w, b.h);
3078
- return sum / boxes.length;
3105
+ /** Whether two axis-aligned envelopes overlap (touching edges count). */
3106
+ function envelopesOverlap(a, b) {
3107
+ return a.minX <= b.maxX && a.maxX >= b.minX && a.minY <= b.maxY && a.maxY >= b.minY;
3108
+ }
3109
+ /**
3110
+ * Ray-casting point-in-polygon (even-odd rule). Points on an edge may
3111
+ * resolve either way — acceptable for zone filtering. A polygon with
3112
+ * fewer than 3 vertices contains nothing.
3113
+ */
3114
+ function pointInPolygon(point, polygon) {
3115
+ if (polygon.length < 3) return false;
3116
+ let inside = false;
3117
+ for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
3118
+ const a = polygon[i];
3119
+ const b = polygon[j];
3120
+ 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;
3121
+ }
3122
+ return inside;
3123
+ }
3124
+ /**
3125
+ * Precise per-position zone test.
3126
+ *
3127
+ * - rect zone → any position bbox (normalized) intersects the rect.
3128
+ * - polygon zone → any position CENTER (normalized `x`/`y` — positions
3129
+ * store the bbox center) falls inside the polygon.
3130
+ *
3131
+ * Unknown/degenerate frame dims → `true` (the track cannot be tested, so it
3132
+ * PASSES — mirroring the NULL-envelope-matches rule).
3133
+ */
3134
+ function positionsIntersectZone(positions, frameWidth, frameHeight, zone) {
3135
+ if (frameWidth === void 0 || frameHeight === void 0 || !(frameWidth > 0) || !(frameHeight > 0)) return true;
3136
+ if (zone.kind === "rect") {
3137
+ const rect = zoneBounds(zone);
3138
+ for (const p of positions) if (envelopesOverlap({
3139
+ minX: p.bbox.x / frameWidth,
3140
+ minY: p.bbox.y / frameHeight,
3141
+ maxX: (p.bbox.x + p.bbox.w) / frameWidth,
3142
+ maxY: (p.bbox.y + p.bbox.h) / frameHeight
3143
+ }, rect)) return true;
3144
+ return false;
3145
+ }
3146
+ for (const p of positions) if (pointInPolygon({
3147
+ x: p.x / frameWidth,
3148
+ y: p.y / frameHeight
3149
+ }, zone.points)) return true;
3150
+ return false;
3151
+ }
3152
+ var DEFAULT_ZERO_MEDIA_POLICY_CONFIG = {
3153
+ suppressMaxDurationMs: 1e3,
3154
+ nothingToShowMaxDurationMs: 1500
3155
+ };
3156
+ /**
3157
+ * Classify a closing track's persistence outcome. Pure — see the module header
3158
+ * for the full contract.
3159
+ */
3160
+ function decideZeroMediaPolicy(input, config = DEFAULT_ZERO_MEDIA_POLICY_CONFIG) {
3161
+ if (input.hasMedia) return "persist";
3162
+ if (input.durationMs < config.suppressMaxDurationMs && !input.confirmed) return "suppress";
3163
+ if (!input.hasRasterFallback && input.durationMs < config.nothingToShowMaxDurationMs) return "suppress";
3164
+ return input.hasRasterFallback ? "raster-fallback" : "persist";
3079
3165
  }
3080
3166
  //#endregion
3081
3167
  //#region src/pipeline-analytics/pipeline/delete-track-cascade.ts
@@ -3140,6 +3226,83 @@ async function runTrackCascadeBatch(deps, trackIds) {
3140
3226
  };
3141
3227
  }
3142
3228
  //#endregion
3229
+ //#region src/pipeline-analytics/pipeline/last-frame-promotion.ts
3230
+ /**
3231
+ * Decide whether a closing track's newest `snapshot` should be promoted to be
3232
+ * its `lastFrame`. Pure — see the module header for the contract.
3233
+ *
3234
+ * Promote (`mode:'move'`) when there is at least one `snapshot` AND either
3235
+ * there is no `lastFrame` yet, or the newest snapshot is strictly newer than
3236
+ * the held `lastFrame`. With NO snapshot AND NO lastFrame at all (a track that
3237
+ * died within one snapshot interval), fall back to `mode:'copy'` from the
3238
+ * newest `keyFrameSmall` so a short track still closes with a full-frame
3239
+ * "Ultimo" view. Otherwise no promotion.
3240
+ */
3241
+ function decideLastFramePromotion(media) {
3242
+ let newestSnapshot;
3243
+ let lastFrame;
3244
+ let newestKeyFrameSmall;
3245
+ for (const m of media) if (m.kind === "snapshot") {
3246
+ if (newestSnapshot === void 0 || m.timestamp > newestSnapshot.timestamp) newestSnapshot = m;
3247
+ } else if (m.kind === "lastFrame") {
3248
+ if (lastFrame === void 0 || m.timestamp > lastFrame.timestamp) lastFrame = m;
3249
+ } else if (m.kind === "keyFrameSmall") {
3250
+ if (newestKeyFrameSmall === void 0 || m.timestamp > newestKeyFrameSmall.timestamp) newestKeyFrameSmall = m;
3251
+ }
3252
+ if (newestSnapshot === void 0) {
3253
+ if (lastFrame === void 0 && newestKeyFrameSmall !== void 0) return {
3254
+ promote: true,
3255
+ mode: "copy",
3256
+ snapshotKey: newestKeyFrameSmall.key,
3257
+ snapshotTimestamp: newestKeyFrameSmall.timestamp
3258
+ };
3259
+ return { promote: false };
3260
+ }
3261
+ if (lastFrame !== void 0 && newestSnapshot.timestamp <= lastFrame.timestamp) return { promote: false };
3262
+ return {
3263
+ promote: true,
3264
+ mode: "move",
3265
+ snapshotKey: newestSnapshot.key,
3266
+ snapshotTimestamp: newestSnapshot.timestamp
3267
+ };
3268
+ }
3269
+ //#endregion
3270
+ //#region src/pipeline-analytics/pipeline/static-track-gate.ts
3271
+ /**
3272
+ * Net displacement + path span for a track's centroid path, normalized to
3273
+ * `referenceDiagonalPx`. Returns undefined when there is nothing meaningful to
3274
+ * measure (fewer than two points, or a degenerate ≤0 reference) so the caller
3275
+ * leaves the importance score untouched.
3276
+ */
3277
+ function computeStaticTrackMetrics(centroids, referenceDiagonalPx) {
3278
+ if (!(referenceDiagonalPx > 0) || centroids.length < 2) return void 0;
3279
+ const first = centroids[0];
3280
+ const last = centroids[centroids.length - 1];
3281
+ const netDisplacementFrac = Math.hypot(last.x - first.x, last.y - first.y) / referenceDiagonalPx;
3282
+ let minX = Infinity;
3283
+ let minY = Infinity;
3284
+ let maxX = -Infinity;
3285
+ let maxY = -Infinity;
3286
+ for (const c of centroids) {
3287
+ if (c.x < minX) minX = c.x;
3288
+ if (c.x > maxX) maxX = c.x;
3289
+ if (c.y < minY) minY = c.y;
3290
+ if (c.y > maxY) maxY = c.y;
3291
+ }
3292
+ return {
3293
+ netDisplacementFrac,
3294
+ pathSpanFrac: Math.hypot(maxX - minX, maxY - minY) / referenceDiagonalPx
3295
+ };
3296
+ }
3297
+ /** Average bbox diagonal (px) across a track's positions — the scale reference
3298
+ * when frame dimensions aren't available. Returns 0 for an empty list. */
3299
+ function averageBboxDiagonal(boxes) {
3300
+ if (boxes.length === 0) return 0;
3301
+ let sum = 0;
3302
+ for (const b of boxes) sum += Math.hypot(b.w, b.h);
3303
+ return sum / boxes.length;
3304
+ }
3305
+ //#endregion
3143
3306
  //#region src/pipeline-analytics/pipeline/track-close.ts
3144
3307
  var TrackCloser = class {
3145
3308
  deps;
@@ -3210,6 +3373,7 @@ var TrackCloser = class {
3210
3373
  }
3211
3374
  if (outcome === "raster-fallback" && closure?.rasterFallback) await this.commitRasterFallback(t, closure.rasterFallback.jpeg, closure.rasterFallback.timestamp);
3212
3375
  await this.maybePromoteLastFrame(t, ownedMedia);
3376
+ await this.maybeDeriveThumbnailFromKeyFrame(t, ownedMedia);
3213
3377
  this.deps.logger.info("track ended", {
3214
3378
  tags: { deviceId: t.deviceId },
3215
3379
  meta: {
@@ -3220,6 +3384,7 @@ var TrackCloser = class {
3220
3384
  }
3221
3385
  });
3222
3386
  const keyFrameMediaKey = this.deps.residents.keyFrameKey(t.trackId);
3387
+ const labelConfidence = this.bestLabelMatchConfidence(t.deviceId, t.trackId);
3223
3388
  this.fireRecognizerEnds(t.deviceId, t.trackId);
3224
3389
  const trackerPeak = this.deps.residents.bestFramePeak(t.trackId);
3225
3390
  const importanceSummary = await this.scoreImportance(t, duration, trackerPeak?.confidence);
@@ -3228,7 +3393,23 @@ var TrackCloser = class {
3228
3393
  this.deps.overlayState.onTrackEnded(t.deviceId, t.trackId);
3229
3394
  this.flushCaptureWindowIfIdle(t.deviceId);
3230
3395
  this.emitEndEvents(t, duration, trackerPeak?.confidence, keyFrameMediaKey, importanceSummary);
3231
- this.deps.onTrackClosed?.(t, ownedMedia, { ...trackerPeak?.confidence !== void 0 ? { bestConfidence: trackerPeak.confidence } : {} });
3396
+ const dims = this.deps.frameDims(t.deviceId);
3397
+ const envelope = computeTrackEnvelope(t.positions, dims?.w, dims?.h);
3398
+ this.deps.onTrackClosed?.(t, ownedMedia, {
3399
+ ...trackerPeak?.confidence !== void 0 ? { bestConfidence: trackerPeak.confidence } : {},
3400
+ ...importanceSummary.importance !== void 0 ? { importance: importanceSummary.importance } : {},
3401
+ ...labelConfidence !== void 0 ? { labelConfidence } : {},
3402
+ ...envelope !== null ? { envelope } : {}
3403
+ });
3404
+ }
3405
+ /** Max recognition match confidence over the face + plate recognizers for a
3406
+ * closing track (undefined when neither recognized a label). */
3407
+ bestLabelMatchConfidence(_deviceId, trackId) {
3408
+ const face = this.deps.faceRecognizer()?.bestLabelMatchConfidence?.(trackId);
3409
+ const plate = this.deps.plateRecognizer()?.bestLabelMatchConfidence?.(trackId);
3410
+ if (face === void 0) return plate;
3411
+ if (plate === void 0) return face;
3412
+ return Math.max(face, plate);
3232
3413
  }
3233
3414
  /**
3234
3415
  * Undo a zero-media false-birth track (spec §"Zero-media track policy"): tear
@@ -3295,27 +3476,95 @@ var TrackCloser = class {
3295
3476
  }
3296
3477
  }
3297
3478
  /**
3298
- * Genuinely-last view (operator, 2026-07-22): the rolling `lastFrame` never
3299
- * fires on a `snapshot` frame and rolls on its own cadence, so at close it
3300
- * can trail the newest appended snapshot by up to one interval. If a newer
3301
- * snapshot exists (or there is no lastFrame but ≥1 snapshot), PROMOTE it
3302
- * into the single lastFrame slot and drop the snapshot row — one
3303
- * genuinely-last view, no duplicate pair. Reuses the `ownedMedia` list
3304
- * already fetched by the caller (base64 present no re-read).
3479
+ * Close-time keyFrame-derived `thumbnail` fallback (#27-A part 3): when the
3480
+ * closing track's owned media has NO `thumbnail` but HAS a native `keyFrame`,
3481
+ * derive the thumbnail (+ `thumbnailSmall`) from the persisted keyFrame,
3482
+ * cropping the BEST-seen subject bbox with the standard 16:9 central-square
3483
+ * framing (the derive is injected `createKeyFrameCrop` composed with the
3484
+ * wide-central-square layout in the addon wiring). Best-effort with its own
3485
+ * catch: any failure leaves the track as it was and never blocks the close.
3305
3486
  */
3306
- async maybePromoteLastFrame(t, ownedMedia) {
3307
- const promotion = decideLastFramePromotion(ownedMedia);
3308
- if (!promotion.promote) return;
3309
- const snapshot = ownedMedia.find((m) => m.key === promotion.snapshotKey);
3310
- if (!snapshot) return;
3487
+ async maybeDeriveThumbnailFromKeyFrame(t, ownedMedia) {
3488
+ const derive = this.deps.deriveThumbnailFromKeyFrame;
3489
+ if (!derive) return;
3490
+ if (ownedMedia.some((m) => m.kind === "thumbnail")) return;
3491
+ const keyFrame = ownedMedia.find((m) => m.kind === "keyFrame");
3492
+ if (!keyFrame) return;
3493
+ const bestSeen = this.deps.residents.bestSeenBbox(t.trackId);
3494
+ if (!bestSeen) return;
3311
3495
  try {
3312
- await this.deps.mediaStore()?.promoteToLastFrame({
3313
- deviceId: t.deviceId,
3314
- trackId: t.trackId,
3315
- snapshot
3496
+ const derived = await derive({
3497
+ mediaKey: keyFrame.key,
3498
+ bbox: {
3499
+ x: bestSeen.x,
3500
+ y: bestSeen.y,
3501
+ w: bestSeen.w,
3502
+ h: bestSeen.h
3503
+ },
3504
+ frameWidth: bestSeen.frameWidth,
3505
+ frameHeight: bestSeen.frameHeight,
3506
+ timestamp: bestSeen.timestamp
3316
3507
  });
3317
- } catch (err) {
3318
- this.deps.logger.debug("lastFrame promotion failed", {
3508
+ if (!derived) return;
3509
+ const mediaStore = this.deps.mediaStore();
3510
+ if (!mediaStore) return;
3511
+ await mediaStore.put({
3512
+ deviceId: t.deviceId,
3513
+ ownerKind: "track",
3514
+ ownerId: t.trackId,
3515
+ kind: "thumbnail",
3516
+ timestamp: bestSeen.timestamp,
3517
+ data: derived.thumbnail
3518
+ });
3519
+ await mediaStore.put({
3520
+ deviceId: t.deviceId,
3521
+ ownerKind: "track",
3522
+ ownerId: t.trackId,
3523
+ kind: "thumbnailSmall",
3524
+ timestamp: bestSeen.timestamp,
3525
+ data: derived.thumbnailSmall
3526
+ });
3527
+ this.deps.logger.info("thumbnail derived from keyFrame at close (no live best-shot landed)", {
3528
+ tags: { deviceId: t.deviceId },
3529
+ meta: {
3530
+ trackId: t.trackId,
3531
+ keyFrameKey: keyFrame.key,
3532
+ skewMs: derived.skewMs
3533
+ }
3534
+ });
3535
+ } catch (err) {
3536
+ this.deps.logger.debug("close-time keyFrame thumbnail derive failed", {
3537
+ tags: { deviceId: t.deviceId },
3538
+ meta: {
3539
+ trackId: t.trackId,
3540
+ error: String(err)
3541
+ }
3542
+ });
3543
+ }
3544
+ }
3545
+ /**
3546
+ * Genuinely-last view (operator, 2026-07-22): the rolling `lastFrame` never
3547
+ * fires on a `snapshot` frame and rolls on its own cadence, so at close it
3548
+ * can trail the newest appended snapshot by up to one interval. If a newer
3549
+ * snapshot exists (or there is no lastFrame but ≥1 snapshot), PROMOTE it
3550
+ * into the single lastFrame slot and drop the snapshot row — one
3551
+ * genuinely-last view, no duplicate pair. Reuses the `ownedMedia` list
3552
+ * already fetched by the caller (base64 present → no re-read).
3553
+ */
3554
+ async maybePromoteLastFrame(t, ownedMedia) {
3555
+ const promotion = decideLastFramePromotion(ownedMedia);
3556
+ if (!promotion.promote) return;
3557
+ const snapshot = ownedMedia.find((m) => m.key === promotion.snapshotKey);
3558
+ if (!snapshot) return;
3559
+ try {
3560
+ await this.deps.mediaStore()?.promoteToLastFrame({
3561
+ deviceId: t.deviceId,
3562
+ trackId: t.trackId,
3563
+ snapshot,
3564
+ keepSource: promotion.mode === "copy"
3565
+ });
3566
+ } catch (err) {
3567
+ this.deps.logger.debug("lastFrame promotion failed", {
3319
3568
  tags: { deviceId: t.deviceId },
3320
3569
  meta: {
3321
3570
  trackId: t.trackId,
@@ -3416,6 +3665,23 @@ var TrackCloser = class {
3416
3665
  };
3417
3666
  //#endregion
3418
3667
  //#region src/notification-center/rule-engine.ts
3668
+ /**
3669
+ * Normalize a PIXEL-space detection bbox onto 0..1 using its detection-frame
3670
+ * dims. Returns `undefined` when the box or the dims are missing/degenerate
3671
+ * (dim ≤ 0) — the caller then omits the subject bbox and `customZones` fails
3672
+ * closed rather than testing pixel coords against a 0..1 polygon.
3673
+ */
3674
+ function normalizeBbox(bbox, frameWidth, frameHeight) {
3675
+ if (bbox === void 0) return void 0;
3676
+ if (frameWidth === void 0 || frameHeight === void 0) return void 0;
3677
+ if (frameWidth <= 0 || frameHeight <= 0) return void 0;
3678
+ return {
3679
+ x: bbox.x / frameWidth,
3680
+ y: bbox.y / frameHeight,
3681
+ w: bbox.w / frameWidth,
3682
+ h: bbox.h / frameHeight
3683
+ };
3684
+ }
3419
3685
  /** Build the subject for an `immediate` (object-event) evaluation. */
3420
3686
  function subjectFromObjectEvent(ev) {
3421
3687
  return {
@@ -3427,7 +3693,132 @@ function subjectFromObjectEvent(ev) {
3427
3693
  ...ev.label !== void 0 ? { label: ev.label } : {},
3428
3694
  ...ev.confidence !== void 0 ? { confidence: ev.confidence } : {},
3429
3695
  zones: ev.zones ?? [],
3430
- ...ev.trackId !== void 0 ? { trackId: ev.trackId } : {}
3696
+ ...ev.trackId !== void 0 ? { trackId: ev.trackId } : {},
3697
+ source: ev.source ?? "pipeline",
3698
+ ...ev.importance !== void 0 ? { importance: ev.importance } : {},
3699
+ ...bboxPatch(normalizeBbox(ev.bbox, ev.frameWidth, ev.frameHeight))
3700
+ };
3701
+ }
3702
+ /** Spread an optional normalized bbox onto a subject (present only if set). */
3703
+ function bboxPatch(bbox) {
3704
+ return bbox !== void 0 ? { bbox } : {};
3705
+ }
3706
+ /** Convert a normalized 0..1 track envelope (min/max) into the subject's
3707
+ * top-left+size `BboxRect`. `undefined` in ⇒ `undefined` out (fail closed). */
3708
+ function envelopeToBbox(envelope) {
3709
+ if (envelope === void 0) return void 0;
3710
+ return {
3711
+ x: envelope.minX,
3712
+ y: envelope.minY,
3713
+ w: envelope.maxX - envelope.minX,
3714
+ h: envelope.maxY - envelope.minY
3715
+ };
3716
+ }
3717
+ /**
3718
+ * Read the raw device event-type token off a persisted `SensorEvent.value`
3719
+ * slice. Only the event-emitter cap carries one (`EventEmitterStatus.lastEvent
3720
+ * .eventType`); doorbell-pulse / passive-sensor slices have none. Pure +
3721
+ * defensive over the untyped record snapshot.
3722
+ */
3723
+ function readSensorEventType(value) {
3724
+ if (value === null) return void 0;
3725
+ const last = value["lastEvent"];
3726
+ if (last === null || typeof last !== "object" || Array.isArray(last)) return void 0;
3727
+ const eventType = last["eventType"];
3728
+ return typeof eventType === "string" && eventType.length > 0 ? eventType : void 0;
3729
+ }
3730
+ /** Build the subject for a `device-event` evaluation (a persisted SensorEvent —
3731
+ * one row per linked camera; `deviceId` is the CAMERA). */
3732
+ function subjectFromSensorEvent(ev) {
3733
+ const eventType = readSensorEventType(ev.value);
3734
+ return {
3735
+ kind: "device-event",
3736
+ recordId: ev.id,
3737
+ deviceId: ev.deviceId,
3738
+ timestamp: ev.timestamp,
3739
+ classNames: [],
3740
+ zones: [],
3741
+ source: "sensor",
3742
+ sensorKind: ev.kind,
3743
+ ...eventType !== void 0 ? { eventType } : {}
3744
+ };
3745
+ }
3746
+ /**
3747
+ * Build the subject for an AUDIO evaluation. Audio classification events
3748
+ * (`eventStore.insertAudio`) persist on a SEPARATE path from object events and
3749
+ * never reach the object-event hook — this normalizes one onto the engine
3750
+ * subject so an `immediate` rule that OPTS IN to an `audio-*` class can fire on
3751
+ * it. The class id is the SAME namespaced `audio-<macroClass>` id the NC
3752
+ * taxonomy exposes (see `nc-taxonomy.ts` audioKinds), so a picker selection
3753
+ * matches exactly. `confidence` carries the classification score (so a
3754
+ * `minConfidence` condition composes naturally). A level-path audio event (no
3755
+ * `classification`) yields NO class ⇒ it can never satisfy the audio opt-in
3756
+ * gate, so only classified audio ever notifies (documented boundary). Audio has
3757
+ * no zones / bbox / label / track, so the object/track-specific conditions all
3758
+ * fail closed (see the {@link evaluateRule} audio gate + the matchers below).
3759
+ */
3760
+ function subjectFromAudioEvent(ev) {
3761
+ const macro = ev.classification?.className;
3762
+ return {
3763
+ kind: "audio-event",
3764
+ recordId: ev.id,
3765
+ deviceId: ev.deviceId,
3766
+ timestamp: ev.timestamp,
3767
+ classNames: macro !== void 0 ? [`audio-${macro}`] : [],
3768
+ ...ev.classification?.score !== void 0 ? { confidence: ev.classification.score } : {},
3769
+ zones: [],
3770
+ source: "pipeline"
3771
+ };
3772
+ }
3773
+ /**
3774
+ * Build the subject for an OCCUPANCY evaluation. A committed `OccupancyEdge`
3775
+ * (ZoneAnalytics count crossing) rides the EXISTING `device-event` delivery via
3776
+ * this INTERNAL subject kind (the audio pattern with a device-event landing —
3777
+ * `device-event` is already a native `NcHistoryRecordKind`, so no history
3778
+ * downgrade is needed). The edge scope + count/threshold ride on `occupancy`;
3779
+ * the engine's occupancy branch matches the rule's `occupancy` condition against
3780
+ * it. Admin-zone provenance is NOT copied onto `zones` (kept empty) so the admin
3781
+ * `zones` condition FAILS CLOSED on occupancy — zone scoping is done inside the
3782
+ * occupancy condition (`occupancy.zoneId`). No detection confidence / label /
3783
+ * bbox / importance / sensorKind, so those conditions all fail closed too.
3784
+ */
3785
+ function subjectFromOccupancyEvent(edge) {
3786
+ return {
3787
+ kind: "occupancy-event",
3788
+ recordId: `occ:${edge.deviceId}:${edge.zoneId ?? "@frame"}:${edge.className ?? "@all"}:t${edge.threshold}:${edge.occupied ? "occ" : "free"}:${edge.timestamp}`,
3789
+ deviceId: edge.deviceId,
3790
+ timestamp: edge.timestamp,
3791
+ classNames: [edge.className ?? "occupancy"],
3792
+ zones: [],
3793
+ source: "pipeline",
3794
+ occupancy: {
3795
+ ...edge.zoneId !== void 0 ? { zoneId: edge.zoneId } : {},
3796
+ ...edge.zoneName !== void 0 ? { zoneName: edge.zoneName } : {},
3797
+ ...edge.className !== void 0 ? { className: edge.className } : {},
3798
+ count: edge.count,
3799
+ previousCount: edge.previousCount,
3800
+ occupied: edge.occupied,
3801
+ threshold: edge.threshold
3802
+ }
3803
+ };
3804
+ }
3805
+ /** Build the subject for a `package-event` evaluation (a persisted `package`
3806
+ * object-event; `phase` derives from the event `state` at the call site). */
3807
+ function subjectFromPackageEvent(ev, phase) {
3808
+ return {
3809
+ kind: "package-event",
3810
+ recordId: ev.id,
3811
+ deviceId: ev.deviceId,
3812
+ timestamp: ev.timestamp,
3813
+ classNames: [ev.className],
3814
+ ...ev.label !== void 0 ? { label: ev.label } : {},
3815
+ ...ev.confidence !== void 0 ? { confidence: ev.confidence } : {},
3816
+ zones: ev.zones ?? [],
3817
+ ...ev.trackId !== void 0 ? { trackId: ev.trackId } : {},
3818
+ source: ev.source ?? "pipeline",
3819
+ ...ev.importance !== void 0 ? { importance: ev.importance } : {},
3820
+ ...bboxPatch(normalizeBbox(ev.bbox, ev.frameWidth, ev.frameHeight)),
3821
+ packagePhase: phase
3431
3822
  };
3432
3823
  }
3433
3824
  /** Build the subject for a `track-end` evaluation. */
@@ -3441,20 +3832,105 @@ function subjectFromTrack(track, info) {
3441
3832
  ...track.label !== void 0 ? { label: track.label } : {},
3442
3833
  ...info?.bestConfidence !== void 0 ? { confidence: info.bestConfidence } : {},
3443
3834
  zones: track.zonesVisited,
3444
- trackId: track.trackId
3835
+ trackId: track.trackId,
3836
+ source: track.source ?? "pipeline",
3837
+ ...(info?.importance ?? track.importance) !== void 0 ? { importance: info?.importance ?? track.importance } : {},
3838
+ dwellSeconds: (track.lastSeen - track.firstSeen) / 1e3,
3839
+ ...info?.labelConfidence !== void 0 ? { labelConfidence: info.labelConfidence } : {},
3840
+ ...bboxPatch(envelopeToBbox(info?.envelope ?? track.envelope))
3445
3841
  };
3446
3842
  }
3447
- var PASS = { matched: true };
3448
3843
  function fail(condition) {
3449
3844
  return {
3450
3845
  matched: false,
3451
3846
  failedCondition: condition
3452
3847
  };
3453
3848
  }
3849
+ /**
3850
+ * The condition ids PRESENT on a rule (an empty/absent group contributes
3851
+ * nothing). Order follows the catalog. On a matched rule every id returned
3852
+ * here passed, so the list is the rule's "matched on" summary.
3853
+ */
3854
+ function presentConditionIds(c) {
3855
+ const ids = [];
3856
+ if (c.devices !== void 0 && c.devices.length > 0) ids.push("devices");
3857
+ if (c.source !== void 0 && c.source !== "any") ids.push("source");
3858
+ if (c.classes !== void 0 && c.classes.length > 0) ids.push("classes");
3859
+ if (c.classesExclude !== void 0 && c.classesExclude.length > 0) ids.push("classesExclude");
3860
+ if (c.minConfidence !== void 0) ids.push("minConfidence");
3861
+ if (c.minImportance !== void 0) ids.push("minImportance");
3862
+ if (c.minDwellSeconds !== void 0) ids.push("minDwellSeconds");
3863
+ if (c.zones !== void 0) ids.push("zones");
3864
+ if (c.zonesExclude !== void 0 && c.zonesExclude.length > 0) ids.push("zonesExclude");
3865
+ if (c.labelEquals !== void 0 && c.labelEquals.length > 0) ids.push("labelEquals");
3866
+ if (c.identities !== void 0 && c.identities.length > 0) ids.push("identities");
3867
+ if (c.identitiesExclude !== void 0 && c.identitiesExclude.length > 0) ids.push("identitiesExclude");
3868
+ if (c.minLabelConfidence !== void 0) ids.push("minLabelConfidence");
3869
+ if (c.plates !== void 0) ids.push("plates");
3870
+ if (c.sensorKinds !== void 0 && c.sensorKinds.length > 0) ids.push("sensorKinds");
3871
+ if (c.eventTypeTokens !== void 0 && c.eventTypeTokens.length > 0) ids.push("eventTypeTokens");
3872
+ if (c.packagePhase !== void 0 && c.packagePhase !== "both") ids.push("packagePhase");
3873
+ if (c.customZones !== void 0 && c.customZones.length > 0) ids.push("customZones");
3874
+ if (c.occupancy !== void 0) ids.push("occupancy");
3875
+ return ids;
3876
+ }
3877
+ /**
3878
+ * Match a rule's occupancy condition against the subject's committed count-edge.
3879
+ * The scope (`zoneId`/`className`) must match EXACTLY (fail-closed), then the
3880
+ * `op` selects the edge polarity + threshold:
3881
+ * - `became-occupied` / `>=` C → the occupied (false→true) edge of the key
3882
+ * whose threshold is C.
3883
+ * - `became-free` C → the un-occupied (true→false) edge of key C.
3884
+ * - `<=` C → the un-occupied edge of key C+1 (count dropped
3885
+ * from >C to ≤C — the predicate `count ≥ C+1` went false).
3886
+ * Per-threshold keying guarantees the subject's `threshold` already equals the
3887
+ * key's, so a rule matches only its OWN edge — a ≥1 and a ≥3 rule never
3888
+ * cross-fire on a gradual accumulation.
3889
+ */
3890
+ function matchesOccupancy(occ, s) {
3891
+ if ((occ.zoneId ?? void 0) !== (s.zoneId ?? void 0)) return false;
3892
+ if ((occ.className ?? void 0) !== (s.className ?? void 0)) return false;
3893
+ switch (occ.op) {
3894
+ case "became-occupied":
3895
+ case ">=": return s.occupied === true && s.threshold === occ.count;
3896
+ case "became-free": return s.occupied === false && s.threshold === occ.count;
3897
+ case "<=": return s.occupied === false && s.threshold === occ.count + 1;
3898
+ }
3899
+ }
3454
3900
  function toLowerSet(values) {
3455
3901
  return new Set(values.map((v) => v.trim().toLowerCase()));
3456
3902
  }
3457
3903
  /**
3904
+ * Expand each selected class id to itself PLUS its taxonomy leaf subs, so a
3905
+ * MACRO selection (`vehicle`) matches its sub classNames (`car`/`truck`) — the
3906
+ * `EVENT_TAXONOMY` tree is the SINGLE source (mirrors the timeline/filter
3907
+ * grouping). A leaf id expands to just itself. Applies to the video class
3908
+ * matchers (`classes` / `classesExclude`); audio ids (`audio-*`) are leaves so
3909
+ * they expand to themselves. Lower-cased for the case-insensitive membership
3910
+ * test.
3911
+ */
3912
+ function expandClassSelector(classes) {
3913
+ const out = /* @__PURE__ */ new Set();
3914
+ for (const raw of classes) {
3915
+ const c = raw.trim().toLowerCase();
3916
+ if (c.length === 0) continue;
3917
+ out.add(c);
3918
+ for (const subEntry of subKindsOf(c)) out.add(subEntry.kind.trim().toLowerCase());
3919
+ }
3920
+ return out;
3921
+ }
3922
+ /**
3923
+ * True when a rule's `classes` condition explicitly opts in to at least one
3924
+ * audio kind (`audio-*` id). The safety gate for audio subjects: an `immediate`
3925
+ * rule fires on an audio subject ONLY when it names an audio class — a rule with
3926
+ * no classes, or with only video classes, NEVER fires on audio (preserves
3927
+ * today's behavior, where classified audio never reached the engine at all).
3928
+ */
3929
+ function referencesAudioClass(classes) {
3930
+ if (classes === void 0) return false;
3931
+ return classes.some((c) => c.trim().toLowerCase().startsWith("audio-"));
3932
+ }
3933
+ /**
3458
3934
  * Evaluate one rule against one subject. The rule's `delivery` must match
3459
3935
  * the subject kind (`immediate` ↔ `object-event`, `track-end` ↔
3460
3936
  * `track-end`) — a mismatch fails with `'delivery'`. Schedule is evaluated
@@ -3465,12 +3941,27 @@ function toLowerSet(values) {
3465
3941
  * is stateful; see {@link cooldownKey} / {@link isCoolingDown}.
3466
3942
  */
3467
3943
  function evaluateRule(rule, subject) {
3468
- if ((rule.delivery === "immediate" ? "object-event" : "track-end") !== subject.kind) return fail("delivery");
3944
+ if (subject.kind === "occupancy-event") {
3945
+ if (rule.delivery !== "device-event") return fail("delivery");
3946
+ const occ = rule.conditions.occupancy;
3947
+ if (occ === void 0) return fail("occupancy");
3948
+ if (subject.occupancy === void 0 || !matchesOccupancy(occ, subject.occupancy)) return fail("occupancy");
3949
+ } else if (subject.kind === "audio-event") {
3950
+ if (rule.delivery !== "immediate") return fail("delivery");
3951
+ if (!referencesAudioClass(rule.conditions.classes)) return fail("classes");
3952
+ if (rule.conditions.occupancy !== void 0) return fail("occupancy");
3953
+ } else {
3954
+ if ((rule.delivery === "immediate" ? "object-event" : rule.delivery) !== subject.kind) return fail("delivery");
3955
+ if (rule.conditions.occupancy !== void 0) return fail("occupancy");
3956
+ }
3469
3957
  const c = rule.conditions;
3470
3958
  if (c.devices !== void 0 && c.devices.length > 0 && !c.devices.includes(subject.deviceId)) return fail("devices");
3959
+ if (c.source !== void 0 && c.source !== "any") {
3960
+ if ((subject.source ?? "pipeline") !== c.source) return fail("source");
3961
+ }
3471
3962
  const subjectClasses = toLowerSet(subject.classNames);
3472
3963
  if (c.classes !== void 0 && c.classes.length > 0) {
3473
- const wanted = toLowerSet(c.classes);
3964
+ const wanted = expandClassSelector(c.classes);
3474
3965
  let overlap = false;
3475
3966
  for (const cls of subjectClasses) if (wanted.has(cls)) {
3476
3967
  overlap = true;
@@ -3479,12 +3970,18 @@ function evaluateRule(rule, subject) {
3479
3970
  if (!overlap) return fail("classes");
3480
3971
  }
3481
3972
  if (c.classesExclude !== void 0 && c.classesExclude.length > 0) {
3482
- const vetoed = toLowerSet(c.classesExclude);
3973
+ const vetoed = expandClassSelector(c.classesExclude);
3483
3974
  for (const cls of subjectClasses) if (vetoed.has(cls)) return fail("classesExclude");
3484
3975
  }
3485
3976
  if (c.minConfidence !== void 0) {
3486
3977
  if (subject.confidence === void 0 || subject.confidence < c.minConfidence) return fail("minConfidence");
3487
3978
  }
3979
+ if (c.minImportance !== void 0) {
3980
+ if (subject.importance === void 0 || subject.importance < c.minImportance) return fail("minImportance");
3981
+ }
3982
+ if (c.minDwellSeconds !== void 0) {
3983
+ if (subject.dwellSeconds === void 0 || subject.dwellSeconds < c.minDwellSeconds) return fail("minDwellSeconds");
3984
+ }
3488
3985
  if (c.zones !== void 0) {
3489
3986
  const visited = new Set(subject.zones);
3490
3987
  if (c.zones.match === "all") {
@@ -3495,6 +3992,10 @@ function evaluateRule(rule, subject) {
3495
3992
  const visited = new Set(subject.zones);
3496
3993
  if (c.zonesExclude.some((id) => visited.has(id))) return fail("zonesExclude");
3497
3994
  }
3995
+ if (c.customZones !== void 0 && c.customZones.length > 0) {
3996
+ const bbox = subject.bbox;
3997
+ if (!(bbox !== void 0 && c.customZones.some((poly) => bboxPolygonOverlap(bbox, poly.points) > 0))) return fail("customZones");
3998
+ }
3498
3999
  const label = subject.label?.trim().toLowerCase();
3499
4000
  if (c.labelEquals !== void 0 && c.labelEquals.length > 0) {
3500
4001
  if (label === void 0 || !toLowerSet(c.labelEquals).has(label)) return fail("labelEquals");
@@ -3502,11 +4003,29 @@ function evaluateRule(rule, subject) {
3502
4003
  if (c.identities !== void 0 && c.identities.length > 0) {
3503
4004
  if (label === void 0 || !toLowerSet(c.identities).has(label)) return fail("identities");
3504
4005
  }
4006
+ if (c.identitiesExclude !== void 0 && c.identitiesExclude.length > 0) {
4007
+ if (label !== void 0 && toLowerSet(c.identitiesExclude).has(label)) return fail("identitiesExclude");
4008
+ }
4009
+ if (c.minLabelConfidence !== void 0) {
4010
+ if (subject.labelConfidence === void 0 || subject.labelConfidence < c.minLabelConfidence) return fail("minLabelConfidence");
4011
+ }
3505
4012
  if (c.plates !== void 0) {
3506
4013
  if (subject.label === void 0 || !matchesPlate(subject.label, c.plates.values, c.plates.maxDistance)) return fail("plates");
3507
4014
  }
4015
+ if (c.sensorKinds !== void 0 && c.sensorKinds.length > 0) {
4016
+ if (subject.sensorKind === void 0 || !toLowerSet(c.sensorKinds).has(subject.sensorKind.trim().toLowerCase())) return fail("sensorKinds");
4017
+ }
4018
+ if (c.eventTypeTokens !== void 0 && c.eventTypeTokens.length > 0) {
4019
+ if (subject.eventType === void 0 || !toLowerSet(c.eventTypeTokens).has(subject.eventType.trim().toLowerCase())) return fail("eventTypeTokens");
4020
+ }
4021
+ if (c.packagePhase !== void 0 && c.packagePhase !== "both") {
4022
+ if (subject.packagePhase !== c.packagePhase) return fail("packagePhase");
4023
+ }
3508
4024
  if (!isScheduleActive(rule.schedule, subject.timestamp)) return fail("schedule");
3509
- return PASS;
4025
+ return {
4026
+ matched: true,
4027
+ matchedOn: presentConditionIds(c)
4028
+ };
3510
4029
  }
3511
4030
  var WEEKDAY_TO_DAY = {
3512
4031
  Sun: 0,
@@ -3601,7 +4120,8 @@ function matchesPlate(label, values, maxDistance) {
3601
4120
  }
3602
4121
  /** Stable cooldown key per the rule's throttle scope. */
3603
4122
  function cooldownKey(rule, subject) {
3604
- return rule.throttle.scope === "rule" ? `r:${rule.id}` : `r:${rule.id}:d:${subject.deviceId}`;
4123
+ const audioClass = subject.kind === "audio-event" && subject.classNames[0] !== void 0 ? `:c:${subject.classNames[0]}` : "";
4124
+ return rule.throttle.scope === "rule" ? `r:${rule.id}${audioClass}` : `r:${rule.id}:d:${subject.deviceId}${audioClass}`;
3605
4125
  }
3606
4126
  /** True when the rule fired within its cooldown window before `now`. */
3607
4127
  function isCoolingDown(rule, lastFiredAt, now) {
@@ -3636,171 +4156,231 @@ function attachmentKindPreference(policy, ownerKind) {
3636
4156
  "fullFrameBoxed"
3637
4157
  ];
3638
4158
  }
4159
+ /**
4160
+ * Derive the `best-matching` media signal from a matched rule's condition
4161
+ * summary ({@link NcEvaluation.matchedOn}). Identity takes priority over plate
4162
+ * (D-3 ordering: a face rule that ALSO plate-matched attaches the face crop).
4163
+ */
4164
+ function matchSignal(matchedOn) {
4165
+ if (matchedOn === void 0) return null;
4166
+ if (matchedOn.includes("identities")) return "face";
4167
+ if (matchedOn.includes("plates")) return "plate";
4168
+ return null;
4169
+ }
4170
+ /**
4171
+ * Ordered media-kind preference for a `best-matching` attachment. The
4172
+ * signal-specific crop (`faceCrop` / `plateCrop`, both event-owned) leads;
4173
+ * then the plain `best` subject ladder, then the `keyFrame` clean-scene
4174
+ * ladder — so a missing specific crop degrades to best → keyFrame → none
4175
+ * (the dispatcher returns null when nothing resolves) without ever blocking
4176
+ * the send. A `null` signal is exactly the `best` ladder.
4177
+ */
4178
+ function bestMatchingKindPreference(signal, ownerKind) {
4179
+ return [
4180
+ ...signal === "face" ? ["faceCrop"] : signal === "plate" ? ["plateCrop"] : [],
4181
+ ...attachmentKindPreference("best", ownerKind),
4182
+ ...attachmentKindPreference("keyFrame", ownerKind)
4183
+ ];
4184
+ }
3639
4185
  //#endregion
3640
- //#region src/notification-center/rule-store.ts
3641
- var NC_RULES_COLLECTION = "notification-center:rules";
3642
- var NC_RULES_COLUMNS = [
3643
- {
3644
- name: "id",
3645
- type: "TEXT",
3646
- primaryKey: true,
3647
- notNull: true
3648
- },
3649
- {
3650
- name: "name",
3651
- type: "TEXT",
3652
- notNull: true
3653
- },
3654
- {
3655
- name: "enabled",
3656
- type: "BOOLEAN",
3657
- notNull: true
3658
- },
3659
- {
3660
- name: "delivery",
3661
- type: "TEXT",
3662
- notNull: true
3663
- },
3664
- {
3665
- name: "updatedAt",
3666
- type: "INTEGER",
3667
- notNull: true
3668
- },
3669
- (
3670
- /** The FULL rule object (Zod-validated on read) — scalars above are
3671
- * indexed projections only. */
3672
- {
3673
- name: "rule",
3674
- type: "JSON",
3675
- notNull: true
3676
- })
3677
- ];
3678
- var NC_RULES_INDEXES = [{
3679
- name: "idx_nc_rules_enabled",
3680
- columns: ["enabled"]
3681
- }];
3682
- var NcRuleStore = class {
3683
- byId = /* @__PURE__ */ new Map();
3684
- store;
3685
- logger;
4186
+ //#region src/notification-center/dispatcher.ts
4187
+ var DEFAULT_TARGET_CACHE_TTL_MS = 6e4;
4188
+ var NcDispatcher = class {
4189
+ deps;
4190
+ targetCache = null;
4191
+ targetCacheAt = 0;
3686
4192
  now;
3687
- newId;
4193
+ targetCacheTtlMs;
3688
4194
  constructor(deps) {
3689
- this.store = deps.store;
3690
- this.logger = deps.logger;
4195
+ this.deps = deps;
3691
4196
  this.now = deps.now ?? (() => Date.now());
3692
- this.newId = deps.newId ?? (() => randomUUID());
3693
- }
3694
- static async declare(store) {
3695
- await store.declareCollection.mutate({
3696
- collection: NC_RULES_COLLECTION,
3697
- columns: [...NC_RULES_COLUMNS],
3698
- indexes: [...NC_RULES_INDEXES]
3699
- });
4197
+ this.targetCacheTtlMs = deps.targetCacheTtlMs ?? DEFAULT_TARGET_CACHE_TTL_MS;
3700
4198
  }
3701
- /**
3702
- * (Re)hydrate the FULL rule set from the store — called at boot and on
3703
- * the periodic refresh tick (cross-node CRUD staleness bound). Replaces
3704
- * the cache wholesale; a row whose JSON no longer validates is skipped
3705
- * with a warning (a degraded rule must never crash evaluation).
3706
- */
3707
- async load() {
4199
+ /** The outbox `deliver` executor. */
4200
+ async deliver(entry) {
4201
+ if (this.deps.isRuleTargetDisabled !== void 0) {
4202
+ if (await this.deps.isRuleTargetDisabled(entry.ruleId, entry.targetId)) {
4203
+ this.deps.logger.debug("notification skipped: target opted out of rule", { meta: {
4204
+ ruleId: entry.ruleId,
4205
+ targetId: entry.targetId
4206
+ } });
4207
+ return { ok: true };
4208
+ }
4209
+ }
4210
+ const target = await this.resolveTarget(entry.targetId);
4211
+ if (target === null) return {
4212
+ ok: false,
4213
+ error: `target not found: ${entry.targetId}`,
4214
+ permanent: true
4215
+ };
4216
+ if (!target.enabled) {
4217
+ this.deps.logger.debug("notification skipped: target globally disabled", { meta: {
4218
+ ruleId: entry.ruleId,
4219
+ targetId: target.id,
4220
+ target: target.name
4221
+ } });
4222
+ return { ok: true };
4223
+ }
4224
+ const notification = await this.buildNotification(entry);
3708
4225
  try {
3709
- const rows = await this.store.query.query({
3710
- collection: NC_RULES_COLLECTION,
3711
- filter: { limit: 1e4 }
4226
+ const result = await this.deps.send({
4227
+ addonId: target.addonId,
4228
+ targetId: target.id,
4229
+ notification
3712
4230
  });
3713
- this.byId.clear();
3714
- let skipped = 0;
3715
- for (const row of rows) {
3716
- const parsed = NcRuleSchema.safeParse(row.data["rule"]);
3717
- if (!parsed.success) {
3718
- skipped += 1;
3719
- continue;
4231
+ if (!result.success) return {
4232
+ ok: false,
4233
+ error: result.error ?? "send failed",
4234
+ permanent: false
4235
+ };
4236
+ this.deps.logger.info("notification delivered", {
4237
+ tags: { deviceId: entry.deviceId },
4238
+ meta: {
4239
+ ruleId: entry.ruleId,
4240
+ target: target.name,
4241
+ kind: target.kind,
4242
+ recordKind: entry.recordKind
3720
4243
  }
3721
- this.byId.set(parsed.data.id, parsed.data);
3722
- }
3723
- this.logger.debug("notification rules loaded", { meta: {
3724
- rules: this.byId.size,
3725
- ...skipped > 0 ? { skippedInvalid: skipped } : {}
3726
- } });
4244
+ });
4245
+ return { ok: true };
3727
4246
  } catch (err) {
3728
- this.logger.warn("notification rules load failed", { meta: { error: String(err) } });
4247
+ return {
4248
+ ok: false,
4249
+ error: String(err),
4250
+ permanent: false
4251
+ };
3729
4252
  }
3730
4253
  }
3731
- list() {
3732
- return [...this.byId.values()].sort((a, b) => b.updatedAt - a.updatedAt);
3733
- }
3734
- listEnabled(delivery) {
3735
- return this.list().filter((r) => r.enabled && r.delivery === delivery);
3736
- }
3737
- get(ruleId) {
3738
- return this.byId.get(ruleId) ?? null;
4254
+ async resolveTarget(targetId) {
4255
+ const cached = this.cachedTarget(targetId);
4256
+ if (cached !== null) return cached;
4257
+ try {
4258
+ const targets = await this.deps.listTargets();
4259
+ this.targetCache = new Map(targets.map((t) => [t.id, t]));
4260
+ this.targetCacheAt = this.now();
4261
+ } catch (err) {
4262
+ this.deps.logger.warn("target catalog refresh failed", { meta: { error: String(err) } });
4263
+ throw err instanceof Error ? err : new Error(String(err));
4264
+ }
4265
+ return this.targetCache.get(targetId) ?? null;
3739
4266
  }
3740
- /** Create a new rule. `createdBy` is the SERVER-injected caller userId. */
3741
- async create(input, createdBy) {
3742
- const now = this.now();
3743
- const rule = {
3744
- ...input,
3745
- id: this.newId(),
3746
- createdBy,
3747
- createdAt: now,
3748
- updatedAt: now
3749
- };
3750
- await this.persist(rule);
3751
- this.byId.set(rule.id, rule);
3752
- return rule;
4267
+ cachedTarget(targetId) {
4268
+ if (this.targetCache === null) return null;
4269
+ if (this.now() - this.targetCacheAt > this.targetCacheTtlMs) return null;
4270
+ return this.targetCache.get(targetId) ?? null;
3753
4271
  }
3754
- /** Apply a partial patch. Immutable: returns the NEW rule object. */
3755
- async update(ruleId, patch) {
3756
- const existing = this.byId.get(ruleId);
3757
- if (!existing) throw new Error(`notification rule not found: ${ruleId}`);
3758
- const candidate = {
3759
- ...existing,
3760
- ...patch,
3761
- id: existing.id,
3762
- createdBy: existing.createdBy,
3763
- createdAt: existing.createdAt,
3764
- updatedAt: this.now()
4272
+ async buildNotification(entry) {
4273
+ const subject = entry.payload.subject;
4274
+ const deviceName = await this.deps.getDeviceName(subject.deviceId).catch(() => null) ?? `camera ${subject.deviceId}`;
4275
+ const vars = buildTemplateVars(entry, deviceName);
4276
+ const title = renderTemplate(entry.payload.template?.title, vars) ?? entry.payload.ruleName;
4277
+ const body = renderTemplate(entry.payload.template?.body, vars) ?? defaultBody(entry, deviceName);
4278
+ const attachment = await this.resolveAttachment(entry);
4279
+ const params = pickParams(entry.payload.params);
4280
+ return {
4281
+ body,
4282
+ title,
4283
+ priority: clampPriority(paramNumber(entry.payload.params, "priority") ?? entry.payload.priority),
4284
+ ...params,
4285
+ tag: entry.ruleId,
4286
+ deviceId: subject.deviceId,
4287
+ ...subject.eventId !== void 0 ? { eventId: subject.eventId } : {},
4288
+ ...attachment !== null ? { attachments: [attachment] } : {}
3765
4289
  };
3766
- const updated = NcRuleSchema.parse(candidate);
3767
- await this.persist(updated);
3768
- this.byId.set(updated.id, updated);
3769
- return updated;
3770
4290
  }
3771
- async setEnabled(ruleId, enabled) {
3772
- return this.update(ruleId, { enabled });
3773
- }
3774
- /** Idempotent delete unknown ids are a no-op. */
3775
- async delete(ruleId) {
3776
- this.byId.delete(ruleId);
3777
- try {
3778
- await this.store.delete.mutate({
3779
- collection: NC_RULES_COLLECTION,
3780
- key: ruleId
3781
- });
4291
+ /**
4292
+ * Resolve ONE image attachment per the rule's media policy, best
4293
+ * AVAILABLE at send time. Preference order comes from the pure
4294
+ * {@link attachmentKindPreference} / {@link bestMatchingKindPreference};
4295
+ * the event owner is tried first for `immediate` entries, falling back to
4296
+ * the parent track's media set. `best-matching` leads with the crop that
4297
+ * explains the fired condition (`faceCrop`/`plateCrop`, both event-owned)
4298
+ * then degrades to the plain `best` → `keyFrame` ladders.
4299
+ */
4300
+ async resolveAttachment(entry) {
4301
+ const policy = entry.payload.media;
4302
+ if (policy === "none") return null;
4303
+ const subject = entry.payload.subject;
4304
+ const signal = policy === "best-matching" ? matchSignal(entry.payload.matchedOn) : null;
4305
+ const owners = [];
4306
+ if (subject.eventId !== void 0) owners.push({
4307
+ kind: "event",
4308
+ id: subject.eventId
4309
+ });
4310
+ if (subject.trackId !== void 0) owners.push({
4311
+ kind: "track",
4312
+ id: subject.trackId
4313
+ });
4314
+ if (policy === "keyFrame") owners.reverse();
4315
+ for (const owner of owners) try {
4316
+ const files = await this.deps.getMediaForOwner(owner.kind, owner.id);
4317
+ if (files.length === 0) continue;
4318
+ const preference = policy === "best-matching" ? bestMatchingKindPreference(signal, owner.kind) : attachmentKindPreference(policy, owner.kind);
4319
+ for (const kind of preference) {
4320
+ const file = files.find((f) => f.kind === kind);
4321
+ if (file === void 0) continue;
4322
+ const raw = Buffer.from(file.base64, "base64");
4323
+ if (raw.byteLength === 0) continue;
4324
+ const bytes = new Uint8Array(raw.byteLength);
4325
+ bytes.set(raw);
4326
+ return {
4327
+ mediaType: "image",
4328
+ bytes,
4329
+ mime: "image/jpeg",
4330
+ name: `${file.kind}.jpg`
4331
+ };
4332
+ }
3782
4333
  } catch (err) {
3783
- this.logger.warn("notification rule delete failed", { meta: {
3784
- ruleId,
4334
+ this.deps.logger.debug("attachment media read failed", { meta: {
4335
+ owner: owner.kind,
4336
+ ownerId: owner.id,
3785
4337
  error: String(err)
3786
4338
  } });
3787
- throw err instanceof Error ? err : new Error(String(err));
3788
4339
  }
3789
- }
3790
- async persist(rule) {
3791
- await this.store.set.mutate({
3792
- collection: NC_RULES_COLLECTION,
3793
- key: rule.id,
3794
- value: {
3795
- name: rule.name,
3796
- enabled: rule.enabled,
3797
- delivery: rule.delivery,
3798
- updatedAt: rule.updatedAt,
3799
- rule
3800
- }
3801
- });
4340
+ return null;
3802
4341
  }
3803
4342
  };
4343
+ function buildTemplateVars(entry, deviceName) {
4344
+ const subject = entry.payload.subject;
4345
+ return {
4346
+ camera: deviceName,
4347
+ class: subject.className,
4348
+ label: subject.label ?? "",
4349
+ zones: subject.zones.join(", "),
4350
+ zone: subject.zones[0] ?? "",
4351
+ confidence: subject.confidence !== void 0 ? `${Math.round(subject.confidence * 100)}%` : "",
4352
+ time: new Date(subject.timestamp).toLocaleTimeString(),
4353
+ rule: entry.payload.ruleName
4354
+ };
4355
+ }
4356
+ /** `{{var}}` interpolation; missing vars render empty. Null template → null. */
4357
+ function renderTemplate(template, vars) {
4358
+ if (template === void 0 || template.trim().length === 0) return null;
4359
+ return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (_m, name) => vars[name] ?? "");
4360
+ }
4361
+ function defaultBody(entry, deviceName) {
4362
+ const subject = entry.payload.subject;
4363
+ const label = subject.label !== void 0 ? ` (${subject.label})` : "";
4364
+ const zones = subject.zones.length > 0 ? ` in ${subject.zones.join(", ")}` : "";
4365
+ const suffix = entry.recordKind === "track-end" ? " — visit ended" : "";
4366
+ return `${subject.className}${label} on ${deviceName}${zones}${suffix}`;
4367
+ }
4368
+ function pickParams(params) {
4369
+ if (params === void 0) return {};
4370
+ const out = {};
4371
+ if (typeof params["level"] === "string") out.level = params["level"];
4372
+ if (typeof params["sound"] === "string") out.sound = params["sound"];
4373
+ if (typeof params["clickUrl"] === "string") out.clickUrl = params["clickUrl"];
4374
+ if (typeof params["ttl"] === "number" && Number.isFinite(params["ttl"])) out.ttl = params["ttl"];
4375
+ return out;
4376
+ }
4377
+ function paramNumber(params, key) {
4378
+ const v = params?.[key];
4379
+ return typeof v === "number" && Number.isFinite(v) ? v : void 0;
4380
+ }
4381
+ function clampPriority(priority) {
4382
+ return Math.max(1, Math.min(5, Math.round(priority)));
4383
+ }
3804
4384
  //#endregion
3805
4385
  //#region src/notification-center/outbox.ts
3806
4386
  var NC_OUTBOX_COLLECTION = "notification-center:outbox";
@@ -4046,6 +4626,48 @@ var NcOutbox = class {
4046
4626
  return [];
4047
4627
  }
4048
4628
  }
4629
+ /**
4630
+ * Query persisted outbox rows as DELIVERY HISTORY — newest-first (by fire
4631
+ * time `createdAt`), bounded by `limit`. History is a read-only VIEW over
4632
+ * the outbox: the very rows the drain loop drives, in every lifecycle
4633
+ * state (pending / sent / dead). There is no second write path, so a
4634
+ * history row can never diverge from delivery state.
4635
+ *
4636
+ * Filters (`ruleId` / `deviceId` / `status` / `since`..`until`) are pushed
4637
+ * to the store; the in-memory `pending` map is deliberately NOT consulted
4638
+ * (terminal sent / dead rows live only in the store). Best-effort: a store
4639
+ * error yields an empty page rather than throwing into the cap call.
4640
+ */
4641
+ async queryHistory(query) {
4642
+ const where = {};
4643
+ if (query.ruleId !== void 0) where["ruleId"] = query.ruleId;
4644
+ if (query.deviceId !== void 0) where["deviceId"] = query.deviceId;
4645
+ if (query.status !== void 0) where["status"] = query.status;
4646
+ const hasRange = query.since !== void 0 || query.until !== void 0;
4647
+ try {
4648
+ const rows = await this.store.query.query({
4649
+ collection: NC_OUTBOX_COLLECTION,
4650
+ filter: {
4651
+ ...Object.keys(where).length > 0 ? { where } : {},
4652
+ ...hasRange ? { whereBetween: { createdAt: [query.since ?? 0, query.until ?? Number.MAX_SAFE_INTEGER] } } : {},
4653
+ orderBy: {
4654
+ field: "createdAt",
4655
+ direction: "desc"
4656
+ },
4657
+ limit: query.limit
4658
+ }
4659
+ });
4660
+ const out = [];
4661
+ for (const row of rows) {
4662
+ const entry = rowToEntry$1(row.id, row.data);
4663
+ if (entry !== null) out.push(entry);
4664
+ }
4665
+ return out;
4666
+ } catch (err) {
4667
+ this.logger.debug("outbox history query failed", { meta: { error: String(err) } });
4668
+ return [];
4669
+ }
4670
+ }
4049
4671
  async getWatermark() {
4050
4672
  try {
4051
4673
  const row = await this.store.get.query({
@@ -4197,6 +4819,16 @@ function entryToRow(entry) {
4197
4819
  payload: entry.payload
4198
4820
  };
4199
4821
  }
4822
+ var OUTBOX_RECORD_KINDS = new Set([
4823
+ "object-event",
4824
+ "track-end",
4825
+ "device-event",
4826
+ "package-event",
4827
+ "audio-event"
4828
+ ]);
4829
+ function isOutboxRecordKind(x) {
4830
+ return typeof x === "string" && OUTBOX_RECORD_KINDS.has(x);
4831
+ }
4200
4832
  function rowToEntry$1(id, data) {
4201
4833
  const ruleId = data["ruleId"];
4202
4834
  const targetId = data["targetId"];
@@ -4205,7 +4837,7 @@ function rowToEntry$1(id, data) {
4205
4837
  const recordId = data["recordId"];
4206
4838
  const status = data["status"];
4207
4839
  const payload = data["payload"];
4208
- if (typeof ruleId !== "string" || typeof targetId !== "string" || !Number.isFinite(deviceId) || recordKind !== "object-event" && recordKind !== "track-end" || typeof recordId !== "string" || status !== "pending" && status !== "sent" && status !== "dead" || payload === null || typeof payload !== "object") return null;
4840
+ if (typeof ruleId !== "string" || typeof targetId !== "string" || !Number.isFinite(deviceId) || !isOutboxRecordKind(recordKind) || typeof recordId !== "string" || status !== "pending" && status !== "sent" && status !== "dead" || payload === null || typeof payload !== "object") return null;
4209
4841
  const trackId = data["trackId"];
4210
4842
  const lastError = data["lastError"];
4211
4843
  return {
@@ -4226,189 +4858,587 @@ function rowToEntry$1(id, data) {
4226
4858
  };
4227
4859
  }
4228
4860
  //#endregion
4229
- //#region src/notification-center/dispatcher.ts
4230
- var DEFAULT_TARGET_CACHE_TTL_MS = 6e4;
4231
- var NcDispatcher = class {
4232
- deps;
4233
- targetCache = null;
4234
- targetCacheAt = 0;
4235
- now;
4236
- targetCacheTtlMs;
4237
- constructor(deps) {
4238
- this.deps = deps;
4239
- this.now = deps.now ?? (() => Date.now());
4240
- this.targetCacheTtlMs = deps.targetCacheTtlMs ?? DEFAULT_TARGET_CACHE_TTL_MS;
4861
+ //#region src/notification-center/occupancy-watcher.ts
4862
+ /** Sentinel key segments for the "no zone" (whole-frame) and "no class" scopes. */
4863
+ var FRAME_SCOPE = "@frame";
4864
+ var ALL_CLASSES = "@all";
4865
+ /** Prefix marking the trailing threshold segment (`t<n>`) — makes the grammar
4866
+ * self-describing so a legacy three-segment key can never be mis-parsed as a
4867
+ * four-segment one (the last segment of a legacy key is a className, never a
4868
+ * `t<digits>` token). */
4869
+ var THRESHOLD_PREFIX = "t";
4870
+ var THRESHOLD_SEGMENT = /^t(\d+)$/;
4871
+ function occupancyKey(deviceId, zoneId, className, threshold) {
4872
+ return `${deviceId}|${zoneId ?? FRAME_SCOPE}|${className ?? ALL_CLASSES}|${THRESHOLD_PREFIX}${threshold}`;
4873
+ }
4874
+ /**
4875
+ * Inverse of {@link occupancyKey} — the ONE place the key grammar is decoded, so
4876
+ * the durable store (whose column schema drops the derivable
4877
+ * `zoneId`/`className`/`threshold`) can reconstruct the full scope on reseed.
4878
+ *
4879
+ * The four-segment grammar is asserted EXACTLY: at least four segments AND a
4880
+ * trailing `t<digits>` threshold marker. A legacy three-segment key (no marker)
4881
+ * is REJECTED (`null`) rather than positionally mis-parsed — the durable store
4882
+ * then SKIPS that row, so a pre-amendment persisted row cold-re-observes instead
4883
+ * of hydrating a corrupt scope (className←threshold, zoneId←class). `deviceId`
4884
+ * is the leading numeric segment, `threshold` the trailing `t<n>`, `className`
4885
+ * the segment before it, and the (possibly `|`-containing) `zoneId` everything
4886
+ * between. Exact per-segment count is impossible because a `zoneId` may itself
4887
+ * contain `|`; the trailing marker is the unambiguous grammar discriminator.
4888
+ */
4889
+ function parseOccupancyKey(key) {
4890
+ const parts = key.split("|");
4891
+ if (parts.length < 4) return null;
4892
+ const deviceId = Number(parts[0]);
4893
+ if (!Number.isFinite(deviceId)) return null;
4894
+ const thresholdMatch = THRESHOLD_SEGMENT.exec(parts[parts.length - 1] ?? "");
4895
+ if (thresholdMatch === null) return null;
4896
+ const threshold = Number(thresholdMatch[1]);
4897
+ const className = parts[parts.length - 2] ?? ALL_CLASSES;
4898
+ const zoneId = parts.slice(1, -2).join("|");
4899
+ return {
4900
+ deviceId,
4901
+ ...zoneId !== FRAME_SCOPE ? { zoneId } : {},
4902
+ ...className !== ALL_CLASSES ? { className } : {},
4903
+ threshold
4904
+ };
4905
+ }
4906
+ /** Device-agnostic partial key (`zone|class|t<threshold>`) — the merge/watch
4907
+ * granularity. Distinct thresholds are distinct partial keys (per-threshold). */
4908
+ function partialKey(zoneId, className, threshold) {
4909
+ return `${zoneId ?? FRAME_SCOPE}|${className ?? ALL_CLASSES}|${THRESHOLD_PREFIX}${threshold}`;
4910
+ }
4911
+ /** The device-agnostic partial key of a full state/edge key — everything after
4912
+ * the leading `deviceId|` segment. */
4913
+ function partialKeyOf(key) {
4914
+ const idx = key.indexOf("|");
4915
+ return idx < 0 ? key : key.slice(idx + 1);
4916
+ }
4917
+ var OccupancyWatcher = class {
4918
+ /** Watched specs, keyed by their device-agnostic partial key. */
4919
+ watched = /* @__PURE__ */ new Map();
4920
+ /** Confirmed + pending state, keyed by the full {@link OccupancyKey}. */
4921
+ states = /* @__PURE__ */ new Map();
4922
+ /**
4923
+ * Replace the watched key set (rule-driven — recomputed on rule change).
4924
+ * Each distinct `(zone, class, threshold)` is its OWN watched partial key
4925
+ * (per-threshold edges — NO min-threshold merge). Specs that collide on the
4926
+ * SAME `(zone, class, threshold)` merge to the MAX sustain (longest debounce),
4927
+ * so one watcher serves those co-threshold rules. Confirmed state for a key
4928
+ * that is no longer watched is DROPPED (bounds the RAM map + lets the caller
4929
+ * prune the durable row to the active set); a still-watched key retains its
4930
+ * level (re-evaluated on the next `observe`).
4931
+ */
4932
+ setWatchedKeys(specs) {
4933
+ this.watched.clear();
4934
+ for (const spec of specs) {
4935
+ const pk = partialKey(spec.zoneId, spec.className, spec.threshold);
4936
+ const existing = this.watched.get(pk);
4937
+ if (existing === void 0) {
4938
+ this.watched.set(pk, {
4939
+ ...spec.zoneId !== void 0 ? { zoneId: spec.zoneId } : {},
4940
+ ...spec.className !== void 0 ? { className: spec.className } : {},
4941
+ threshold: spec.threshold,
4942
+ sustainSeconds: spec.sustainSeconds
4943
+ });
4944
+ continue;
4945
+ }
4946
+ this.watched.set(pk, {
4947
+ ...existing,
4948
+ sustainSeconds: Math.max(existing.sustainSeconds, spec.sustainSeconds)
4949
+ });
4950
+ }
4951
+ for (const key of [...this.states.keys()]) if (!this.watched.has(partialKeyOf(key))) this.states.delete(key);
4241
4952
  }
4242
- /** The outbox `deliver` executor. */
4243
- async deliver(entry) {
4244
- const target = await this.resolveTarget(entry.targetId);
4245
- if (target === null) return {
4246
- ok: false,
4247
- error: `target not found: ${entry.targetId}`,
4248
- permanent: true
4249
- };
4250
- if (!target.enabled) return {
4251
- ok: false,
4252
- error: `target disabled: ${target.name}`,
4253
- permanent: true
4254
- };
4255
- const notification = await this.buildNotification(entry);
4256
- try {
4257
- const result = await this.deps.send({
4258
- addonId: target.addonId,
4259
- targetId: target.id,
4260
- notification
4953
+ /**
4954
+ * Feed one camera snapshot at time `now`, returning the edges that COMMIT on
4955
+ * this tick (usually none). Every watched key is evaluated for `deviceId`;
4956
+ * fail-closed keys (absent zone) are skipped and hold no state.
4957
+ */
4958
+ observe(deviceId, snapshot, now) {
4959
+ const edges = [];
4960
+ for (const spec of this.watched.values()) {
4961
+ const resolved = resolveScope(snapshot, spec);
4962
+ if (resolved === null) continue;
4963
+ const key = occupancyKey(deviceId, spec.zoneId, spec.className, spec.threshold);
4964
+ const edge = step(this.stateFor(key, deviceId, spec, now), spec, resolved, now);
4965
+ if (edge !== null) edges.push(edge);
4966
+ }
4967
+ return edges;
4968
+ }
4969
+ /** Reseed confirmed state from durable rows (boot). Only rows whose key is
4970
+ * currently WATCHED are restored — an orphaned durable row (its rule gone)
4971
+ * is dropped, keeping the RAM map bounded to the active set. Pending edges
4972
+ * are not restored (fail-closed — they re-open on the next snapshots). */
4973
+ hydrate(rows) {
4974
+ for (const row of rows) {
4975
+ if (!this.watched.has(partialKeyOf(row.key))) continue;
4976
+ this.states.set(row.key, {
4977
+ deviceId: row.deviceId,
4978
+ ...row.zoneId !== void 0 ? { zoneId: row.zoneId } : {},
4979
+ ...row.className !== void 0 ? { className: row.className } : {},
4980
+ threshold: row.threshold,
4981
+ confirmedCount: row.confirmedCount,
4982
+ occupied: row.occupied,
4983
+ lastChangeAt: row.lastChangeAt
4261
4984
  });
4262
- if (!result.success) return {
4263
- ok: false,
4264
- error: result.error ?? "send failed",
4265
- permanent: false
4266
- };
4267
- this.deps.logger.info("notification delivered", {
4268
- tags: { deviceId: entry.deviceId },
4269
- meta: {
4270
- ruleId: entry.ruleId,
4271
- target: target.name,
4272
- kind: target.kind,
4273
- recordKind: entry.recordKind
4274
- }
4275
- });
4276
- return { ok: true };
4277
- } catch (err) {
4278
- return {
4279
- ok: false,
4280
- error: String(err),
4281
- permanent: false
4282
- };
4283
4985
  }
4284
4986
  }
4285
- async resolveTarget(targetId) {
4286
- const cached = this.cachedTarget(targetId);
4287
- if (cached !== null) return cached;
4987
+ /** Snapshot the CONFIRMED state for durable persistence (pending excluded). */
4988
+ snapshotState() {
4989
+ const rows = [];
4990
+ for (const [key, state] of this.states) rows.push({
4991
+ key,
4992
+ deviceId: state.deviceId,
4993
+ ...state.zoneId !== void 0 ? { zoneId: state.zoneId } : {},
4994
+ ...state.className !== void 0 ? { className: state.className } : {},
4995
+ threshold: state.threshold,
4996
+ confirmedCount: state.confirmedCount,
4997
+ occupied: state.occupied,
4998
+ lastChangeAt: state.lastChangeAt,
4999
+ updatedAt: state.lastChangeAt
5000
+ });
5001
+ return rows;
5002
+ }
5003
+ stateFor(key, deviceId, spec, now) {
5004
+ const existing = this.states.get(key);
5005
+ if (existing !== void 0) return existing;
5006
+ const fresh = {
5007
+ deviceId,
5008
+ ...spec.zoneId !== void 0 ? { zoneId: spec.zoneId } : {},
5009
+ ...spec.className !== void 0 ? { className: spec.className } : {},
5010
+ threshold: spec.threshold,
5011
+ confirmedCount: 0,
5012
+ occupied: false,
5013
+ lastChangeAt: now
5014
+ };
5015
+ this.states.set(key, fresh);
5016
+ return fresh;
5017
+ }
5018
+ };
5019
+ /** Read the count (+ zone name) for a spec, or `null` when fail-closed. */
5020
+ function resolveScope(snapshot, spec) {
5021
+ if (spec.zoneId === void 0) return { count: spec.className === void 0 ? snapshot.frame.totalObjects : snapshot.frame.byClass[spec.className] ?? 0 };
5022
+ const zone = snapshot.zones.find((z) => z.zoneId === spec.zoneId);
5023
+ if (zone === void 0) return null;
5024
+ return {
5025
+ count: spec.className === void 0 ? zone.totalObjects : zone.byClass[spec.className] ?? 0,
5026
+ zoneName: zone.zoneName
5027
+ };
5028
+ }
5029
+ /**
5030
+ * Advance one key's state by one observation. Mutates `state` in place (the
5031
+ * watcher owns it) and returns a committed edge, or `null`.
5032
+ */
5033
+ function step(state, spec, resolved, now) {
5034
+ const rawOccupied = resolved.count >= spec.threshold;
5035
+ const sustainMs = spec.sustainSeconds * 1e3;
5036
+ if (rawOccupied === state.occupied) {
5037
+ state.pendingTargetOccupied = void 0;
5038
+ state.pendingSince = void 0;
5039
+ return null;
5040
+ }
5041
+ if (state.pendingTargetOccupied !== rawOccupied) {
5042
+ state.pendingTargetOccupied = rawOccupied;
5043
+ state.pendingSince = now;
5044
+ }
5045
+ if (now - (state.pendingSince ?? now) < sustainMs) return null;
5046
+ const previousCount = state.confirmedCount;
5047
+ state.confirmedCount = resolved.count;
5048
+ state.occupied = rawOccupied;
5049
+ state.lastChangeAt = now;
5050
+ state.pendingTargetOccupied = void 0;
5051
+ state.pendingSince = void 0;
5052
+ return {
5053
+ deviceId: state.deviceId,
5054
+ ...spec.zoneId !== void 0 ? { zoneId: spec.zoneId } : {},
5055
+ ...resolved.zoneName !== void 0 ? { zoneName: resolved.zoneName } : {},
5056
+ ...spec.className !== void 0 ? { className: spec.className } : {},
5057
+ count: resolved.count,
5058
+ previousCount,
5059
+ occupied: rawOccupied,
5060
+ threshold: spec.threshold,
5061
+ timestamp: now
5062
+ };
5063
+ }
5064
+ //#endregion
5065
+ //#region src/notification-center/occupancy-store.ts
5066
+ var NC_OCCUPANCY_COLLECTION = "notification-center:occupancy";
5067
+ var NC_OCCUPANCY_COLUMNS = [
5068
+ {
5069
+ name: "key",
5070
+ type: "TEXT",
5071
+ primaryKey: true,
5072
+ notNull: true
5073
+ },
5074
+ {
5075
+ name: "deviceId",
5076
+ type: "INTEGER",
5077
+ notNull: true
5078
+ },
5079
+ {
5080
+ name: "confirmedCount",
5081
+ type: "INTEGER",
5082
+ notNull: true
5083
+ },
5084
+ {
5085
+ name: "occupied",
5086
+ type: "BOOLEAN",
5087
+ notNull: true
5088
+ },
5089
+ {
5090
+ name: "lastChangeAt",
5091
+ type: "INTEGER",
5092
+ notNull: true
5093
+ },
5094
+ {
5095
+ name: "updatedAt",
5096
+ type: "INTEGER",
5097
+ notNull: true
5098
+ }
5099
+ ];
5100
+ var NC_OCCUPANCY_INDEXES = [{
5101
+ name: "idx_nc_occupancy_device",
5102
+ columns: ["deviceId"]
5103
+ }];
5104
+ /** Query cap — a per-(device, zone, class) key set is small; this is a
5105
+ * generous ceiling that still bounds a pathological read. */
5106
+ var LOAD_LIMIT = 1e5;
5107
+ var OccupancyStore = class {
5108
+ cache = /* @__PURE__ */ new Map();
5109
+ store;
5110
+ logger;
5111
+ constructor(deps) {
5112
+ this.store = deps.store;
5113
+ this.logger = deps.logger;
5114
+ }
5115
+ static async declare(store) {
5116
+ await store.declareCollection.mutate({
5117
+ collection: NC_OCCUPANCY_COLLECTION,
5118
+ columns: [...NC_OCCUPANCY_COLUMNS],
5119
+ indexes: [...NC_OCCUPANCY_INDEXES]
5120
+ });
5121
+ }
5122
+ /**
5123
+ * Reseed the confirmed edge-state from the store (boot) — replaces the cache
5124
+ * wholesale and returns the rows for {@link OccupancyWatcher.hydrate}. A row
5125
+ * whose scalars/key no longer parse is skipped with a warning (a degraded row
5126
+ * must never crash the reseed). Best-effort: a store error yields `[]` and a
5127
+ * cold watcher, never a throw into boot.
5128
+ */
5129
+ async load() {
4288
5130
  try {
4289
- const targets = await this.deps.listTargets();
4290
- this.targetCache = new Map(targets.map((t) => [t.id, t]));
4291
- this.targetCacheAt = this.now();
5131
+ const records = await this.store.query.query({
5132
+ collection: NC_OCCUPANCY_COLLECTION,
5133
+ filter: { limit: LOAD_LIMIT }
5134
+ });
5135
+ this.cache.clear();
5136
+ let skipped = 0;
5137
+ for (const record of records) {
5138
+ const row = recordToRow(record.id, record.data);
5139
+ if (row === null) {
5140
+ skipped += 1;
5141
+ continue;
5142
+ }
5143
+ this.cache.set(row.key, row);
5144
+ }
5145
+ this.logger.debug("occupancy state loaded", { meta: {
5146
+ keys: this.cache.size,
5147
+ ...skipped > 0 ? { skippedInvalid: skipped } : {}
5148
+ } });
5149
+ return [...this.cache.values()];
4292
5150
  } catch (err) {
4293
- this.deps.logger.warn("target catalog refresh failed", { meta: { error: String(err) } });
4294
- throw err instanceof Error ? err : new Error(String(err));
5151
+ this.logger.warn("occupancy state load failed", { meta: { error: String(err) } });
5152
+ return [];
4295
5153
  }
4296
- return this.targetCache.get(targetId) ?? null;
4297
- }
4298
- cachedTarget(targetId) {
4299
- if (this.targetCache === null) return null;
4300
- if (this.now() - this.targetCacheAt > this.targetCacheTtlMs) return null;
4301
- return this.targetCache.get(targetId) ?? null;
4302
5154
  }
4303
- async buildNotification(entry) {
4304
- const subject = entry.payload.subject;
4305
- const deviceName = await this.deps.getDeviceName(subject.deviceId).catch(() => null) ?? `camera ${subject.deviceId}`;
4306
- const vars = buildTemplateVars(entry, deviceName);
4307
- const title = renderTemplate(entry.payload.template?.title, vars) ?? entry.payload.ruleName;
4308
- const body = renderTemplate(entry.payload.template?.body, vars) ?? defaultBody(entry, deviceName);
4309
- const attachment = await this.resolveAttachment(entry);
4310
- const params = pickParams(entry.payload.params);
4311
- return {
4312
- body,
4313
- title,
4314
- priority: clampPriority(paramNumber(entry.payload.params, "priority") ?? entry.payload.priority),
4315
- ...params,
4316
- tag: entry.ruleId,
4317
- deviceId: subject.deviceId,
4318
- ...subject.eventId !== void 0 ? { eventId: subject.eventId } : {},
4319
- ...attachment !== null ? { attachments: [attachment] } : {}
4320
- };
5155
+ /** The in-RAM confirmed-state mirror (post-{@link load}/{@link persist}). */
5156
+ snapshot() {
5157
+ return [...this.cache.values()];
4321
5158
  }
4322
5159
  /**
4323
- * Resolve ONE image attachment per the rule's media policy, best
4324
- * AVAILABLE at send time. Preference order comes from the pure
4325
- * {@link attachmentKindPreference}; the event owner is tried first for
4326
- * `immediate` entries, falling back to the parent track's media set.
5160
+ * Durably upsert one confirmed edge-state row (write-through: the store FIRST,
5161
+ * then the cache a failed persist never leaves a phantom in-RAM level). The
5162
+ * `key` is the PK, so re-persisting a key advances it in place.
4327
5163
  */
4328
- async resolveAttachment(entry) {
4329
- const policy = entry.payload.media;
4330
- if (policy === "none") return null;
4331
- const subject = entry.payload.subject;
4332
- const owners = [];
4333
- if (subject.eventId !== void 0) owners.push({
4334
- kind: "event",
4335
- id: subject.eventId
4336
- });
4337
- if (subject.trackId !== void 0) owners.push({
4338
- kind: "track",
4339
- id: subject.trackId
5164
+ async persist(row) {
5165
+ await this.store.set.mutate({
5166
+ collection: NC_OCCUPANCY_COLLECTION,
5167
+ key: row.key,
5168
+ value: rowToValue(row)
4340
5169
  });
4341
- if (policy === "keyFrame") owners.reverse();
4342
- for (const owner of owners) try {
4343
- const files = await this.deps.getMediaForOwner(owner.kind, owner.id);
4344
- if (files.length === 0) continue;
4345
- const preference = attachmentKindPreference(policy, owner.kind);
4346
- for (const kind of preference) {
4347
- const file = files.find((f) => f.kind === kind);
4348
- if (file === void 0) continue;
4349
- const raw = Buffer.from(file.base64, "base64");
4350
- if (raw.byteLength === 0) continue;
4351
- const bytes = new Uint8Array(raw.byteLength);
4352
- bytes.set(raw);
4353
- return {
4354
- mediaType: "image",
4355
- bytes,
4356
- mime: "image/jpeg",
4357
- name: `${file.kind}.jpg`
4358
- };
5170
+ this.cache.set(row.key, row);
5171
+ }
5172
+ /**
5173
+ * Prune every persisted key NOT in `activeKeys` (the currently watched set)
5174
+ * the bounded-row-count guarantee when rules stop watching a key. Returns the
5175
+ * number of rows dropped. Best-effort per row: a failed delete is logged and
5176
+ * the key retained (retried next prune) rather than aborting the sweep.
5177
+ */
5178
+ async pruneExcept(activeKeys) {
5179
+ let pruned = 0;
5180
+ for (const key of [...this.cache.keys()]) {
5181
+ if (activeKeys.has(key)) continue;
5182
+ try {
5183
+ await this.store.delete.mutate({
5184
+ collection: NC_OCCUPANCY_COLLECTION,
5185
+ key
5186
+ });
5187
+ this.cache.delete(key);
5188
+ pruned += 1;
5189
+ } catch (err) {
5190
+ this.logger.debug("occupancy prune delete failed", { meta: {
5191
+ key,
5192
+ error: String(err)
5193
+ } });
4359
5194
  }
4360
- } catch (err) {
4361
- this.deps.logger.debug("attachment media read failed", { meta: {
4362
- owner: owner.kind,
4363
- ownerId: owner.id,
4364
- error: String(err)
4365
- } });
4366
5195
  }
4367
- return null;
5196
+ return pruned;
4368
5197
  }
4369
5198
  };
4370
- function buildTemplateVars(entry, deviceName) {
4371
- const subject = entry.payload.subject;
5199
+ /** The persisted column map for a row (the `key` PK is passed separately). */
5200
+ function rowToValue(row) {
4372
5201
  return {
4373
- camera: deviceName,
4374
- class: subject.className,
4375
- label: subject.label ?? "",
4376
- zones: subject.zones.join(", "),
4377
- zone: subject.zones[0] ?? "",
4378
- confidence: subject.confidence !== void 0 ? `${Math.round(subject.confidence * 100)}%` : "",
4379
- time: new Date(subject.timestamp).toLocaleTimeString(),
4380
- rule: entry.payload.ruleName
5202
+ deviceId: row.deviceId,
5203
+ confirmedCount: row.confirmedCount,
5204
+ occupied: row.occupied,
5205
+ lastChangeAt: row.lastChangeAt,
5206
+ updatedAt: row.updatedAt
4381
5207
  };
4382
5208
  }
4383
- /** `{{var}}` interpolation; missing vars render empty. Null template → null. */
4384
- function renderTemplate(template, vars) {
4385
- if (template === void 0 || template.trim().length === 0) return null;
4386
- return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (_m, name) => vars[name] ?? "");
4387
- }
4388
- function defaultBody(entry, deviceName) {
4389
- const subject = entry.payload.subject;
4390
- const label = subject.label !== void 0 ? ` (${subject.label})` : "";
4391
- const zones = subject.zones.length > 0 ? ` in ${subject.zones.join(", ")}` : "";
4392
- const suffix = entry.recordKind === "track-end" ? " — visit ended" : "";
4393
- return `${subject.className}${label} on ${deviceName}${zones}${suffix}`;
4394
- }
4395
- function pickParams(params) {
4396
- if (params === void 0) return {};
4397
- const out = {};
4398
- if (typeof params["level"] === "string") out.level = params["level"];
4399
- if (typeof params["sound"] === "string") out.sound = params["sound"];
4400
- if (typeof params["clickUrl"] === "string") out.clickUrl = params["clickUrl"];
4401
- if (typeof params["ttl"] === "number" && Number.isFinite(params["ttl"])) out.ttl = params["ttl"];
4402
- return out;
4403
- }
4404
- function paramNumber(params, key) {
4405
- const v = params?.[key];
4406
- return typeof v === "number" && Number.isFinite(v) ? v : void 0;
4407
- }
4408
- function clampPriority(priority) {
4409
- return Math.max(1, Math.min(5, Math.round(priority)));
5209
+ /**
5210
+ * Structurally validate a persisted record and reconstruct the full
5211
+ * {@link OccupancyStateRow} (deriving `zoneId`/`className`/`threshold` from the
5212
+ * key). Returns `null` for any malformed row — including a legacy
5213
+ * three-segment key with no `t<n>` threshold marker, which
5214
+ * {@link parseOccupancyKey} rejects — so the caller skips it (cold re-observe)
5215
+ * rather than hydrating a mis-parsed scope.
5216
+ */
5217
+ function recordToRow(key, data) {
5218
+ const scope = parseOccupancyKey(key);
5219
+ if (scope === null) return null;
5220
+ const deviceId = Number(data["deviceId"]);
5221
+ const confirmedCount = Number(data["confirmedCount"]);
5222
+ const lastChangeAt = Number(data["lastChangeAt"]);
5223
+ const updatedAt = Number(data["updatedAt"]);
5224
+ if (!Number.isFinite(deviceId) || !Number.isFinite(confirmedCount) || !Number.isFinite(lastChangeAt) || !Number.isFinite(updatedAt)) return null;
5225
+ const rawOccupied = data["occupied"];
5226
+ const occupied = rawOccupied === true || rawOccupied === 1;
5227
+ return {
5228
+ key,
5229
+ deviceId,
5230
+ ...scope.zoneId !== void 0 ? { zoneId: scope.zoneId } : {},
5231
+ ...scope.className !== void 0 ? { className: scope.className } : {},
5232
+ threshold: scope.threshold,
5233
+ confirmedCount,
5234
+ occupied,
5235
+ lastChangeAt,
5236
+ updatedAt
5237
+ };
4410
5238
  }
4411
5239
  //#endregion
5240
+ //#region src/notification-center/rule-store.ts
5241
+ var NC_RULES_COLLECTION = "notification-center:rules";
5242
+ var NC_RULES_COLUMNS = [
5243
+ {
5244
+ name: "id",
5245
+ type: "TEXT",
5246
+ primaryKey: true,
5247
+ notNull: true
5248
+ },
5249
+ {
5250
+ name: "name",
5251
+ type: "TEXT",
5252
+ notNull: true
5253
+ },
5254
+ {
5255
+ name: "enabled",
5256
+ type: "BOOLEAN",
5257
+ notNull: true
5258
+ },
5259
+ {
5260
+ name: "delivery",
5261
+ type: "TEXT",
5262
+ notNull: true
5263
+ },
5264
+ {
5265
+ name: "updatedAt",
5266
+ type: "INTEGER",
5267
+ notNull: true
5268
+ },
5269
+ (
5270
+ /** The FULL rule object (Zod-validated on read) — scalars above are
5271
+ * indexed projections only. */
5272
+ {
5273
+ name: "rule",
5274
+ type: "JSON",
5275
+ notNull: true
5276
+ })
5277
+ ];
5278
+ var NC_RULES_INDEXES = [{
5279
+ name: "idx_nc_rules_enabled",
5280
+ columns: ["enabled"]
5281
+ }];
5282
+ var NcRuleStore = class {
5283
+ byId = /* @__PURE__ */ new Map();
5284
+ store;
5285
+ logger;
5286
+ now;
5287
+ newId;
5288
+ constructor(deps) {
5289
+ this.store = deps.store;
5290
+ this.logger = deps.logger;
5291
+ this.now = deps.now ?? (() => Date.now());
5292
+ this.newId = deps.newId ?? (() => randomUUID());
5293
+ }
5294
+ static async declare(store) {
5295
+ await store.declareCollection.mutate({
5296
+ collection: NC_RULES_COLLECTION,
5297
+ columns: [...NC_RULES_COLUMNS],
5298
+ indexes: [...NC_RULES_INDEXES]
5299
+ });
5300
+ }
5301
+ /**
5302
+ * (Re)hydrate the FULL rule set from the store — called at boot and on
5303
+ * the periodic refresh tick (cross-node CRUD staleness bound). Replaces
5304
+ * the cache wholesale; a row whose JSON no longer validates is skipped
5305
+ * with a warning (a degraded rule must never crash evaluation).
5306
+ */
5307
+ async load() {
5308
+ try {
5309
+ const rows = await this.store.query.query({
5310
+ collection: NC_RULES_COLLECTION,
5311
+ filter: { limit: 1e4 }
5312
+ });
5313
+ this.byId.clear();
5314
+ let skipped = 0;
5315
+ for (const row of rows) {
5316
+ const parsed = NcRuleSchema.safeParse(row.data["rule"]);
5317
+ if (!parsed.success) {
5318
+ skipped += 1;
5319
+ continue;
5320
+ }
5321
+ this.byId.set(parsed.data.id, parsed.data);
5322
+ }
5323
+ this.logger.debug("notification rules loaded", { meta: {
5324
+ rules: this.byId.size,
5325
+ ...skipped > 0 ? { skippedInvalid: skipped } : {}
5326
+ } });
5327
+ } catch (err) {
5328
+ this.logger.warn("notification rules load failed", { meta: { error: String(err) } });
5329
+ }
5330
+ }
5331
+ list() {
5332
+ return [...this.byId.values()].sort((a, b) => b.updatedAt - a.updatedAt);
5333
+ }
5334
+ listEnabled(delivery) {
5335
+ return this.list().filter((r) => r.enabled && r.delivery === delivery);
5336
+ }
5337
+ get(ruleId) {
5338
+ return this.byId.get(ruleId) ?? null;
5339
+ }
5340
+ /**
5341
+ * Rules visible to `userId`: their OWN personal rules (`ownerUserId ===
5342
+ * userId`) plus every admin/global rule (`ownerUserId` absent). Never
5343
+ * another user's personal rows. Newest-first (inherits {@link list}).
5344
+ *
5345
+ * The caller identity is server-derived; an absent/undefined caller must be
5346
+ * resolved to a fail-closed value by the bridge action BEFORE calling this —
5347
+ * this store never treats a missing caller as admin/global.
5348
+ */
5349
+ listForOwner(userId) {
5350
+ return this.list().filter((r) => r.ownerUserId === void 0 || r.ownerUserId === userId);
5351
+ }
5352
+ /** Create a new rule. `createdBy` is the SERVER-injected caller userId. */
5353
+ async create(input, createdBy) {
5354
+ const now = this.now();
5355
+ const rule = {
5356
+ ...input,
5357
+ id: this.newId(),
5358
+ createdBy,
5359
+ createdAt: now,
5360
+ updatedAt: now,
5361
+ disabledTargetIds: []
5362
+ };
5363
+ await this.persist(rule);
5364
+ this.byId.set(rule.id, rule);
5365
+ return rule;
5366
+ }
5367
+ /** Apply a partial patch. Immutable: returns the NEW rule object. */
5368
+ async update(ruleId, patch) {
5369
+ const existing = this.byId.get(ruleId);
5370
+ if (!existing) throw new Error(`notification rule not found: ${ruleId}`);
5371
+ const candidate = {
5372
+ ...existing,
5373
+ ...patch,
5374
+ id: existing.id,
5375
+ createdBy: existing.createdBy,
5376
+ createdAt: existing.createdAt,
5377
+ updatedAt: this.now()
5378
+ };
5379
+ const updated = NcRuleSchema.parse(candidate);
5380
+ await this.persist(updated);
5381
+ this.byId.set(updated.id, updated);
5382
+ return updated;
5383
+ }
5384
+ async setEnabled(ruleId, enabled) {
5385
+ return this.update(ruleId, { enabled });
5386
+ }
5387
+ /**
5388
+ * Per-target opt-out toggle for a rule. `enabled: false` suppresses the
5389
+ * target for THIS rule at send time; `true` re-enables it. Idempotent per
5390
+ * target (a `Set` dedups; removing an absent id is a no-op). Throws when the
5391
+ * rule id is unknown.
5392
+ *
5393
+ * OWNERSHIP: this store applies the toggle unconditionally — the owner-only
5394
+ * restriction ("only a target's owner may opt it out") is enforced by the
5395
+ * `nc.setRuleTargetEnabled` bridge action, which resolves the fail-closed
5396
+ * caller and validates target ownership before calling here.
5397
+ */
5398
+ async setRuleTargetEnabled(ruleId, targetId, enabled) {
5399
+ const existing = this.byId.get(ruleId);
5400
+ if (!existing) throw new Error(`notification rule not found: ${ruleId}`);
5401
+ const next = new Set(existing.disabledTargetIds);
5402
+ if (enabled) next.delete(targetId);
5403
+ else next.add(targetId);
5404
+ return this.update(ruleId, { disabledTargetIds: [...next] });
5405
+ }
5406
+ /** Hot-path read for the dispatcher: is `targetId` opted out of `ruleId`? */
5407
+ isRuleTargetDisabled(ruleId, targetId) {
5408
+ const rule = this.byId.get(ruleId);
5409
+ return rule ? rule.disabledTargetIds.includes(targetId) : false;
5410
+ }
5411
+ /** Idempotent delete — unknown ids are a no-op. */
5412
+ async delete(ruleId) {
5413
+ this.byId.delete(ruleId);
5414
+ try {
5415
+ await this.store.delete.mutate({
5416
+ collection: NC_RULES_COLLECTION,
5417
+ key: ruleId
5418
+ });
5419
+ } catch (err) {
5420
+ this.logger.warn("notification rule delete failed", { meta: {
5421
+ ruleId,
5422
+ error: String(err)
5423
+ } });
5424
+ throw err instanceof Error ? err : new Error(String(err));
5425
+ }
5426
+ }
5427
+ async persist(rule) {
5428
+ await this.store.set.mutate({
5429
+ collection: NC_RULES_COLLECTION,
5430
+ key: rule.id,
5431
+ value: {
5432
+ name: rule.name,
5433
+ enabled: rule.enabled,
5434
+ delivery: rule.delivery,
5435
+ updatedAt: rule.updatedAt,
5436
+ rule
5437
+ }
5438
+ });
5439
+ }
5440
+ };
5441
+ //#endregion
4412
5442
  //#region src/notification-center/index.ts
4413
5443
  var DEFAULT_DRAIN_INTERVAL_MS = 2e3;
4414
5444
  var DEFAULT_RULE_RELOAD_INTERVAL_MS = 3e4;
@@ -4421,6 +5451,69 @@ var WATERMARK_EVERY_TICKS = 15;
4421
5451
  /** Terminal outbox rows older than this are pruned at boot. */
4422
5452
  var OUTBOX_RETENTION_MS = 168 * 36e5;
4423
5453
  var TEST_RULE_MAX_RESULTS = 200;
5454
+ /**
5455
+ * The className every durable package event carries (mirrors
5456
+ * `PackageDropDetector.PACKAGE_EVENT_CLASS` — declared locally so the NC module
5457
+ * stays free of a cross-module import into the pipeline). Delivery events use
5458
+ * the `idle` state, pick-ups the `left` state.
5459
+ */
5460
+ var PACKAGE_EVENT_CLASS$1 = "package";
5461
+ /**
5462
+ * Map an occupancy condition to the watcher key spec. Every op keys on its own
5463
+ * `count` EXCEPT `<=` C, which keys on threshold C+1 — the `count ≥ C+1`
5464
+ * predicate goes false exactly when the count drops to ≤C (mirrors the engine's
5465
+ * `matchesOccupancy`, so the watched key and the matched edge always agree).
5466
+ */
5467
+ function occupancySpecFromCondition(occ) {
5468
+ const threshold = occ.op === "<=" ? occ.count + 1 : occ.count;
5469
+ return {
5470
+ ...occ.zoneId !== void 0 ? { zoneId: occ.zoneId } : {},
5471
+ ...occ.className !== void 0 ? { className: occ.className } : {},
5472
+ threshold,
5473
+ sustainSeconds: occ.sustainSeconds
5474
+ };
5475
+ }
5476
+ /** Classify an object-event row as a package delivery / pick-up, or `null` when
5477
+ * it is an ordinary detection. */
5478
+ function packagePhaseOf(ev) {
5479
+ if (ev.className !== PACKAGE_EVENT_CLASS$1) return null;
5480
+ if (ev.state === "left") return "picked-up";
5481
+ if (ev.state === "idle") return "delivered";
5482
+ return null;
5483
+ }
5484
+ /**
5485
+ * Project a durable outbox row onto a delivery-history entry (the cap
5486
+ * contract). History is a VIEW — this is the ONLY mapping; there is no
5487
+ * separate history collection to write, so a row and its history entry can
5488
+ * never disagree. `ruleName` + `subject` come from the intent snapshot
5489
+ * frozen at enqueue; `error` surfaces only on a dead row (`lastError`).
5490
+ */
5491
+ function outboxEntryToHistory(entry) {
5492
+ const s = entry.payload.subject;
5493
+ return {
5494
+ id: entry.id,
5495
+ ruleId: entry.ruleId,
5496
+ ruleName: entry.payload.ruleName,
5497
+ delivery: entry.payload.delivery,
5498
+ targetId: entry.targetId,
5499
+ deviceId: entry.deviceId,
5500
+ recordKind: entry.recordKind === "audio-event" ? "object-event" : entry.recordKind,
5501
+ recordId: entry.recordId,
5502
+ ...entry.trackId !== void 0 ? { trackId: entry.trackId } : {},
5503
+ status: entry.status,
5504
+ attempts: entry.attempts,
5505
+ createdAt: entry.createdAt,
5506
+ updatedAt: entry.updatedAt,
5507
+ ...entry.lastError !== void 0 ? { error: entry.lastError } : {},
5508
+ subject: {
5509
+ className: s.className,
5510
+ ...s.label !== void 0 ? { label: s.label } : {},
5511
+ ...s.confidence !== void 0 ? { confidence: s.confidence } : {},
5512
+ zones: [...s.zones],
5513
+ timestamp: s.timestamp
5514
+ }
5515
+ };
5516
+ }
4424
5517
  var NotificationCenter = class {
4425
5518
  logger;
4426
5519
  rules;
@@ -4428,6 +5521,14 @@ var NotificationCenter = class {
4428
5521
  dispatcher;
4429
5522
  deps;
4430
5523
  now;
5524
+ /** Debounced occupancy edge state machine (pure) + its durable confirmed
5525
+ * state. Fed in-process by {@link observeOccupancy} from ZoneAnalytics
5526
+ * snapshots; watched keys are recomputed from the enabled occupancy rules. */
5527
+ occupancyWatcher = new OccupancyWatcher();
5528
+ occupancyStore;
5529
+ /** True when ≥1 enabled `device-event` rule declares an occupancy condition —
5530
+ * the watcher is idle (zero per-frame cost) otherwise. */
5531
+ occupancyEnabled = false;
4431
5532
  /** In-memory cooldown map — seeded from persisted outbox rows on start. */
4432
5533
  lastFiredAt = /* @__PURE__ */ new Map();
4433
5534
  /**
@@ -4450,9 +5551,14 @@ var NotificationCenter = class {
4450
5551
  store: deps.store,
4451
5552
  logger: this.logger.child("rules")
4452
5553
  });
5554
+ this.occupancyStore = new OccupancyStore({
5555
+ store: deps.store,
5556
+ logger: this.logger.child("occupancy")
5557
+ });
4453
5558
  this.dispatcher = new NcDispatcher({
4454
5559
  ...deps.dispatcher,
4455
- logger: this.logger.child("dispatch")
5560
+ logger: this.logger.child("dispatch"),
5561
+ isRuleTargetDisabled: (ruleId, targetId) => this.rules.isRuleTargetDisabled(ruleId, targetId)
4456
5562
  });
4457
5563
  this.outbox = new NcOutbox({
4458
5564
  store: deps.store,
@@ -4461,10 +5567,20 @@ var NotificationCenter = class {
4461
5567
  ...deps.now !== void 0 ? { now: deps.now } : {}
4462
5568
  });
4463
5569
  }
5570
+ /**
5571
+ * The durable rule store — exposed so the hub-only `nc.*` bridge actions
5572
+ * (`nc-actions.ts`, addonId `pipeline-analytics`) can serve the viewer's
5573
+ * ownership-scoped rule CRUD over `addons.custom`. Read/write goes through
5574
+ * the same write-through cache the evaluation path reads.
5575
+ */
5576
+ get ruleStore() {
5577
+ return this.rules;
5578
+ }
4464
5579
  /** Declare every Notification Center collection (idempotent, boot-time). */
4465
5580
  static async declare(store) {
4466
5581
  await NcRuleStore.declare(store);
4467
5582
  await NcOutbox.declare(store);
5583
+ await OccupancyStore.declare(store);
4468
5584
  }
4469
5585
  /**
4470
5586
  * Load rules (every node — the cap provider serves CRUD from any node).
@@ -4474,17 +5590,19 @@ var NotificationCenter = class {
4474
5590
  */
4475
5591
  async start(opts) {
4476
5592
  await this.rules.load();
5593
+ this.refreshOccupancyWatch();
4477
5594
  if (!opts.evaluation) return;
4478
5595
  this.evaluationActive = true;
4479
5596
  await this.outbox.load();
4480
5597
  await this.seedCooldowns();
4481
5598
  await this.outbox.pruneBefore(this.now() - OUTBOX_RETENTION_MS);
5599
+ await this.hydrateOccupancy();
4482
5600
  await this.reconcile();
4483
5601
  this.drainTimer = setInterval(() => {
4484
5602
  this.drainTick();
4485
5603
  }, this.deps.drainIntervalMs ?? DEFAULT_DRAIN_INTERVAL_MS);
4486
5604
  this.reloadTimer = setInterval(() => {
4487
- this.rules.load();
5605
+ this.reloadRules();
4488
5606
  }, this.deps.ruleReloadIntervalMs ?? DEFAULT_RULE_RELOAD_INTERVAL_MS);
4489
5607
  this.logger.info("notification center started", { meta: {
4490
5608
  rules: this.rules.list().length,
@@ -4510,6 +5628,7 @@ var NotificationCenter = class {
4510
5628
  */
4511
5629
  onObjectEventPersisted(event, _track) {
4512
5630
  if (!this.evaluationActive) return;
5631
+ if (packagePhaseOf(event) !== null) return;
4513
5632
  const subject = subjectFromObjectEvent(event);
4514
5633
  this.scheduleEvaluation(subject, "object-event", () => ({
4515
5634
  tags: { deviceId: event.deviceId },
@@ -4530,6 +5649,117 @@ var NotificationCenter = class {
4530
5649
  meta: { trackId: track.trackId }
4531
5650
  }));
4532
5651
  }
5652
+ /**
5653
+ * Called at the SensorEvent persist site (`ingestSensorStateChange` — one row
5654
+ * per linked camera), in the SAME moment as the durable insert. Feeds the
5655
+ * `device-event` trigger (doorbell press / sensor state change). Fire-and-
5656
+ * forget from the ingest loop; the outbox owns delivery from here.
5657
+ *
5658
+ * Delivery-grade boundary (honest): the SensorEventStore is itself fed from
5659
+ * the LOSSY `DeviceStateChanged` telemetry bus, and there is no NC crash-gap
5660
+ * reconcile for sensor rows in this slice (unlike object events). So a dropped
5661
+ * upstream bus event, or a crash in the persist→outbox window, drops the
5662
+ * device-event notification — the durable guarantee begins at this hook, not
5663
+ * before it.
5664
+ */
5665
+ onSensorEventPersisted(event) {
5666
+ if (!this.evaluationActive) return;
5667
+ const subject = subjectFromSensorEvent(event);
5668
+ this.scheduleEvaluation(subject, "device-event", () => ({
5669
+ tags: { deviceId: event.deviceId },
5670
+ meta: {
5671
+ sensorEventId: event.id,
5672
+ kind: event.kind
5673
+ }
5674
+ }));
5675
+ }
5676
+ /**
5677
+ * Called at the AUDIO-event persist site (`eventStore.insertAudio`), in the
5678
+ * SAME moment as the durable insert. Feeds an `immediate` rule that OPTS IN
5679
+ * to an `audio-*` class (the safety gate in `evaluateRule` — a rule without
5680
+ * an audio class never fires here). Fire-and-forget; the outbox owns delivery.
5681
+ *
5682
+ * Delivery-grade boundary (honest): audio events persist on a SEPARATE store
5683
+ * from object events, and there is no NC crash-gap reconcile for audio rows
5684
+ * in this slice (unlike object events, which the boot reconcile re-scans). So
5685
+ * a crash in the persist→outbox window drops the audio notification — the
5686
+ * durable guarantee begins at this hook, matching the device-event boundary.
5687
+ */
5688
+ onAudioEventPersisted(event) {
5689
+ if (!this.evaluationActive) return;
5690
+ const subject = subjectFromAudioEvent(event);
5691
+ this.scheduleEvaluation(subject, "audio-event", () => ({
5692
+ tags: { deviceId: event.deviceId },
5693
+ meta: {
5694
+ audioEventId: event.id,
5695
+ class: event.classification?.className
5696
+ }
5697
+ }));
5698
+ }
5699
+ /**
5700
+ * Called at the package object-event persist site (`PackageDropDetector` —
5701
+ * delivered/picked-up), in the SAME moment as the durable insert. Feeds the
5702
+ * `package-event` trigger. Package events ARE object-event rows, so the boot
5703
+ * crash-gap reconcile re-covers them (routed by className in {@link reconcile}).
5704
+ */
5705
+ onPackageEventPersisted(event, phase) {
5706
+ if (!this.evaluationActive) return;
5707
+ const subject = subjectFromPackageEvent(event, phase);
5708
+ this.scheduleEvaluation(subject, "package-event", () => ({
5709
+ tags: { deviceId: event.deviceId },
5710
+ meta: {
5711
+ eventId: event.id,
5712
+ phase
5713
+ }
5714
+ }));
5715
+ }
5716
+ /**
5717
+ * Feed one ZoneAnalytics occupancy snapshot into the debounced watcher
5718
+ * (in-process, telemetry-loss-tolerant — a dropped snapshot just misses a
5719
+ * sample; the durable confirmed edge-state is the recovery). Called per
5720
+ * ≤1 Hz snapshot from `ZoneAnalyticsProvider` (live frames + the parked-object
5721
+ * baseline sampler). Cheap no-op when no occupancy rule is enabled. Each
5722
+ * COMMITTED edge is persisted (rare — only on a confirmed flip, never
5723
+ * per-frame) and routed to {@link onOccupancyEdge}.
5724
+ */
5725
+ observeOccupancy(deviceId, snapshot) {
5726
+ if (!this.evaluationActive || !this.occupancyEnabled) return;
5727
+ const now = this.now();
5728
+ let edges;
5729
+ try {
5730
+ edges = this.occupancyWatcher.observe(deviceId, snapshot, now);
5731
+ } catch (err) {
5732
+ this.logger.debug("occupancy observe failed", {
5733
+ tags: { deviceId },
5734
+ meta: { error: String(err) }
5735
+ });
5736
+ return;
5737
+ }
5738
+ for (const edge of edges) {
5739
+ this.persistOccupancyEdge(edge, now);
5740
+ this.onOccupancyEdge(edge);
5741
+ }
5742
+ }
5743
+ /**
5744
+ * Persist hook for a committed occupancy edge — mirrors the other persist
5745
+ * hooks (serialized via {@link scheduleEvaluation}). The subject rides the
5746
+ * EXISTING `device-event` delivery via the internal `occupancy-event` kind.
5747
+ * Public so a test / a future out-of-band edge source can drive it directly.
5748
+ */
5749
+ onOccupancyEdge(edge) {
5750
+ if (!this.evaluationActive) return;
5751
+ const subject = subjectFromOccupancyEvent(edge);
5752
+ this.scheduleEvaluation(subject, "occupancy-event", () => ({
5753
+ tags: { deviceId: edge.deviceId },
5754
+ meta: {
5755
+ zoneId: edge.zoneId ?? "@frame",
5756
+ className: edge.className ?? "@all",
5757
+ count: edge.count,
5758
+ threshold: edge.threshold,
5759
+ occupied: edge.occupied
5760
+ }
5761
+ }));
5762
+ }
4533
5763
  buildProvider() {
4534
5764
  return {
4535
5765
  listRules: async () => ({ rules: [...this.rules.list()] }),
@@ -4546,7 +5776,8 @@ var NotificationCenter = class {
4546
5776
  },
4547
5777
  updateRule: async ({ ruleId, patch, caller }) => {
4548
5778
  if (patch.targets !== void 0) await this.validateTargetRefs(patch.targets.map((t) => t.targetId));
4549
- const updated = await this.rules.update(ruleId, patch);
5779
+ const { disabledTargetIds: _optOut, ...safePatch } = patch;
5780
+ const updated = await this.rules.update(ruleId, safePatch);
4550
5781
  this.logger.info("notification rule updated", { meta: {
4551
5782
  ruleId,
4552
5783
  by: caller.userId
@@ -4562,7 +5793,24 @@ var NotificationCenter = class {
4562
5793
  return { success: true };
4563
5794
  },
4564
5795
  testRule: async ({ rule, lookbackMinutes }) => ({ results: [...await this.dryRun(rule, lookbackMinutes)] }),
4565
- getConditionCatalog: async () => ({ catalog: [...NC_CONDITION_CATALOG] })
5796
+ getConditionCatalog: async () => ({ catalog: [...NC_CONDITION_CATALOG] }),
5797
+ getHistory: async ({ filter }) => {
5798
+ const entries = await this.outbox.queryHistory({
5799
+ ...filter.ruleId !== void 0 ? { ruleId: filter.ruleId } : {},
5800
+ ...filter.deviceId !== void 0 ? { deviceId: filter.deviceId } : {},
5801
+ ...filter.status !== void 0 ? { status: filter.status } : {},
5802
+ ...filter.since !== void 0 ? { since: filter.since } : {},
5803
+ ...filter.until !== void 0 ? { until: filter.until } : {},
5804
+ limit: filter.limit
5805
+ });
5806
+ const mapped = [];
5807
+ for (const entry of entries) try {
5808
+ mapped.push(outboxEntryToHistory(entry));
5809
+ } catch {
5810
+ this.logger.debug("getHistory: skipping malformed outbox row", { meta: { id: entry.id } });
5811
+ }
5812
+ return { entries: mapped };
5813
+ }
4566
5814
  };
4567
5815
  }
4568
5816
  /** Append one evaluation to the serialized chain (see {@link evalChain}). */
@@ -4584,31 +5832,35 @@ var NotificationCenter = class {
4584
5832
  });
4585
5833
  }
4586
5834
  async evaluateAndEnqueue(subject, kind) {
4587
- const delivery = kind === "object-event" ? "immediate" : "track-end";
5835
+ const delivery = kind === "object-event" || kind === "audio-event" ? "immediate" : kind === "occupancy-event" ? "device-event" : kind;
4588
5836
  const candidates = this.rules.listEnabled(delivery);
4589
5837
  if (candidates.length === 0) return;
4590
5838
  const now = this.now();
4591
5839
  for (const rule of candidates) {
4592
- if (!evaluateRule(rule, subject).matched) continue;
5840
+ const evaluation = evaluateRule(rule, subject);
5841
+ if (!evaluation.matched) continue;
4593
5842
  const key = cooldownKey(rule, subject);
4594
5843
  if (isCoolingDown(rule, this.lastFiredAt.get(key), now)) continue;
4595
- if (await this.outbox.enqueue(this.buildEntries(rule, subject, kind)) > 0) this.lastFiredAt.set(key, now);
5844
+ if (await this.outbox.enqueue(this.buildEntries(rule, subject, kind, evaluation.matchedOn)) > 0) this.lastFiredAt.set(key, now);
4596
5845
  }
4597
5846
  }
4598
- buildEntries(rule, subject, kind) {
4599
- return rule.targets.map((target) => {
5847
+ buildEntries(rule, subject, kind, matchedOn) {
5848
+ const hasEventMedia = kind === "object-event" || kind === "package-event";
5849
+ const isTrackScoped = kind === "object-event" || kind === "track-end";
5850
+ return rule.targets.filter((target) => !rule.disabledTargetIds.includes(target.targetId)).map((target) => {
4600
5851
  const payload = {
4601
5852
  ruleName: rule.name,
4602
5853
  delivery: rule.delivery,
4603
5854
  priority: rule.priority,
4604
5855
  ...rule.template !== void 0 ? { template: rule.template } : {},
4605
5856
  media: rule.media.attach,
5857
+ ...matchedOn !== void 0 && matchedOn.length > 0 ? { matchedOn } : {},
4606
5858
  ...target.params !== void 0 ? { params: target.params } : {},
4607
5859
  subject: {
4608
5860
  deviceId: subject.deviceId,
4609
5861
  ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
4610
- ...kind === "object-event" ? { eventId: subject.recordId } : {},
4611
- className: subject.classNames[0] ?? "object",
5862
+ ...hasEventMedia ? { eventId: subject.recordId } : {},
5863
+ className: subject.classNames[0] ?? subject.sensorKind ?? "event",
4612
5864
  ...subject.label !== void 0 ? { label: subject.label } : {},
4613
5865
  ...subject.confidence !== void 0 ? { confidence: subject.confidence } : {},
4614
5866
  zones: subject.zones,
@@ -4619,9 +5871,9 @@ var NotificationCenter = class {
4619
5871
  ruleId: rule.id,
4620
5872
  targetId: target.targetId,
4621
5873
  deviceId: subject.deviceId,
4622
- recordKind: kind,
5874
+ recordKind: kind === "occupancy-event" ? "device-event" : kind,
4623
5875
  recordId: subject.recordId,
4624
- ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
5876
+ ...isTrackScoped && subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
4625
5877
  payload
4626
5878
  };
4627
5879
  });
@@ -4637,6 +5889,67 @@ var NotificationCenter = class {
4637
5889
  if (entry.createdAt > prev) this.lastFiredAt.set(key, entry.createdAt);
4638
5890
  }
4639
5891
  }
5892
+ /**
5893
+ * Recompute the watcher's watched key set from the enabled occupancy rules
5894
+ * (rule-driven — refreshed on boot + every reload tick). `setWatchedKeys`
5895
+ * drops confirmed state for keys no longer watched; the caller then prunes the
5896
+ * durable rows to the surviving set.
5897
+ */
5898
+ refreshOccupancyWatch() {
5899
+ const specs = [];
5900
+ for (const rule of this.rules.listEnabled("device-event")) {
5901
+ const occ = rule.conditions.occupancy;
5902
+ if (occ === void 0) continue;
5903
+ specs.push(occupancySpecFromCondition(occ));
5904
+ }
5905
+ this.occupancyWatcher.setWatchedKeys(specs);
5906
+ this.occupancyEnabled = specs.length > 0;
5907
+ }
5908
+ /** Boot reseed of confirmed occupancy edge-state (durability, constraint 4) —
5909
+ * hydrate the watcher from the store, then prune orphaned durable rows to the
5910
+ * active watched set. Runs AFTER {@link refreshOccupancyWatch} so hydrate
5911
+ * restores only currently-watched keys. */
5912
+ async hydrateOccupancy() {
5913
+ const rows = await this.occupancyStore.load();
5914
+ this.occupancyWatcher.hydrate(rows);
5915
+ await this.occupancyStore.pruneExcept(this.activeOccupancyKeys());
5916
+ }
5917
+ /** Rule-reload tick: refresh the rule cache (cross-node CRUD staleness bound),
5918
+ * recompute the occupancy watched set + prune stale durable rows. */
5919
+ async reloadRules() {
5920
+ await this.rules.load();
5921
+ this.refreshOccupancyWatch();
5922
+ if (!this.evaluationActive) return;
5923
+ await this.occupancyStore.pruneExcept(this.activeOccupancyKeys());
5924
+ }
5925
+ /** The full-key set the watcher currently tracks (post prune) — the bound for
5926
+ * {@link OccupancyStore.pruneExcept}. */
5927
+ activeOccupancyKeys() {
5928
+ return new Set(this.occupancyWatcher.snapshotState().map((r) => r.key));
5929
+ }
5930
+ /** Write-through the committed confirmed level for one edge (rare — only on a
5931
+ * boolean flip). Best-effort: a failed persist is logged, never thrown into
5932
+ * the frame path (the boot reseed + next edge recover). */
5933
+ async persistOccupancyEdge(edge, now) {
5934
+ try {
5935
+ await this.occupancyStore.persist({
5936
+ key: occupancyKey(edge.deviceId, edge.zoneId, edge.className, edge.threshold),
5937
+ deviceId: edge.deviceId,
5938
+ ...edge.zoneId !== void 0 ? { zoneId: edge.zoneId } : {},
5939
+ ...edge.className !== void 0 ? { className: edge.className } : {},
5940
+ threshold: edge.threshold,
5941
+ confirmedCount: edge.count,
5942
+ occupied: edge.occupied,
5943
+ lastChangeAt: edge.timestamp,
5944
+ updatedAt: now
5945
+ });
5946
+ } catch (err) {
5947
+ this.logger.debug("occupancy persist failed", {
5948
+ tags: { deviceId: edge.deviceId },
5949
+ meta: { error: String(err) }
5950
+ });
5951
+ }
5952
+ }
4640
5953
  /** Boot crash-gap reconcile — see the module docstring. */
4641
5954
  async reconcile() {
4642
5955
  const now = this.now();
@@ -4645,13 +5958,24 @@ var NotificationCenter = class {
4645
5958
  const since = Math.max(windowStart, (watermark ?? 0) - RECONCILE_OVERLAP_MS);
4646
5959
  try {
4647
5960
  const ordered = [...await this.deps.listObjectEventsSince(since, RECONCILE_SCAN_LIMIT)].sort((a, b) => a.timestamp - b.timestamp);
4648
- for (const event of ordered) this.scheduleEvaluation(subjectFromObjectEvent(event), "object-event", () => ({
4649
- tags: { deviceId: event.deviceId },
4650
- meta: {
4651
- eventId: event.id,
4652
- reconcile: true
4653
- }
4654
- }));
5961
+ for (const event of ordered) {
5962
+ const phase = packagePhaseOf(event);
5963
+ if (phase !== null) this.scheduleEvaluation(subjectFromPackageEvent(event, phase), "package-event", () => ({
5964
+ tags: { deviceId: event.deviceId },
5965
+ meta: {
5966
+ eventId: event.id,
5967
+ phase,
5968
+ reconcile: true
5969
+ }
5970
+ }));
5971
+ else this.scheduleEvaluation(subjectFromObjectEvent(event), "object-event", () => ({
5972
+ tags: { deviceId: event.deviceId },
5973
+ meta: {
5974
+ eventId: event.id,
5975
+ reconcile: true
5976
+ }
5977
+ }));
5978
+ }
4655
5979
  await this.evalChain;
4656
5980
  if (ordered.length > 0) this.logger.info("notification reconcile scanned missed events", { meta: {
4657
5981
  since,
@@ -4680,8 +6004,14 @@ var NotificationCenter = class {
4680
6004
  const subjects = [];
4681
6005
  if (rule.delivery === "immediate") {
4682
6006
  const events = await this.deps.listObjectEventsSince(since, RECONCILE_SCAN_LIMIT);
4683
- for (const ev of events) subjects.push(subjectFromObjectEvent(ev));
4684
- } else {
6007
+ for (const ev of events) if (packagePhaseOf(ev) === null) subjects.push(subjectFromObjectEvent(ev));
6008
+ } else if (rule.delivery === "package-event") {
6009
+ const events = await this.deps.listObjectEventsSince(since, RECONCILE_SCAN_LIMIT);
6010
+ for (const ev of events) {
6011
+ const phase = packagePhaseOf(ev);
6012
+ if (phase !== null) subjects.push(subjectFromPackageEvent(ev, phase));
6013
+ }
6014
+ } else if (rule.delivery === "track-end") {
4685
6015
  const tracks = await this.deps.listRecentTracks(since, TEST_RULE_MAX_RESULTS);
4686
6016
  for (const track of tracks) subjects.push(subjectFromTrack(track));
4687
6017
  }
@@ -4691,7 +6021,7 @@ var NotificationCenter = class {
4691
6021
  const evaluation = evaluateRule(rule, subject);
4692
6022
  results.push({
4693
6023
  recordId: subject.recordId,
4694
- recordKind: subject.kind === "object-event" ? "object-event" : "track",
6024
+ recordKind: subject.kind === "track-end" ? "track" : subject.kind === "audio-event" ? "object-event" : subject.kind === "occupancy-event" ? "device-event" : subject.kind,
4695
6025
  deviceId: subject.deviceId,
4696
6026
  timestamp: subject.timestamp,
4697
6027
  wouldFire: evaluation.matched,
@@ -4704,6 +6034,156 @@ var NotificationCenter = class {
4704
6034
  }
4705
6035
  };
4706
6036
  //#endregion
6037
+ //#region src/notification-center/nc-actions.ts
6038
+ /**
6039
+ * nc-actions — the `pipeline-analytics` rule-bridge action catalog.
6040
+ *
6041
+ * The Notification Center viewer NEVER adds a cap method: every rule read/write
6042
+ * rides the generic `addons.custom` bridge (`{ addonId, action, input }`) with a
6043
+ * per-action `nc.*` name, and THIS module enforces the per-action auth +
6044
+ * ownership server-side (spec C1/C2). The addon id is `'pipeline-analytics'`.
6045
+ *
6046
+ * Auth model (spec §3.1 / §5 — server-derived, never client-trusted):
6047
+ * - `nc.listRules` / `nc.getConditionCatalog` — any authenticated caller;
6048
+ * listRules is SCOPED (own personal + global rules only) and stamps a
6049
+ * per-row `readOnly` verdict.
6050
+ * - `nc.createRule` / `nc.updateRule` / `nc.deleteRule` — operate ONLY on
6051
+ * caller-OWNED rules; `ownerUserId` is stamped from the caller and can
6052
+ * never be re-owned through a patch.
6053
+ * - `nc.setRuleTargetEnabled` — a user opting HIS OWN target out of a rule
6054
+ * VISIBLE to him (his own personal rule OR a global/admin rule).
6055
+ *
6056
+ * Target-ownership limits (personal-rule delivery target list + the opt-out
6057
+ * target) are enforced against the notifiers target catalog; ADMINS bypass the
6058
+ * target-ownership limit (they may deliver to / opt any target) but NEVER the
6059
+ * fail-closed caller check — an absent caller (UDS version-skew can still
6060
+ * deliver `undefined`) is rejected before any ownership decision.
6061
+ */
6062
+ /** A rule as served to the viewer — carries the server's `readOnly` verdict. */
6063
+ var NcViewerRuleSchema = NcRuleSchema.extend({ readOnly: boolean() });
6064
+ /**
6065
+ * The action catalog — the tRPC contract Group B (the viewer client) consumes
6066
+ * against `addonId: 'pipeline-analytics'`. Every entry that depends on the
6067
+ * caller identity is declared `caller: 'required'` so the dispatcher forwards
6068
+ * the server-derived `{ userId, isAdmin }` as the handler's second argument.
6069
+ */
6070
+ var ncActions = defineCustomActions({
6071
+ "nc.listRules": customAction(object({}), object({ rules: array(NcViewerRuleSchema) }), { caller: "required" }),
6072
+ "nc.getConditionCatalog": customAction(object({}), object({
6073
+ catalog: array(NcConditionDescriptorSchema),
6074
+ taxonomy: NcTaxonomySchema
6075
+ })),
6076
+ "nc.createRule": customAction(object({ rule: NcRuleInputSchema }), object({ rule: NcRuleSchema }), {
6077
+ kind: "mutation",
6078
+ caller: "required"
6079
+ }),
6080
+ "nc.updateRule": customAction(object({
6081
+ ruleId: string(),
6082
+ patch: NcRulePatchSchema
6083
+ }), object({ rule: NcRuleSchema }), {
6084
+ kind: "mutation",
6085
+ caller: "required"
6086
+ }),
6087
+ "nc.deleteRule": customAction(object({ ruleId: string() }), object({ success: literal(true) }), {
6088
+ kind: "mutation",
6089
+ caller: "required"
6090
+ }),
6091
+ "nc.setRuleTargetEnabled": customAction(object({
6092
+ ruleId: string(),
6093
+ targetId: string(),
6094
+ enabled: boolean()
6095
+ }), object({ success: literal(true) }), {
6096
+ kind: "mutation",
6097
+ caller: "required"
6098
+ })
6099
+ });
6100
+ /** Fail-closed caller resolution — an absent forwarded caller is NEVER admin. */
6101
+ function requireCaller(caller) {
6102
+ if (!caller || typeof caller.userId !== "string" || caller.userId.length === 0) throw new Error("forbidden: authenticated caller required");
6103
+ return caller;
6104
+ }
6105
+ function makeNcActionHandlers(deps) {
6106
+ /** A rule the caller may EDIT — must exist and be owned by the caller. */
6107
+ const assertOwnsRule = (ruleId) => {
6108
+ const rule = deps.ruleStore.get(ruleId);
6109
+ if (rule === null) throw new Error(`forbidden: rule not found: ${ruleId}`);
6110
+ return rule;
6111
+ };
6112
+ const assertRuleOwned = (ruleId, userId) => {
6113
+ const rule = assertOwnsRule(ruleId);
6114
+ if (rule.ownerUserId !== userId) throw new Error(`forbidden: rule not owned: ${ruleId}`);
6115
+ return rule;
6116
+ };
6117
+ /** A rule the caller may SEE — his own personal rule OR a global/admin rule. */
6118
+ const assertRuleVisible = (ruleId, userId) => {
6119
+ const rule = assertOwnsRule(ruleId);
6120
+ if (rule.ownerUserId !== void 0 && rule.ownerUserId !== userId) throw new Error(`forbidden: rule not owned: ${ruleId}`);
6121
+ return rule;
6122
+ };
6123
+ const assertTargetsOwned = async (targetIds, caller) => {
6124
+ if (caller.isAdmin) return;
6125
+ const owned = new Set(await deps.listCallerTargetIds(caller.userId));
6126
+ for (const id of targetIds) if (!owned.has(id)) throw new Error(`forbidden: target not owned: ${id}`);
6127
+ };
6128
+ return {
6129
+ "nc.listRules": async (_input, caller) => {
6130
+ const c = requireCaller(caller);
6131
+ return { rules: deps.ruleStore.listForOwner(c.userId).map((r) => ({
6132
+ ...r,
6133
+ readOnly: r.ownerUserId !== c.userId
6134
+ })) };
6135
+ },
6136
+ "nc.getConditionCatalog": async () => ({
6137
+ catalog: [...NC_CONDITION_CATALOG],
6138
+ taxonomy: NC_TAXONOMY
6139
+ }),
6140
+ "nc.createRule": async (input, caller) => {
6141
+ const c = requireCaller(caller);
6142
+ const parsed = NcRuleInputSchema.parse(input.rule);
6143
+ await assertTargetsOwned(parsed.targets.map((t) => t.targetId), c);
6144
+ const rule = await deps.ruleStore.create({
6145
+ ...parsed,
6146
+ ownerUserId: c.userId
6147
+ }, c.userId);
6148
+ deps.logger.info("nc rule created", { meta: {
6149
+ ruleId: rule.id,
6150
+ owner: c.userId
6151
+ } });
6152
+ return { rule };
6153
+ },
6154
+ "nc.updateRule": async (input, caller) => {
6155
+ const c = requireCaller(caller);
6156
+ assertRuleOwned(input.ruleId, c.userId);
6157
+ const patch = NcRulePatchSchema.parse(input.patch);
6158
+ if (patch.targets !== void 0) await assertTargetsOwned(patch.targets.map((t) => t.targetId), c);
6159
+ const { ownerUserId: _owner, disabledTargetIds: _optOut, ...safe } = patch;
6160
+ const rule = await deps.ruleStore.update(input.ruleId, safe);
6161
+ deps.logger.info("nc rule updated", { meta: {
6162
+ ruleId: rule.id,
6163
+ owner: c.userId
6164
+ } });
6165
+ return { rule };
6166
+ },
6167
+ "nc.deleteRule": async (input, caller) => {
6168
+ const c = requireCaller(caller);
6169
+ assertRuleOwned(input.ruleId, c.userId);
6170
+ await deps.ruleStore.delete(input.ruleId);
6171
+ deps.logger.info("nc rule deleted", { meta: {
6172
+ ruleId: input.ruleId,
6173
+ owner: c.userId
6174
+ } });
6175
+ return { success: true };
6176
+ },
6177
+ "nc.setRuleTargetEnabled": async (input, caller) => {
6178
+ const c = requireCaller(caller);
6179
+ assertRuleVisible(input.ruleId, c.userId);
6180
+ await assertTargetsOwned([input.targetId], c);
6181
+ await deps.ruleStore.setRuleTargetEnabled(input.ruleId, input.targetId, input.enabled);
6182
+ return { success: true };
6183
+ }
6184
+ };
6185
+ }
6186
+ //#endregion
4707
6187
  //#region src/pipeline-analytics/pipeline/object-embedding-selection.ts
4708
6188
  function isClipObjectEmbedding(t) {
4709
6189
  return Array.isArray(t.embedding) && t.embedding.length > 0 && typeof t.embeddingModelId === "string" && t.embeddingModelId.startsWith("mobileclip-");
@@ -4713,39 +6193,42 @@ function resolveSearchThumbnailUrl(input) {
4713
6193
  const id = input.embeddingMediaKey ?? input.eventId;
4714
6194
  return `${input.baseUrl}/${encodeURIComponent(id)}`;
4715
6195
  }
4716
- //#endregion
4717
- //#region src/pipeline-analytics/best-thumbnail-guard.ts
4718
- /**
4719
- * Void/envArea guard for best-`thumbnail` selection.
4720
- *
4721
- * ## Why this exists (the dawn/night "void" thumbnail)
4722
- *
4723
- * At dawn/night a moving subject's tracker box intermittently EXPLODES to
4724
- * (near-)the whole frame — the "envelope exploded to full-frame" signature. If
4725
- * that frame happens to win the best-detection race, the gallery/reel best
4726
- * `thumbnail` becomes a useless full-scene tile (the subject crop is the entire
4727
- * washed-out frame), not the subject. This guard rejects such a frame from the
4728
- * best-`thumbnail` decision so the track keeps a real subject-centered tile.
4729
- *
4730
- * Conservative by design: it only rejects boxes covering ≥ {@link
4731
- * NEAR_FULL_FRAME_AREA} of the frame — a genuine large close-up subject stays
4732
- * well under this. Rejecting a frame does NOT land a thumbnail, so the #27-A
4733
- * per-frame retry keeps trying until a plausible frame wins.
4734
- */
6196
+ function isPlausibleThumbnailBox(bbox, frameWidth, frameHeight, score) {
6197
+ if (frameWidth <= 0 || frameHeight <= 0) return true;
6198
+ if (score !== void 0 && score >= .5) return true;
6199
+ if (bbox.w * bbox.h / (frameWidth * frameHeight) >= .85) return false;
6200
+ if (bbox.h >= frameHeight * .95) return false;
6201
+ if (bbox.w >= frameWidth * .95) return false;
6202
+ return true;
6203
+ }
4735
6204
  /**
4736
- * Area fraction at/above which a detection bbox is treated as an exploded
4737
- * "envelope" (near-full-frame) box rather than a real subject. 0.85 keeps the
4738
- * guard conservative only boxes covering ≥85% of the frame are rejected.
6205
+ * Minimum fraction of the track's BEST-SEEN bbox area a retry frame's bbox must
6206
+ * still cover to be an acceptable best-`thumbnail` RETRY subject. 0.45 tolerates
6207
+ * normal breathing of the tracker box (~2/3 per side) while rejecting the
6208
+ * degenerate shrink (observed live 2026-07-22: a car born at 78px retried at
6209
+ * 26px once the native lease warmed — a ~0.11 area fraction — and the landed
6210
+ * "best" thumbnail was a sliver of a distant car).
4739
6211
  */
4740
- var NEAR_FULL_FRAME_AREA = .85;
6212
+ var RETRY_MIN_FRACTION_OF_BEST = .45;
4741
6213
  /**
4742
- * True when `bbox` is a plausible SUBJECT box for a best `thumbnail` i.e. its
4743
- * area is below the near-full-frame threshold. Degenerate frame dimensions
4744
- * (≤0) are treated as plausible (no info to reject on).
6214
+ * RETRY-ONLY gate (#27-A degenerate-retry fix): on a cold on-motion session the
6215
+ * native lease misses the first seconds, so the large-subject captures return
6216
+ * null and the per-frame retry re-fires with the CURRENT frame's bbox. By the
6217
+ * time the lease warms the subject may have shrunk to a sliver; capturing THAT
6218
+ * frame lands a genuine-native-but-useless thumbnail and `thumbnailLanded`
6219
+ * stops all further retries. Reject a retry whose current bbox area has
6220
+ * collapsed below `minFractionOfBest` of the track's best-seen bbox area.
6221
+ *
6222
+ * Applies ONLY to the `!thumbnailLanded` retry path — a genuine new-best
6223
+ * capture is never routed through this gate (the caller bypasses it on
6224
+ * `isNewBest`). With no best-seen reference (or a degenerate one) there is no
6225
+ * info to reject on → plausible.
4745
6226
  */
4746
- function isPlausibleThumbnailBox(bbox, frameWidth, frameHeight) {
4747
- if (frameWidth <= 0 || frameHeight <= 0) return true;
4748
- return bbox.w * bbox.h / (frameWidth * frameHeight) < NEAR_FULL_FRAME_AREA;
6227
+ function isPlausibleRetryBox(current, bestSeen, minFractionOfBest = RETRY_MIN_FRACTION_OF_BEST) {
6228
+ if (bestSeen === null) return true;
6229
+ const bestArea = bestSeen.w * bestSeen.h;
6230
+ if (bestArea <= 0) return true;
6231
+ return current.w * current.h >= minFractionOfBest * bestArea;
4749
6232
  }
4750
6233
  //#endregion
4751
6234
  //#region src/pipeline-analytics/pipeline/stationary/stationary-types.ts
@@ -5488,7 +6971,7 @@ var BindingCache = class {
5488
6971
  this.state.set(deviceId, active);
5489
6972
  return active;
5490
6973
  } catch (err) {
5491
- this.logger.debug("BindingCache.isActive lookup failed", {
6974
+ this.logger.warn("BindingCache.isActive lookup failed — frames will drop as inactive", {
5492
6975
  tags: { deviceId },
5493
6976
  meta: { error: String(err) }
5494
6977
  });
@@ -5961,9 +7444,9 @@ var DISPLAY_FALLBACK_MAX_WIDTH = 1920;
5961
7444
  /** Throttle for the native-crop HIT/FALLBACK metric window line. */
5962
7445
  var NATIVE_CROP_METRIC_INTERVAL_MS = 3e4;
5963
7446
  function createNativeFrameTransport(deps) {
5964
- const { api, ownNodeId, logger } = deps;
7447
+ const { api, logger } = deps;
5965
7448
  const pipelineRunnerApi = api.pipelineRunner;
5966
- const isRemoteHandle = (handle) => handle.nodeId !== ownNodeId;
7449
+ const wantJpeg = (_handle) => true;
5967
7450
  const cropReplyToRgb = async (reply) => {
5968
7451
  if (reply.jpeg !== void 0) {
5969
7452
  const rgb = await decodeJpegToRgb(reply.jpeg);
@@ -5991,7 +7474,7 @@ function createNativeFrameTransport(deps) {
5991
7474
  h: 1
5992
7475
  },
5993
7476
  maxWidth: handle.width,
5994
- encodeJpeg: isRemoteHandle(handle)
7477
+ encodeJpeg: wantJpeg(handle)
5995
7478
  }, nodePin(handle.nodeId));
5996
7479
  if (!full || full.width <= 0 || full.height <= 0) return null;
5997
7480
  const rgb = await cropReplyToRgb(full);
@@ -6015,7 +7498,7 @@ function createNativeFrameTransport(deps) {
6015
7498
  h: 1
6016
7499
  },
6017
7500
  maxWidth,
6018
- encodeJpeg: isRemoteHandle(handle)
7501
+ encodeJpeg: wantJpeg(handle)
6019
7502
  }, nodePin(handle.nodeId));
6020
7503
  if (!full || full.width <= 0 || full.height <= 0) return null;
6021
7504
  const rgb = await cropReplyToRgb(full);
@@ -6055,7 +7538,7 @@ function createNativeFrameTransport(deps) {
6055
7538
  handle: frameHandle,
6056
7539
  bbox: paddedNorm,
6057
7540
  ...maxWidth !== void 0 ? { maxWidth } : {},
6058
- encodeJpeg: isRemoteHandle(frameHandle)
7541
+ encodeJpeg: wantJpeg(frameHandle)
6059
7542
  }, nodePin(frameHandle.nodeId));
6060
7543
  if (!native || native.width <= 0 || native.height <= 0) return null;
6061
7544
  return await cropReplyToRgb(native);
@@ -6352,153 +7835,41 @@ function createAnalyticsWidgetsProvider() {
6352
7835
  }
6353
7836
  //#endregion
6354
7837
  //#region src/pipeline-analytics/store/recent-cursor.ts
6355
- function encodeRecentCursor(cursor) {
6356
- return Buffer.from(JSON.stringify({
6357
- l: cursor.lastSeen,
6358
- i: cursor.trackId
6359
- }), "utf8").toString("base64url");
6360
- }
6361
- /**
6362
- * Decode + validate an opaque cursor. Fails fast with a clear error on any
6363
- * malformed input (bad base64, bad JSON, wrong field types) — a garbage
6364
- * cursor must never silently degrade into a full-history first page.
6365
- */
6366
- function decodeRecentCursor(raw) {
6367
- let parsed;
6368
- try {
6369
- parsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
6370
- } catch {
6371
- throw new Error("listRecentTracks: malformed cursor");
6372
- }
6373
- 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");
6374
- return {
6375
- lastSeen: parsed.l,
6376
- trackId: parsed.i
6377
- };
6378
- }
6379
- /** Comparator for the (lastSeen DESC, id DESC) total order. */
6380
- function compareRecentDesc(a, b) {
6381
- if (a.lastSeen !== b.lastSeen) return b.lastSeen - a.lastSeen;
6382
- if (a.id === b.id) return 0;
6383
- return a.id < b.id ? 1 : -1;
6384
- }
6385
- /** True when `row` sits strictly AFTER the cursor position in DESC order
6386
- * (i.e. belongs to the next page). */
6387
- function isAfterCursor(row, cursor) {
6388
- if (row.lastSeen < cursor.lastSeen) return true;
6389
- return row.lastSeen === cursor.lastSeen && row.id < cursor.trackId;
6390
- }
6391
- //#endregion
6392
- //#region src/pipeline-analytics/store/zone-geometry.ts
6393
- /**
6394
- * Normalized min/max envelope over every position's bbox. Returns `null`
6395
- * when the frame dimensions are unknown/degenerate or there are no
6396
- * positions — the caller persists NULL envelope columns in that case.
6397
- */
6398
- function computeTrackEnvelope(positions, frameWidth, frameHeight) {
6399
- if (frameWidth === void 0 || frameHeight === void 0 || !(frameWidth > 0) || !(frameHeight > 0) || positions.length === 0) return null;
6400
- let minX = Number.POSITIVE_INFINITY;
6401
- let minY = Number.POSITIVE_INFINITY;
6402
- let maxX = Number.NEGATIVE_INFINITY;
6403
- let maxY = Number.NEGATIVE_INFINITY;
6404
- for (const p of positions) {
6405
- const x0 = p.bbox.x / frameWidth;
6406
- const y0 = p.bbox.y / frameHeight;
6407
- const x1 = (p.bbox.x + p.bbox.w) / frameWidth;
6408
- const y1 = (p.bbox.y + p.bbox.h) / frameHeight;
6409
- if (x0 < minX) minX = x0;
6410
- if (y0 < minY) minY = y0;
6411
- if (x1 > maxX) maxX = x1;
6412
- if (y1 > maxY) maxY = y1;
6413
- }
6414
- return {
6415
- minX,
6416
- minY,
6417
- maxX,
6418
- maxY
6419
- };
6420
- }
6421
- /**
6422
- * Axis-aligned bounds of a zone filter (rect: itself; polygon: vertex
6423
- * min/max). A degenerate polygon (< 3 points) yields the full frame so the
6424
- * SQL prefilter never silently drops rows the precise test would keep.
6425
- */
6426
- function zoneBounds(zone) {
6427
- if (zone.kind === "rect") return {
6428
- minX: zone.x,
6429
- minY: zone.y,
6430
- maxX: zone.x + zone.width,
6431
- maxY: zone.y + zone.height
6432
- };
6433
- if (zone.points.length < 3) return {
6434
- minX: 0,
6435
- minY: 0,
6436
- maxX: 1,
6437
- maxY: 1
6438
- };
6439
- let minX = Number.POSITIVE_INFINITY;
6440
- let minY = Number.POSITIVE_INFINITY;
6441
- let maxX = Number.NEGATIVE_INFINITY;
6442
- let maxY = Number.NEGATIVE_INFINITY;
6443
- for (const p of zone.points) {
6444
- if (p.x < minX) minX = p.x;
6445
- if (p.y < minY) minY = p.y;
6446
- if (p.x > maxX) maxX = p.x;
6447
- if (p.y > maxY) maxY = p.y;
6448
- }
6449
- return {
6450
- minX,
6451
- minY,
6452
- maxX,
6453
- maxY
6454
- };
6455
- }
6456
- /** Whether two axis-aligned envelopes overlap (touching edges count). */
6457
- function envelopesOverlap(a, b) {
6458
- return a.minX <= b.maxX && a.maxX >= b.minX && a.minY <= b.maxY && a.maxY >= b.minY;
7838
+ function encodeRecentCursor(cursor) {
7839
+ return Buffer.from(JSON.stringify({
7840
+ l: cursor.lastSeen,
7841
+ i: cursor.trackId
7842
+ }), "utf8").toString("base64url");
6459
7843
  }
6460
7844
  /**
6461
- * Ray-casting point-in-polygon (even-odd rule). Points on an edge may
6462
- * resolve either way acceptable for zone filtering. A polygon with
6463
- * fewer than 3 vertices contains nothing.
7845
+ * Decode + validate an opaque cursor. Fails fast with a clear error on any
7846
+ * malformed input (bad base64, bad JSON, wrong field types) a garbage
7847
+ * cursor must never silently degrade into a full-history first page.
6464
7848
  */
6465
- function pointInPolygon(point, polygon) {
6466
- if (polygon.length < 3) return false;
6467
- let inside = false;
6468
- for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
6469
- const a = polygon[i];
6470
- const b = polygon[j];
6471
- 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;
7849
+ function decodeRecentCursor(raw) {
7850
+ let parsed;
7851
+ try {
7852
+ parsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
7853
+ } catch {
7854
+ throw new Error("listRecentTracks: malformed cursor");
6472
7855
  }
6473
- return inside;
7856
+ 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");
7857
+ return {
7858
+ lastSeen: parsed.l,
7859
+ trackId: parsed.i
7860
+ };
6474
7861
  }
6475
- /**
6476
- * Precise per-position zone test.
6477
- *
6478
- * - rect zone → any position bbox (normalized) intersects the rect.
6479
- * - polygon zone → any position CENTER (normalized `x`/`y` — positions
6480
- * store the bbox center) falls inside the polygon.
6481
- *
6482
- * Unknown/degenerate frame dims → `true` (the track cannot be tested, so it
6483
- * PASSES mirroring the NULL-envelope-matches rule).
6484
- */
6485
- function positionsIntersectZone(positions, frameWidth, frameHeight, zone) {
6486
- if (frameWidth === void 0 || frameHeight === void 0 || !(frameWidth > 0) || !(frameHeight > 0)) return true;
6487
- if (zone.kind === "rect") {
6488
- const rect = zoneBounds(zone);
6489
- for (const p of positions) if (envelopesOverlap({
6490
- minX: p.bbox.x / frameWidth,
6491
- minY: p.bbox.y / frameHeight,
6492
- maxX: (p.bbox.x + p.bbox.w) / frameWidth,
6493
- maxY: (p.bbox.y + p.bbox.h) / frameHeight
6494
- }, rect)) return true;
6495
- return false;
6496
- }
6497
- for (const p of positions) if (pointInPolygon({
6498
- x: p.x / frameWidth,
6499
- y: p.y / frameHeight
6500
- }, zone.points)) return true;
6501
- return false;
7862
+ /** Comparator for the (lastSeen DESC, id DESC) total order. */
7863
+ function compareRecentDesc(a, b) {
7864
+ if (a.lastSeen !== b.lastSeen) return b.lastSeen - a.lastSeen;
7865
+ if (a.id === b.id) return 0;
7866
+ return a.id < b.id ? 1 : -1;
7867
+ }
7868
+ /** True when `row` sits strictly AFTER the cursor position in DESC order
7869
+ * (i.e. belongs to the next page). */
7870
+ function isAfterCursor(row, cursor) {
7871
+ if (row.lastSeen < cursor.lastSeen) return true;
7872
+ return row.lastSeen === cursor.lastSeen && row.id < cursor.trackId;
6502
7873
  }
6503
7874
  //#endregion
6504
7875
  //#region src/pipeline-analytics/store/track-store.ts
@@ -7590,6 +8961,11 @@ var MediaStore = class {
7590
8961
  * and no duplicate snapshot/lastFrame pair. The `lastFrame` write lands BEFORE
7591
8962
  * the snapshot delete, so the view is never momentarily absent. Returns the new
7592
8963
  * `lastFrame` key.
8964
+ *
8965
+ * `keepSource: true` (short-track keyFrameSmall fallback, 2026-07-23): copy
8966
+ * the bytes into the `lastFrame` slot WITHOUT deleting the source row — used
8967
+ * when the source is the track's `keyFrameSmall`, which must keep serving its
8968
+ * own kind.
7593
8969
  */
7594
8970
  async promoteToLastFrame(input) {
7595
8971
  const data = Buffer.from(input.snapshot.base64, "base64");
@@ -7601,7 +8977,7 @@ var MediaStore = class {
7601
8977
  timestamp: input.snapshot.timestamp,
7602
8978
  data
7603
8979
  });
7604
- await this.deleteByKey(input.snapshot.key);
8980
+ if (input.keepSource !== true) await this.deleteByKey(input.snapshot.key);
7605
8981
  return newKey;
7606
8982
  }
7607
8983
  /**
@@ -9179,15 +10555,19 @@ async function ingestSensorStateChange(deps, data, timestamp) {
9179
10555
  const makeId = deps.makeId ?? (() => `pa-sensor-${randomUUID()}`);
9180
10556
  let inserted = 0;
9181
10557
  for (const cameraId of cameraIds) {
9182
- await deps.sink.insert({
10558
+ const ev = {
9183
10559
  id: makeId(),
9184
10560
  deviceId: cameraId,
9185
10561
  sourceDeviceId: data.deviceId,
9186
10562
  kind: descriptor.kind,
9187
10563
  value,
9188
10564
  timestamp
9189
- });
10565
+ };
10566
+ await deps.sink.insert(ev);
9190
10567
  inserted++;
10568
+ if (deps.onPersisted !== void 0) try {
10569
+ deps.onPersisted(ev);
10570
+ } catch {}
9191
10571
  }
9192
10572
  return inserted;
9193
10573
  }
@@ -9575,7 +10955,7 @@ async function composeWideCentralSquareThumbnail(slab, layout) {
9575
10955
  const leftPad = Math.max(0, Math.round(layout.slabOffsetX * scale));
9576
10956
  const rightPad = Math.max(0, canvasNativeW - nativeW - leftPad);
9577
10957
  if (leftPad === 0 && rightPad === 0) return slab;
9578
- return sharp(await sharp(slab).resize(canvasNativeW, nativeH, { fit: "fill" }).blur(LETTERBOX_BLUR_SIGMA).modulate({ brightness: LETTERBOX_BLUR_BRIGHTNESS }).toBuffer()).composite([{
10958
+ return sharp(await sharp(slab).resize(canvasNativeW, nativeH, { fit: "cover" }).blur(LETTERBOX_BLUR_SIGMA).modulate({ brightness: LETTERBOX_BLUR_BRIGHTNESS }).toBuffer()).composite([{
9579
10959
  input: slab,
9580
10960
  left: leftPad,
9581
10961
  top: 0
@@ -9742,8 +11122,11 @@ function isUniformRgbFrame(data, width, height) {
9742
11122
  *
9743
11123
  * The detection frame is resolved ONCE per call (blank-frame guard); native full
9744
11124
  * frames + subject crops are fetched per target by handle. Frames recycle in
9745
- * milliseconds, so the caller must invoke this immediately after producing the
9746
- * events while the handle is still live.
11125
+ * milliseconds, so the caller either invokes this while the handle is still
11126
+ * live, or — when the call is queued behind a capture lane (the S3 batch
11127
+ * dispatch) — pre-resolves the ≤640 frame in the live window via
11128
+ * {@link resolvePinnedFrame} and passes it as `pinnedFrame`, which skips the
11129
+ * by-handle resolve for the ≤640 tier entirely.
9747
11130
  */
9748
11131
  var EventMediaDispatcher = class {
9749
11132
  deps;
@@ -9763,6 +11146,61 @@ var EventMediaDispatcher = class {
9763
11146
  this.deps = deps;
9764
11147
  this.sharedNativeFullFrame = createSharedFrameResolver((handle) => this.deps.getNativeFullFrameRgb(handle, SNAPSHOT_MAX_WIDTH));
9765
11148
  }
11149
+ /**
11150
+ * Resolve the ≤640 detection frame by handle NOW — in the caller's LIVE frame
11151
+ * window — and COPY its pixel data (`Buffer.from`) so ring recycling can never
11152
+ * touch it. This is the seam the per-frame batch dispatch uses to pin the
11153
+ * frame BEFORE queueing on the CaptureScheduler: since S3 the batch is one
11154
+ * scheduler request against a 3-slot device lane and can start seconds late,
11155
+ * by which time the handle's ring slot is usually recycled and the by-handle
11156
+ * resolve in {@link captureForFrame} finds nothing (the 2026-07-23 boxed-tile
11157
+ * regression). ~0.7MB per pinned frame, freed with the batch — bounded by the
11158
+ * lane. Returns `null` on a genuine live-window miss (logged at debug — the
11159
+ * caller then omits `pinnedFrame` and captureForFrame's by-handle resolve,
11160
+ * WARN-logged on failure, remains the fallback).
11161
+ */
11162
+ async resolvePinnedFrame(deviceId, frameHandle) {
11163
+ try {
11164
+ const decoded = await resolveFrame(frameHandle, { getRemoteFrame: this.deps.getRemoteFrame });
11165
+ if (!decoded) return null;
11166
+ return {
11167
+ ...decoded,
11168
+ data: Buffer.from(decoded.data)
11169
+ };
11170
+ } catch (err) {
11171
+ this.deps.logger.debug("event media: pinned-frame resolve threw (live window)", {
11172
+ tags: { deviceId },
11173
+ meta: {
11174
+ deviceId,
11175
+ shmId: frameHandle.shmId,
11176
+ error: String(err)
11177
+ }
11178
+ });
11179
+ return null;
11180
+ }
11181
+ }
11182
+ /**
11183
+ * WARN-log a whole-batch capture abort: every early return in
11184
+ * {@link captureForFrame} loses ALL of the frame's targets at once —
11185
+ * firstFrame + snapshot/lastFrame/thumbnail + event child crops — which
11186
+ * previously died with DEBUG-only logs (invisible in production, the silent
11187
+ * half of the boxed-tile regression). The per-kind lost-target counts make
11188
+ * the blast radius visible.
11189
+ */
11190
+ warnBatchLost(reason, input, extraMeta = {}) {
11191
+ this.deps.logger.warn(`event media: capture batch lost — ${reason}`, {
11192
+ tags: { deviceId: input.deviceId },
11193
+ meta: {
11194
+ deviceId: input.deviceId,
11195
+ shmId: input.frameHandle.shmId,
11196
+ pinned: input.pinnedFrame !== void 0,
11197
+ lostEventTargets: input.events.length,
11198
+ lostFirstFrameTargets: input.trackFrames.length,
11199
+ lostSnapshotTargets: input.snapshots?.length ?? 0,
11200
+ ...extraMeta
11201
+ }
11202
+ });
11203
+ }
9766
11204
  async captureForFrame(input) {
9767
11205
  const { deviceId, frameHandle, events, trackFrames } = input;
9768
11206
  const snapshots = input.snapshots ?? [];
@@ -9776,51 +11214,28 @@ var EventMediaDispatcher = class {
9776
11214
  const extraCandidateCount = input.rasterFallbackCandidates?.length ?? 0;
9777
11215
  if (events.length === 0 && trackFrames.length === 0 && snapshots.length === 0 && extraCandidateCount === 0) return empty;
9778
11216
  let decoded;
9779
- try {
11217
+ if (input.pinnedFrame !== void 0) decoded = input.pinnedFrame;
11218
+ else try {
9780
11219
  decoded = await resolveFrame(frameHandle, { getRemoteFrame: this.deps.getRemoteFrame });
9781
11220
  } catch (err) {
9782
- this.deps.logger.debug("event media: resolveFrame threw", {
9783
- tags: { deviceId },
9784
- meta: {
9785
- deviceId,
9786
- shmId: frameHandle.shmId,
9787
- error: String(err)
9788
- }
9789
- });
11221
+ this.warnBatchLost("resolveFrame threw", input, { error: String(err) });
9790
11222
  return empty;
9791
11223
  }
9792
11224
  if (!decoded) {
9793
- this.deps.logger.debug("event media: frame recycled before resolve", {
9794
- tags: { deviceId },
9795
- meta: {
9796
- deviceId,
9797
- shmId: frameHandle.shmId
9798
- }
9799
- });
11225
+ this.warnBatchLost("frame recycled before resolve", input);
9800
11226
  return empty;
9801
11227
  }
9802
11228
  if (decoded.format !== "rgb") {
9803
- this.deps.logger.debug("event media: resolved frame is not RGB", {
9804
- tags: { deviceId },
9805
- meta: {
9806
- deviceId,
9807
- format: decoded.format
9808
- }
9809
- });
11229
+ this.warnBatchLost("resolved frame is not RGB", input, { format: decoded.format });
9810
11230
  return empty;
9811
11231
  }
9812
- const frameData = Buffer.from(decoded.data);
11232
+ const frameData = input.pinnedFrame !== void 0 ? decoded.data : Buffer.from(decoded.data);
9813
11233
  const fw = decoded.width;
9814
11234
  const fh = decoded.height;
9815
11235
  if (isUniformRgbFrame(frameData, fw, fh)) {
9816
- this.deps.logger.debug("event media: resolved frame uniform (blank/hwaccel) — skipping crop", {
9817
- tags: { deviceId },
9818
- meta: {
9819
- deviceId,
9820
- shmId: frameHandle.shmId,
9821
- width: fw,
9822
- height: fh
9823
- }
11236
+ this.warnBatchLost("resolved frame uniform (blank/hwaccel)", input, {
11237
+ width: fw,
11238
+ height: fh
9824
11239
  });
9825
11240
  return empty;
9826
11241
  }
@@ -10633,6 +12048,7 @@ var ZoneAnalyticsProvider = class {
10633
12048
  }
10634
12049
  this.snapshots.set(input.deviceId, snapshot);
10635
12050
  this.appendHistory(input.deviceId, snapshot);
12051
+ this.emitSnapshot(input.deviceId, snapshot);
10636
12052
  this.sliceThrottle.push(input.deviceId, snapshot);
10637
12053
  }
10638
12054
  /** Drop a device's snapshot + history. Called on device removal. */
@@ -10697,10 +12113,24 @@ var ZoneAnalyticsProvider = class {
10697
12113
  });
10698
12114
  if (snapshot) {
10699
12115
  this.appendHistory(deviceId, snapshot);
12116
+ this.emitSnapshot(deviceId, snapshot);
10700
12117
  this.sliceThrottle.push(deviceId, snapshot);
10701
12118
  }
10702
12119
  }
10703
12120
  }
12121
+ /** Push one snapshot to the occupancy tap, isolating any consumer throw from
12122
+ * the frame path (occupancy notifications must never break analytics). */
12123
+ emitSnapshot(deviceId, snapshot) {
12124
+ if (!this.ctx.onSnapshot) return;
12125
+ try {
12126
+ this.ctx.onSnapshot(deviceId, snapshot);
12127
+ } catch (err) {
12128
+ this.ctx.logger.debug("zone-analytics occupancy tap failed", {
12129
+ tags: { deviceId },
12130
+ meta: { error: err instanceof Error ? err.message : String(err) }
12131
+ });
12132
+ }
12133
+ }
10704
12134
  appendHistory(deviceId, snapshot) {
10705
12135
  const ring = this.history.get(deviceId) ?? [];
10706
12136
  const cutoff = snapshot.ts - HISTORY_WINDOW_MS;
@@ -11610,21 +13040,47 @@ var FaceSettingsSchema = object({
11610
13040
  * stripped from the per-device schema).
11611
13041
  */
11612
13042
  enabled: boolean().default(true),
11613
- /** Cosine similarity (on L2-normalized arcface vectors) required to match. */
11614
- similarityThreshold: number().min(0).max(1).default(.55),
11615
- /** Reject ambiguous matches: require best secondBest ≥ margin. */
11616
- margin: number().min(0).max(1).default(.1),
13043
+ /** Cosine similarity (on L2-normalized arcface vectors) required to match.
13044
+ * Raised 0.55→0.62 (2026-07-23 face-quality batch): 0.55 was implausibly
13045
+ * loose against thin/low-quality galleries and produced ~25-40% visually
13046
+ * wrong auto-assignments in the 12h audit. */
13047
+ similarityThreshold: number().min(0).max(1).default(.62),
13048
+ /** Reject ambiguous matches: require best − secondBest ≥ margin. Raised
13049
+ * 0.10→0.15 (2026-07-23 face-quality batch) to widen the confusion gap. */
13050
+ margin: number().min(0).max(1).default(.15),
11617
13051
  /** Minimum face-detection confidence for a face to be considered. */
11618
13052
  minFaceConfidence: number().min(0).max(1).default(.5),
11619
13053
  /**
11620
- * Minimum face bbox size (px, shorter side of the face box in detection-frame
11621
- * space) for a face to be eligible for embedding-based auto-matching. Below
11622
- * this, ArcFace resolution is unreliable and auto-assignment produces the
11623
- * observed false positives (tiny/distant faces collapsing onto one identity).
11624
- * Such faces are dropped BEFORE matching/enrolment (#26.1).
13054
+ * Minimum face bbox size (px, shorter side, NATIVE scale when the runner
13055
+ * measured it, else detection-frame space) for a face to be DETECTED and
13056
+ * COLLECTED into the recent-faces buffer. Below this the face is dropped
13057
+ * BEFORE ingest/enrolment (#26.1). This is the DETECTION/collection floor —
13058
+ * NOT the auto-assignment floor (see {@link recognitionMinFacePx}).
11625
13059
  */
11626
13060
  minFacePx: number().min(0).default(30),
11627
13061
  /**
13062
+ * Minimum face short side (px, NATIVE scale when the runner measured it, else
13063
+ * detection-frame space) for a collected face to be eligible for AUTO-MATCH
13064
+ * (identity assignment). Separate from — and ≥ — {@link minFacePx}: faces
13065
+ * between `minFacePx` and this floor are still detected, cropped, and stored
13066
+ * in the buffer (available for MANUAL assignment), but are NEVER auto-assigned
13067
+ * an identity. ArcFace embeddings below ~48px are unreliable and drove the
13068
+ * observed false positives (2026-07-23 face-quality batch). A face below this
13069
+ * floor keeps `recognizedIdentityId` UNSET (fail-safe).
13070
+ */
13071
+ recognitionMinFacePx: number().min(0).default(48),
13072
+ /**
13073
+ * Lower cosine bound of the SUGGESTION band (2026-07-24). A face whose best
13074
+ * gallery match MISSES auto-assignment but is still plausible surfaces as a
13075
+ * SUGGESTION (persisted `suggestedIdentityId`/`suggestedMatchScore`, never an
13076
+ * assignment) when EITHER: its match cosine is in [`suggestionMinCosine`,
13077
+ * `similarityThreshold`) AND its face clears the recognition size floor; OR its
13078
+ * cosine is ≥ `similarityThreshold` but the face is below the recognition floor
13079
+ * (blocked ONLY by size). Below this cosine nothing is suggested. Operator-
13080
+ * overridable per field, like the other face thresholds.
13081
+ */
13082
+ suggestionMinCosine: number().min(0).max(1).default(.5),
13083
+ /**
11628
13084
  * Minimum enrolled-sample count an identity must have before it can be an
11629
13085
  * AUTO-MATCH target. A single-sample identity is an unreliable sink (one noisy
11630
13086
  * enrolment attracted 81% of matches); identities below this are excluded from
@@ -11652,6 +13108,8 @@ function resolveFaceSettings(raw) {
11652
13108
  margin: pick("margin"),
11653
13109
  minFaceConfidence: pick("minFaceConfidence"),
11654
13110
  minFacePx: pick("minFacePx"),
13111
+ recognitionMinFacePx: pick("recognitionMinFacePx"),
13112
+ suggestionMinCosine: pick("suggestionMinCosine"),
11655
13113
  minIdentitySamples: pick("minIdentitySamples"),
11656
13114
  confirmFrames: pick("confirmFrames"),
11657
13115
  bufferRetentionDays: pick("bufferRetentionDays"),
@@ -13062,13 +14520,33 @@ function buildGlobalSettingsSchema() {
13062
14520
  {
13063
14521
  type: "number",
13064
14522
  key: "minFacePx",
13065
- label: "Min face size",
13066
- description: "Minimum face box size (shorter side, pixels) for a face to be eligible for auto-matching. Faces smaller than this are too low-resolution for reliable recognition and are ignored. Raise it to suppress false matches on tiny/distant faces.",
14523
+ label: "Min face size (detect)",
14524
+ description: "Minimum face box size (shorter side, pixels) for a face to be DETECTED and stored in the recent-faces buffer. Faces smaller than this are ignored entirely. This is the collection floor auto-assignment uses the separate, higher recognition floor below.",
13067
14525
  min: 0,
13068
14526
  step: 1,
13069
14527
  default: FACE_DEFAULTS.minFacePx,
13070
14528
  unit: "px"
13071
14529
  },
14530
+ {
14531
+ type: "number",
14532
+ key: "recognitionMinFacePx",
14533
+ label: "Min face size (recognize)",
14534
+ description: "Minimum face size (shorter side, pixels) for a stored face to be AUTO-ASSIGNED an identity. Faces between the detection floor and this value are still stored and can be assigned MANUALLY, but are never auto-matched — ArcFace embeddings on small faces are unreliable and produce false identities. Raise to suppress wrong auto-assignments on distant faces.",
14535
+ min: 0,
14536
+ step: 1,
14537
+ default: FACE_DEFAULTS.recognitionMinFacePx,
14538
+ unit: "px"
14539
+ },
14540
+ {
14541
+ type: "number",
14542
+ key: "suggestionMinCosine",
14543
+ label: "Suggestion threshold",
14544
+ description: "Lower cosine bound (0–1) of the SUGGESTION band. A plausible match that misses auto-assignment — cosine between this value and the similarity threshold with a large-enough face, or above the similarity threshold but below the recognition size floor — is surfaced as a one-tap SUGGESTION instead of being assigned. The face stays unassigned. Below this cosine nothing is suggested.",
14545
+ min: 0,
14546
+ max: 1,
14547
+ step: .05,
14548
+ default: FACE_DEFAULTS.suggestionMinCosine
14549
+ },
13072
14550
  {
13073
14551
  type: "number",
13074
14552
  key: "minIdentitySamples",
@@ -13193,6 +14671,7 @@ var PackageDropDetector = class {
13193
14671
  importance: PACKAGE_IMPORTANCE
13194
14672
  };
13195
14673
  await this.deps.events.insertObject(ev);
14674
+ this.deps.onPersisted?.(ev, "delivered");
13196
14675
  this.deps.emit.delivered({
13197
14676
  deviceId: entry.deviceId,
13198
14677
  entryId: entry.id,
@@ -13243,6 +14722,7 @@ var PackageDropDetector = class {
13243
14722
  importance: PACKAGE_IMPORTANCE
13244
14723
  };
13245
14724
  await this.deps.events.insertObject(ev);
14725
+ this.deps.onPersisted?.(ev, "picked-up");
13246
14726
  this.deps.emit.pickedUp({
13247
14727
  deviceId: entry.deviceId,
13248
14728
  entryId: entry.id,
@@ -13728,6 +15208,22 @@ var FACE_COLUMNS = [
13728
15208
  {
13729
15209
  name: "faceBbox",
13730
15210
  type: "JSON"
15211
+ },
15212
+ {
15213
+ name: "bestMatchScore",
15214
+ type: "REAL"
15215
+ },
15216
+ {
15217
+ name: "nativeFaceShortSidePx",
15218
+ type: "REAL"
15219
+ },
15220
+ {
15221
+ name: "suggestedIdentityId",
15222
+ type: "TEXT"
15223
+ },
15224
+ {
15225
+ name: "suggestedMatchScore",
15226
+ type: "REAL"
13731
15227
  }
13732
15228
  ];
13733
15229
  var FACE_INDEXES = [{
@@ -13802,7 +15298,11 @@ var FaceStore = class {
13802
15298
  recognizedIdentityId: data.recognizedIdentityId ?? void 0,
13803
15299
  assignedSampleId: data.assignedSampleId ?? void 0,
13804
15300
  keyFrameMediaKey: data.keyFrameMediaKey ?? void 0,
13805
- faceBbox: data.faceBbox ?? void 0
15301
+ faceBbox: data.faceBbox ?? void 0,
15302
+ bestMatchScore: data.bestMatchScore ?? void 0,
15303
+ nativeFaceShortSidePx: data.nativeFaceShortSidePx ?? void 0,
15304
+ suggestedIdentityId: data.suggestedIdentityId ?? void 0,
15305
+ suggestedMatchScore: data.suggestedMatchScore ?? void 0
13806
15306
  };
13807
15307
  }).filter((f) => !f.assigned);
13808
15308
  }
@@ -14002,7 +15502,11 @@ var FaceStore = class {
14002
15502
  recognizedIdentityId: data.recognizedIdentityId ?? void 0,
14003
15503
  assignedSampleId: data.assignedSampleId ?? void 0,
14004
15504
  keyFrameMediaKey: data.keyFrameMediaKey ?? void 0,
14005
- faceBbox: data.faceBbox ?? void 0
15505
+ faceBbox: data.faceBbox ?? void 0,
15506
+ bestMatchScore: data.bestMatchScore ?? void 0,
15507
+ nativeFaceShortSidePx: data.nativeFaceShortSidePx ?? void 0,
15508
+ suggestedIdentityId: data.suggestedIdentityId ?? void 0,
15509
+ suggestedMatchScore: data.suggestedMatchScore ?? void 0
14006
15510
  };
14007
15511
  }
14008
15512
  /**
@@ -14048,7 +15552,11 @@ var FaceStore = class {
14048
15552
  recognizedIdentityId: data.recognizedIdentityId ?? void 0,
14049
15553
  assignedSampleId: data.assignedSampleId ?? void 0,
14050
15554
  keyFrameMediaKey: data.keyFrameMediaKey ?? void 0,
14051
- faceBbox: data.faceBbox ?? void 0
15555
+ faceBbox: data.faceBbox ?? void 0,
15556
+ bestMatchScore: data.bestMatchScore ?? void 0,
15557
+ nativeFaceShortSidePx: data.nativeFaceShortSidePx ?? void 0,
15558
+ suggestedIdentityId: data.suggestedIdentityId ?? void 0,
15559
+ suggestedMatchScore: data.suggestedMatchScore ?? void 0
14052
15560
  };
14053
15561
  });
14054
15562
  const filterMode = input.filter ?? "all";
@@ -14280,6 +15788,40 @@ function eligibleIdentities(gallery, probeModelId, probeDim, minIdentitySamples)
14280
15788
  return eligible;
14281
15789
  }
14282
15790
  /**
15791
+ * Match a probe embedding against the gallery. Only samples with the same
15792
+ * `modelId` AND the same dimension are compared (model-version safety). Returns
15793
+ * the best identity when its score ≥ threshold and it beats the best OTHER
15794
+ * identity by ≥ margin; otherwise null.
15795
+ */
15796
+ function matchEmbedding(probe, gallery, opts) {
15797
+ const probeVec = new Float32Array(probe.embedding);
15798
+ const eligible = eligibleIdentities(gallery, probe.modelId, probe.embedding.length, opts.minIdentitySamples ?? 1);
15799
+ const bestByIdentity = /* @__PURE__ */ new Map();
15800
+ for (const s of gallery) {
15801
+ if (s.modelId !== probe.modelId) continue;
15802
+ if (s.embedding.length !== probe.embedding.length) continue;
15803
+ if (!eligible.has(s.identityId)) continue;
15804
+ const score = cosineSimilarity(probeVec, new Float32Array(s.embedding));
15805
+ const prev = bestByIdentity.get(s.identityId);
15806
+ if (prev === void 0 || score > prev) bestByIdentity.set(s.identityId, score);
15807
+ }
15808
+ if (bestByIdentity.size === 0) return null;
15809
+ let bestId = null;
15810
+ let bestScore = -Infinity;
15811
+ let secondScore = -Infinity;
15812
+ for (const [id, score] of bestByIdentity) if (score > bestScore) {
15813
+ secondScore = bestScore;
15814
+ bestScore = score;
15815
+ bestId = id;
15816
+ } else if (score > secondScore) secondScore = score;
15817
+ if (bestId === null || bestScore < opts.threshold) return null;
15818
+ if (secondScore > -Infinity && bestScore - secondScore < opts.margin) return null;
15819
+ return {
15820
+ identityId: bestId,
15821
+ score: bestScore
15822
+ };
15823
+ }
15824
+ /**
14283
15825
  * Assign at most one identity per track AND at most one track per identity for
14284
15826
  * a single frame. Greedy by score: compute every candidate's full ranked match
14285
15827
  * list, then repeatedly take the globally-highest (track, identity) pair whose
@@ -14386,6 +15928,24 @@ var FaceRecognizer = class {
14386
15928
  names = /* @__PURE__ */ new Map();
14387
15929
  aggregates = /* @__PURE__ */ new Map();
14388
15930
  bestFace = /* @__PURE__ */ new Map();
15931
+ /**
15932
+ * Peak identity-MATCH cosine per track for the currently-assigned identity
15933
+ * (NOT the detection confidence held in {@link bestFace}). Reset on an
15934
+ * identity switch so a track carries the confidence of the label it ends up
15935
+ * with. Read synchronously at close (NC `minLabelConfidence`) via
15936
+ * {@link bestLabelMatchConfidence}, then dropped in {@link onTrackEnd}.
15937
+ */
15938
+ bestMatchScore = /* @__PURE__ */ new Map();
15939
+ /**
15940
+ * Peak SUGGESTION (identity + cosine) per track: the best plausible-but-not-
15941
+ * confident match that MISSED auto-assignment (cosine in the suggestion band
15942
+ * with a large-enough face, OR ≥ threshold but below the recognition size
15943
+ * floor). Accumulated across frames exactly like {@link bestMatchScore},
15944
+ * read at close, then dropped in {@link onTrackEnd}. A track that is ever
15945
+ * auto-assigned discards this at close (mutual exclusivity: assigned rows
15946
+ * carry `recognizedIdentityId`, suggestion rows carry `suggested*`).
15947
+ */
15948
+ bestSuggestion = /* @__PURE__ */ new Map();
14389
15949
  /** Best-face-per-track ranking via the shared tracker primitive, constructed
14390
15950
  * WITH the DECLARED face policy (`BEST_FACE_POLICY` in
14391
15951
  * `best-selection-policies.ts`): confidence-only, no hysteresis, no rate
@@ -14455,7 +16015,9 @@ var FaceRecognizer = class {
14455
16015
  gallerySize: this.gallery.length
14456
16016
  }
14457
16017
  });
14458
- const matches = this.gallery.length > 0 ? assignUniquePerFrame(candidates.map((c) => ({
16018
+ const meetsRecognitionFloor = (t) => (t.nativeFaceShortSidePx ?? (t.faceBbox ? Math.min(t.faceBbox.w, t.faceBbox.h) : void 0) ?? Number.POSITIVE_INFINITY) >= settings.recognitionMinFacePx;
16019
+ const matchCandidates = candidates.filter(meetsRecognitionFloor);
16020
+ const matches = this.gallery.length > 0 && matchCandidates.length > 0 ? assignUniquePerFrame(matchCandidates.map((c) => ({
14459
16021
  trackId: c.trackId,
14460
16022
  embedding: c.embedding,
14461
16023
  modelId: c.embeddingModelId
@@ -14464,16 +16026,42 @@ var FaceRecognizer = class {
14464
16026
  margin: settings.margin,
14465
16027
  minIdentitySamples: settings.minIdentitySamples
14466
16028
  }) : /* @__PURE__ */ new Map();
16029
+ if (this.gallery.length > 0) for (const c of candidates) {
16030
+ const suggestion = matchEmbedding({
16031
+ embedding: c.embedding,
16032
+ modelId: c.embeddingModelId
16033
+ }, this.gallery, {
16034
+ threshold: settings.suggestionMinCosine,
16035
+ margin: settings.margin,
16036
+ minIdentitySamples: settings.minIdentitySamples
16037
+ });
16038
+ if (suggestion === null) continue;
16039
+ const meetsRecognitionFloor = (c.nativeFaceShortSidePx ?? (c.faceBbox ? Math.min(c.faceBbox.w, c.faceBbox.h) : void 0) ?? Number.POSITIVE_INFINITY) >= settings.recognitionMinFacePx;
16040
+ const inNearMissBand = meetsRecognitionFloor && suggestion.score < settings.similarityThreshold;
16041
+ const inSizeBlockedBand = !meetsRecognitionFloor && suggestion.score >= settings.similarityThreshold;
16042
+ if (!inNearMissBand && !inSizeBlockedBand) continue;
16043
+ const prev = this.bestSuggestion.get(c.trackId);
16044
+ if (prev === void 0 || suggestion.score > prev.score) this.bestSuggestion.set(c.trackId, {
16045
+ identityId: suggestion.identityId,
16046
+ score: suggestion.score
16047
+ });
16048
+ }
14467
16049
  const labelWork = [];
14468
16050
  for (const c of candidates) {
14469
16051
  const match = matches.get(c.trackId) ?? null;
14470
16052
  const { state, changed } = updateTrackAggregate(this.aggregates.get(c.trackId) ?? EMPTY_AGGREGATE, match, { confirmFrames: settings.confirmFrames });
14471
16053
  this.aggregates.set(c.trackId, state);
14472
- if (changed && state.assignedIdentityId !== null) labelWork.push({
14473
- trackId: c.trackId,
14474
- assignedIdentityId: state.assignedIdentityId,
14475
- matchScore: match?.score ?? null
14476
- });
16054
+ if (changed && state.assignedIdentityId !== null) {
16055
+ labelWork.push({
16056
+ trackId: c.trackId,
16057
+ assignedIdentityId: state.assignedIdentityId,
16058
+ matchScore: match?.score ?? null
16059
+ });
16060
+ if (match?.score !== void 0) this.bestMatchScore.set(c.trackId, match.score);
16061
+ } else if (match !== null && state.assignedIdentityId !== null && match.identityId === state.assignedIdentityId) {
16062
+ const prev = this.bestMatchScore.get(c.trackId);
16063
+ if (prev === void 0 || match.score > prev) this.bestMatchScore.set(c.trackId, match.score);
16064
+ }
14477
16065
  const recognizedIdentityId = state.assignedIdentityId ?? match?.identityId ?? void 0;
14478
16066
  const held = this.bestFace.get(c.trackId);
14479
16067
  const isNewBest = this.bestTracker.observe(c.trackId, c.confidence, input.timestamp);
@@ -14502,7 +16090,8 @@ var FaceRecognizer = class {
14502
16090
  bbox: cropBbox,
14503
16091
  timestamp: input.timestamp,
14504
16092
  ...bestCrop !== void 0 ? { crop: bestCrop } : {},
14505
- ...recognizedIdentityId !== void 0 ? { recognizedIdentityId } : {}
16093
+ ...recognizedIdentityId !== void 0 ? { recognizedIdentityId } : {},
16094
+ ...c.nativeFaceShortSidePx !== void 0 ? { nativeFaceShortSidePx: c.nativeFaceShortSidePx } : {}
14506
16095
  });
14507
16096
  } else if (crop !== void 0 && held !== void 0) this.bestFace.set(c.trackId, {
14508
16097
  ...held,
@@ -14579,11 +16168,23 @@ var FaceRecognizer = class {
14579
16168
  * entry (crop -> MediaStore under ownerKind 'face'), then drop the track's
14580
16169
  * in-memory state. Best-effort; a track with no held face is a no-op.
14581
16170
  */
16171
+ /**
16172
+ * Peak identity-match cosine for the track's assigned identity, or undefined
16173
+ * when no identity was ever confirmed. Read at close BEFORE {@link onTrackEnd}
16174
+ * drops the per-track state (the NC `minLabelConfidence` seam).
16175
+ */
16176
+ bestLabelMatchConfidence(trackId) {
16177
+ return this.bestMatchScore.get(trackId);
16178
+ }
14582
16179
  async onTrackEnd(deviceId, trackId) {
14583
16180
  const held = this.bestFace.get(trackId);
16181
+ const bestMatchScore = this.bestMatchScore.get(trackId);
16182
+ const bestSuggestion = this.bestSuggestion.get(trackId);
14584
16183
  this.aggregates.delete(trackId);
14585
16184
  this.bestFace.delete(trackId);
14586
16185
  this.bestTracker.delete(trackId);
16186
+ this.bestMatchScore.delete(trackId);
16187
+ this.bestSuggestion.delete(trackId);
14587
16188
  if (held === void 0) {
14588
16189
  this.deps.logger.debug("face: track ended without a held face", { tags: {
14589
16190
  deviceId,
@@ -14647,7 +16248,13 @@ var FaceRecognizer = class {
14647
16248
  ...held.recognizedIdentityId !== void 0 ? { recognizedIdentityId: held.recognizedIdentityId } : {},
14648
16249
  assigned: false,
14649
16250
  faceBbox: held.bbox,
14650
- ...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {}
16251
+ ...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {},
16252
+ ...bestMatchScore !== void 0 ? { bestMatchScore } : {},
16253
+ ...held.nativeFaceShortSidePx !== void 0 ? { nativeFaceShortSidePx: held.nativeFaceShortSidePx } : {},
16254
+ ...held.recognizedIdentityId === void 0 && bestSuggestion !== void 0 ? {
16255
+ suggestedIdentityId: bestSuggestion.identityId,
16256
+ suggestedMatchScore: bestSuggestion.score
16257
+ } : {}
14651
16258
  });
14652
16259
  this.deps.logger.info("face: buffered to gallery", {
14653
16260
  tags: {
@@ -15970,11 +17577,13 @@ var PlateRecognizer = class {
15970
17577
  } : null;
15971
17578
  }
15972
17579
  /** Live label for a plate read: the recognized vehicle NAME when matched,
15973
- * else the raw OCR text. Returns `null` for an implausible read (junk OCR
15974
- * off a distant/oblique plate) the caller must NOT stamp a label then. */
17580
+ * else the NORMALIZED (uppercase, alnum-only) OCR text never the raw read,
17581
+ * so lowercase/separator residue can't reach track labels. Returns `null`
17582
+ * for an implausible read (junk OCR off a distant/oblique plate) — the
17583
+ * caller must NOT stamp a label then. */
15975
17584
  resolveLabel(text, score) {
15976
17585
  if (!isPlausiblePlateRead(text, score)) return null;
15977
- return this.matchVehicle(text, score)?.name ?? text;
17586
+ return this.matchVehicle(text, score)?.name ?? normalizePlate$1(text);
15978
17587
  }
15979
17588
  async processFrame(input) {
15980
17589
  const minConfidence = input.minConfidence ?? 0;
@@ -16035,6 +17644,15 @@ var PlateRecognizer = class {
16035
17644
  ...crop !== void 0 ? { crop } : {}
16036
17645
  });
16037
17646
  }
17647
+ /**
17648
+ * Best OCR read score for the track's held plate (`plateText.confidence`),
17649
+ * or undefined when no plausible plate was read. Read at close BEFORE
17650
+ * {@link onTrackEnd} drops the per-track state (the NC `minLabelConfidence`
17651
+ * seam — mirrors the face recognizer's identity-match confidence).
17652
+ */
17653
+ bestLabelMatchConfidence(trackId) {
17654
+ return this.bestPlate.get(trackId)?.score;
17655
+ }
16038
17656
  /** Persist the held best plate for a finished track as one PlateStore row
16039
17657
  * (crop → MediaStore under ownerKind 'plate'), then drop in-memory state. */
16040
17658
  async onTrackEnd(deviceId, trackId) {
@@ -16140,6 +17758,47 @@ var PlateRecognizer = class {
16140
17758
  }
16141
17759
  };
16142
17760
  //#endregion
17761
+ //#region src/shared/frame/keyframe-wide-thumbnail.ts
17762
+ /**
17763
+ * 16:9 central-square subject crop out of a DECODED (raw RGB) full frame — the
17764
+ * close-time keyFrame→`thumbnail` derive (#27-A degenerate-retry fix, part 3).
17765
+ *
17766
+ * The live best-shot path fetches the 16:9 central-square WINDOW from the
17767
+ * runner's retained NATIVE surface ({@link wideCentralSquareLayout} +
17768
+ * {@link composeWideCentralSquareThumbnail} — see
17769
+ * `services/event-media-dispatcher.cropSubjectVariants`). At track close that
17770
+ * surface is long gone; the only durable full frame is the persisted native
17771
+ * `keyFrame` JPEG. This helper applies the SAME framing to that decoded frame:
17772
+ * normalized subject bbox → pixel bbox in the frame's OWN space → central-square
17773
+ * 16:9 layout → extract the in-frame slab → compose (lateral ambience fill for
17774
+ * any out-of-frame part). Same containment guarantee, same variants downstream
17775
+ * (`deriveThumbnailSmall` runs on the returned window).
17776
+ *
17777
+ * Shaped as a `cropRegionToJpeg` drop-in for {@link createKeyFrameCrop} (same
17778
+ * signature as the plain `extractCrop` composition used for plate crops), so
17779
+ * the close-time derive reuses the EXISTING keyframe-crop utility — media
17780
+ * fetch, temporal-skew guard and decode included — with only the framing
17781
+ * swapped.
17782
+ */
17783
+ /**
17784
+ * Compose the 16:9 central-square `thumbnail` window for a normalized subject
17785
+ * region of a raw RGB frame. Returns the composed JPEG (never upscaled — the
17786
+ * window is at the frame's native scale).
17787
+ */
17788
+ async function cropWideCentralSquareFromRgb(rgb, frameWidth, frameHeight, norm) {
17789
+ const layout = wideCentralSquareLayout({
17790
+ x: norm.x * frameWidth,
17791
+ y: norm.y * frameHeight,
17792
+ w: norm.w * frameWidth,
17793
+ h: norm.h * frameHeight
17794
+ }, {
17795
+ W: frameWidth,
17796
+ H: frameHeight
17797
+ });
17798
+ const { crop: slab } = await extractCrop(rgb, frameWidth, frameHeight, layout.fetch);
17799
+ return composeWideCentralSquareThumbnail(slab, layout);
17800
+ }
17801
+ //#endregion
16143
17802
  //#region src/pipeline-analytics/pipeline/event-child-crops.ts
16144
17803
  /**
16145
17804
  * Compute Intersection-over-Union between two (x,y,w,h) bounding boxes.
@@ -16705,6 +18364,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
16705
18364
  * path). Null on non-post-processing nodes or when disabled. */
16706
18365
  embeddingDispatcher = null;
16707
18366
  bindingCache = null;
18367
+ /** Per-device throttle for the inactive-binding frame-drop warn (5 min). */
18368
+ inactiveBindingDropWarnAt = /* @__PURE__ */ new Map();
16708
18369
  zoneAnalytics = null;
16709
18370
  audioMetrics = null;
16710
18371
  /**
@@ -16837,40 +18498,67 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
16837
18498
  return this._captureScheduler;
16838
18499
  }
16839
18500
  get trackCloser() {
16840
- if (!this._trackCloser) this._trackCloser = new TrackCloser({
16841
- logger: this.ctx.logger,
16842
- residents: this.residents,
16843
- trackStore: () => this.trackStore,
16844
- mediaStore: () => this.mediaStore,
16845
- eventStore: () => this.eventStore,
16846
- faceRecognizer: () => this.faceRecognizer,
16847
- plateRecognizer: () => this.plateRecognizer,
16848
- detailDispatcher: () => this.detailDispatcher,
16849
- overlayState: this.overlayState,
16850
- mediaCaptureLog: this.mediaCaptureLog,
16851
- frameDims: (deviceId) => this.lastFrameDimsByDevice.get(deviceId),
16852
- isShuttingDown: () => this.shuttingDown,
16853
- emitTrackEnded: (data, timestampMs) => {
16854
- this.ctx.eventBus.emit({
16855
- id: `pa-end-${data.trackId}`,
16856
- timestamp: new Date(timestampMs),
16857
- source: {
16858
- type: "addon",
16859
- id: "pipeline-analytics",
16860
- addonId: "pipeline-analytics"
16861
- },
16862
- category: EventCategory.PipelineAnalyticsTrackEnded,
16863
- data: {
16864
- deviceId: data.deviceId,
16865
- trackId: data.trackId,
16866
- className: data.className,
16867
- durationMs: data.durationMs
16868
- }
16869
- });
16870
- },
16871
- emitTrackLifecycle: (payload, timestampMs) => this.emitTrackLifecycle(payload, timestampMs),
16872
- onTrackClosed: (track, ownedMedia, info) => this.notificationCenter?.onTrackClosed(track, ownedMedia, info)
16873
- });
18501
+ if (!this._trackCloser) {
18502
+ const deriveKeyFrameThumbnailJpeg = createKeyFrameCrop({
18503
+ getMedia: async (mediaKey) => {
18504
+ const m = await this.mediaStore?.getByKey(mediaKey);
18505
+ return m ? {
18506
+ base64: m.base64,
18507
+ timestamp: m.timestamp
18508
+ } : null;
18509
+ },
18510
+ decodeJpegToRgb: (base64) => decodeJpegToRgb(base64),
18511
+ cropRegionToJpeg: (bytes, w, h, norm) => cropWideCentralSquareFromRgb(bytes, w, h, norm),
18512
+ maxSkewMs: KEYFRAME_CROP_MAX_SKEW_MS,
18513
+ logger: this.ctx.logger.child("CloseKeyFrameThumbnail")
18514
+ });
18515
+ this._trackCloser = new TrackCloser({
18516
+ logger: this.ctx.logger,
18517
+ residents: this.residents,
18518
+ trackStore: () => this.trackStore,
18519
+ mediaStore: () => this.mediaStore,
18520
+ eventStore: () => this.eventStore,
18521
+ faceRecognizer: () => this.faceRecognizer,
18522
+ plateRecognizer: () => this.plateRecognizer,
18523
+ detailDispatcher: () => this.detailDispatcher,
18524
+ overlayState: this.overlayState,
18525
+ mediaCaptureLog: this.mediaCaptureLog,
18526
+ frameDims: (deviceId) => this.lastFrameDimsByDevice.get(deviceId),
18527
+ isShuttingDown: () => this.shuttingDown,
18528
+ emitTrackEnded: (data, timestampMs) => {
18529
+ this.ctx.eventBus.emit({
18530
+ id: `pa-end-${data.trackId}`,
18531
+ timestamp: new Date(timestampMs),
18532
+ source: {
18533
+ type: "addon",
18534
+ id: "pipeline-analytics",
18535
+ addonId: "pipeline-analytics"
18536
+ },
18537
+ category: EventCategory.PipelineAnalyticsTrackEnded,
18538
+ data: {
18539
+ deviceId: data.deviceId,
18540
+ trackId: data.trackId,
18541
+ className: data.className,
18542
+ durationMs: data.durationMs
18543
+ }
18544
+ });
18545
+ },
18546
+ emitTrackLifecycle: (payload, timestampMs) => this.emitTrackLifecycle(payload, timestampMs),
18547
+ onTrackClosed: (track, ownedMedia, info) => this.notificationCenter?.onTrackClosed(track, ownedMedia, info),
18548
+ deriveThumbnailFromKeyFrame: async (input) => {
18549
+ const derived = await deriveKeyFrameThumbnailJpeg({
18550
+ ...input,
18551
+ padding: 0
18552
+ });
18553
+ if (!derived) return null;
18554
+ return {
18555
+ thumbnail: derived.jpeg,
18556
+ thumbnailSmall: await deriveThumbnailSmall(derived.jpeg),
18557
+ skewMs: derived.skewMs
18558
+ };
18559
+ }
18560
+ });
18561
+ }
16874
18562
  return this._trackCloser;
16875
18563
  }
16876
18564
  /** Master toggle for the migrated object/face EmbeddingDispatcher (default
@@ -16948,7 +18636,33 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
16948
18636
  if (this.isPostProcessingNode) await this.startEmbeddingDispatcher(logger, transport);
16949
18637
  this.startSweepTimers();
16950
18638
  this.ctx.logger.info("pipeline-analytics subscribers installed");
16951
- return this.buildProviderRegistrations(api, stores, capProviders);
18639
+ const providers = this.buildProviderRegistrations(api, stores, capProviders);
18640
+ const isHub = ownNodeId === "hub";
18641
+ const ncHandlers = isHub ? this.buildNcActionHandlers(api) : void 0;
18642
+ return {
18643
+ providers,
18644
+ ...isHub && ncHandlers !== void 0 ? {
18645
+ customActions: ncActions,
18646
+ actionHandlers: ncHandlers
18647
+ } : {}
18648
+ };
18649
+ }
18650
+ /**
18651
+ * Build the `nc.*` bridge handlers over the rule store, resolving the
18652
+ * caller-owned target set from the notifiers target catalog (a target is
18653
+ * owned when its open `config.ownerUserId` blob matches the caller). Returns
18654
+ * `undefined` when the Notification Center is absent (never built).
18655
+ */
18656
+ buildNcActionHandlers(api) {
18657
+ const center = this.notificationCenter;
18658
+ if (center === null) return void 0;
18659
+ return makeNcActionHandlers({
18660
+ ruleStore: center.ruleStore,
18661
+ logger: this.ctx.logger.child("nc-actions"),
18662
+ listCallerTargetIds: async (userId) => {
18663
+ return (await api.notificationOutput.listTargets.query({})).filter((t) => t.config["ownerUserId"] === userId).map((t) => t.id);
18664
+ }
18665
+ });
16952
18666
  }
16953
18667
  /** Declare typed collections up-front so the first insert doesn't race with
16954
18668
  * a CREATE TABLE. Idempotent. */
@@ -17073,6 +18787,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
17073
18787
  resolveZones: (deviceId) => this.resolveDeviceZones(deviceId),
17074
18788
  resolvePackageRules: (deviceId) => this.resolveDevicePackageRules(deviceId),
17075
18789
  resolveSettings: (deviceId) => this.resolveDevicePackageDropSettings(deviceId),
18790
+ onPersisted: (ev, phase) => this.notificationCenter?.onPackageEventPersisted(ev, phase),
17076
18791
  onError: (scope, err) => logger.warn("package-drop detector error", { meta: {
17077
18792
  scope,
17078
18793
  error: errMsg(err)
@@ -17231,7 +18946,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
17231
18946
  fetchDevice: (deviceId) => this.ctx.fetchDevice(deviceId),
17232
18947
  listStationaryObjects: (deviceId) => this.stationaryRegistry?.listViews(deviceId) ?? [],
17233
18948
  listStationaryDeviceIds: () => this.stationaryRegistry?.deviceIds() ?? [],
17234
- resolveZones: (deviceId) => this.resolveDeviceZones(deviceId)
18949
+ resolveZones: (deviceId) => this.resolveDeviceZones(deviceId),
18950
+ onSnapshot: (deviceId, snapshot) => this.notificationCenter?.observeOccupancy(deviceId, snapshot)
17235
18951
  });
17236
18952
  this.zoneAnalytics = zoneAnalytics;
17237
18953
  const audioMetrics = new AudioMetricsProvider({
@@ -17578,7 +19294,20 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
17578
19294
  */
17579
19295
  async processFrame(deviceId, frame, source, frameHandle, detailSteps) {
17580
19296
  if (this.shuttingDown) return;
17581
- if (!await this.bindingCache.isActive(deviceId)) return;
19297
+ if (!await this.bindingCache.isActive(deviceId)) {
19298
+ const now = Date.now();
19299
+ if (now - (this.inactiveBindingDropWarnAt.get(deviceId) ?? 0) >= 3e5) {
19300
+ this.inactiveBindingDropWarnAt.set(deviceId, now);
19301
+ this.ctx.logger.warn("inference frames dropped — pipeline-analytics binding inactive", {
19302
+ tags: { deviceId },
19303
+ meta: {
19304
+ source,
19305
+ detections: frame.detections.length
19306
+ }
19307
+ });
19308
+ }
19309
+ return;
19310
+ }
17582
19311
  const key = this.procKey(deviceId, source);
17583
19312
  const trk = await this.resolveDeviceTrackingSettings(deviceId);
17584
19313
  const activeCount = this.lastActiveTrackIds.get(key)?.size ?? 0;
@@ -17684,7 +19413,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
17684
19413
  source,
17685
19414
  resurrected: true
17686
19415
  } });
17687
- if (this.eventMediaDispatcher) this.residents.setLastFrameAt(deviceId, id, result.timestamp);
19416
+ if (this.eventMediaDispatcher) {
19417
+ this.residents.setLastFrameAt(deviceId, id, result.timestamp);
19418
+ if (!this.residents.isFirstFrameLanded(id) && !this.residents.isFirstFramePending(id)) this.residents.markFirstFramePending(deviceId, id);
19419
+ }
17688
19420
  continue;
17689
19421
  }
17690
19422
  bornCandidates.push({
@@ -17871,8 +19603,6 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
17871
19603
  else plateCrops += 1;
17872
19604
  const snapshotTargets = this.buildSnapshotTargets(deviceId, result.tracked, result.timestamp, mediaSettings, result.frameWidth, result.frameHeight);
17873
19605
  for (const retry of this.collectFirstFrameRetries(deviceId, result.tracked, result.timestamp, firstFrameTargets)) firstFrameTargets.push(retry);
17874
- const keyFrameTrackIds = selectKeyFrameTrackIds(snapshotTargets);
17875
- if (keyFrameTrackIds.length > 0) this.persistKeyFrames(deviceId, result.timestamp, keyFrameTrackIds, frameHandle);
17876
19606
  const targetedThisFrame = new Set([...firstFrameTargets.map((t) => t.trackId), ...snapshotTargets.map((s) => s.trackId)]);
17877
19607
  const rasterFallbackCandidates = [];
17878
19608
  const widenedRasterWantedIds = /* @__PURE__ */ new Set();
@@ -17918,6 +19648,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
17918
19648
  for (const t of firstFrameTargets) if (!this.residents.closure(t.trackId)?.rasterFallback) rasterFallbackWantedTrackIds.add(t.trackId);
17919
19649
  for (const s of snapshotTargets) if (!this.residents.closure(s.trackId)?.rasterFallback) rasterFallbackWantedTrackIds.add(s.trackId);
17920
19650
  const dispatcher = this.eventMediaDispatcher;
19651
+ const pinnedFramePromise = dispatcher.resolvePinnedFrame(deviceId, frameHandle);
17921
19652
  this.captureScheduler.request({
17922
19653
  deviceId,
17923
19654
  kind: deriveFrameDispatchKind({
@@ -17927,7 +19658,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
17927
19658
  hasSnapshot: snapshotTargets.some((t) => t.appendSnapshot)
17928
19659
  }),
17929
19660
  holdKeys: heldCaptureKeys,
17930
- exec: () => dispatcher.captureForFrame({
19661
+ exec: () => pinnedFramePromise.then((pinnedFrame) => dispatcher.captureForFrame({
17931
19662
  deviceId,
17932
19663
  frameHandle,
17933
19664
  events: eventTargets,
@@ -17935,8 +19666,9 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
17935
19666
  snapshots: snapshotTargets,
17936
19667
  cropPadding: mediaSettings.cropPadding,
17937
19668
  rasterFallbackWantedTrackIds,
17938
- rasterFallbackCandidates
17939
- }).then((res) => {
19669
+ rasterFallbackCandidates,
19670
+ ...pinnedFrame !== null ? { pinnedFrame } : {}
19671
+ })).then((res) => {
17940
19672
  for (const rf of res.rasterFallbacks) this.residents.retainRasterFallback(deviceId, rf.trackId, {
17941
19673
  jpeg: rf.jpeg,
17942
19674
  timestamp: rf.timestamp
@@ -17952,11 +19684,16 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
17952
19684
  mediaKey: s.mediaKey
17953
19685
  });
17954
19686
  for (const trackId of res.thumbnailTrackIds) this.residents.markThumbnailLanded(deviceId, trackId);
17955
- for (const trackId of res.firstFrameTrackIds) this.residents.clearFirstFramePending(trackId);
19687
+ for (const trackId of res.firstFrameTrackIds) {
19688
+ this.residents.clearFirstFramePending(trackId);
19689
+ this.residents.markFirstFrameLanded(deviceId, trackId);
19690
+ }
17956
19691
  for (const trackId of res.lastFrameTrackIds) this.residents.setLastFrameAt(deviceId, trackId, dispatchTimestamp);
17957
19692
  })
17958
19693
  }).catch(() => {});
17959
19694
  }
19695
+ const keyFrameTrackIds = selectKeyFrameTrackIds(snapshotTargets);
19696
+ if (keyFrameTrackIds.length > 0) this.persistKeyFrames(deviceId, result.timestamp, keyFrameTrackIds, frameHandle);
17960
19697
  }
17961
19698
  if (this.faceRecognizer && faceGloballyEnabled && faceSettings) this.faceRecognizer.processFrame({
17962
19699
  deviceId,
@@ -18302,7 +20039,18 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
18302
20039
  if (!await this.resolveGlobalFaceEnabled()) return;
18303
20040
  const [settings, media] = await Promise.all([this.resolveDeviceFaceSettings(deviceId), this.resolveDeviceMediaSettings(deviceId)]);
18304
20041
  const faceShortSidePx = detail.nativeFaceShortSidePx ?? (detail.bbox !== void 0 ? Math.min(detail.bbox.w, detail.bbox.h) : void 0);
18305
- if (faceShortSidePx !== void 0 && faceShortSidePx < settings.minFacePx) return;
20042
+ if (faceShortSidePx !== void 0 && faceShortSidePx < settings.minFacePx) {
20043
+ this.ctx.logger.debug("face detail below minFacePx — dropped", {
20044
+ tags: { deviceId },
20045
+ meta: {
20046
+ trackId,
20047
+ faceShortSidePx: Math.round(faceShortSidePx),
20048
+ minFacePx: settings.minFacePx,
20049
+ gatedOn: detail.nativeFaceShortSidePx !== void 0 ? "native" : "bbox"
20050
+ }
20051
+ });
20052
+ return;
20053
+ }
18306
20054
  await this.faceRecognizer.ingestFaceDetail({
18307
20055
  deviceId,
18308
20056
  trackId,
@@ -18313,6 +20061,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
18313
20061
  embedding: decodeEmbeddingBase64(detail.embedding),
18314
20062
  parentBbox: { ...frame.bbox },
18315
20063
  ...detail.bbox !== void 0 ? { faceBbox: { ...detail.bbox } } : {},
20064
+ ...detail.nativeFaceShortSidePx !== void 0 ? { nativeFaceShortSidePx: detail.nativeFaceShortSidePx } : {},
18316
20065
  ...detail.alignedCropJpeg !== void 0 ? { alignedCropJpeg: detail.alignedCropJpeg } : {},
18317
20066
  settings,
18318
20067
  cropPadding: media.cropPadding,
@@ -18675,8 +20424,18 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
18675
20424
  });
18676
20425
  const rollingLastFrame = plan.rollingLastFrame && !this.captureScheduler.isKeyActive(captureCoalesceKey(deviceId, "lastFrame", t.trackId));
18677
20426
  if (plan.appendSnapshot) this.trackStore.markSnapshotPending(t.trackId, timestamp, t.bbox);
18678
- const plausibleBox = isPlausibleThumbnailBox(t.bbox, frameWidth, frameHeight);
18679
- const bestThumbnail = plan.bestThumbnail && plausibleBox && !this.captureScheduler.isKeyActive(captureCoalesceKey(deviceId, "thumbnail", t.trackId));
20427
+ const plausibleBox = isPlausibleThumbnailBox(t.bbox, frameWidth, frameHeight, t.confidence);
20428
+ if (isNewBest && plausibleBox) this.residents.recordBestSeenBbox(deviceId, t.trackId, {
20429
+ x: t.bbox.x,
20430
+ y: t.bbox.y,
20431
+ w: t.bbox.w,
20432
+ h: t.bbox.h,
20433
+ frameWidth,
20434
+ frameHeight,
20435
+ timestamp
20436
+ });
20437
+ const retryBoxPlausible = isNewBest || isPlausibleRetryBox(t.bbox, this.residents.bestSeenBbox(t.trackId) ?? null);
20438
+ const bestThumbnail = plan.bestThumbnail && plausibleBox && retryBoxPlausible && !this.captureScheduler.isKeyActive(captureCoalesceKey(deviceId, "thumbnail", t.trackId));
18680
20439
  const keyFrame = isNewBest && plausibleBox;
18681
20440
  if (!plan.appendSnapshot && !rollingLastFrame && !bestThumbnail && !keyFrame) continue;
18682
20441
  targets.push({
@@ -18743,6 +20502,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
18743
20502
  atMs: timestamp
18744
20503
  });
18745
20504
  await this.eventStore.insertAudio(ev);
20505
+ this.notificationCenter?.onAudioEventPersisted(ev);
18746
20506
  this.trackStore?.addAudioLabelEpisode(deviceId, route.className, topClassification.score, timestamp);
18747
20507
  this.ctx.eventBus.emit({
18748
20508
  id: `pa-${ev.id}`,
@@ -19376,7 +21136,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
19376
21136
  try {
19377
21137
  await ingestSensorStateChange({
19378
21138
  sink: store,
19379
- cache
21139
+ cache,
21140
+ onPersisted: (ev) => this.notificationCenter?.onSensorEventPersisted(ev)
19380
21141
  }, data, timestamp);
19381
21142
  } catch (err) {
19382
21143
  this.ctx.logger.warn("sensor-event ingest failed", {
@@ -19895,4 +21656,4 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
19895
21656
  }
19896
21657
  };
19897
21658
  //#endregion
19898
- export { DETECTION_PIPELINE_SECTION_IDS, PipelineAnalyticsAddon as default, pickCleanMedia, retagDetectionSections, stripGlobalOnlyFields, toAnalyticsDeviceSections };
21659
+ export { DETECTION_PIPELINE_SECTION_IDS, ncActions as customActions, ncActions, PipelineAnalyticsAddon as default, pickCleanMedia, retagDetectionSections, stripGlobalOnlyFields, toAnalyticsDeviceSections };