@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.
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-BW-IoQDL.js");
5
+ const require_dist = require("../dist-Bezh0l-m.js");
6
6
  let node_crypto = require("node:crypto");
7
7
  let sharp = require("sharp");
8
8
  sharp = require_dist.__toESM(sharp);
@@ -400,7 +400,11 @@ var FaceGalleryProvider = class {
400
400
  assigned: face.assigned,
401
401
  ...base64 !== void 0 ? { base64 } : {},
402
402
  ...face.faceBbox !== void 0 ? { faceBbox: face.faceBbox } : {},
403
- ...face.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: face.keyFrameMediaKey } : {}
403
+ ...face.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: face.keyFrameMediaKey } : {},
404
+ ...face.bestMatchScore != null ? { bestMatchScore: face.bestMatchScore } : {},
405
+ ...face.nativeFaceShortSidePx != null ? { nativeFaceShortSidePx: face.nativeFaceShortSidePx } : {},
406
+ ...face.suggestedIdentityId != null ? { suggestedIdentityId: face.suggestedIdentityId } : {},
407
+ ...face.suggestedMatchScore != null ? { suggestedMatchScore: face.suggestedMatchScore } : {}
404
408
  });
405
409
  }
406
410
  return result;
@@ -430,7 +434,11 @@ var FaceGalleryProvider = class {
430
434
  assigned: face.assigned,
431
435
  ...base64 !== void 0 ? { base64 } : {},
432
436
  ...face.faceBbox !== void 0 ? { faceBbox: face.faceBbox } : {},
433
- ...face.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: face.keyFrameMediaKey } : {}
437
+ ...face.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: face.keyFrameMediaKey } : {},
438
+ ...face.bestMatchScore != null ? { bestMatchScore: face.bestMatchScore } : {},
439
+ ...face.nativeFaceShortSidePx != null ? { nativeFaceShortSidePx: face.nativeFaceShortSidePx } : {},
440
+ ...face.suggestedIdentityId != null ? { suggestedIdentityId: face.suggestedIdentityId } : {},
441
+ ...face.suggestedMatchScore != null ? { suggestedMatchScore: face.suggestedMatchScore } : {}
434
442
  };
435
443
  }
436
444
  /**
@@ -632,16 +640,25 @@ var FaceGalleryProvider = class {
632
640
  function normalizePlate$1(text) {
633
641
  return text.toUpperCase().replace(/[^A-Z0-9]/g, "");
634
642
  }
643
+ /** Raw-read glyphs a real plate can never contain: anything outside letters,
644
+ * digits, whitespace and the hyphen (display separator). The OCR charset
645
+ * includes bracket/punctuation glyphs, so a blurred crop can hallucinate
646
+ * reads like "[miv4o)" / "6ou.2" (live-observed 2026-07-22) that a
647
+ * length-only gate lets through. */
648
+ var PLATE_SYMBOL_GLYPH = /[^A-Za-z0-9\s-]/;
635
649
  /**
636
650
  * Quality gate for a raw OCR plate read BEFORE it becomes a track label or a
637
651
  * gallery row. Distant/oblique parked plates produce junk reads ("N", "Idag",
638
652
  * "@em") that otherwise flood track labels (live-observed on the parking
639
653
  * camera, 2026-07-16). A plausible European plate is ≥{@link PLATE_MIN_LENGTH}
640
- * alphanumerics and mixes letters AND digits; anything else or a read below
654
+ * alphanumerics, mixes letters AND digits, and carries NO symbol glyph in the
655
+ * RAW read (spaces/hyphens allowed — normalization would silently strip a
656
+ * hallucinated "[" and launder the junk); anything else — or a read below
641
657
  * {@link PLATE_MIN_SCORE} — is discarded, not stored.
642
658
  */
643
659
  function isPlausiblePlateRead(text, score) {
644
660
  if (score < .4) return false;
661
+ if (PLATE_SYMBOL_GLYPH.test(text)) return false;
645
662
  const norm = normalizePlate$1(text);
646
663
  if (norm.length < 4) return false;
647
664
  return /[0-9]/.test(norm) && /[A-Z]/.test(norm);
@@ -2872,6 +2889,7 @@ var TrackResidentState = class {
2872
2889
  const created = {
2873
2890
  deviceId,
2874
2891
  firstFramePending: false,
2892
+ firstFrameLanded: false,
2875
2893
  thumbnailLanded: false,
2876
2894
  confirmed: false
2877
2895
  };
@@ -2929,6 +2947,17 @@ var TrackResidentState = class {
2929
2947
  bestFramePeak(trackId) {
2930
2948
  return this.bestFrameTracker.peak(trackId);
2931
2949
  }
2950
+ /** #27-A: record the track's best-seen subject bbox. Called by the frame
2951
+ * loop on every ACCEPTED (new-best AND plausible-box) observe, so the value
2952
+ * always mirrors the frame the latest keyFrame re-shot was seeded from. */
2953
+ recordBestSeenBbox(deviceId, trackId, bbox) {
2954
+ this.ensure(deviceId, trackId).bestBbox = bbox;
2955
+ }
2956
+ /** The track's best-seen subject bbox (undefined until a plausible new-best
2957
+ * was observed). Backs the retry gate + the close-time keyFrame crop. */
2958
+ bestSeenBbox(trackId) {
2959
+ return this.residents.get(trackId)?.bestBbox;
2960
+ }
2932
2961
  /** Record a CLIP-object observation for the track's best-EMBEDDING ranking
2933
2962
  * (confidence-only policy). Returns true on a new best. */
2934
2963
  observeObjectEmbeddingBest(deviceId, obs) {
@@ -2972,6 +3001,13 @@ var TrackResidentState = class {
2972
3001
  const resident = this.residents.get(trackId);
2973
3002
  if (resident) resident.firstFramePending = false;
2974
3003
  }
3004
+ /** A `firstFrame` write landed for this track (see {@link TrackResident.firstFrameLanded}). */
3005
+ markFirstFrameLanded(deviceId, trackId) {
3006
+ this.ensure(deviceId, trackId).firstFrameLanded = true;
3007
+ }
3008
+ isFirstFrameLanded(trackId) {
3009
+ return this.residents.get(trackId)?.firstFrameLanded ?? false;
3010
+ }
2975
3011
  isThumbnailLanded(trackId) {
2976
3012
  return this.residents.get(trackId)?.thumbnailLanded ?? false;
2977
3013
  }
@@ -3006,81 +3042,131 @@ var TrackResidentState = class {
3006
3042
  if (resident.rasterFallback === void 0) resident.rasterFallback = fallback;
3007
3043
  }
3008
3044
  };
3009
- var DEFAULT_ZERO_MEDIA_POLICY_CONFIG = {
3010
- suppressMaxDurationMs: 1e3,
3011
- nothingToShowMaxDurationMs: 1500
3012
- };
3013
- /**
3014
- * Classify a closing track's persistence outcome. Pure — see the module header
3015
- * for the full contract.
3016
- */
3017
- function decideZeroMediaPolicy(input, config = DEFAULT_ZERO_MEDIA_POLICY_CONFIG) {
3018
- if (input.hasMedia) return "persist";
3019
- if (input.durationMs < config.suppressMaxDurationMs && !input.confirmed) return "suppress";
3020
- if (!input.hasRasterFallback && input.durationMs < config.nothingToShowMaxDurationMs) return "suppress";
3021
- return input.hasRasterFallback ? "raster-fallback" : "persist";
3022
- }
3023
3045
  //#endregion
3024
- //#region src/pipeline-analytics/pipeline/last-frame-promotion.ts
3046
+ //#region src/pipeline-analytics/store/zone-geometry.ts
3025
3047
  /**
3026
- * Decide whether a closing track's newest `snapshot` should be promoted to be
3027
- * its `lastFrame`. Pure see the module header for the contract.
3028
- *
3029
- * Promote when there is at least one `snapshot` AND either there is no
3030
- * `lastFrame` yet, or the newest snapshot is strictly newer than the held
3031
- * `lastFrame`. Otherwise keep the current behaviour (no promotion).
3048
+ * Normalized min/max envelope over every position's bbox. Returns `null`
3049
+ * when the frame dimensions are unknown/degenerate or there are no
3050
+ * positions — the caller persists NULL envelope columns in that case.
3032
3051
  */
3033
- function decideLastFramePromotion(media) {
3034
- let newestSnapshot;
3035
- let lastFrame;
3036
- for (const m of media) if (m.kind === "snapshot") {
3037
- if (newestSnapshot === void 0 || m.timestamp > newestSnapshot.timestamp) newestSnapshot = m;
3038
- } else if (m.kind === "lastFrame") {
3039
- if (lastFrame === void 0 || m.timestamp > lastFrame.timestamp) lastFrame = m;
3052
+ function computeTrackEnvelope(positions, frameWidth, frameHeight) {
3053
+ if (frameWidth === void 0 || frameHeight === void 0 || !(frameWidth > 0) || !(frameHeight > 0) || positions.length === 0) return null;
3054
+ let minX = Number.POSITIVE_INFINITY;
3055
+ let minY = Number.POSITIVE_INFINITY;
3056
+ let maxX = Number.NEGATIVE_INFINITY;
3057
+ let maxY = Number.NEGATIVE_INFINITY;
3058
+ for (const p of positions) {
3059
+ const x0 = p.bbox.x / frameWidth;
3060
+ const y0 = p.bbox.y / frameHeight;
3061
+ const x1 = (p.bbox.x + p.bbox.w) / frameWidth;
3062
+ const y1 = (p.bbox.y + p.bbox.h) / frameHeight;
3063
+ if (x0 < minX) minX = x0;
3064
+ if (y0 < minY) minY = y0;
3065
+ if (x1 > maxX) maxX = x1;
3066
+ if (y1 > maxY) maxY = y1;
3040
3067
  }
3041
- if (newestSnapshot === void 0) return { promote: false };
3042
- if (lastFrame !== void 0 && newestSnapshot.timestamp <= lastFrame.timestamp) return { promote: false };
3043
3068
  return {
3044
- promote: true,
3045
- snapshotKey: newestSnapshot.key,
3046
- snapshotTimestamp: newestSnapshot.timestamp
3069
+ minX,
3070
+ minY,
3071
+ maxX,
3072
+ maxY
3047
3073
  };
3048
3074
  }
3049
- //#endregion
3050
- //#region src/pipeline-analytics/pipeline/static-track-gate.ts
3051
3075
  /**
3052
- * Net displacement + path span for a track's centroid path, normalized to
3053
- * `referenceDiagonalPx`. Returns undefined when there is nothing meaningful to
3054
- * measure (fewer than two points, or a degenerate ≤0 reference) so the caller
3055
- * leaves the importance score untouched.
3076
+ * Axis-aligned bounds of a zone filter (rect: itself; polygon: vertex
3077
+ * min/max). A degenerate polygon (< 3 points) yields the full frame so the
3078
+ * SQL prefilter never silently drops rows the precise test would keep.
3056
3079
  */
3057
- function computeStaticTrackMetrics(centroids, referenceDiagonalPx) {
3058
- if (!(referenceDiagonalPx > 0) || centroids.length < 2) return void 0;
3059
- const first = centroids[0];
3060
- const last = centroids[centroids.length - 1];
3061
- const netDisplacementFrac = Math.hypot(last.x - first.x, last.y - first.y) / referenceDiagonalPx;
3062
- let minX = Infinity;
3063
- let minY = Infinity;
3064
- let maxX = -Infinity;
3065
- let maxY = -Infinity;
3066
- for (const c of centroids) {
3067
- if (c.x < minX) minX = c.x;
3068
- if (c.x > maxX) maxX = c.x;
3069
- if (c.y < minY) minY = c.y;
3070
- if (c.y > maxY) maxY = c.y;
3080
+ function zoneBounds(zone) {
3081
+ if (zone.kind === "rect") return {
3082
+ minX: zone.x,
3083
+ minY: zone.y,
3084
+ maxX: zone.x + zone.width,
3085
+ maxY: zone.y + zone.height
3086
+ };
3087
+ if (zone.points.length < 3) return {
3088
+ minX: 0,
3089
+ minY: 0,
3090
+ maxX: 1,
3091
+ maxY: 1
3092
+ };
3093
+ let minX = Number.POSITIVE_INFINITY;
3094
+ let minY = Number.POSITIVE_INFINITY;
3095
+ let maxX = Number.NEGATIVE_INFINITY;
3096
+ let maxY = Number.NEGATIVE_INFINITY;
3097
+ for (const p of zone.points) {
3098
+ if (p.x < minX) minX = p.x;
3099
+ if (p.y < minY) minY = p.y;
3100
+ if (p.x > maxX) maxX = p.x;
3101
+ if (p.y > maxY) maxY = p.y;
3071
3102
  }
3072
3103
  return {
3073
- netDisplacementFrac,
3074
- pathSpanFrac: Math.hypot(maxX - minX, maxY - minY) / referenceDiagonalPx
3104
+ minX,
3105
+ minY,
3106
+ maxX,
3107
+ maxY
3075
3108
  };
3076
3109
  }
3077
- /** Average bbox diagonal (px) across a track's positions — the scale reference
3078
- * when frame dimensions aren't available. Returns 0 for an empty list. */
3079
- function averageBboxDiagonal(boxes) {
3080
- if (boxes.length === 0) return 0;
3081
- let sum = 0;
3082
- for (const b of boxes) sum += Math.hypot(b.w, b.h);
3083
- return sum / boxes.length;
3110
+ /** Whether two axis-aligned envelopes overlap (touching edges count). */
3111
+ function envelopesOverlap(a, b) {
3112
+ return a.minX <= b.maxX && a.maxX >= b.minX && a.minY <= b.maxY && a.maxY >= b.minY;
3113
+ }
3114
+ /**
3115
+ * Ray-casting point-in-polygon (even-odd rule). Points on an edge may
3116
+ * resolve either way — acceptable for zone filtering. A polygon with
3117
+ * fewer than 3 vertices contains nothing.
3118
+ */
3119
+ function pointInPolygon(point, polygon) {
3120
+ if (polygon.length < 3) return false;
3121
+ let inside = false;
3122
+ for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
3123
+ const a = polygon[i];
3124
+ const b = polygon[j];
3125
+ 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;
3126
+ }
3127
+ return inside;
3128
+ }
3129
+ /**
3130
+ * Precise per-position zone test.
3131
+ *
3132
+ * - rect zone → any position bbox (normalized) intersects the rect.
3133
+ * - polygon zone → any position CENTER (normalized `x`/`y` — positions
3134
+ * store the bbox center) falls inside the polygon.
3135
+ *
3136
+ * Unknown/degenerate frame dims → `true` (the track cannot be tested, so it
3137
+ * PASSES — mirroring the NULL-envelope-matches rule).
3138
+ */
3139
+ function positionsIntersectZone(positions, frameWidth, frameHeight, zone) {
3140
+ if (frameWidth === void 0 || frameHeight === void 0 || !(frameWidth > 0) || !(frameHeight > 0)) return true;
3141
+ if (zone.kind === "rect") {
3142
+ const rect = zoneBounds(zone);
3143
+ for (const p of positions) if (envelopesOverlap({
3144
+ minX: p.bbox.x / frameWidth,
3145
+ minY: p.bbox.y / frameHeight,
3146
+ maxX: (p.bbox.x + p.bbox.w) / frameWidth,
3147
+ maxY: (p.bbox.y + p.bbox.h) / frameHeight
3148
+ }, rect)) return true;
3149
+ return false;
3150
+ }
3151
+ for (const p of positions) if (pointInPolygon({
3152
+ x: p.x / frameWidth,
3153
+ y: p.y / frameHeight
3154
+ }, zone.points)) return true;
3155
+ return false;
3156
+ }
3157
+ var DEFAULT_ZERO_MEDIA_POLICY_CONFIG = {
3158
+ suppressMaxDurationMs: 1e3,
3159
+ nothingToShowMaxDurationMs: 1500
3160
+ };
3161
+ /**
3162
+ * Classify a closing track's persistence outcome. Pure — see the module header
3163
+ * for the full contract.
3164
+ */
3165
+ function decideZeroMediaPolicy(input, config = DEFAULT_ZERO_MEDIA_POLICY_CONFIG) {
3166
+ if (input.hasMedia) return "persist";
3167
+ if (input.durationMs < config.suppressMaxDurationMs && !input.confirmed) return "suppress";
3168
+ if (!input.hasRasterFallback && input.durationMs < config.nothingToShowMaxDurationMs) return "suppress";
3169
+ return input.hasRasterFallback ? "raster-fallback" : "persist";
3084
3170
  }
3085
3171
  //#endregion
3086
3172
  //#region src/pipeline-analytics/pipeline/delete-track-cascade.ts
@@ -3145,6 +3231,83 @@ async function runTrackCascadeBatch(deps, trackIds) {
3145
3231
  };
3146
3232
  }
3147
3233
  //#endregion
3234
+ //#region src/pipeline-analytics/pipeline/last-frame-promotion.ts
3235
+ /**
3236
+ * Decide whether a closing track's newest `snapshot` should be promoted to be
3237
+ * its `lastFrame`. Pure — see the module header for the contract.
3238
+ *
3239
+ * Promote (`mode:'move'`) when there is at least one `snapshot` AND either
3240
+ * there is no `lastFrame` yet, or the newest snapshot is strictly newer than
3241
+ * the held `lastFrame`. With NO snapshot AND NO lastFrame at all (a track that
3242
+ * died within one snapshot interval), fall back to `mode:'copy'` from the
3243
+ * newest `keyFrameSmall` so a short track still closes with a full-frame
3244
+ * "Ultimo" view. Otherwise no promotion.
3245
+ */
3246
+ function decideLastFramePromotion(media) {
3247
+ let newestSnapshot;
3248
+ let lastFrame;
3249
+ let newestKeyFrameSmall;
3250
+ for (const m of media) if (m.kind === "snapshot") {
3251
+ if (newestSnapshot === void 0 || m.timestamp > newestSnapshot.timestamp) newestSnapshot = m;
3252
+ } else if (m.kind === "lastFrame") {
3253
+ if (lastFrame === void 0 || m.timestamp > lastFrame.timestamp) lastFrame = m;
3254
+ } else if (m.kind === "keyFrameSmall") {
3255
+ if (newestKeyFrameSmall === void 0 || m.timestamp > newestKeyFrameSmall.timestamp) newestKeyFrameSmall = m;
3256
+ }
3257
+ if (newestSnapshot === void 0) {
3258
+ if (lastFrame === void 0 && newestKeyFrameSmall !== void 0) return {
3259
+ promote: true,
3260
+ mode: "copy",
3261
+ snapshotKey: newestKeyFrameSmall.key,
3262
+ snapshotTimestamp: newestKeyFrameSmall.timestamp
3263
+ };
3264
+ return { promote: false };
3265
+ }
3266
+ if (lastFrame !== void 0 && newestSnapshot.timestamp <= lastFrame.timestamp) return { promote: false };
3267
+ return {
3268
+ promote: true,
3269
+ mode: "move",
3270
+ snapshotKey: newestSnapshot.key,
3271
+ snapshotTimestamp: newestSnapshot.timestamp
3272
+ };
3273
+ }
3274
+ //#endregion
3275
+ //#region src/pipeline-analytics/pipeline/static-track-gate.ts
3276
+ /**
3277
+ * Net displacement + path span for a track's centroid path, normalized to
3278
+ * `referenceDiagonalPx`. Returns undefined when there is nothing meaningful to
3279
+ * measure (fewer than two points, or a degenerate ≤0 reference) so the caller
3280
+ * leaves the importance score untouched.
3281
+ */
3282
+ function computeStaticTrackMetrics(centroids, referenceDiagonalPx) {
3283
+ if (!(referenceDiagonalPx > 0) || centroids.length < 2) return void 0;
3284
+ const first = centroids[0];
3285
+ const last = centroids[centroids.length - 1];
3286
+ const netDisplacementFrac = Math.hypot(last.x - first.x, last.y - first.y) / referenceDiagonalPx;
3287
+ let minX = Infinity;
3288
+ let minY = Infinity;
3289
+ let maxX = -Infinity;
3290
+ let maxY = -Infinity;
3291
+ for (const c of centroids) {
3292
+ if (c.x < minX) minX = c.x;
3293
+ if (c.x > maxX) maxX = c.x;
3294
+ if (c.y < minY) minY = c.y;
3295
+ if (c.y > maxY) maxY = c.y;
3296
+ }
3297
+ return {
3298
+ netDisplacementFrac,
3299
+ pathSpanFrac: Math.hypot(maxX - minX, maxY - minY) / referenceDiagonalPx
3300
+ };
3301
+ }
3302
+ /** Average bbox diagonal (px) across a track's positions — the scale reference
3303
+ * when frame dimensions aren't available. Returns 0 for an empty list. */
3304
+ function averageBboxDiagonal(boxes) {
3305
+ if (boxes.length === 0) return 0;
3306
+ let sum = 0;
3307
+ for (const b of boxes) sum += Math.hypot(b.w, b.h);
3308
+ return sum / boxes.length;
3309
+ }
3310
+ //#endregion
3148
3311
  //#region src/pipeline-analytics/pipeline/track-close.ts
3149
3312
  var TrackCloser = class {
3150
3313
  deps;
@@ -3215,6 +3378,7 @@ var TrackCloser = class {
3215
3378
  }
3216
3379
  if (outcome === "raster-fallback" && closure?.rasterFallback) await this.commitRasterFallback(t, closure.rasterFallback.jpeg, closure.rasterFallback.timestamp);
3217
3380
  await this.maybePromoteLastFrame(t, ownedMedia);
3381
+ await this.maybeDeriveThumbnailFromKeyFrame(t, ownedMedia);
3218
3382
  this.deps.logger.info("track ended", {
3219
3383
  tags: { deviceId: t.deviceId },
3220
3384
  meta: {
@@ -3225,6 +3389,7 @@ var TrackCloser = class {
3225
3389
  }
3226
3390
  });
3227
3391
  const keyFrameMediaKey = this.deps.residents.keyFrameKey(t.trackId);
3392
+ const labelConfidence = this.bestLabelMatchConfidence(t.deviceId, t.trackId);
3228
3393
  this.fireRecognizerEnds(t.deviceId, t.trackId);
3229
3394
  const trackerPeak = this.deps.residents.bestFramePeak(t.trackId);
3230
3395
  const importanceSummary = await this.scoreImportance(t, duration, trackerPeak?.confidence);
@@ -3233,7 +3398,23 @@ var TrackCloser = class {
3233
3398
  this.deps.overlayState.onTrackEnded(t.deviceId, t.trackId);
3234
3399
  this.flushCaptureWindowIfIdle(t.deviceId);
3235
3400
  this.emitEndEvents(t, duration, trackerPeak?.confidence, keyFrameMediaKey, importanceSummary);
3236
- this.deps.onTrackClosed?.(t, ownedMedia, { ...trackerPeak?.confidence !== void 0 ? { bestConfidence: trackerPeak.confidence } : {} });
3401
+ const dims = this.deps.frameDims(t.deviceId);
3402
+ const envelope = computeTrackEnvelope(t.positions, dims?.w, dims?.h);
3403
+ this.deps.onTrackClosed?.(t, ownedMedia, {
3404
+ ...trackerPeak?.confidence !== void 0 ? { bestConfidence: trackerPeak.confidence } : {},
3405
+ ...importanceSummary.importance !== void 0 ? { importance: importanceSummary.importance } : {},
3406
+ ...labelConfidence !== void 0 ? { labelConfidence } : {},
3407
+ ...envelope !== null ? { envelope } : {}
3408
+ });
3409
+ }
3410
+ /** Max recognition match confidence over the face + plate recognizers for a
3411
+ * closing track (undefined when neither recognized a label). */
3412
+ bestLabelMatchConfidence(_deviceId, trackId) {
3413
+ const face = this.deps.faceRecognizer()?.bestLabelMatchConfidence?.(trackId);
3414
+ const plate = this.deps.plateRecognizer()?.bestLabelMatchConfidence?.(trackId);
3415
+ if (face === void 0) return plate;
3416
+ if (plate === void 0) return face;
3417
+ return Math.max(face, plate);
3237
3418
  }
3238
3419
  /**
3239
3420
  * Undo a zero-media false-birth track (spec §"Zero-media track policy"): tear
@@ -3300,27 +3481,95 @@ var TrackCloser = class {
3300
3481
  }
3301
3482
  }
3302
3483
  /**
3303
- * Genuinely-last view (operator, 2026-07-22): the rolling `lastFrame` never
3304
- * fires on a `snapshot` frame and rolls on its own cadence, so at close it
3305
- * can trail the newest appended snapshot by up to one interval. If a newer
3306
- * snapshot exists (or there is no lastFrame but ≥1 snapshot), PROMOTE it
3307
- * into the single lastFrame slot and drop the snapshot row — one
3308
- * genuinely-last view, no duplicate pair. Reuses the `ownedMedia` list
3309
- * already fetched by the caller (base64 present no re-read).
3484
+ * Close-time keyFrame-derived `thumbnail` fallback (#27-A part 3): when the
3485
+ * closing track's owned media has NO `thumbnail` but HAS a native `keyFrame`,
3486
+ * derive the thumbnail (+ `thumbnailSmall`) from the persisted keyFrame,
3487
+ * cropping the BEST-seen subject bbox with the standard 16:9 central-square
3488
+ * framing (the derive is injected `createKeyFrameCrop` composed with the
3489
+ * wide-central-square layout in the addon wiring). Best-effort with its own
3490
+ * catch: any failure leaves the track as it was and never blocks the close.
3310
3491
  */
3311
- async maybePromoteLastFrame(t, ownedMedia) {
3312
- const promotion = decideLastFramePromotion(ownedMedia);
3313
- if (!promotion.promote) return;
3314
- const snapshot = ownedMedia.find((m) => m.key === promotion.snapshotKey);
3315
- if (!snapshot) return;
3492
+ async maybeDeriveThumbnailFromKeyFrame(t, ownedMedia) {
3493
+ const derive = this.deps.deriveThumbnailFromKeyFrame;
3494
+ if (!derive) return;
3495
+ if (ownedMedia.some((m) => m.kind === "thumbnail")) return;
3496
+ const keyFrame = ownedMedia.find((m) => m.kind === "keyFrame");
3497
+ if (!keyFrame) return;
3498
+ const bestSeen = this.deps.residents.bestSeenBbox(t.trackId);
3499
+ if (!bestSeen) return;
3316
3500
  try {
3317
- await this.deps.mediaStore()?.promoteToLastFrame({
3318
- deviceId: t.deviceId,
3319
- trackId: t.trackId,
3320
- snapshot
3501
+ const derived = await derive({
3502
+ mediaKey: keyFrame.key,
3503
+ bbox: {
3504
+ x: bestSeen.x,
3505
+ y: bestSeen.y,
3506
+ w: bestSeen.w,
3507
+ h: bestSeen.h
3508
+ },
3509
+ frameWidth: bestSeen.frameWidth,
3510
+ frameHeight: bestSeen.frameHeight,
3511
+ timestamp: bestSeen.timestamp
3321
3512
  });
3322
- } catch (err) {
3323
- this.deps.logger.debug("lastFrame promotion failed", {
3513
+ if (!derived) return;
3514
+ const mediaStore = this.deps.mediaStore();
3515
+ if (!mediaStore) return;
3516
+ await mediaStore.put({
3517
+ deviceId: t.deviceId,
3518
+ ownerKind: "track",
3519
+ ownerId: t.trackId,
3520
+ kind: "thumbnail",
3521
+ timestamp: bestSeen.timestamp,
3522
+ data: derived.thumbnail
3523
+ });
3524
+ await mediaStore.put({
3525
+ deviceId: t.deviceId,
3526
+ ownerKind: "track",
3527
+ ownerId: t.trackId,
3528
+ kind: "thumbnailSmall",
3529
+ timestamp: bestSeen.timestamp,
3530
+ data: derived.thumbnailSmall
3531
+ });
3532
+ this.deps.logger.info("thumbnail derived from keyFrame at close (no live best-shot landed)", {
3533
+ tags: { deviceId: t.deviceId },
3534
+ meta: {
3535
+ trackId: t.trackId,
3536
+ keyFrameKey: keyFrame.key,
3537
+ skewMs: derived.skewMs
3538
+ }
3539
+ });
3540
+ } catch (err) {
3541
+ this.deps.logger.debug("close-time keyFrame thumbnail derive failed", {
3542
+ tags: { deviceId: t.deviceId },
3543
+ meta: {
3544
+ trackId: t.trackId,
3545
+ error: String(err)
3546
+ }
3547
+ });
3548
+ }
3549
+ }
3550
+ /**
3551
+ * Genuinely-last view (operator, 2026-07-22): the rolling `lastFrame` never
3552
+ * fires on a `snapshot` frame and rolls on its own cadence, so at close it
3553
+ * can trail the newest appended snapshot by up to one interval. If a newer
3554
+ * snapshot exists (or there is no lastFrame but ≥1 snapshot), PROMOTE it
3555
+ * into the single lastFrame slot and drop the snapshot row — one
3556
+ * genuinely-last view, no duplicate pair. Reuses the `ownedMedia` list
3557
+ * already fetched by the caller (base64 present → no re-read).
3558
+ */
3559
+ async maybePromoteLastFrame(t, ownedMedia) {
3560
+ const promotion = decideLastFramePromotion(ownedMedia);
3561
+ if (!promotion.promote) return;
3562
+ const snapshot = ownedMedia.find((m) => m.key === promotion.snapshotKey);
3563
+ if (!snapshot) return;
3564
+ try {
3565
+ await this.deps.mediaStore()?.promoteToLastFrame({
3566
+ deviceId: t.deviceId,
3567
+ trackId: t.trackId,
3568
+ snapshot,
3569
+ keepSource: promotion.mode === "copy"
3570
+ });
3571
+ } catch (err) {
3572
+ this.deps.logger.debug("lastFrame promotion failed", {
3324
3573
  tags: { deviceId: t.deviceId },
3325
3574
  meta: {
3326
3575
  trackId: t.trackId,
@@ -3421,6 +3670,23 @@ var TrackCloser = class {
3421
3670
  };
3422
3671
  //#endregion
3423
3672
  //#region src/notification-center/rule-engine.ts
3673
+ /**
3674
+ * Normalize a PIXEL-space detection bbox onto 0..1 using its detection-frame
3675
+ * dims. Returns `undefined` when the box or the dims are missing/degenerate
3676
+ * (dim ≤ 0) — the caller then omits the subject bbox and `customZones` fails
3677
+ * closed rather than testing pixel coords against a 0..1 polygon.
3678
+ */
3679
+ function normalizeBbox(bbox, frameWidth, frameHeight) {
3680
+ if (bbox === void 0) return void 0;
3681
+ if (frameWidth === void 0 || frameHeight === void 0) return void 0;
3682
+ if (frameWidth <= 0 || frameHeight <= 0) return void 0;
3683
+ return {
3684
+ x: bbox.x / frameWidth,
3685
+ y: bbox.y / frameHeight,
3686
+ w: bbox.w / frameWidth,
3687
+ h: bbox.h / frameHeight
3688
+ };
3689
+ }
3424
3690
  /** Build the subject for an `immediate` (object-event) evaluation. */
3425
3691
  function subjectFromObjectEvent(ev) {
3426
3692
  return {
@@ -3432,7 +3698,132 @@ function subjectFromObjectEvent(ev) {
3432
3698
  ...ev.label !== void 0 ? { label: ev.label } : {},
3433
3699
  ...ev.confidence !== void 0 ? { confidence: ev.confidence } : {},
3434
3700
  zones: ev.zones ?? [],
3435
- ...ev.trackId !== void 0 ? { trackId: ev.trackId } : {}
3701
+ ...ev.trackId !== void 0 ? { trackId: ev.trackId } : {},
3702
+ source: ev.source ?? "pipeline",
3703
+ ...ev.importance !== void 0 ? { importance: ev.importance } : {},
3704
+ ...bboxPatch(normalizeBbox(ev.bbox, ev.frameWidth, ev.frameHeight))
3705
+ };
3706
+ }
3707
+ /** Spread an optional normalized bbox onto a subject (present only if set). */
3708
+ function bboxPatch(bbox) {
3709
+ return bbox !== void 0 ? { bbox } : {};
3710
+ }
3711
+ /** Convert a normalized 0..1 track envelope (min/max) into the subject's
3712
+ * top-left+size `BboxRect`. `undefined` in ⇒ `undefined` out (fail closed). */
3713
+ function envelopeToBbox(envelope) {
3714
+ if (envelope === void 0) return void 0;
3715
+ return {
3716
+ x: envelope.minX,
3717
+ y: envelope.minY,
3718
+ w: envelope.maxX - envelope.minX,
3719
+ h: envelope.maxY - envelope.minY
3720
+ };
3721
+ }
3722
+ /**
3723
+ * Read the raw device event-type token off a persisted `SensorEvent.value`
3724
+ * slice. Only the event-emitter cap carries one (`EventEmitterStatus.lastEvent
3725
+ * .eventType`); doorbell-pulse / passive-sensor slices have none. Pure +
3726
+ * defensive over the untyped record snapshot.
3727
+ */
3728
+ function readSensorEventType(value) {
3729
+ if (value === null) return void 0;
3730
+ const last = value["lastEvent"];
3731
+ if (last === null || typeof last !== "object" || Array.isArray(last)) return void 0;
3732
+ const eventType = last["eventType"];
3733
+ return typeof eventType === "string" && eventType.length > 0 ? eventType : void 0;
3734
+ }
3735
+ /** Build the subject for a `device-event` evaluation (a persisted SensorEvent —
3736
+ * one row per linked camera; `deviceId` is the CAMERA). */
3737
+ function subjectFromSensorEvent(ev) {
3738
+ const eventType = readSensorEventType(ev.value);
3739
+ return {
3740
+ kind: "device-event",
3741
+ recordId: ev.id,
3742
+ deviceId: ev.deviceId,
3743
+ timestamp: ev.timestamp,
3744
+ classNames: [],
3745
+ zones: [],
3746
+ source: "sensor",
3747
+ sensorKind: ev.kind,
3748
+ ...eventType !== void 0 ? { eventType } : {}
3749
+ };
3750
+ }
3751
+ /**
3752
+ * Build the subject for an AUDIO evaluation. Audio classification events
3753
+ * (`eventStore.insertAudio`) persist on a SEPARATE path from object events and
3754
+ * never reach the object-event hook — this normalizes one onto the engine
3755
+ * subject so an `immediate` rule that OPTS IN to an `audio-*` class can fire on
3756
+ * it. The class id is the SAME namespaced `audio-<macroClass>` id the NC
3757
+ * taxonomy exposes (see `nc-taxonomy.ts` audioKinds), so a picker selection
3758
+ * matches exactly. `confidence` carries the classification score (so a
3759
+ * `minConfidence` condition composes naturally). A level-path audio event (no
3760
+ * `classification`) yields NO class ⇒ it can never satisfy the audio opt-in
3761
+ * gate, so only classified audio ever notifies (documented boundary). Audio has
3762
+ * no zones / bbox / label / track, so the object/track-specific conditions all
3763
+ * fail closed (see the {@link evaluateRule} audio gate + the matchers below).
3764
+ */
3765
+ function subjectFromAudioEvent(ev) {
3766
+ const macro = ev.classification?.className;
3767
+ return {
3768
+ kind: "audio-event",
3769
+ recordId: ev.id,
3770
+ deviceId: ev.deviceId,
3771
+ timestamp: ev.timestamp,
3772
+ classNames: macro !== void 0 ? [`audio-${macro}`] : [],
3773
+ ...ev.classification?.score !== void 0 ? { confidence: ev.classification.score } : {},
3774
+ zones: [],
3775
+ source: "pipeline"
3776
+ };
3777
+ }
3778
+ /**
3779
+ * Build the subject for an OCCUPANCY evaluation. A committed `OccupancyEdge`
3780
+ * (ZoneAnalytics count crossing) rides the EXISTING `device-event` delivery via
3781
+ * this INTERNAL subject kind (the audio pattern with a device-event landing —
3782
+ * `device-event` is already a native `NcHistoryRecordKind`, so no history
3783
+ * downgrade is needed). The edge scope + count/threshold ride on `occupancy`;
3784
+ * the engine's occupancy branch matches the rule's `occupancy` condition against
3785
+ * it. Admin-zone provenance is NOT copied onto `zones` (kept empty) so the admin
3786
+ * `zones` condition FAILS CLOSED on occupancy — zone scoping is done inside the
3787
+ * occupancy condition (`occupancy.zoneId`). No detection confidence / label /
3788
+ * bbox / importance / sensorKind, so those conditions all fail closed too.
3789
+ */
3790
+ function subjectFromOccupancyEvent(edge) {
3791
+ return {
3792
+ kind: "occupancy-event",
3793
+ recordId: `occ:${edge.deviceId}:${edge.zoneId ?? "@frame"}:${edge.className ?? "@all"}:t${edge.threshold}:${edge.occupied ? "occ" : "free"}:${edge.timestamp}`,
3794
+ deviceId: edge.deviceId,
3795
+ timestamp: edge.timestamp,
3796
+ classNames: [edge.className ?? "occupancy"],
3797
+ zones: [],
3798
+ source: "pipeline",
3799
+ occupancy: {
3800
+ ...edge.zoneId !== void 0 ? { zoneId: edge.zoneId } : {},
3801
+ ...edge.zoneName !== void 0 ? { zoneName: edge.zoneName } : {},
3802
+ ...edge.className !== void 0 ? { className: edge.className } : {},
3803
+ count: edge.count,
3804
+ previousCount: edge.previousCount,
3805
+ occupied: edge.occupied,
3806
+ threshold: edge.threshold
3807
+ }
3808
+ };
3809
+ }
3810
+ /** Build the subject for a `package-event` evaluation (a persisted `package`
3811
+ * object-event; `phase` derives from the event `state` at the call site). */
3812
+ function subjectFromPackageEvent(ev, phase) {
3813
+ return {
3814
+ kind: "package-event",
3815
+ recordId: ev.id,
3816
+ deviceId: ev.deviceId,
3817
+ timestamp: ev.timestamp,
3818
+ classNames: [ev.className],
3819
+ ...ev.label !== void 0 ? { label: ev.label } : {},
3820
+ ...ev.confidence !== void 0 ? { confidence: ev.confidence } : {},
3821
+ zones: ev.zones ?? [],
3822
+ ...ev.trackId !== void 0 ? { trackId: ev.trackId } : {},
3823
+ source: ev.source ?? "pipeline",
3824
+ ...ev.importance !== void 0 ? { importance: ev.importance } : {},
3825
+ ...bboxPatch(normalizeBbox(ev.bbox, ev.frameWidth, ev.frameHeight)),
3826
+ packagePhase: phase
3436
3827
  };
3437
3828
  }
3438
3829
  /** Build the subject for a `track-end` evaluation. */
@@ -3446,20 +3837,105 @@ function subjectFromTrack(track, info) {
3446
3837
  ...track.label !== void 0 ? { label: track.label } : {},
3447
3838
  ...info?.bestConfidence !== void 0 ? { confidence: info.bestConfidence } : {},
3448
3839
  zones: track.zonesVisited,
3449
- trackId: track.trackId
3840
+ trackId: track.trackId,
3841
+ source: track.source ?? "pipeline",
3842
+ ...(info?.importance ?? track.importance) !== void 0 ? { importance: info?.importance ?? track.importance } : {},
3843
+ dwellSeconds: (track.lastSeen - track.firstSeen) / 1e3,
3844
+ ...info?.labelConfidence !== void 0 ? { labelConfidence: info.labelConfidence } : {},
3845
+ ...bboxPatch(envelopeToBbox(info?.envelope ?? track.envelope))
3450
3846
  };
3451
3847
  }
3452
- var PASS = { matched: true };
3453
3848
  function fail(condition) {
3454
3849
  return {
3455
3850
  matched: false,
3456
3851
  failedCondition: condition
3457
3852
  };
3458
3853
  }
3854
+ /**
3855
+ * The condition ids PRESENT on a rule (an empty/absent group contributes
3856
+ * nothing). Order follows the catalog. On a matched rule every id returned
3857
+ * here passed, so the list is the rule's "matched on" summary.
3858
+ */
3859
+ function presentConditionIds(c) {
3860
+ const ids = [];
3861
+ if (c.devices !== void 0 && c.devices.length > 0) ids.push("devices");
3862
+ if (c.source !== void 0 && c.source !== "any") ids.push("source");
3863
+ if (c.classes !== void 0 && c.classes.length > 0) ids.push("classes");
3864
+ if (c.classesExclude !== void 0 && c.classesExclude.length > 0) ids.push("classesExclude");
3865
+ if (c.minConfidence !== void 0) ids.push("minConfidence");
3866
+ if (c.minImportance !== void 0) ids.push("minImportance");
3867
+ if (c.minDwellSeconds !== void 0) ids.push("minDwellSeconds");
3868
+ if (c.zones !== void 0) ids.push("zones");
3869
+ if (c.zonesExclude !== void 0 && c.zonesExclude.length > 0) ids.push("zonesExclude");
3870
+ if (c.labelEquals !== void 0 && c.labelEquals.length > 0) ids.push("labelEquals");
3871
+ if (c.identities !== void 0 && c.identities.length > 0) ids.push("identities");
3872
+ if (c.identitiesExclude !== void 0 && c.identitiesExclude.length > 0) ids.push("identitiesExclude");
3873
+ if (c.minLabelConfidence !== void 0) ids.push("minLabelConfidence");
3874
+ if (c.plates !== void 0) ids.push("plates");
3875
+ if (c.sensorKinds !== void 0 && c.sensorKinds.length > 0) ids.push("sensorKinds");
3876
+ if (c.eventTypeTokens !== void 0 && c.eventTypeTokens.length > 0) ids.push("eventTypeTokens");
3877
+ if (c.packagePhase !== void 0 && c.packagePhase !== "both") ids.push("packagePhase");
3878
+ if (c.customZones !== void 0 && c.customZones.length > 0) ids.push("customZones");
3879
+ if (c.occupancy !== void 0) ids.push("occupancy");
3880
+ return ids;
3881
+ }
3882
+ /**
3883
+ * Match a rule's occupancy condition against the subject's committed count-edge.
3884
+ * The scope (`zoneId`/`className`) must match EXACTLY (fail-closed), then the
3885
+ * `op` selects the edge polarity + threshold:
3886
+ * - `became-occupied` / `>=` C → the occupied (false→true) edge of the key
3887
+ * whose threshold is C.
3888
+ * - `became-free` C → the un-occupied (true→false) edge of key C.
3889
+ * - `<=` C → the un-occupied edge of key C+1 (count dropped
3890
+ * from >C to ≤C — the predicate `count ≥ C+1` went false).
3891
+ * Per-threshold keying guarantees the subject's `threshold` already equals the
3892
+ * key's, so a rule matches only its OWN edge — a ≥1 and a ≥3 rule never
3893
+ * cross-fire on a gradual accumulation.
3894
+ */
3895
+ function matchesOccupancy(occ, s) {
3896
+ if ((occ.zoneId ?? void 0) !== (s.zoneId ?? void 0)) return false;
3897
+ if ((occ.className ?? void 0) !== (s.className ?? void 0)) return false;
3898
+ switch (occ.op) {
3899
+ case "became-occupied":
3900
+ case ">=": return s.occupied === true && s.threshold === occ.count;
3901
+ case "became-free": return s.occupied === false && s.threshold === occ.count;
3902
+ case "<=": return s.occupied === false && s.threshold === occ.count + 1;
3903
+ }
3904
+ }
3459
3905
  function toLowerSet(values) {
3460
3906
  return new Set(values.map((v) => v.trim().toLowerCase()));
3461
3907
  }
3462
3908
  /**
3909
+ * Expand each selected class id to itself PLUS its taxonomy leaf subs, so a
3910
+ * MACRO selection (`vehicle`) matches its sub classNames (`car`/`truck`) — the
3911
+ * `EVENT_TAXONOMY` tree is the SINGLE source (mirrors the timeline/filter
3912
+ * grouping). A leaf id expands to just itself. Applies to the video class
3913
+ * matchers (`classes` / `classesExclude`); audio ids (`audio-*`) are leaves so
3914
+ * they expand to themselves. Lower-cased for the case-insensitive membership
3915
+ * test.
3916
+ */
3917
+ function expandClassSelector(classes) {
3918
+ const out = /* @__PURE__ */ new Set();
3919
+ for (const raw of classes) {
3920
+ const c = raw.trim().toLowerCase();
3921
+ if (c.length === 0) continue;
3922
+ out.add(c);
3923
+ for (const subEntry of require_dist.subKindsOf(c)) out.add(subEntry.kind.trim().toLowerCase());
3924
+ }
3925
+ return out;
3926
+ }
3927
+ /**
3928
+ * True when a rule's `classes` condition explicitly opts in to at least one
3929
+ * audio kind (`audio-*` id). The safety gate for audio subjects: an `immediate`
3930
+ * rule fires on an audio subject ONLY when it names an audio class — a rule with
3931
+ * no classes, or with only video classes, NEVER fires on audio (preserves
3932
+ * today's behavior, where classified audio never reached the engine at all).
3933
+ */
3934
+ function referencesAudioClass(classes) {
3935
+ if (classes === void 0) return false;
3936
+ return classes.some((c) => c.trim().toLowerCase().startsWith("audio-"));
3937
+ }
3938
+ /**
3463
3939
  * Evaluate one rule against one subject. The rule's `delivery` must match
3464
3940
  * the subject kind (`immediate` ↔ `object-event`, `track-end` ↔
3465
3941
  * `track-end`) — a mismatch fails with `'delivery'`. Schedule is evaluated
@@ -3470,12 +3946,27 @@ function toLowerSet(values) {
3470
3946
  * is stateful; see {@link cooldownKey} / {@link isCoolingDown}.
3471
3947
  */
3472
3948
  function evaluateRule(rule, subject) {
3473
- if ((rule.delivery === "immediate" ? "object-event" : "track-end") !== subject.kind) return fail("delivery");
3949
+ if (subject.kind === "occupancy-event") {
3950
+ if (rule.delivery !== "device-event") return fail("delivery");
3951
+ const occ = rule.conditions.occupancy;
3952
+ if (occ === void 0) return fail("occupancy");
3953
+ if (subject.occupancy === void 0 || !matchesOccupancy(occ, subject.occupancy)) return fail("occupancy");
3954
+ } else if (subject.kind === "audio-event") {
3955
+ if (rule.delivery !== "immediate") return fail("delivery");
3956
+ if (!referencesAudioClass(rule.conditions.classes)) return fail("classes");
3957
+ if (rule.conditions.occupancy !== void 0) return fail("occupancy");
3958
+ } else {
3959
+ if ((rule.delivery === "immediate" ? "object-event" : rule.delivery) !== subject.kind) return fail("delivery");
3960
+ if (rule.conditions.occupancy !== void 0) return fail("occupancy");
3961
+ }
3474
3962
  const c = rule.conditions;
3475
3963
  if (c.devices !== void 0 && c.devices.length > 0 && !c.devices.includes(subject.deviceId)) return fail("devices");
3964
+ if (c.source !== void 0 && c.source !== "any") {
3965
+ if ((subject.source ?? "pipeline") !== c.source) return fail("source");
3966
+ }
3476
3967
  const subjectClasses = toLowerSet(subject.classNames);
3477
3968
  if (c.classes !== void 0 && c.classes.length > 0) {
3478
- const wanted = toLowerSet(c.classes);
3969
+ const wanted = expandClassSelector(c.classes);
3479
3970
  let overlap = false;
3480
3971
  for (const cls of subjectClasses) if (wanted.has(cls)) {
3481
3972
  overlap = true;
@@ -3484,12 +3975,18 @@ function evaluateRule(rule, subject) {
3484
3975
  if (!overlap) return fail("classes");
3485
3976
  }
3486
3977
  if (c.classesExclude !== void 0 && c.classesExclude.length > 0) {
3487
- const vetoed = toLowerSet(c.classesExclude);
3978
+ const vetoed = expandClassSelector(c.classesExclude);
3488
3979
  for (const cls of subjectClasses) if (vetoed.has(cls)) return fail("classesExclude");
3489
3980
  }
3490
3981
  if (c.minConfidence !== void 0) {
3491
3982
  if (subject.confidence === void 0 || subject.confidence < c.minConfidence) return fail("minConfidence");
3492
3983
  }
3984
+ if (c.minImportance !== void 0) {
3985
+ if (subject.importance === void 0 || subject.importance < c.minImportance) return fail("minImportance");
3986
+ }
3987
+ if (c.minDwellSeconds !== void 0) {
3988
+ if (subject.dwellSeconds === void 0 || subject.dwellSeconds < c.minDwellSeconds) return fail("minDwellSeconds");
3989
+ }
3493
3990
  if (c.zones !== void 0) {
3494
3991
  const visited = new Set(subject.zones);
3495
3992
  if (c.zones.match === "all") {
@@ -3500,6 +3997,10 @@ function evaluateRule(rule, subject) {
3500
3997
  const visited = new Set(subject.zones);
3501
3998
  if (c.zonesExclude.some((id) => visited.has(id))) return fail("zonesExclude");
3502
3999
  }
4000
+ if (c.customZones !== void 0 && c.customZones.length > 0) {
4001
+ const bbox = subject.bbox;
4002
+ if (!(bbox !== void 0 && c.customZones.some((poly) => bboxPolygonOverlap(bbox, poly.points) > 0))) return fail("customZones");
4003
+ }
3503
4004
  const label = subject.label?.trim().toLowerCase();
3504
4005
  if (c.labelEquals !== void 0 && c.labelEquals.length > 0) {
3505
4006
  if (label === void 0 || !toLowerSet(c.labelEquals).has(label)) return fail("labelEquals");
@@ -3507,11 +4008,29 @@ function evaluateRule(rule, subject) {
3507
4008
  if (c.identities !== void 0 && c.identities.length > 0) {
3508
4009
  if (label === void 0 || !toLowerSet(c.identities).has(label)) return fail("identities");
3509
4010
  }
4011
+ if (c.identitiesExclude !== void 0 && c.identitiesExclude.length > 0) {
4012
+ if (label !== void 0 && toLowerSet(c.identitiesExclude).has(label)) return fail("identitiesExclude");
4013
+ }
4014
+ if (c.minLabelConfidence !== void 0) {
4015
+ if (subject.labelConfidence === void 0 || subject.labelConfidence < c.minLabelConfidence) return fail("minLabelConfidence");
4016
+ }
3510
4017
  if (c.plates !== void 0) {
3511
4018
  if (subject.label === void 0 || !matchesPlate(subject.label, c.plates.values, c.plates.maxDistance)) return fail("plates");
3512
4019
  }
4020
+ if (c.sensorKinds !== void 0 && c.sensorKinds.length > 0) {
4021
+ if (subject.sensorKind === void 0 || !toLowerSet(c.sensorKinds).has(subject.sensorKind.trim().toLowerCase())) return fail("sensorKinds");
4022
+ }
4023
+ if (c.eventTypeTokens !== void 0 && c.eventTypeTokens.length > 0) {
4024
+ if (subject.eventType === void 0 || !toLowerSet(c.eventTypeTokens).has(subject.eventType.trim().toLowerCase())) return fail("eventTypeTokens");
4025
+ }
4026
+ if (c.packagePhase !== void 0 && c.packagePhase !== "both") {
4027
+ if (subject.packagePhase !== c.packagePhase) return fail("packagePhase");
4028
+ }
3513
4029
  if (!isScheduleActive(rule.schedule, subject.timestamp)) return fail("schedule");
3514
- return PASS;
4030
+ return {
4031
+ matched: true,
4032
+ matchedOn: presentConditionIds(c)
4033
+ };
3515
4034
  }
3516
4035
  var WEEKDAY_TO_DAY = {
3517
4036
  Sun: 0,
@@ -3606,7 +4125,8 @@ function matchesPlate(label, values, maxDistance) {
3606
4125
  }
3607
4126
  /** Stable cooldown key per the rule's throttle scope. */
3608
4127
  function cooldownKey(rule, subject) {
3609
- return rule.throttle.scope === "rule" ? `r:${rule.id}` : `r:${rule.id}:d:${subject.deviceId}`;
4128
+ const audioClass = subject.kind === "audio-event" && subject.classNames[0] !== void 0 ? `:c:${subject.classNames[0]}` : "";
4129
+ return rule.throttle.scope === "rule" ? `r:${rule.id}${audioClass}` : `r:${rule.id}:d:${subject.deviceId}${audioClass}`;
3610
4130
  }
3611
4131
  /** True when the rule fired within its cooldown window before `now`. */
3612
4132
  function isCoolingDown(rule, lastFiredAt, now) {
@@ -3641,171 +4161,231 @@ function attachmentKindPreference(policy, ownerKind) {
3641
4161
  "fullFrameBoxed"
3642
4162
  ];
3643
4163
  }
4164
+ /**
4165
+ * Derive the `best-matching` media signal from a matched rule's condition
4166
+ * summary ({@link NcEvaluation.matchedOn}). Identity takes priority over plate
4167
+ * (D-3 ordering: a face rule that ALSO plate-matched attaches the face crop).
4168
+ */
4169
+ function matchSignal(matchedOn) {
4170
+ if (matchedOn === void 0) return null;
4171
+ if (matchedOn.includes("identities")) return "face";
4172
+ if (matchedOn.includes("plates")) return "plate";
4173
+ return null;
4174
+ }
4175
+ /**
4176
+ * Ordered media-kind preference for a `best-matching` attachment. The
4177
+ * signal-specific crop (`faceCrop` / `plateCrop`, both event-owned) leads;
4178
+ * then the plain `best` subject ladder, then the `keyFrame` clean-scene
4179
+ * ladder — so a missing specific crop degrades to best → keyFrame → none
4180
+ * (the dispatcher returns null when nothing resolves) without ever blocking
4181
+ * the send. A `null` signal is exactly the `best` ladder.
4182
+ */
4183
+ function bestMatchingKindPreference(signal, ownerKind) {
4184
+ return [
4185
+ ...signal === "face" ? ["faceCrop"] : signal === "plate" ? ["plateCrop"] : [],
4186
+ ...attachmentKindPreference("best", ownerKind),
4187
+ ...attachmentKindPreference("keyFrame", ownerKind)
4188
+ ];
4189
+ }
3644
4190
  //#endregion
3645
- //#region src/notification-center/rule-store.ts
3646
- var NC_RULES_COLLECTION = "notification-center:rules";
3647
- var NC_RULES_COLUMNS = [
3648
- {
3649
- name: "id",
3650
- type: "TEXT",
3651
- primaryKey: true,
3652
- notNull: true
3653
- },
3654
- {
3655
- name: "name",
3656
- type: "TEXT",
3657
- notNull: true
3658
- },
3659
- {
3660
- name: "enabled",
3661
- type: "BOOLEAN",
3662
- notNull: true
3663
- },
3664
- {
3665
- name: "delivery",
3666
- type: "TEXT",
3667
- notNull: true
3668
- },
3669
- {
3670
- name: "updatedAt",
3671
- type: "INTEGER",
3672
- notNull: true
3673
- },
3674
- (
3675
- /** The FULL rule object (Zod-validated on read) — scalars above are
3676
- * indexed projections only. */
3677
- {
3678
- name: "rule",
3679
- type: "JSON",
3680
- notNull: true
3681
- })
3682
- ];
3683
- var NC_RULES_INDEXES = [{
3684
- name: "idx_nc_rules_enabled",
3685
- columns: ["enabled"]
3686
- }];
3687
- var NcRuleStore = class {
3688
- byId = /* @__PURE__ */ new Map();
3689
- store;
3690
- logger;
4191
+ //#region src/notification-center/dispatcher.ts
4192
+ var DEFAULT_TARGET_CACHE_TTL_MS = 6e4;
4193
+ var NcDispatcher = class {
4194
+ deps;
4195
+ targetCache = null;
4196
+ targetCacheAt = 0;
3691
4197
  now;
3692
- newId;
4198
+ targetCacheTtlMs;
3693
4199
  constructor(deps) {
3694
- this.store = deps.store;
3695
- this.logger = deps.logger;
4200
+ this.deps = deps;
3696
4201
  this.now = deps.now ?? (() => Date.now());
3697
- this.newId = deps.newId ?? (() => (0, node_crypto.randomUUID)());
3698
- }
3699
- static async declare(store) {
3700
- await store.declareCollection.mutate({
3701
- collection: NC_RULES_COLLECTION,
3702
- columns: [...NC_RULES_COLUMNS],
3703
- indexes: [...NC_RULES_INDEXES]
3704
- });
4202
+ this.targetCacheTtlMs = deps.targetCacheTtlMs ?? DEFAULT_TARGET_CACHE_TTL_MS;
3705
4203
  }
3706
- /**
3707
- * (Re)hydrate the FULL rule set from the store — called at boot and on
3708
- * the periodic refresh tick (cross-node CRUD staleness bound). Replaces
3709
- * the cache wholesale; a row whose JSON no longer validates is skipped
3710
- * with a warning (a degraded rule must never crash evaluation).
3711
- */
3712
- async load() {
4204
+ /** The outbox `deliver` executor. */
4205
+ async deliver(entry) {
4206
+ if (this.deps.isRuleTargetDisabled !== void 0) {
4207
+ if (await this.deps.isRuleTargetDisabled(entry.ruleId, entry.targetId)) {
4208
+ this.deps.logger.debug("notification skipped: target opted out of rule", { meta: {
4209
+ ruleId: entry.ruleId,
4210
+ targetId: entry.targetId
4211
+ } });
4212
+ return { ok: true };
4213
+ }
4214
+ }
4215
+ const target = await this.resolveTarget(entry.targetId);
4216
+ if (target === null) return {
4217
+ ok: false,
4218
+ error: `target not found: ${entry.targetId}`,
4219
+ permanent: true
4220
+ };
4221
+ if (!target.enabled) {
4222
+ this.deps.logger.debug("notification skipped: target globally disabled", { meta: {
4223
+ ruleId: entry.ruleId,
4224
+ targetId: target.id,
4225
+ target: target.name
4226
+ } });
4227
+ return { ok: true };
4228
+ }
4229
+ const notification = await this.buildNotification(entry);
3713
4230
  try {
3714
- const rows = await this.store.query.query({
3715
- collection: NC_RULES_COLLECTION,
3716
- filter: { limit: 1e4 }
4231
+ const result = await this.deps.send({
4232
+ addonId: target.addonId,
4233
+ targetId: target.id,
4234
+ notification
3717
4235
  });
3718
- this.byId.clear();
3719
- let skipped = 0;
3720
- for (const row of rows) {
3721
- const parsed = require_dist.NcRuleSchema.safeParse(row.data["rule"]);
3722
- if (!parsed.success) {
3723
- skipped += 1;
3724
- continue;
4236
+ if (!result.success) return {
4237
+ ok: false,
4238
+ error: result.error ?? "send failed",
4239
+ permanent: false
4240
+ };
4241
+ this.deps.logger.info("notification delivered", {
4242
+ tags: { deviceId: entry.deviceId },
4243
+ meta: {
4244
+ ruleId: entry.ruleId,
4245
+ target: target.name,
4246
+ kind: target.kind,
4247
+ recordKind: entry.recordKind
3725
4248
  }
3726
- this.byId.set(parsed.data.id, parsed.data);
3727
- }
3728
- this.logger.debug("notification rules loaded", { meta: {
3729
- rules: this.byId.size,
3730
- ...skipped > 0 ? { skippedInvalid: skipped } : {}
3731
- } });
4249
+ });
4250
+ return { ok: true };
3732
4251
  } catch (err) {
3733
- this.logger.warn("notification rules load failed", { meta: { error: String(err) } });
4252
+ return {
4253
+ ok: false,
4254
+ error: String(err),
4255
+ permanent: false
4256
+ };
3734
4257
  }
3735
4258
  }
3736
- list() {
3737
- return [...this.byId.values()].sort((a, b) => b.updatedAt - a.updatedAt);
3738
- }
3739
- listEnabled(delivery) {
3740
- return this.list().filter((r) => r.enabled && r.delivery === delivery);
3741
- }
3742
- get(ruleId) {
3743
- return this.byId.get(ruleId) ?? null;
4259
+ async resolveTarget(targetId) {
4260
+ const cached = this.cachedTarget(targetId);
4261
+ if (cached !== null) return cached;
4262
+ try {
4263
+ const targets = await this.deps.listTargets();
4264
+ this.targetCache = new Map(targets.map((t) => [t.id, t]));
4265
+ this.targetCacheAt = this.now();
4266
+ } catch (err) {
4267
+ this.deps.logger.warn("target catalog refresh failed", { meta: { error: String(err) } });
4268
+ throw err instanceof Error ? err : new Error(String(err));
4269
+ }
4270
+ return this.targetCache.get(targetId) ?? null;
3744
4271
  }
3745
- /** Create a new rule. `createdBy` is the SERVER-injected caller userId. */
3746
- async create(input, createdBy) {
3747
- const now = this.now();
3748
- const rule = {
3749
- ...input,
3750
- id: this.newId(),
3751
- createdBy,
3752
- createdAt: now,
3753
- updatedAt: now
3754
- };
3755
- await this.persist(rule);
3756
- this.byId.set(rule.id, rule);
3757
- return rule;
4272
+ cachedTarget(targetId) {
4273
+ if (this.targetCache === null) return null;
4274
+ if (this.now() - this.targetCacheAt > this.targetCacheTtlMs) return null;
4275
+ return this.targetCache.get(targetId) ?? null;
3758
4276
  }
3759
- /** Apply a partial patch. Immutable: returns the NEW rule object. */
3760
- async update(ruleId, patch) {
3761
- const existing = this.byId.get(ruleId);
3762
- if (!existing) throw new Error(`notification rule not found: ${ruleId}`);
3763
- const candidate = {
3764
- ...existing,
3765
- ...patch,
3766
- id: existing.id,
3767
- createdBy: existing.createdBy,
3768
- createdAt: existing.createdAt,
3769
- updatedAt: this.now()
4277
+ async buildNotification(entry) {
4278
+ const subject = entry.payload.subject;
4279
+ const deviceName = await this.deps.getDeviceName(subject.deviceId).catch(() => null) ?? `camera ${subject.deviceId}`;
4280
+ const vars = buildTemplateVars(entry, deviceName);
4281
+ const title = renderTemplate(entry.payload.template?.title, vars) ?? entry.payload.ruleName;
4282
+ const body = renderTemplate(entry.payload.template?.body, vars) ?? defaultBody(entry, deviceName);
4283
+ const attachment = await this.resolveAttachment(entry);
4284
+ const params = pickParams(entry.payload.params);
4285
+ return {
4286
+ body,
4287
+ title,
4288
+ priority: clampPriority(paramNumber(entry.payload.params, "priority") ?? entry.payload.priority),
4289
+ ...params,
4290
+ tag: entry.ruleId,
4291
+ deviceId: subject.deviceId,
4292
+ ...subject.eventId !== void 0 ? { eventId: subject.eventId } : {},
4293
+ ...attachment !== null ? { attachments: [attachment] } : {}
3770
4294
  };
3771
- const updated = require_dist.NcRuleSchema.parse(candidate);
3772
- await this.persist(updated);
3773
- this.byId.set(updated.id, updated);
3774
- return updated;
3775
4295
  }
3776
- async setEnabled(ruleId, enabled) {
3777
- return this.update(ruleId, { enabled });
3778
- }
3779
- /** Idempotent delete unknown ids are a no-op. */
3780
- async delete(ruleId) {
3781
- this.byId.delete(ruleId);
3782
- try {
3783
- await this.store.delete.mutate({
3784
- collection: NC_RULES_COLLECTION,
3785
- key: ruleId
3786
- });
4296
+ /**
4297
+ * Resolve ONE image attachment per the rule's media policy, best
4298
+ * AVAILABLE at send time. Preference order comes from the pure
4299
+ * {@link attachmentKindPreference} / {@link bestMatchingKindPreference};
4300
+ * the event owner is tried first for `immediate` entries, falling back to
4301
+ * the parent track's media set. `best-matching` leads with the crop that
4302
+ * explains the fired condition (`faceCrop`/`plateCrop`, both event-owned)
4303
+ * then degrades to the plain `best` → `keyFrame` ladders.
4304
+ */
4305
+ async resolveAttachment(entry) {
4306
+ const policy = entry.payload.media;
4307
+ if (policy === "none") return null;
4308
+ const subject = entry.payload.subject;
4309
+ const signal = policy === "best-matching" ? matchSignal(entry.payload.matchedOn) : null;
4310
+ const owners = [];
4311
+ if (subject.eventId !== void 0) owners.push({
4312
+ kind: "event",
4313
+ id: subject.eventId
4314
+ });
4315
+ if (subject.trackId !== void 0) owners.push({
4316
+ kind: "track",
4317
+ id: subject.trackId
4318
+ });
4319
+ if (policy === "keyFrame") owners.reverse();
4320
+ for (const owner of owners) try {
4321
+ const files = await this.deps.getMediaForOwner(owner.kind, owner.id);
4322
+ if (files.length === 0) continue;
4323
+ const preference = policy === "best-matching" ? bestMatchingKindPreference(signal, owner.kind) : attachmentKindPreference(policy, owner.kind);
4324
+ for (const kind of preference) {
4325
+ const file = files.find((f) => f.kind === kind);
4326
+ if (file === void 0) continue;
4327
+ const raw = Buffer.from(file.base64, "base64");
4328
+ if (raw.byteLength === 0) continue;
4329
+ const bytes = new Uint8Array(raw.byteLength);
4330
+ bytes.set(raw);
4331
+ return {
4332
+ mediaType: "image",
4333
+ bytes,
4334
+ mime: "image/jpeg",
4335
+ name: `${file.kind}.jpg`
4336
+ };
4337
+ }
3787
4338
  } catch (err) {
3788
- this.logger.warn("notification rule delete failed", { meta: {
3789
- ruleId,
4339
+ this.deps.logger.debug("attachment media read failed", { meta: {
4340
+ owner: owner.kind,
4341
+ ownerId: owner.id,
3790
4342
  error: String(err)
3791
4343
  } });
3792
- throw err instanceof Error ? err : new Error(String(err));
3793
4344
  }
3794
- }
3795
- async persist(rule) {
3796
- await this.store.set.mutate({
3797
- collection: NC_RULES_COLLECTION,
3798
- key: rule.id,
3799
- value: {
3800
- name: rule.name,
3801
- enabled: rule.enabled,
3802
- delivery: rule.delivery,
3803
- updatedAt: rule.updatedAt,
3804
- rule
3805
- }
3806
- });
4345
+ return null;
3807
4346
  }
3808
4347
  };
4348
+ function buildTemplateVars(entry, deviceName) {
4349
+ const subject = entry.payload.subject;
4350
+ return {
4351
+ camera: deviceName,
4352
+ class: subject.className,
4353
+ label: subject.label ?? "",
4354
+ zones: subject.zones.join(", "),
4355
+ zone: subject.zones[0] ?? "",
4356
+ confidence: subject.confidence !== void 0 ? `${Math.round(subject.confidence * 100)}%` : "",
4357
+ time: new Date(subject.timestamp).toLocaleTimeString(),
4358
+ rule: entry.payload.ruleName
4359
+ };
4360
+ }
4361
+ /** `{{var}}` interpolation; missing vars render empty. Null template → null. */
4362
+ function renderTemplate(template, vars) {
4363
+ if (template === void 0 || template.trim().length === 0) return null;
4364
+ return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (_m, name) => vars[name] ?? "");
4365
+ }
4366
+ function defaultBody(entry, deviceName) {
4367
+ const subject = entry.payload.subject;
4368
+ const label = subject.label !== void 0 ? ` (${subject.label})` : "";
4369
+ const zones = subject.zones.length > 0 ? ` in ${subject.zones.join(", ")}` : "";
4370
+ const suffix = entry.recordKind === "track-end" ? " — visit ended" : "";
4371
+ return `${subject.className}${label} on ${deviceName}${zones}${suffix}`;
4372
+ }
4373
+ function pickParams(params) {
4374
+ if (params === void 0) return {};
4375
+ const out = {};
4376
+ if (typeof params["level"] === "string") out.level = params["level"];
4377
+ if (typeof params["sound"] === "string") out.sound = params["sound"];
4378
+ if (typeof params["clickUrl"] === "string") out.clickUrl = params["clickUrl"];
4379
+ if (typeof params["ttl"] === "number" && Number.isFinite(params["ttl"])) out.ttl = params["ttl"];
4380
+ return out;
4381
+ }
4382
+ function paramNumber(params, key) {
4383
+ const v = params?.[key];
4384
+ return typeof v === "number" && Number.isFinite(v) ? v : void 0;
4385
+ }
4386
+ function clampPriority(priority) {
4387
+ return Math.max(1, Math.min(5, Math.round(priority)));
4388
+ }
3809
4389
  //#endregion
3810
4390
  //#region src/notification-center/outbox.ts
3811
4391
  var NC_OUTBOX_COLLECTION = "notification-center:outbox";
@@ -4051,6 +4631,48 @@ var NcOutbox = class {
4051
4631
  return [];
4052
4632
  }
4053
4633
  }
4634
+ /**
4635
+ * Query persisted outbox rows as DELIVERY HISTORY — newest-first (by fire
4636
+ * time `createdAt`), bounded by `limit`. History is a read-only VIEW over
4637
+ * the outbox: the very rows the drain loop drives, in every lifecycle
4638
+ * state (pending / sent / dead). There is no second write path, so a
4639
+ * history row can never diverge from delivery state.
4640
+ *
4641
+ * Filters (`ruleId` / `deviceId` / `status` / `since`..`until`) are pushed
4642
+ * to the store; the in-memory `pending` map is deliberately NOT consulted
4643
+ * (terminal sent / dead rows live only in the store). Best-effort: a store
4644
+ * error yields an empty page rather than throwing into the cap call.
4645
+ */
4646
+ async queryHistory(query) {
4647
+ const where = {};
4648
+ if (query.ruleId !== void 0) where["ruleId"] = query.ruleId;
4649
+ if (query.deviceId !== void 0) where["deviceId"] = query.deviceId;
4650
+ if (query.status !== void 0) where["status"] = query.status;
4651
+ const hasRange = query.since !== void 0 || query.until !== void 0;
4652
+ try {
4653
+ const rows = await this.store.query.query({
4654
+ collection: NC_OUTBOX_COLLECTION,
4655
+ filter: {
4656
+ ...Object.keys(where).length > 0 ? { where } : {},
4657
+ ...hasRange ? { whereBetween: { createdAt: [query.since ?? 0, query.until ?? Number.MAX_SAFE_INTEGER] } } : {},
4658
+ orderBy: {
4659
+ field: "createdAt",
4660
+ direction: "desc"
4661
+ },
4662
+ limit: query.limit
4663
+ }
4664
+ });
4665
+ const out = [];
4666
+ for (const row of rows) {
4667
+ const entry = rowToEntry$1(row.id, row.data);
4668
+ if (entry !== null) out.push(entry);
4669
+ }
4670
+ return out;
4671
+ } catch (err) {
4672
+ this.logger.debug("outbox history query failed", { meta: { error: String(err) } });
4673
+ return [];
4674
+ }
4675
+ }
4054
4676
  async getWatermark() {
4055
4677
  try {
4056
4678
  const row = await this.store.get.query({
@@ -4202,6 +4824,16 @@ function entryToRow(entry) {
4202
4824
  payload: entry.payload
4203
4825
  };
4204
4826
  }
4827
+ var OUTBOX_RECORD_KINDS = new Set([
4828
+ "object-event",
4829
+ "track-end",
4830
+ "device-event",
4831
+ "package-event",
4832
+ "audio-event"
4833
+ ]);
4834
+ function isOutboxRecordKind(x) {
4835
+ return typeof x === "string" && OUTBOX_RECORD_KINDS.has(x);
4836
+ }
4205
4837
  function rowToEntry$1(id, data) {
4206
4838
  const ruleId = data["ruleId"];
4207
4839
  const targetId = data["targetId"];
@@ -4210,7 +4842,7 @@ function rowToEntry$1(id, data) {
4210
4842
  const recordId = data["recordId"];
4211
4843
  const status = data["status"];
4212
4844
  const payload = data["payload"];
4213
- 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;
4845
+ 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;
4214
4846
  const trackId = data["trackId"];
4215
4847
  const lastError = data["lastError"];
4216
4848
  return {
@@ -4231,189 +4863,587 @@ function rowToEntry$1(id, data) {
4231
4863
  };
4232
4864
  }
4233
4865
  //#endregion
4234
- //#region src/notification-center/dispatcher.ts
4235
- var DEFAULT_TARGET_CACHE_TTL_MS = 6e4;
4236
- var NcDispatcher = class {
4237
- deps;
4238
- targetCache = null;
4239
- targetCacheAt = 0;
4240
- now;
4241
- targetCacheTtlMs;
4242
- constructor(deps) {
4243
- this.deps = deps;
4244
- this.now = deps.now ?? (() => Date.now());
4245
- this.targetCacheTtlMs = deps.targetCacheTtlMs ?? DEFAULT_TARGET_CACHE_TTL_MS;
4866
+ //#region src/notification-center/occupancy-watcher.ts
4867
+ /** Sentinel key segments for the "no zone" (whole-frame) and "no class" scopes. */
4868
+ var FRAME_SCOPE = "@frame";
4869
+ var ALL_CLASSES = "@all";
4870
+ /** Prefix marking the trailing threshold segment (`t<n>`) — makes the grammar
4871
+ * self-describing so a legacy three-segment key can never be mis-parsed as a
4872
+ * four-segment one (the last segment of a legacy key is a className, never a
4873
+ * `t<digits>` token). */
4874
+ var THRESHOLD_PREFIX = "t";
4875
+ var THRESHOLD_SEGMENT = /^t(\d+)$/;
4876
+ function occupancyKey(deviceId, zoneId, className, threshold) {
4877
+ return `${deviceId}|${zoneId ?? FRAME_SCOPE}|${className ?? ALL_CLASSES}|${THRESHOLD_PREFIX}${threshold}`;
4878
+ }
4879
+ /**
4880
+ * Inverse of {@link occupancyKey} — the ONE place the key grammar is decoded, so
4881
+ * the durable store (whose column schema drops the derivable
4882
+ * `zoneId`/`className`/`threshold`) can reconstruct the full scope on reseed.
4883
+ *
4884
+ * The four-segment grammar is asserted EXACTLY: at least four segments AND a
4885
+ * trailing `t<digits>` threshold marker. A legacy three-segment key (no marker)
4886
+ * is REJECTED (`null`) rather than positionally mis-parsed — the durable store
4887
+ * then SKIPS that row, so a pre-amendment persisted row cold-re-observes instead
4888
+ * of hydrating a corrupt scope (className←threshold, zoneId←class). `deviceId`
4889
+ * is the leading numeric segment, `threshold` the trailing `t<n>`, `className`
4890
+ * the segment before it, and the (possibly `|`-containing) `zoneId` everything
4891
+ * between. Exact per-segment count is impossible because a `zoneId` may itself
4892
+ * contain `|`; the trailing marker is the unambiguous grammar discriminator.
4893
+ */
4894
+ function parseOccupancyKey(key) {
4895
+ const parts = key.split("|");
4896
+ if (parts.length < 4) return null;
4897
+ const deviceId = Number(parts[0]);
4898
+ if (!Number.isFinite(deviceId)) return null;
4899
+ const thresholdMatch = THRESHOLD_SEGMENT.exec(parts[parts.length - 1] ?? "");
4900
+ if (thresholdMatch === null) return null;
4901
+ const threshold = Number(thresholdMatch[1]);
4902
+ const className = parts[parts.length - 2] ?? ALL_CLASSES;
4903
+ const zoneId = parts.slice(1, -2).join("|");
4904
+ return {
4905
+ deviceId,
4906
+ ...zoneId !== FRAME_SCOPE ? { zoneId } : {},
4907
+ ...className !== ALL_CLASSES ? { className } : {},
4908
+ threshold
4909
+ };
4910
+ }
4911
+ /** Device-agnostic partial key (`zone|class|t<threshold>`) — the merge/watch
4912
+ * granularity. Distinct thresholds are distinct partial keys (per-threshold). */
4913
+ function partialKey(zoneId, className, threshold) {
4914
+ return `${zoneId ?? FRAME_SCOPE}|${className ?? ALL_CLASSES}|${THRESHOLD_PREFIX}${threshold}`;
4915
+ }
4916
+ /** The device-agnostic partial key of a full state/edge key — everything after
4917
+ * the leading `deviceId|` segment. */
4918
+ function partialKeyOf(key) {
4919
+ const idx = key.indexOf("|");
4920
+ return idx < 0 ? key : key.slice(idx + 1);
4921
+ }
4922
+ var OccupancyWatcher = class {
4923
+ /** Watched specs, keyed by their device-agnostic partial key. */
4924
+ watched = /* @__PURE__ */ new Map();
4925
+ /** Confirmed + pending state, keyed by the full {@link OccupancyKey}. */
4926
+ states = /* @__PURE__ */ new Map();
4927
+ /**
4928
+ * Replace the watched key set (rule-driven — recomputed on rule change).
4929
+ * Each distinct `(zone, class, threshold)` is its OWN watched partial key
4930
+ * (per-threshold edges — NO min-threshold merge). Specs that collide on the
4931
+ * SAME `(zone, class, threshold)` merge to the MAX sustain (longest debounce),
4932
+ * so one watcher serves those co-threshold rules. Confirmed state for a key
4933
+ * that is no longer watched is DROPPED (bounds the RAM map + lets the caller
4934
+ * prune the durable row to the active set); a still-watched key retains its
4935
+ * level (re-evaluated on the next `observe`).
4936
+ */
4937
+ setWatchedKeys(specs) {
4938
+ this.watched.clear();
4939
+ for (const spec of specs) {
4940
+ const pk = partialKey(spec.zoneId, spec.className, spec.threshold);
4941
+ const existing = this.watched.get(pk);
4942
+ if (existing === void 0) {
4943
+ this.watched.set(pk, {
4944
+ ...spec.zoneId !== void 0 ? { zoneId: spec.zoneId } : {},
4945
+ ...spec.className !== void 0 ? { className: spec.className } : {},
4946
+ threshold: spec.threshold,
4947
+ sustainSeconds: spec.sustainSeconds
4948
+ });
4949
+ continue;
4950
+ }
4951
+ this.watched.set(pk, {
4952
+ ...existing,
4953
+ sustainSeconds: Math.max(existing.sustainSeconds, spec.sustainSeconds)
4954
+ });
4955
+ }
4956
+ for (const key of [...this.states.keys()]) if (!this.watched.has(partialKeyOf(key))) this.states.delete(key);
4246
4957
  }
4247
- /** The outbox `deliver` executor. */
4248
- async deliver(entry) {
4249
- const target = await this.resolveTarget(entry.targetId);
4250
- if (target === null) return {
4251
- ok: false,
4252
- error: `target not found: ${entry.targetId}`,
4253
- permanent: true
4254
- };
4255
- if (!target.enabled) return {
4256
- ok: false,
4257
- error: `target disabled: ${target.name}`,
4258
- permanent: true
4259
- };
4260
- const notification = await this.buildNotification(entry);
4261
- try {
4262
- const result = await this.deps.send({
4263
- addonId: target.addonId,
4264
- targetId: target.id,
4265
- notification
4958
+ /**
4959
+ * Feed one camera snapshot at time `now`, returning the edges that COMMIT on
4960
+ * this tick (usually none). Every watched key is evaluated for `deviceId`;
4961
+ * fail-closed keys (absent zone) are skipped and hold no state.
4962
+ */
4963
+ observe(deviceId, snapshot, now) {
4964
+ const edges = [];
4965
+ for (const spec of this.watched.values()) {
4966
+ const resolved = resolveScope(snapshot, spec);
4967
+ if (resolved === null) continue;
4968
+ const key = occupancyKey(deviceId, spec.zoneId, spec.className, spec.threshold);
4969
+ const edge = step(this.stateFor(key, deviceId, spec, now), spec, resolved, now);
4970
+ if (edge !== null) edges.push(edge);
4971
+ }
4972
+ return edges;
4973
+ }
4974
+ /** Reseed confirmed state from durable rows (boot). Only rows whose key is
4975
+ * currently WATCHED are restored — an orphaned durable row (its rule gone)
4976
+ * is dropped, keeping the RAM map bounded to the active set. Pending edges
4977
+ * are not restored (fail-closed — they re-open on the next snapshots). */
4978
+ hydrate(rows) {
4979
+ for (const row of rows) {
4980
+ if (!this.watched.has(partialKeyOf(row.key))) continue;
4981
+ this.states.set(row.key, {
4982
+ deviceId: row.deviceId,
4983
+ ...row.zoneId !== void 0 ? { zoneId: row.zoneId } : {},
4984
+ ...row.className !== void 0 ? { className: row.className } : {},
4985
+ threshold: row.threshold,
4986
+ confirmedCount: row.confirmedCount,
4987
+ occupied: row.occupied,
4988
+ lastChangeAt: row.lastChangeAt
4266
4989
  });
4267
- if (!result.success) return {
4268
- ok: false,
4269
- error: result.error ?? "send failed",
4270
- permanent: false
4271
- };
4272
- this.deps.logger.info("notification delivered", {
4273
- tags: { deviceId: entry.deviceId },
4274
- meta: {
4275
- ruleId: entry.ruleId,
4276
- target: target.name,
4277
- kind: target.kind,
4278
- recordKind: entry.recordKind
4279
- }
4280
- });
4281
- return { ok: true };
4282
- } catch (err) {
4283
- return {
4284
- ok: false,
4285
- error: String(err),
4286
- permanent: false
4287
- };
4288
4990
  }
4289
4991
  }
4290
- async resolveTarget(targetId) {
4291
- const cached = this.cachedTarget(targetId);
4292
- if (cached !== null) return cached;
4992
+ /** Snapshot the CONFIRMED state for durable persistence (pending excluded). */
4993
+ snapshotState() {
4994
+ const rows = [];
4995
+ for (const [key, state] of this.states) rows.push({
4996
+ key,
4997
+ deviceId: state.deviceId,
4998
+ ...state.zoneId !== void 0 ? { zoneId: state.zoneId } : {},
4999
+ ...state.className !== void 0 ? { className: state.className } : {},
5000
+ threshold: state.threshold,
5001
+ confirmedCount: state.confirmedCount,
5002
+ occupied: state.occupied,
5003
+ lastChangeAt: state.lastChangeAt,
5004
+ updatedAt: state.lastChangeAt
5005
+ });
5006
+ return rows;
5007
+ }
5008
+ stateFor(key, deviceId, spec, now) {
5009
+ const existing = this.states.get(key);
5010
+ if (existing !== void 0) return existing;
5011
+ const fresh = {
5012
+ deviceId,
5013
+ ...spec.zoneId !== void 0 ? { zoneId: spec.zoneId } : {},
5014
+ ...spec.className !== void 0 ? { className: spec.className } : {},
5015
+ threshold: spec.threshold,
5016
+ confirmedCount: 0,
5017
+ occupied: false,
5018
+ lastChangeAt: now
5019
+ };
5020
+ this.states.set(key, fresh);
5021
+ return fresh;
5022
+ }
5023
+ };
5024
+ /** Read the count (+ zone name) for a spec, or `null` when fail-closed. */
5025
+ function resolveScope(snapshot, spec) {
5026
+ if (spec.zoneId === void 0) return { count: spec.className === void 0 ? snapshot.frame.totalObjects : snapshot.frame.byClass[spec.className] ?? 0 };
5027
+ const zone = snapshot.zones.find((z) => z.zoneId === spec.zoneId);
5028
+ if (zone === void 0) return null;
5029
+ return {
5030
+ count: spec.className === void 0 ? zone.totalObjects : zone.byClass[spec.className] ?? 0,
5031
+ zoneName: zone.zoneName
5032
+ };
5033
+ }
5034
+ /**
5035
+ * Advance one key's state by one observation. Mutates `state` in place (the
5036
+ * watcher owns it) and returns a committed edge, or `null`.
5037
+ */
5038
+ function step(state, spec, resolved, now) {
5039
+ const rawOccupied = resolved.count >= spec.threshold;
5040
+ const sustainMs = spec.sustainSeconds * 1e3;
5041
+ if (rawOccupied === state.occupied) {
5042
+ state.pendingTargetOccupied = void 0;
5043
+ state.pendingSince = void 0;
5044
+ return null;
5045
+ }
5046
+ if (state.pendingTargetOccupied !== rawOccupied) {
5047
+ state.pendingTargetOccupied = rawOccupied;
5048
+ state.pendingSince = now;
5049
+ }
5050
+ if (now - (state.pendingSince ?? now) < sustainMs) return null;
5051
+ const previousCount = state.confirmedCount;
5052
+ state.confirmedCount = resolved.count;
5053
+ state.occupied = rawOccupied;
5054
+ state.lastChangeAt = now;
5055
+ state.pendingTargetOccupied = void 0;
5056
+ state.pendingSince = void 0;
5057
+ return {
5058
+ deviceId: state.deviceId,
5059
+ ...spec.zoneId !== void 0 ? { zoneId: spec.zoneId } : {},
5060
+ ...resolved.zoneName !== void 0 ? { zoneName: resolved.zoneName } : {},
5061
+ ...spec.className !== void 0 ? { className: spec.className } : {},
5062
+ count: resolved.count,
5063
+ previousCount,
5064
+ occupied: rawOccupied,
5065
+ threshold: spec.threshold,
5066
+ timestamp: now
5067
+ };
5068
+ }
5069
+ //#endregion
5070
+ //#region src/notification-center/occupancy-store.ts
5071
+ var NC_OCCUPANCY_COLLECTION = "notification-center:occupancy";
5072
+ var NC_OCCUPANCY_COLUMNS = [
5073
+ {
5074
+ name: "key",
5075
+ type: "TEXT",
5076
+ primaryKey: true,
5077
+ notNull: true
5078
+ },
5079
+ {
5080
+ name: "deviceId",
5081
+ type: "INTEGER",
5082
+ notNull: true
5083
+ },
5084
+ {
5085
+ name: "confirmedCount",
5086
+ type: "INTEGER",
5087
+ notNull: true
5088
+ },
5089
+ {
5090
+ name: "occupied",
5091
+ type: "BOOLEAN",
5092
+ notNull: true
5093
+ },
5094
+ {
5095
+ name: "lastChangeAt",
5096
+ type: "INTEGER",
5097
+ notNull: true
5098
+ },
5099
+ {
5100
+ name: "updatedAt",
5101
+ type: "INTEGER",
5102
+ notNull: true
5103
+ }
5104
+ ];
5105
+ var NC_OCCUPANCY_INDEXES = [{
5106
+ name: "idx_nc_occupancy_device",
5107
+ columns: ["deviceId"]
5108
+ }];
5109
+ /** Query cap — a per-(device, zone, class) key set is small; this is a
5110
+ * generous ceiling that still bounds a pathological read. */
5111
+ var LOAD_LIMIT = 1e5;
5112
+ var OccupancyStore = class {
5113
+ cache = /* @__PURE__ */ new Map();
5114
+ store;
5115
+ logger;
5116
+ constructor(deps) {
5117
+ this.store = deps.store;
5118
+ this.logger = deps.logger;
5119
+ }
5120
+ static async declare(store) {
5121
+ await store.declareCollection.mutate({
5122
+ collection: NC_OCCUPANCY_COLLECTION,
5123
+ columns: [...NC_OCCUPANCY_COLUMNS],
5124
+ indexes: [...NC_OCCUPANCY_INDEXES]
5125
+ });
5126
+ }
5127
+ /**
5128
+ * Reseed the confirmed edge-state from the store (boot) — replaces the cache
5129
+ * wholesale and returns the rows for {@link OccupancyWatcher.hydrate}. A row
5130
+ * whose scalars/key no longer parse is skipped with a warning (a degraded row
5131
+ * must never crash the reseed). Best-effort: a store error yields `[]` and a
5132
+ * cold watcher, never a throw into boot.
5133
+ */
5134
+ async load() {
4293
5135
  try {
4294
- const targets = await this.deps.listTargets();
4295
- this.targetCache = new Map(targets.map((t) => [t.id, t]));
4296
- this.targetCacheAt = this.now();
5136
+ const records = await this.store.query.query({
5137
+ collection: NC_OCCUPANCY_COLLECTION,
5138
+ filter: { limit: LOAD_LIMIT }
5139
+ });
5140
+ this.cache.clear();
5141
+ let skipped = 0;
5142
+ for (const record of records) {
5143
+ const row = recordToRow(record.id, record.data);
5144
+ if (row === null) {
5145
+ skipped += 1;
5146
+ continue;
5147
+ }
5148
+ this.cache.set(row.key, row);
5149
+ }
5150
+ this.logger.debug("occupancy state loaded", { meta: {
5151
+ keys: this.cache.size,
5152
+ ...skipped > 0 ? { skippedInvalid: skipped } : {}
5153
+ } });
5154
+ return [...this.cache.values()];
4297
5155
  } catch (err) {
4298
- this.deps.logger.warn("target catalog refresh failed", { meta: { error: String(err) } });
4299
- throw err instanceof Error ? err : new Error(String(err));
5156
+ this.logger.warn("occupancy state load failed", { meta: { error: String(err) } });
5157
+ return [];
4300
5158
  }
4301
- return this.targetCache.get(targetId) ?? null;
4302
- }
4303
- cachedTarget(targetId) {
4304
- if (this.targetCache === null) return null;
4305
- if (this.now() - this.targetCacheAt > this.targetCacheTtlMs) return null;
4306
- return this.targetCache.get(targetId) ?? null;
4307
5159
  }
4308
- async buildNotification(entry) {
4309
- const subject = entry.payload.subject;
4310
- const deviceName = await this.deps.getDeviceName(subject.deviceId).catch(() => null) ?? `camera ${subject.deviceId}`;
4311
- const vars = buildTemplateVars(entry, deviceName);
4312
- const title = renderTemplate(entry.payload.template?.title, vars) ?? entry.payload.ruleName;
4313
- const body = renderTemplate(entry.payload.template?.body, vars) ?? defaultBody(entry, deviceName);
4314
- const attachment = await this.resolveAttachment(entry);
4315
- const params = pickParams(entry.payload.params);
4316
- return {
4317
- body,
4318
- title,
4319
- priority: clampPriority(paramNumber(entry.payload.params, "priority") ?? entry.payload.priority),
4320
- ...params,
4321
- tag: entry.ruleId,
4322
- deviceId: subject.deviceId,
4323
- ...subject.eventId !== void 0 ? { eventId: subject.eventId } : {},
4324
- ...attachment !== null ? { attachments: [attachment] } : {}
4325
- };
5160
+ /** The in-RAM confirmed-state mirror (post-{@link load}/{@link persist}). */
5161
+ snapshot() {
5162
+ return [...this.cache.values()];
4326
5163
  }
4327
5164
  /**
4328
- * Resolve ONE image attachment per the rule's media policy, best
4329
- * AVAILABLE at send time. Preference order comes from the pure
4330
- * {@link attachmentKindPreference}; the event owner is tried first for
4331
- * `immediate` entries, falling back to the parent track's media set.
5165
+ * Durably upsert one confirmed edge-state row (write-through: the store FIRST,
5166
+ * then the cache a failed persist never leaves a phantom in-RAM level). The
5167
+ * `key` is the PK, so re-persisting a key advances it in place.
4332
5168
  */
4333
- async resolveAttachment(entry) {
4334
- const policy = entry.payload.media;
4335
- if (policy === "none") return null;
4336
- const subject = entry.payload.subject;
4337
- const owners = [];
4338
- if (subject.eventId !== void 0) owners.push({
4339
- kind: "event",
4340
- id: subject.eventId
4341
- });
4342
- if (subject.trackId !== void 0) owners.push({
4343
- kind: "track",
4344
- id: subject.trackId
5169
+ async persist(row) {
5170
+ await this.store.set.mutate({
5171
+ collection: NC_OCCUPANCY_COLLECTION,
5172
+ key: row.key,
5173
+ value: rowToValue(row)
4345
5174
  });
4346
- if (policy === "keyFrame") owners.reverse();
4347
- for (const owner of owners) try {
4348
- const files = await this.deps.getMediaForOwner(owner.kind, owner.id);
4349
- if (files.length === 0) continue;
4350
- const preference = attachmentKindPreference(policy, owner.kind);
4351
- for (const kind of preference) {
4352
- const file = files.find((f) => f.kind === kind);
4353
- if (file === void 0) continue;
4354
- const raw = Buffer.from(file.base64, "base64");
4355
- if (raw.byteLength === 0) continue;
4356
- const bytes = new Uint8Array(raw.byteLength);
4357
- bytes.set(raw);
4358
- return {
4359
- mediaType: "image",
4360
- bytes,
4361
- mime: "image/jpeg",
4362
- name: `${file.kind}.jpg`
4363
- };
5175
+ this.cache.set(row.key, row);
5176
+ }
5177
+ /**
5178
+ * Prune every persisted key NOT in `activeKeys` (the currently watched set)
5179
+ * the bounded-row-count guarantee when rules stop watching a key. Returns the
5180
+ * number of rows dropped. Best-effort per row: a failed delete is logged and
5181
+ * the key retained (retried next prune) rather than aborting the sweep.
5182
+ */
5183
+ async pruneExcept(activeKeys) {
5184
+ let pruned = 0;
5185
+ for (const key of [...this.cache.keys()]) {
5186
+ if (activeKeys.has(key)) continue;
5187
+ try {
5188
+ await this.store.delete.mutate({
5189
+ collection: NC_OCCUPANCY_COLLECTION,
5190
+ key
5191
+ });
5192
+ this.cache.delete(key);
5193
+ pruned += 1;
5194
+ } catch (err) {
5195
+ this.logger.debug("occupancy prune delete failed", { meta: {
5196
+ key,
5197
+ error: String(err)
5198
+ } });
4364
5199
  }
4365
- } catch (err) {
4366
- this.deps.logger.debug("attachment media read failed", { meta: {
4367
- owner: owner.kind,
4368
- ownerId: owner.id,
4369
- error: String(err)
4370
- } });
4371
5200
  }
4372
- return null;
5201
+ return pruned;
4373
5202
  }
4374
5203
  };
4375
- function buildTemplateVars(entry, deviceName) {
4376
- const subject = entry.payload.subject;
5204
+ /** The persisted column map for a row (the `key` PK is passed separately). */
5205
+ function rowToValue(row) {
4377
5206
  return {
4378
- camera: deviceName,
4379
- class: subject.className,
4380
- label: subject.label ?? "",
4381
- zones: subject.zones.join(", "),
4382
- zone: subject.zones[0] ?? "",
4383
- confidence: subject.confidence !== void 0 ? `${Math.round(subject.confidence * 100)}%` : "",
4384
- time: new Date(subject.timestamp).toLocaleTimeString(),
4385
- rule: entry.payload.ruleName
5207
+ deviceId: row.deviceId,
5208
+ confirmedCount: row.confirmedCount,
5209
+ occupied: row.occupied,
5210
+ lastChangeAt: row.lastChangeAt,
5211
+ updatedAt: row.updatedAt
4386
5212
  };
4387
5213
  }
4388
- /** `{{var}}` interpolation; missing vars render empty. Null template → null. */
4389
- function renderTemplate(template, vars) {
4390
- if (template === void 0 || template.trim().length === 0) return null;
4391
- return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (_m, name) => vars[name] ?? "");
4392
- }
4393
- function defaultBody(entry, deviceName) {
4394
- const subject = entry.payload.subject;
4395
- const label = subject.label !== void 0 ? ` (${subject.label})` : "";
4396
- const zones = subject.zones.length > 0 ? ` in ${subject.zones.join(", ")}` : "";
4397
- const suffix = entry.recordKind === "track-end" ? " — visit ended" : "";
4398
- return `${subject.className}${label} on ${deviceName}${zones}${suffix}`;
4399
- }
4400
- function pickParams(params) {
4401
- if (params === void 0) return {};
4402
- const out = {};
4403
- if (typeof params["level"] === "string") out.level = params["level"];
4404
- if (typeof params["sound"] === "string") out.sound = params["sound"];
4405
- if (typeof params["clickUrl"] === "string") out.clickUrl = params["clickUrl"];
4406
- if (typeof params["ttl"] === "number" && Number.isFinite(params["ttl"])) out.ttl = params["ttl"];
4407
- return out;
4408
- }
4409
- function paramNumber(params, key) {
4410
- const v = params?.[key];
4411
- return typeof v === "number" && Number.isFinite(v) ? v : void 0;
4412
- }
4413
- function clampPriority(priority) {
4414
- return Math.max(1, Math.min(5, Math.round(priority)));
5214
+ /**
5215
+ * Structurally validate a persisted record and reconstruct the full
5216
+ * {@link OccupancyStateRow} (deriving `zoneId`/`className`/`threshold` from the
5217
+ * key). Returns `null` for any malformed row — including a legacy
5218
+ * three-segment key with no `t<n>` threshold marker, which
5219
+ * {@link parseOccupancyKey} rejects — so the caller skips it (cold re-observe)
5220
+ * rather than hydrating a mis-parsed scope.
5221
+ */
5222
+ function recordToRow(key, data) {
5223
+ const scope = parseOccupancyKey(key);
5224
+ if (scope === null) return null;
5225
+ const deviceId = Number(data["deviceId"]);
5226
+ const confirmedCount = Number(data["confirmedCount"]);
5227
+ const lastChangeAt = Number(data["lastChangeAt"]);
5228
+ const updatedAt = Number(data["updatedAt"]);
5229
+ if (!Number.isFinite(deviceId) || !Number.isFinite(confirmedCount) || !Number.isFinite(lastChangeAt) || !Number.isFinite(updatedAt)) return null;
5230
+ const rawOccupied = data["occupied"];
5231
+ const occupied = rawOccupied === true || rawOccupied === 1;
5232
+ return {
5233
+ key,
5234
+ deviceId,
5235
+ ...scope.zoneId !== void 0 ? { zoneId: scope.zoneId } : {},
5236
+ ...scope.className !== void 0 ? { className: scope.className } : {},
5237
+ threshold: scope.threshold,
5238
+ confirmedCount,
5239
+ occupied,
5240
+ lastChangeAt,
5241
+ updatedAt
5242
+ };
4415
5243
  }
4416
5244
  //#endregion
5245
+ //#region src/notification-center/rule-store.ts
5246
+ var NC_RULES_COLLECTION = "notification-center:rules";
5247
+ var NC_RULES_COLUMNS = [
5248
+ {
5249
+ name: "id",
5250
+ type: "TEXT",
5251
+ primaryKey: true,
5252
+ notNull: true
5253
+ },
5254
+ {
5255
+ name: "name",
5256
+ type: "TEXT",
5257
+ notNull: true
5258
+ },
5259
+ {
5260
+ name: "enabled",
5261
+ type: "BOOLEAN",
5262
+ notNull: true
5263
+ },
5264
+ {
5265
+ name: "delivery",
5266
+ type: "TEXT",
5267
+ notNull: true
5268
+ },
5269
+ {
5270
+ name: "updatedAt",
5271
+ type: "INTEGER",
5272
+ notNull: true
5273
+ },
5274
+ (
5275
+ /** The FULL rule object (Zod-validated on read) — scalars above are
5276
+ * indexed projections only. */
5277
+ {
5278
+ name: "rule",
5279
+ type: "JSON",
5280
+ notNull: true
5281
+ })
5282
+ ];
5283
+ var NC_RULES_INDEXES = [{
5284
+ name: "idx_nc_rules_enabled",
5285
+ columns: ["enabled"]
5286
+ }];
5287
+ var NcRuleStore = class {
5288
+ byId = /* @__PURE__ */ new Map();
5289
+ store;
5290
+ logger;
5291
+ now;
5292
+ newId;
5293
+ constructor(deps) {
5294
+ this.store = deps.store;
5295
+ this.logger = deps.logger;
5296
+ this.now = deps.now ?? (() => Date.now());
5297
+ this.newId = deps.newId ?? (() => (0, node_crypto.randomUUID)());
5298
+ }
5299
+ static async declare(store) {
5300
+ await store.declareCollection.mutate({
5301
+ collection: NC_RULES_COLLECTION,
5302
+ columns: [...NC_RULES_COLUMNS],
5303
+ indexes: [...NC_RULES_INDEXES]
5304
+ });
5305
+ }
5306
+ /**
5307
+ * (Re)hydrate the FULL rule set from the store — called at boot and on
5308
+ * the periodic refresh tick (cross-node CRUD staleness bound). Replaces
5309
+ * the cache wholesale; a row whose JSON no longer validates is skipped
5310
+ * with a warning (a degraded rule must never crash evaluation).
5311
+ */
5312
+ async load() {
5313
+ try {
5314
+ const rows = await this.store.query.query({
5315
+ collection: NC_RULES_COLLECTION,
5316
+ filter: { limit: 1e4 }
5317
+ });
5318
+ this.byId.clear();
5319
+ let skipped = 0;
5320
+ for (const row of rows) {
5321
+ const parsed = require_dist.NcRuleSchema.safeParse(row.data["rule"]);
5322
+ if (!parsed.success) {
5323
+ skipped += 1;
5324
+ continue;
5325
+ }
5326
+ this.byId.set(parsed.data.id, parsed.data);
5327
+ }
5328
+ this.logger.debug("notification rules loaded", { meta: {
5329
+ rules: this.byId.size,
5330
+ ...skipped > 0 ? { skippedInvalid: skipped } : {}
5331
+ } });
5332
+ } catch (err) {
5333
+ this.logger.warn("notification rules load failed", { meta: { error: String(err) } });
5334
+ }
5335
+ }
5336
+ list() {
5337
+ return [...this.byId.values()].sort((a, b) => b.updatedAt - a.updatedAt);
5338
+ }
5339
+ listEnabled(delivery) {
5340
+ return this.list().filter((r) => r.enabled && r.delivery === delivery);
5341
+ }
5342
+ get(ruleId) {
5343
+ return this.byId.get(ruleId) ?? null;
5344
+ }
5345
+ /**
5346
+ * Rules visible to `userId`: their OWN personal rules (`ownerUserId ===
5347
+ * userId`) plus every admin/global rule (`ownerUserId` absent). Never
5348
+ * another user's personal rows. Newest-first (inherits {@link list}).
5349
+ *
5350
+ * The caller identity is server-derived; an absent/undefined caller must be
5351
+ * resolved to a fail-closed value by the bridge action BEFORE calling this —
5352
+ * this store never treats a missing caller as admin/global.
5353
+ */
5354
+ listForOwner(userId) {
5355
+ return this.list().filter((r) => r.ownerUserId === void 0 || r.ownerUserId === userId);
5356
+ }
5357
+ /** Create a new rule. `createdBy` is the SERVER-injected caller userId. */
5358
+ async create(input, createdBy) {
5359
+ const now = this.now();
5360
+ const rule = {
5361
+ ...input,
5362
+ id: this.newId(),
5363
+ createdBy,
5364
+ createdAt: now,
5365
+ updatedAt: now,
5366
+ disabledTargetIds: []
5367
+ };
5368
+ await this.persist(rule);
5369
+ this.byId.set(rule.id, rule);
5370
+ return rule;
5371
+ }
5372
+ /** Apply a partial patch. Immutable: returns the NEW rule object. */
5373
+ async update(ruleId, patch) {
5374
+ const existing = this.byId.get(ruleId);
5375
+ if (!existing) throw new Error(`notification rule not found: ${ruleId}`);
5376
+ const candidate = {
5377
+ ...existing,
5378
+ ...patch,
5379
+ id: existing.id,
5380
+ createdBy: existing.createdBy,
5381
+ createdAt: existing.createdAt,
5382
+ updatedAt: this.now()
5383
+ };
5384
+ const updated = require_dist.NcRuleSchema.parse(candidate);
5385
+ await this.persist(updated);
5386
+ this.byId.set(updated.id, updated);
5387
+ return updated;
5388
+ }
5389
+ async setEnabled(ruleId, enabled) {
5390
+ return this.update(ruleId, { enabled });
5391
+ }
5392
+ /**
5393
+ * Per-target opt-out toggle for a rule. `enabled: false` suppresses the
5394
+ * target for THIS rule at send time; `true` re-enables it. Idempotent per
5395
+ * target (a `Set` dedups; removing an absent id is a no-op). Throws when the
5396
+ * rule id is unknown.
5397
+ *
5398
+ * OWNERSHIP: this store applies the toggle unconditionally — the owner-only
5399
+ * restriction ("only a target's owner may opt it out") is enforced by the
5400
+ * `nc.setRuleTargetEnabled` bridge action, which resolves the fail-closed
5401
+ * caller and validates target ownership before calling here.
5402
+ */
5403
+ async setRuleTargetEnabled(ruleId, targetId, enabled) {
5404
+ const existing = this.byId.get(ruleId);
5405
+ if (!existing) throw new Error(`notification rule not found: ${ruleId}`);
5406
+ const next = new Set(existing.disabledTargetIds);
5407
+ if (enabled) next.delete(targetId);
5408
+ else next.add(targetId);
5409
+ return this.update(ruleId, { disabledTargetIds: [...next] });
5410
+ }
5411
+ /** Hot-path read for the dispatcher: is `targetId` opted out of `ruleId`? */
5412
+ isRuleTargetDisabled(ruleId, targetId) {
5413
+ const rule = this.byId.get(ruleId);
5414
+ return rule ? rule.disabledTargetIds.includes(targetId) : false;
5415
+ }
5416
+ /** Idempotent delete — unknown ids are a no-op. */
5417
+ async delete(ruleId) {
5418
+ this.byId.delete(ruleId);
5419
+ try {
5420
+ await this.store.delete.mutate({
5421
+ collection: NC_RULES_COLLECTION,
5422
+ key: ruleId
5423
+ });
5424
+ } catch (err) {
5425
+ this.logger.warn("notification rule delete failed", { meta: {
5426
+ ruleId,
5427
+ error: String(err)
5428
+ } });
5429
+ throw err instanceof Error ? err : new Error(String(err));
5430
+ }
5431
+ }
5432
+ async persist(rule) {
5433
+ await this.store.set.mutate({
5434
+ collection: NC_RULES_COLLECTION,
5435
+ key: rule.id,
5436
+ value: {
5437
+ name: rule.name,
5438
+ enabled: rule.enabled,
5439
+ delivery: rule.delivery,
5440
+ updatedAt: rule.updatedAt,
5441
+ rule
5442
+ }
5443
+ });
5444
+ }
5445
+ };
5446
+ //#endregion
4417
5447
  //#region src/notification-center/index.ts
4418
5448
  var DEFAULT_DRAIN_INTERVAL_MS = 2e3;
4419
5449
  var DEFAULT_RULE_RELOAD_INTERVAL_MS = 3e4;
@@ -4426,6 +5456,69 @@ var WATERMARK_EVERY_TICKS = 15;
4426
5456
  /** Terminal outbox rows older than this are pruned at boot. */
4427
5457
  var OUTBOX_RETENTION_MS = 168 * 36e5;
4428
5458
  var TEST_RULE_MAX_RESULTS = 200;
5459
+ /**
5460
+ * The className every durable package event carries (mirrors
5461
+ * `PackageDropDetector.PACKAGE_EVENT_CLASS` — declared locally so the NC module
5462
+ * stays free of a cross-module import into the pipeline). Delivery events use
5463
+ * the `idle` state, pick-ups the `left` state.
5464
+ */
5465
+ var PACKAGE_EVENT_CLASS$1 = "package";
5466
+ /**
5467
+ * Map an occupancy condition to the watcher key spec. Every op keys on its own
5468
+ * `count` EXCEPT `<=` C, which keys on threshold C+1 — the `count ≥ C+1`
5469
+ * predicate goes false exactly when the count drops to ≤C (mirrors the engine's
5470
+ * `matchesOccupancy`, so the watched key and the matched edge always agree).
5471
+ */
5472
+ function occupancySpecFromCondition(occ) {
5473
+ const threshold = occ.op === "<=" ? occ.count + 1 : occ.count;
5474
+ return {
5475
+ ...occ.zoneId !== void 0 ? { zoneId: occ.zoneId } : {},
5476
+ ...occ.className !== void 0 ? { className: occ.className } : {},
5477
+ threshold,
5478
+ sustainSeconds: occ.sustainSeconds
5479
+ };
5480
+ }
5481
+ /** Classify an object-event row as a package delivery / pick-up, or `null` when
5482
+ * it is an ordinary detection. */
5483
+ function packagePhaseOf(ev) {
5484
+ if (ev.className !== PACKAGE_EVENT_CLASS$1) return null;
5485
+ if (ev.state === "left") return "picked-up";
5486
+ if (ev.state === "idle") return "delivered";
5487
+ return null;
5488
+ }
5489
+ /**
5490
+ * Project a durable outbox row onto a delivery-history entry (the cap
5491
+ * contract). History is a VIEW — this is the ONLY mapping; there is no
5492
+ * separate history collection to write, so a row and its history entry can
5493
+ * never disagree. `ruleName` + `subject` come from the intent snapshot
5494
+ * frozen at enqueue; `error` surfaces only on a dead row (`lastError`).
5495
+ */
5496
+ function outboxEntryToHistory(entry) {
5497
+ const s = entry.payload.subject;
5498
+ return {
5499
+ id: entry.id,
5500
+ ruleId: entry.ruleId,
5501
+ ruleName: entry.payload.ruleName,
5502
+ delivery: entry.payload.delivery,
5503
+ targetId: entry.targetId,
5504
+ deviceId: entry.deviceId,
5505
+ recordKind: entry.recordKind === "audio-event" ? "object-event" : entry.recordKind,
5506
+ recordId: entry.recordId,
5507
+ ...entry.trackId !== void 0 ? { trackId: entry.trackId } : {},
5508
+ status: entry.status,
5509
+ attempts: entry.attempts,
5510
+ createdAt: entry.createdAt,
5511
+ updatedAt: entry.updatedAt,
5512
+ ...entry.lastError !== void 0 ? { error: entry.lastError } : {},
5513
+ subject: {
5514
+ className: s.className,
5515
+ ...s.label !== void 0 ? { label: s.label } : {},
5516
+ ...s.confidence !== void 0 ? { confidence: s.confidence } : {},
5517
+ zones: [...s.zones],
5518
+ timestamp: s.timestamp
5519
+ }
5520
+ };
5521
+ }
4429
5522
  var NotificationCenter = class {
4430
5523
  logger;
4431
5524
  rules;
@@ -4433,6 +5526,14 @@ var NotificationCenter = class {
4433
5526
  dispatcher;
4434
5527
  deps;
4435
5528
  now;
5529
+ /** Debounced occupancy edge state machine (pure) + its durable confirmed
5530
+ * state. Fed in-process by {@link observeOccupancy} from ZoneAnalytics
5531
+ * snapshots; watched keys are recomputed from the enabled occupancy rules. */
5532
+ occupancyWatcher = new OccupancyWatcher();
5533
+ occupancyStore;
5534
+ /** True when ≥1 enabled `device-event` rule declares an occupancy condition —
5535
+ * the watcher is idle (zero per-frame cost) otherwise. */
5536
+ occupancyEnabled = false;
4436
5537
  /** In-memory cooldown map — seeded from persisted outbox rows on start. */
4437
5538
  lastFiredAt = /* @__PURE__ */ new Map();
4438
5539
  /**
@@ -4455,9 +5556,14 @@ var NotificationCenter = class {
4455
5556
  store: deps.store,
4456
5557
  logger: this.logger.child("rules")
4457
5558
  });
5559
+ this.occupancyStore = new OccupancyStore({
5560
+ store: deps.store,
5561
+ logger: this.logger.child("occupancy")
5562
+ });
4458
5563
  this.dispatcher = new NcDispatcher({
4459
5564
  ...deps.dispatcher,
4460
- logger: this.logger.child("dispatch")
5565
+ logger: this.logger.child("dispatch"),
5566
+ isRuleTargetDisabled: (ruleId, targetId) => this.rules.isRuleTargetDisabled(ruleId, targetId)
4461
5567
  });
4462
5568
  this.outbox = new NcOutbox({
4463
5569
  store: deps.store,
@@ -4466,10 +5572,20 @@ var NotificationCenter = class {
4466
5572
  ...deps.now !== void 0 ? { now: deps.now } : {}
4467
5573
  });
4468
5574
  }
5575
+ /**
5576
+ * The durable rule store — exposed so the hub-only `nc.*` bridge actions
5577
+ * (`nc-actions.ts`, addonId `pipeline-analytics`) can serve the viewer's
5578
+ * ownership-scoped rule CRUD over `addons.custom`. Read/write goes through
5579
+ * the same write-through cache the evaluation path reads.
5580
+ */
5581
+ get ruleStore() {
5582
+ return this.rules;
5583
+ }
4469
5584
  /** Declare every Notification Center collection (idempotent, boot-time). */
4470
5585
  static async declare(store) {
4471
5586
  await NcRuleStore.declare(store);
4472
5587
  await NcOutbox.declare(store);
5588
+ await OccupancyStore.declare(store);
4473
5589
  }
4474
5590
  /**
4475
5591
  * Load rules (every node — the cap provider serves CRUD from any node).
@@ -4479,17 +5595,19 @@ var NotificationCenter = class {
4479
5595
  */
4480
5596
  async start(opts) {
4481
5597
  await this.rules.load();
5598
+ this.refreshOccupancyWatch();
4482
5599
  if (!opts.evaluation) return;
4483
5600
  this.evaluationActive = true;
4484
5601
  await this.outbox.load();
4485
5602
  await this.seedCooldowns();
4486
5603
  await this.outbox.pruneBefore(this.now() - OUTBOX_RETENTION_MS);
5604
+ await this.hydrateOccupancy();
4487
5605
  await this.reconcile();
4488
5606
  this.drainTimer = setInterval(() => {
4489
5607
  this.drainTick();
4490
5608
  }, this.deps.drainIntervalMs ?? DEFAULT_DRAIN_INTERVAL_MS);
4491
5609
  this.reloadTimer = setInterval(() => {
4492
- this.rules.load();
5610
+ this.reloadRules();
4493
5611
  }, this.deps.ruleReloadIntervalMs ?? DEFAULT_RULE_RELOAD_INTERVAL_MS);
4494
5612
  this.logger.info("notification center started", { meta: {
4495
5613
  rules: this.rules.list().length,
@@ -4515,6 +5633,7 @@ var NotificationCenter = class {
4515
5633
  */
4516
5634
  onObjectEventPersisted(event, _track) {
4517
5635
  if (!this.evaluationActive) return;
5636
+ if (packagePhaseOf(event) !== null) return;
4518
5637
  const subject = subjectFromObjectEvent(event);
4519
5638
  this.scheduleEvaluation(subject, "object-event", () => ({
4520
5639
  tags: { deviceId: event.deviceId },
@@ -4535,6 +5654,117 @@ var NotificationCenter = class {
4535
5654
  meta: { trackId: track.trackId }
4536
5655
  }));
4537
5656
  }
5657
+ /**
5658
+ * Called at the SensorEvent persist site (`ingestSensorStateChange` — one row
5659
+ * per linked camera), in the SAME moment as the durable insert. Feeds the
5660
+ * `device-event` trigger (doorbell press / sensor state change). Fire-and-
5661
+ * forget from the ingest loop; the outbox owns delivery from here.
5662
+ *
5663
+ * Delivery-grade boundary (honest): the SensorEventStore is itself fed from
5664
+ * the LOSSY `DeviceStateChanged` telemetry bus, and there is no NC crash-gap
5665
+ * reconcile for sensor rows in this slice (unlike object events). So a dropped
5666
+ * upstream bus event, or a crash in the persist→outbox window, drops the
5667
+ * device-event notification — the durable guarantee begins at this hook, not
5668
+ * before it.
5669
+ */
5670
+ onSensorEventPersisted(event) {
5671
+ if (!this.evaluationActive) return;
5672
+ const subject = subjectFromSensorEvent(event);
5673
+ this.scheduleEvaluation(subject, "device-event", () => ({
5674
+ tags: { deviceId: event.deviceId },
5675
+ meta: {
5676
+ sensorEventId: event.id,
5677
+ kind: event.kind
5678
+ }
5679
+ }));
5680
+ }
5681
+ /**
5682
+ * Called at the AUDIO-event persist site (`eventStore.insertAudio`), in the
5683
+ * SAME moment as the durable insert. Feeds an `immediate` rule that OPTS IN
5684
+ * to an `audio-*` class (the safety gate in `evaluateRule` — a rule without
5685
+ * an audio class never fires here). Fire-and-forget; the outbox owns delivery.
5686
+ *
5687
+ * Delivery-grade boundary (honest): audio events persist on a SEPARATE store
5688
+ * from object events, and there is no NC crash-gap reconcile for audio rows
5689
+ * in this slice (unlike object events, which the boot reconcile re-scans). So
5690
+ * a crash in the persist→outbox window drops the audio notification — the
5691
+ * durable guarantee begins at this hook, matching the device-event boundary.
5692
+ */
5693
+ onAudioEventPersisted(event) {
5694
+ if (!this.evaluationActive) return;
5695
+ const subject = subjectFromAudioEvent(event);
5696
+ this.scheduleEvaluation(subject, "audio-event", () => ({
5697
+ tags: { deviceId: event.deviceId },
5698
+ meta: {
5699
+ audioEventId: event.id,
5700
+ class: event.classification?.className
5701
+ }
5702
+ }));
5703
+ }
5704
+ /**
5705
+ * Called at the package object-event persist site (`PackageDropDetector` —
5706
+ * delivered/picked-up), in the SAME moment as the durable insert. Feeds the
5707
+ * `package-event` trigger. Package events ARE object-event rows, so the boot
5708
+ * crash-gap reconcile re-covers them (routed by className in {@link reconcile}).
5709
+ */
5710
+ onPackageEventPersisted(event, phase) {
5711
+ if (!this.evaluationActive) return;
5712
+ const subject = subjectFromPackageEvent(event, phase);
5713
+ this.scheduleEvaluation(subject, "package-event", () => ({
5714
+ tags: { deviceId: event.deviceId },
5715
+ meta: {
5716
+ eventId: event.id,
5717
+ phase
5718
+ }
5719
+ }));
5720
+ }
5721
+ /**
5722
+ * Feed one ZoneAnalytics occupancy snapshot into the debounced watcher
5723
+ * (in-process, telemetry-loss-tolerant — a dropped snapshot just misses a
5724
+ * sample; the durable confirmed edge-state is the recovery). Called per
5725
+ * ≤1 Hz snapshot from `ZoneAnalyticsProvider` (live frames + the parked-object
5726
+ * baseline sampler). Cheap no-op when no occupancy rule is enabled. Each
5727
+ * COMMITTED edge is persisted (rare — only on a confirmed flip, never
5728
+ * per-frame) and routed to {@link onOccupancyEdge}.
5729
+ */
5730
+ observeOccupancy(deviceId, snapshot) {
5731
+ if (!this.evaluationActive || !this.occupancyEnabled) return;
5732
+ const now = this.now();
5733
+ let edges;
5734
+ try {
5735
+ edges = this.occupancyWatcher.observe(deviceId, snapshot, now);
5736
+ } catch (err) {
5737
+ this.logger.debug("occupancy observe failed", {
5738
+ tags: { deviceId },
5739
+ meta: { error: String(err) }
5740
+ });
5741
+ return;
5742
+ }
5743
+ for (const edge of edges) {
5744
+ this.persistOccupancyEdge(edge, now);
5745
+ this.onOccupancyEdge(edge);
5746
+ }
5747
+ }
5748
+ /**
5749
+ * Persist hook for a committed occupancy edge — mirrors the other persist
5750
+ * hooks (serialized via {@link scheduleEvaluation}). The subject rides the
5751
+ * EXISTING `device-event` delivery via the internal `occupancy-event` kind.
5752
+ * Public so a test / a future out-of-band edge source can drive it directly.
5753
+ */
5754
+ onOccupancyEdge(edge) {
5755
+ if (!this.evaluationActive) return;
5756
+ const subject = subjectFromOccupancyEvent(edge);
5757
+ this.scheduleEvaluation(subject, "occupancy-event", () => ({
5758
+ tags: { deviceId: edge.deviceId },
5759
+ meta: {
5760
+ zoneId: edge.zoneId ?? "@frame",
5761
+ className: edge.className ?? "@all",
5762
+ count: edge.count,
5763
+ threshold: edge.threshold,
5764
+ occupied: edge.occupied
5765
+ }
5766
+ }));
5767
+ }
4538
5768
  buildProvider() {
4539
5769
  return {
4540
5770
  listRules: async () => ({ rules: [...this.rules.list()] }),
@@ -4551,7 +5781,8 @@ var NotificationCenter = class {
4551
5781
  },
4552
5782
  updateRule: async ({ ruleId, patch, caller }) => {
4553
5783
  if (patch.targets !== void 0) await this.validateTargetRefs(patch.targets.map((t) => t.targetId));
4554
- const updated = await this.rules.update(ruleId, patch);
5784
+ const { disabledTargetIds: _optOut, ...safePatch } = patch;
5785
+ const updated = await this.rules.update(ruleId, safePatch);
4555
5786
  this.logger.info("notification rule updated", { meta: {
4556
5787
  ruleId,
4557
5788
  by: caller.userId
@@ -4567,7 +5798,24 @@ var NotificationCenter = class {
4567
5798
  return { success: true };
4568
5799
  },
4569
5800
  testRule: async ({ rule, lookbackMinutes }) => ({ results: [...await this.dryRun(rule, lookbackMinutes)] }),
4570
- getConditionCatalog: async () => ({ catalog: [...require_dist.NC_CONDITION_CATALOG] })
5801
+ getConditionCatalog: async () => ({ catalog: [...require_dist.NC_CONDITION_CATALOG] }),
5802
+ getHistory: async ({ filter }) => {
5803
+ const entries = await this.outbox.queryHistory({
5804
+ ...filter.ruleId !== void 0 ? { ruleId: filter.ruleId } : {},
5805
+ ...filter.deviceId !== void 0 ? { deviceId: filter.deviceId } : {},
5806
+ ...filter.status !== void 0 ? { status: filter.status } : {},
5807
+ ...filter.since !== void 0 ? { since: filter.since } : {},
5808
+ ...filter.until !== void 0 ? { until: filter.until } : {},
5809
+ limit: filter.limit
5810
+ });
5811
+ const mapped = [];
5812
+ for (const entry of entries) try {
5813
+ mapped.push(outboxEntryToHistory(entry));
5814
+ } catch {
5815
+ this.logger.debug("getHistory: skipping malformed outbox row", { meta: { id: entry.id } });
5816
+ }
5817
+ return { entries: mapped };
5818
+ }
4571
5819
  };
4572
5820
  }
4573
5821
  /** Append one evaluation to the serialized chain (see {@link evalChain}). */
@@ -4589,31 +5837,35 @@ var NotificationCenter = class {
4589
5837
  });
4590
5838
  }
4591
5839
  async evaluateAndEnqueue(subject, kind) {
4592
- const delivery = kind === "object-event" ? "immediate" : "track-end";
5840
+ const delivery = kind === "object-event" || kind === "audio-event" ? "immediate" : kind === "occupancy-event" ? "device-event" : kind;
4593
5841
  const candidates = this.rules.listEnabled(delivery);
4594
5842
  if (candidates.length === 0) return;
4595
5843
  const now = this.now();
4596
5844
  for (const rule of candidates) {
4597
- if (!evaluateRule(rule, subject).matched) continue;
5845
+ const evaluation = evaluateRule(rule, subject);
5846
+ if (!evaluation.matched) continue;
4598
5847
  const key = cooldownKey(rule, subject);
4599
5848
  if (isCoolingDown(rule, this.lastFiredAt.get(key), now)) continue;
4600
- if (await this.outbox.enqueue(this.buildEntries(rule, subject, kind)) > 0) this.lastFiredAt.set(key, now);
5849
+ if (await this.outbox.enqueue(this.buildEntries(rule, subject, kind, evaluation.matchedOn)) > 0) this.lastFiredAt.set(key, now);
4601
5850
  }
4602
5851
  }
4603
- buildEntries(rule, subject, kind) {
4604
- return rule.targets.map((target) => {
5852
+ buildEntries(rule, subject, kind, matchedOn) {
5853
+ const hasEventMedia = kind === "object-event" || kind === "package-event";
5854
+ const isTrackScoped = kind === "object-event" || kind === "track-end";
5855
+ return rule.targets.filter((target) => !rule.disabledTargetIds.includes(target.targetId)).map((target) => {
4605
5856
  const payload = {
4606
5857
  ruleName: rule.name,
4607
5858
  delivery: rule.delivery,
4608
5859
  priority: rule.priority,
4609
5860
  ...rule.template !== void 0 ? { template: rule.template } : {},
4610
5861
  media: rule.media.attach,
5862
+ ...matchedOn !== void 0 && matchedOn.length > 0 ? { matchedOn } : {},
4611
5863
  ...target.params !== void 0 ? { params: target.params } : {},
4612
5864
  subject: {
4613
5865
  deviceId: subject.deviceId,
4614
5866
  ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
4615
- ...kind === "object-event" ? { eventId: subject.recordId } : {},
4616
- className: subject.classNames[0] ?? "object",
5867
+ ...hasEventMedia ? { eventId: subject.recordId } : {},
5868
+ className: subject.classNames[0] ?? subject.sensorKind ?? "event",
4617
5869
  ...subject.label !== void 0 ? { label: subject.label } : {},
4618
5870
  ...subject.confidence !== void 0 ? { confidence: subject.confidence } : {},
4619
5871
  zones: subject.zones,
@@ -4624,9 +5876,9 @@ var NotificationCenter = class {
4624
5876
  ruleId: rule.id,
4625
5877
  targetId: target.targetId,
4626
5878
  deviceId: subject.deviceId,
4627
- recordKind: kind,
5879
+ recordKind: kind === "occupancy-event" ? "device-event" : kind,
4628
5880
  recordId: subject.recordId,
4629
- ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
5881
+ ...isTrackScoped && subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
4630
5882
  payload
4631
5883
  };
4632
5884
  });
@@ -4642,6 +5894,67 @@ var NotificationCenter = class {
4642
5894
  if (entry.createdAt > prev) this.lastFiredAt.set(key, entry.createdAt);
4643
5895
  }
4644
5896
  }
5897
+ /**
5898
+ * Recompute the watcher's watched key set from the enabled occupancy rules
5899
+ * (rule-driven — refreshed on boot + every reload tick). `setWatchedKeys`
5900
+ * drops confirmed state for keys no longer watched; the caller then prunes the
5901
+ * durable rows to the surviving set.
5902
+ */
5903
+ refreshOccupancyWatch() {
5904
+ const specs = [];
5905
+ for (const rule of this.rules.listEnabled("device-event")) {
5906
+ const occ = rule.conditions.occupancy;
5907
+ if (occ === void 0) continue;
5908
+ specs.push(occupancySpecFromCondition(occ));
5909
+ }
5910
+ this.occupancyWatcher.setWatchedKeys(specs);
5911
+ this.occupancyEnabled = specs.length > 0;
5912
+ }
5913
+ /** Boot reseed of confirmed occupancy edge-state (durability, constraint 4) —
5914
+ * hydrate the watcher from the store, then prune orphaned durable rows to the
5915
+ * active watched set. Runs AFTER {@link refreshOccupancyWatch} so hydrate
5916
+ * restores only currently-watched keys. */
5917
+ async hydrateOccupancy() {
5918
+ const rows = await this.occupancyStore.load();
5919
+ this.occupancyWatcher.hydrate(rows);
5920
+ await this.occupancyStore.pruneExcept(this.activeOccupancyKeys());
5921
+ }
5922
+ /** Rule-reload tick: refresh the rule cache (cross-node CRUD staleness bound),
5923
+ * recompute the occupancy watched set + prune stale durable rows. */
5924
+ async reloadRules() {
5925
+ await this.rules.load();
5926
+ this.refreshOccupancyWatch();
5927
+ if (!this.evaluationActive) return;
5928
+ await this.occupancyStore.pruneExcept(this.activeOccupancyKeys());
5929
+ }
5930
+ /** The full-key set the watcher currently tracks (post prune) — the bound for
5931
+ * {@link OccupancyStore.pruneExcept}. */
5932
+ activeOccupancyKeys() {
5933
+ return new Set(this.occupancyWatcher.snapshotState().map((r) => r.key));
5934
+ }
5935
+ /** Write-through the committed confirmed level for one edge (rare — only on a
5936
+ * boolean flip). Best-effort: a failed persist is logged, never thrown into
5937
+ * the frame path (the boot reseed + next edge recover). */
5938
+ async persistOccupancyEdge(edge, now) {
5939
+ try {
5940
+ await this.occupancyStore.persist({
5941
+ key: occupancyKey(edge.deviceId, edge.zoneId, edge.className, edge.threshold),
5942
+ deviceId: edge.deviceId,
5943
+ ...edge.zoneId !== void 0 ? { zoneId: edge.zoneId } : {},
5944
+ ...edge.className !== void 0 ? { className: edge.className } : {},
5945
+ threshold: edge.threshold,
5946
+ confirmedCount: edge.count,
5947
+ occupied: edge.occupied,
5948
+ lastChangeAt: edge.timestamp,
5949
+ updatedAt: now
5950
+ });
5951
+ } catch (err) {
5952
+ this.logger.debug("occupancy persist failed", {
5953
+ tags: { deviceId: edge.deviceId },
5954
+ meta: { error: String(err) }
5955
+ });
5956
+ }
5957
+ }
4645
5958
  /** Boot crash-gap reconcile — see the module docstring. */
4646
5959
  async reconcile() {
4647
5960
  const now = this.now();
@@ -4650,13 +5963,24 @@ var NotificationCenter = class {
4650
5963
  const since = Math.max(windowStart, (watermark ?? 0) - RECONCILE_OVERLAP_MS);
4651
5964
  try {
4652
5965
  const ordered = [...await this.deps.listObjectEventsSince(since, RECONCILE_SCAN_LIMIT)].sort((a, b) => a.timestamp - b.timestamp);
4653
- for (const event of ordered) this.scheduleEvaluation(subjectFromObjectEvent(event), "object-event", () => ({
4654
- tags: { deviceId: event.deviceId },
4655
- meta: {
4656
- eventId: event.id,
4657
- reconcile: true
4658
- }
4659
- }));
5966
+ for (const event of ordered) {
5967
+ const phase = packagePhaseOf(event);
5968
+ if (phase !== null) this.scheduleEvaluation(subjectFromPackageEvent(event, phase), "package-event", () => ({
5969
+ tags: { deviceId: event.deviceId },
5970
+ meta: {
5971
+ eventId: event.id,
5972
+ phase,
5973
+ reconcile: true
5974
+ }
5975
+ }));
5976
+ else this.scheduleEvaluation(subjectFromObjectEvent(event), "object-event", () => ({
5977
+ tags: { deviceId: event.deviceId },
5978
+ meta: {
5979
+ eventId: event.id,
5980
+ reconcile: true
5981
+ }
5982
+ }));
5983
+ }
4660
5984
  await this.evalChain;
4661
5985
  if (ordered.length > 0) this.logger.info("notification reconcile scanned missed events", { meta: {
4662
5986
  since,
@@ -4685,8 +6009,14 @@ var NotificationCenter = class {
4685
6009
  const subjects = [];
4686
6010
  if (rule.delivery === "immediate") {
4687
6011
  const events = await this.deps.listObjectEventsSince(since, RECONCILE_SCAN_LIMIT);
4688
- for (const ev of events) subjects.push(subjectFromObjectEvent(ev));
4689
- } else {
6012
+ for (const ev of events) if (packagePhaseOf(ev) === null) subjects.push(subjectFromObjectEvent(ev));
6013
+ } else if (rule.delivery === "package-event") {
6014
+ const events = await this.deps.listObjectEventsSince(since, RECONCILE_SCAN_LIMIT);
6015
+ for (const ev of events) {
6016
+ const phase = packagePhaseOf(ev);
6017
+ if (phase !== null) subjects.push(subjectFromPackageEvent(ev, phase));
6018
+ }
6019
+ } else if (rule.delivery === "track-end") {
4690
6020
  const tracks = await this.deps.listRecentTracks(since, TEST_RULE_MAX_RESULTS);
4691
6021
  for (const track of tracks) subjects.push(subjectFromTrack(track));
4692
6022
  }
@@ -4696,7 +6026,7 @@ var NotificationCenter = class {
4696
6026
  const evaluation = evaluateRule(rule, subject);
4697
6027
  results.push({
4698
6028
  recordId: subject.recordId,
4699
- recordKind: subject.kind === "object-event" ? "object-event" : "track",
6029
+ recordKind: subject.kind === "track-end" ? "track" : subject.kind === "audio-event" ? "object-event" : subject.kind === "occupancy-event" ? "device-event" : subject.kind,
4700
6030
  deviceId: subject.deviceId,
4701
6031
  timestamp: subject.timestamp,
4702
6032
  wouldFire: evaluation.matched,
@@ -4709,6 +6039,156 @@ var NotificationCenter = class {
4709
6039
  }
4710
6040
  };
4711
6041
  //#endregion
6042
+ //#region src/notification-center/nc-actions.ts
6043
+ /**
6044
+ * nc-actions — the `pipeline-analytics` rule-bridge action catalog.
6045
+ *
6046
+ * The Notification Center viewer NEVER adds a cap method: every rule read/write
6047
+ * rides the generic `addons.custom` bridge (`{ addonId, action, input }`) with a
6048
+ * per-action `nc.*` name, and THIS module enforces the per-action auth +
6049
+ * ownership server-side (spec C1/C2). The addon id is `'pipeline-analytics'`.
6050
+ *
6051
+ * Auth model (spec §3.1 / §5 — server-derived, never client-trusted):
6052
+ * - `nc.listRules` / `nc.getConditionCatalog` — any authenticated caller;
6053
+ * listRules is SCOPED (own personal + global rules only) and stamps a
6054
+ * per-row `readOnly` verdict.
6055
+ * - `nc.createRule` / `nc.updateRule` / `nc.deleteRule` — operate ONLY on
6056
+ * caller-OWNED rules; `ownerUserId` is stamped from the caller and can
6057
+ * never be re-owned through a patch.
6058
+ * - `nc.setRuleTargetEnabled` — a user opting HIS OWN target out of a rule
6059
+ * VISIBLE to him (his own personal rule OR a global/admin rule).
6060
+ *
6061
+ * Target-ownership limits (personal-rule delivery target list + the opt-out
6062
+ * target) are enforced against the notifiers target catalog; ADMINS bypass the
6063
+ * target-ownership limit (they may deliver to / opt any target) but NEVER the
6064
+ * fail-closed caller check — an absent caller (UDS version-skew can still
6065
+ * deliver `undefined`) is rejected before any ownership decision.
6066
+ */
6067
+ /** A rule as served to the viewer — carries the server's `readOnly` verdict. */
6068
+ var NcViewerRuleSchema = require_dist.NcRuleSchema.extend({ readOnly: require_dist.boolean() });
6069
+ /**
6070
+ * The action catalog — the tRPC contract Group B (the viewer client) consumes
6071
+ * against `addonId: 'pipeline-analytics'`. Every entry that depends on the
6072
+ * caller identity is declared `caller: 'required'` so the dispatcher forwards
6073
+ * the server-derived `{ userId, isAdmin }` as the handler's second argument.
6074
+ */
6075
+ var ncActions = require_dist.defineCustomActions({
6076
+ "nc.listRules": require_dist.customAction(require_dist.object({}), require_dist.object({ rules: require_dist.array(NcViewerRuleSchema) }), { caller: "required" }),
6077
+ "nc.getConditionCatalog": require_dist.customAction(require_dist.object({}), require_dist.object({
6078
+ catalog: require_dist.array(require_dist.NcConditionDescriptorSchema),
6079
+ taxonomy: require_dist.NcTaxonomySchema
6080
+ })),
6081
+ "nc.createRule": require_dist.customAction(require_dist.object({ rule: require_dist.NcRuleInputSchema }), require_dist.object({ rule: require_dist.NcRuleSchema }), {
6082
+ kind: "mutation",
6083
+ caller: "required"
6084
+ }),
6085
+ "nc.updateRule": require_dist.customAction(require_dist.object({
6086
+ ruleId: require_dist.string(),
6087
+ patch: require_dist.NcRulePatchSchema
6088
+ }), require_dist.object({ rule: require_dist.NcRuleSchema }), {
6089
+ kind: "mutation",
6090
+ caller: "required"
6091
+ }),
6092
+ "nc.deleteRule": require_dist.customAction(require_dist.object({ ruleId: require_dist.string() }), require_dist.object({ success: require_dist.literal(true) }), {
6093
+ kind: "mutation",
6094
+ caller: "required"
6095
+ }),
6096
+ "nc.setRuleTargetEnabled": require_dist.customAction(require_dist.object({
6097
+ ruleId: require_dist.string(),
6098
+ targetId: require_dist.string(),
6099
+ enabled: require_dist.boolean()
6100
+ }), require_dist.object({ success: require_dist.literal(true) }), {
6101
+ kind: "mutation",
6102
+ caller: "required"
6103
+ })
6104
+ });
6105
+ /** Fail-closed caller resolution — an absent forwarded caller is NEVER admin. */
6106
+ function requireCaller(caller) {
6107
+ if (!caller || typeof caller.userId !== "string" || caller.userId.length === 0) throw new Error("forbidden: authenticated caller required");
6108
+ return caller;
6109
+ }
6110
+ function makeNcActionHandlers(deps) {
6111
+ /** A rule the caller may EDIT — must exist and be owned by the caller. */
6112
+ const assertOwnsRule = (ruleId) => {
6113
+ const rule = deps.ruleStore.get(ruleId);
6114
+ if (rule === null) throw new Error(`forbidden: rule not found: ${ruleId}`);
6115
+ return rule;
6116
+ };
6117
+ const assertRuleOwned = (ruleId, userId) => {
6118
+ const rule = assertOwnsRule(ruleId);
6119
+ if (rule.ownerUserId !== userId) throw new Error(`forbidden: rule not owned: ${ruleId}`);
6120
+ return rule;
6121
+ };
6122
+ /** A rule the caller may SEE — his own personal rule OR a global/admin rule. */
6123
+ const assertRuleVisible = (ruleId, userId) => {
6124
+ const rule = assertOwnsRule(ruleId);
6125
+ if (rule.ownerUserId !== void 0 && rule.ownerUserId !== userId) throw new Error(`forbidden: rule not owned: ${ruleId}`);
6126
+ return rule;
6127
+ };
6128
+ const assertTargetsOwned = async (targetIds, caller) => {
6129
+ if (caller.isAdmin) return;
6130
+ const owned = new Set(await deps.listCallerTargetIds(caller.userId));
6131
+ for (const id of targetIds) if (!owned.has(id)) throw new Error(`forbidden: target not owned: ${id}`);
6132
+ };
6133
+ return {
6134
+ "nc.listRules": async (_input, caller) => {
6135
+ const c = requireCaller(caller);
6136
+ return { rules: deps.ruleStore.listForOwner(c.userId).map((r) => ({
6137
+ ...r,
6138
+ readOnly: r.ownerUserId !== c.userId
6139
+ })) };
6140
+ },
6141
+ "nc.getConditionCatalog": async () => ({
6142
+ catalog: [...require_dist.NC_CONDITION_CATALOG],
6143
+ taxonomy: require_dist.NC_TAXONOMY
6144
+ }),
6145
+ "nc.createRule": async (input, caller) => {
6146
+ const c = requireCaller(caller);
6147
+ const parsed = require_dist.NcRuleInputSchema.parse(input.rule);
6148
+ await assertTargetsOwned(parsed.targets.map((t) => t.targetId), c);
6149
+ const rule = await deps.ruleStore.create({
6150
+ ...parsed,
6151
+ ownerUserId: c.userId
6152
+ }, c.userId);
6153
+ deps.logger.info("nc rule created", { meta: {
6154
+ ruleId: rule.id,
6155
+ owner: c.userId
6156
+ } });
6157
+ return { rule };
6158
+ },
6159
+ "nc.updateRule": async (input, caller) => {
6160
+ const c = requireCaller(caller);
6161
+ assertRuleOwned(input.ruleId, c.userId);
6162
+ const patch = require_dist.NcRulePatchSchema.parse(input.patch);
6163
+ if (patch.targets !== void 0) await assertTargetsOwned(patch.targets.map((t) => t.targetId), c);
6164
+ const { ownerUserId: _owner, disabledTargetIds: _optOut, ...safe } = patch;
6165
+ const rule = await deps.ruleStore.update(input.ruleId, safe);
6166
+ deps.logger.info("nc rule updated", { meta: {
6167
+ ruleId: rule.id,
6168
+ owner: c.userId
6169
+ } });
6170
+ return { rule };
6171
+ },
6172
+ "nc.deleteRule": async (input, caller) => {
6173
+ const c = requireCaller(caller);
6174
+ assertRuleOwned(input.ruleId, c.userId);
6175
+ await deps.ruleStore.delete(input.ruleId);
6176
+ deps.logger.info("nc rule deleted", { meta: {
6177
+ ruleId: input.ruleId,
6178
+ owner: c.userId
6179
+ } });
6180
+ return { success: true };
6181
+ },
6182
+ "nc.setRuleTargetEnabled": async (input, caller) => {
6183
+ const c = requireCaller(caller);
6184
+ assertRuleVisible(input.ruleId, c.userId);
6185
+ await assertTargetsOwned([input.targetId], c);
6186
+ await deps.ruleStore.setRuleTargetEnabled(input.ruleId, input.targetId, input.enabled);
6187
+ return { success: true };
6188
+ }
6189
+ };
6190
+ }
6191
+ //#endregion
4712
6192
  //#region src/pipeline-analytics/pipeline/object-embedding-selection.ts
4713
6193
  function isClipObjectEmbedding(t) {
4714
6194
  return Array.isArray(t.embedding) && t.embedding.length > 0 && typeof t.embeddingModelId === "string" && t.embeddingModelId.startsWith("mobileclip-");
@@ -4718,39 +6198,42 @@ function resolveSearchThumbnailUrl(input) {
4718
6198
  const id = input.embeddingMediaKey ?? input.eventId;
4719
6199
  return `${input.baseUrl}/${encodeURIComponent(id)}`;
4720
6200
  }
4721
- //#endregion
4722
- //#region src/pipeline-analytics/best-thumbnail-guard.ts
4723
- /**
4724
- * Void/envArea guard for best-`thumbnail` selection.
4725
- *
4726
- * ## Why this exists (the dawn/night "void" thumbnail)
4727
- *
4728
- * At dawn/night a moving subject's tracker box intermittently EXPLODES to
4729
- * (near-)the whole frame — the "envelope exploded to full-frame" signature. If
4730
- * that frame happens to win the best-detection race, the gallery/reel best
4731
- * `thumbnail` becomes a useless full-scene tile (the subject crop is the entire
4732
- * washed-out frame), not the subject. This guard rejects such a frame from the
4733
- * best-`thumbnail` decision so the track keeps a real subject-centered tile.
4734
- *
4735
- * Conservative by design: it only rejects boxes covering ≥ {@link
4736
- * NEAR_FULL_FRAME_AREA} of the frame — a genuine large close-up subject stays
4737
- * well under this. Rejecting a frame does NOT land a thumbnail, so the #27-A
4738
- * per-frame retry keeps trying until a plausible frame wins.
4739
- */
6201
+ function isPlausibleThumbnailBox(bbox, frameWidth, frameHeight, score) {
6202
+ if (frameWidth <= 0 || frameHeight <= 0) return true;
6203
+ if (score !== void 0 && score >= .5) return true;
6204
+ if (bbox.w * bbox.h / (frameWidth * frameHeight) >= .85) return false;
6205
+ if (bbox.h >= frameHeight * .95) return false;
6206
+ if (bbox.w >= frameWidth * .95) return false;
6207
+ return true;
6208
+ }
4740
6209
  /**
4741
- * Area fraction at/above which a detection bbox is treated as an exploded
4742
- * "envelope" (near-full-frame) box rather than a real subject. 0.85 keeps the
4743
- * guard conservative only boxes covering ≥85% of the frame are rejected.
6210
+ * Minimum fraction of the track's BEST-SEEN bbox area a retry frame's bbox must
6211
+ * still cover to be an acceptable best-`thumbnail` RETRY subject. 0.45 tolerates
6212
+ * normal breathing of the tracker box (~2/3 per side) while rejecting the
6213
+ * degenerate shrink (observed live 2026-07-22: a car born at 78px retried at
6214
+ * 26px once the native lease warmed — a ~0.11 area fraction — and the landed
6215
+ * "best" thumbnail was a sliver of a distant car).
4744
6216
  */
4745
- var NEAR_FULL_FRAME_AREA = .85;
6217
+ var RETRY_MIN_FRACTION_OF_BEST = .45;
4746
6218
  /**
4747
- * True when `bbox` is a plausible SUBJECT box for a best `thumbnail` i.e. its
4748
- * area is below the near-full-frame threshold. Degenerate frame dimensions
4749
- * (≤0) are treated as plausible (no info to reject on).
6219
+ * RETRY-ONLY gate (#27-A degenerate-retry fix): on a cold on-motion session the
6220
+ * native lease misses the first seconds, so the large-subject captures return
6221
+ * null and the per-frame retry re-fires with the CURRENT frame's bbox. By the
6222
+ * time the lease warms the subject may have shrunk to a sliver; capturing THAT
6223
+ * frame lands a genuine-native-but-useless thumbnail and `thumbnailLanded`
6224
+ * stops all further retries. Reject a retry whose current bbox area has
6225
+ * collapsed below `minFractionOfBest` of the track's best-seen bbox area.
6226
+ *
6227
+ * Applies ONLY to the `!thumbnailLanded` retry path — a genuine new-best
6228
+ * capture is never routed through this gate (the caller bypasses it on
6229
+ * `isNewBest`). With no best-seen reference (or a degenerate one) there is no
6230
+ * info to reject on → plausible.
4750
6231
  */
4751
- function isPlausibleThumbnailBox(bbox, frameWidth, frameHeight) {
4752
- if (frameWidth <= 0 || frameHeight <= 0) return true;
4753
- return bbox.w * bbox.h / (frameWidth * frameHeight) < NEAR_FULL_FRAME_AREA;
6232
+ function isPlausibleRetryBox(current, bestSeen, minFractionOfBest = RETRY_MIN_FRACTION_OF_BEST) {
6233
+ if (bestSeen === null) return true;
6234
+ const bestArea = bestSeen.w * bestSeen.h;
6235
+ if (bestArea <= 0) return true;
6236
+ return current.w * current.h >= minFractionOfBest * bestArea;
4754
6237
  }
4755
6238
  //#endregion
4756
6239
  //#region src/pipeline-analytics/pipeline/stationary/stationary-types.ts
@@ -5493,7 +6976,7 @@ var BindingCache = class {
5493
6976
  this.state.set(deviceId, active);
5494
6977
  return active;
5495
6978
  } catch (err) {
5496
- this.logger.debug("BindingCache.isActive lookup failed", {
6979
+ this.logger.warn("BindingCache.isActive lookup failed — frames will drop as inactive", {
5497
6980
  tags: { deviceId },
5498
6981
  meta: { error: String(err) }
5499
6982
  });
@@ -5966,9 +7449,9 @@ var DISPLAY_FALLBACK_MAX_WIDTH = 1920;
5966
7449
  /** Throttle for the native-crop HIT/FALLBACK metric window line. */
5967
7450
  var NATIVE_CROP_METRIC_INTERVAL_MS = 3e4;
5968
7451
  function createNativeFrameTransport(deps) {
5969
- const { api, ownNodeId, logger } = deps;
7452
+ const { api, logger } = deps;
5970
7453
  const pipelineRunnerApi = api.pipelineRunner;
5971
- const isRemoteHandle = (handle) => handle.nodeId !== ownNodeId;
7454
+ const wantJpeg = (_handle) => true;
5972
7455
  const cropReplyToRgb = async (reply) => {
5973
7456
  if (reply.jpeg !== void 0) {
5974
7457
  const rgb = await decodeJpegToRgb(reply.jpeg);
@@ -5996,7 +7479,7 @@ function createNativeFrameTransport(deps) {
5996
7479
  h: 1
5997
7480
  },
5998
7481
  maxWidth: handle.width,
5999
- encodeJpeg: isRemoteHandle(handle)
7482
+ encodeJpeg: wantJpeg(handle)
6000
7483
  }, require_dist.nodePin(handle.nodeId));
6001
7484
  if (!full || full.width <= 0 || full.height <= 0) return null;
6002
7485
  const rgb = await cropReplyToRgb(full);
@@ -6020,7 +7503,7 @@ function createNativeFrameTransport(deps) {
6020
7503
  h: 1
6021
7504
  },
6022
7505
  maxWidth,
6023
- encodeJpeg: isRemoteHandle(handle)
7506
+ encodeJpeg: wantJpeg(handle)
6024
7507
  }, require_dist.nodePin(handle.nodeId));
6025
7508
  if (!full || full.width <= 0 || full.height <= 0) return null;
6026
7509
  const rgb = await cropReplyToRgb(full);
@@ -6060,7 +7543,7 @@ function createNativeFrameTransport(deps) {
6060
7543
  handle: frameHandle,
6061
7544
  bbox: paddedNorm,
6062
7545
  ...maxWidth !== void 0 ? { maxWidth } : {},
6063
- encodeJpeg: isRemoteHandle(frameHandle)
7546
+ encodeJpeg: wantJpeg(frameHandle)
6064
7547
  }, require_dist.nodePin(frameHandle.nodeId));
6065
7548
  if (!native || native.width <= 0 || native.height <= 0) return null;
6066
7549
  return await cropReplyToRgb(native);
@@ -6357,153 +7840,41 @@ function createAnalyticsWidgetsProvider() {
6357
7840
  }
6358
7841
  //#endregion
6359
7842
  //#region src/pipeline-analytics/store/recent-cursor.ts
6360
- function encodeRecentCursor(cursor) {
6361
- return Buffer.from(JSON.stringify({
6362
- l: cursor.lastSeen,
6363
- i: cursor.trackId
6364
- }), "utf8").toString("base64url");
6365
- }
6366
- /**
6367
- * Decode + validate an opaque cursor. Fails fast with a clear error on any
6368
- * malformed input (bad base64, bad JSON, wrong field types) — a garbage
6369
- * cursor must never silently degrade into a full-history first page.
6370
- */
6371
- function decodeRecentCursor(raw) {
6372
- let parsed;
6373
- try {
6374
- parsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
6375
- } catch {
6376
- throw new Error("listRecentTracks: malformed cursor");
6377
- }
6378
- 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");
6379
- return {
6380
- lastSeen: parsed.l,
6381
- trackId: parsed.i
6382
- };
6383
- }
6384
- /** Comparator for the (lastSeen DESC, id DESC) total order. */
6385
- function compareRecentDesc(a, b) {
6386
- if (a.lastSeen !== b.lastSeen) return b.lastSeen - a.lastSeen;
6387
- if (a.id === b.id) return 0;
6388
- return a.id < b.id ? 1 : -1;
6389
- }
6390
- /** True when `row` sits strictly AFTER the cursor position in DESC order
6391
- * (i.e. belongs to the next page). */
6392
- function isAfterCursor(row, cursor) {
6393
- if (row.lastSeen < cursor.lastSeen) return true;
6394
- return row.lastSeen === cursor.lastSeen && row.id < cursor.trackId;
6395
- }
6396
- //#endregion
6397
- //#region src/pipeline-analytics/store/zone-geometry.ts
6398
- /**
6399
- * Normalized min/max envelope over every position's bbox. Returns `null`
6400
- * when the frame dimensions are unknown/degenerate or there are no
6401
- * positions — the caller persists NULL envelope columns in that case.
6402
- */
6403
- function computeTrackEnvelope(positions, frameWidth, frameHeight) {
6404
- if (frameWidth === void 0 || frameHeight === void 0 || !(frameWidth > 0) || !(frameHeight > 0) || positions.length === 0) return null;
6405
- let minX = Number.POSITIVE_INFINITY;
6406
- let minY = Number.POSITIVE_INFINITY;
6407
- let maxX = Number.NEGATIVE_INFINITY;
6408
- let maxY = Number.NEGATIVE_INFINITY;
6409
- for (const p of positions) {
6410
- const x0 = p.bbox.x / frameWidth;
6411
- const y0 = p.bbox.y / frameHeight;
6412
- const x1 = (p.bbox.x + p.bbox.w) / frameWidth;
6413
- const y1 = (p.bbox.y + p.bbox.h) / frameHeight;
6414
- if (x0 < minX) minX = x0;
6415
- if (y0 < minY) minY = y0;
6416
- if (x1 > maxX) maxX = x1;
6417
- if (y1 > maxY) maxY = y1;
6418
- }
6419
- return {
6420
- minX,
6421
- minY,
6422
- maxX,
6423
- maxY
6424
- };
6425
- }
6426
- /**
6427
- * Axis-aligned bounds of a zone filter (rect: itself; polygon: vertex
6428
- * min/max). A degenerate polygon (< 3 points) yields the full frame so the
6429
- * SQL prefilter never silently drops rows the precise test would keep.
6430
- */
6431
- function zoneBounds(zone) {
6432
- if (zone.kind === "rect") return {
6433
- minX: zone.x,
6434
- minY: zone.y,
6435
- maxX: zone.x + zone.width,
6436
- maxY: zone.y + zone.height
6437
- };
6438
- if (zone.points.length < 3) return {
6439
- minX: 0,
6440
- minY: 0,
6441
- maxX: 1,
6442
- maxY: 1
6443
- };
6444
- let minX = Number.POSITIVE_INFINITY;
6445
- let minY = Number.POSITIVE_INFINITY;
6446
- let maxX = Number.NEGATIVE_INFINITY;
6447
- let maxY = Number.NEGATIVE_INFINITY;
6448
- for (const p of zone.points) {
6449
- if (p.x < minX) minX = p.x;
6450
- if (p.y < minY) minY = p.y;
6451
- if (p.x > maxX) maxX = p.x;
6452
- if (p.y > maxY) maxY = p.y;
6453
- }
6454
- return {
6455
- minX,
6456
- minY,
6457
- maxX,
6458
- maxY
6459
- };
6460
- }
6461
- /** Whether two axis-aligned envelopes overlap (touching edges count). */
6462
- function envelopesOverlap(a, b) {
6463
- return a.minX <= b.maxX && a.maxX >= b.minX && a.minY <= b.maxY && a.maxY >= b.minY;
7843
+ function encodeRecentCursor(cursor) {
7844
+ return Buffer.from(JSON.stringify({
7845
+ l: cursor.lastSeen,
7846
+ i: cursor.trackId
7847
+ }), "utf8").toString("base64url");
6464
7848
  }
6465
7849
  /**
6466
- * Ray-casting point-in-polygon (even-odd rule). Points on an edge may
6467
- * resolve either way acceptable for zone filtering. A polygon with
6468
- * fewer than 3 vertices contains nothing.
7850
+ * Decode + validate an opaque cursor. Fails fast with a clear error on any
7851
+ * malformed input (bad base64, bad JSON, wrong field types) a garbage
7852
+ * cursor must never silently degrade into a full-history first page.
6469
7853
  */
6470
- function pointInPolygon(point, polygon) {
6471
- if (polygon.length < 3) return false;
6472
- let inside = false;
6473
- for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
6474
- const a = polygon[i];
6475
- const b = polygon[j];
6476
- 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;
7854
+ function decodeRecentCursor(raw) {
7855
+ let parsed;
7856
+ try {
7857
+ parsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
7858
+ } catch {
7859
+ throw new Error("listRecentTracks: malformed cursor");
6477
7860
  }
6478
- return inside;
7861
+ 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");
7862
+ return {
7863
+ lastSeen: parsed.l,
7864
+ trackId: parsed.i
7865
+ };
6479
7866
  }
6480
- /**
6481
- * Precise per-position zone test.
6482
- *
6483
- * - rect zone → any position bbox (normalized) intersects the rect.
6484
- * - polygon zone → any position CENTER (normalized `x`/`y` — positions
6485
- * store the bbox center) falls inside the polygon.
6486
- *
6487
- * Unknown/degenerate frame dims → `true` (the track cannot be tested, so it
6488
- * PASSES mirroring the NULL-envelope-matches rule).
6489
- */
6490
- function positionsIntersectZone(positions, frameWidth, frameHeight, zone) {
6491
- if (frameWidth === void 0 || frameHeight === void 0 || !(frameWidth > 0) || !(frameHeight > 0)) return true;
6492
- if (zone.kind === "rect") {
6493
- const rect = zoneBounds(zone);
6494
- for (const p of positions) if (envelopesOverlap({
6495
- minX: p.bbox.x / frameWidth,
6496
- minY: p.bbox.y / frameHeight,
6497
- maxX: (p.bbox.x + p.bbox.w) / frameWidth,
6498
- maxY: (p.bbox.y + p.bbox.h) / frameHeight
6499
- }, rect)) return true;
6500
- return false;
6501
- }
6502
- for (const p of positions) if (pointInPolygon({
6503
- x: p.x / frameWidth,
6504
- y: p.y / frameHeight
6505
- }, zone.points)) return true;
6506
- return false;
7867
+ /** Comparator for the (lastSeen DESC, id DESC) total order. */
7868
+ function compareRecentDesc(a, b) {
7869
+ if (a.lastSeen !== b.lastSeen) return b.lastSeen - a.lastSeen;
7870
+ if (a.id === b.id) return 0;
7871
+ return a.id < b.id ? 1 : -1;
7872
+ }
7873
+ /** True when `row` sits strictly AFTER the cursor position in DESC order
7874
+ * (i.e. belongs to the next page). */
7875
+ function isAfterCursor(row, cursor) {
7876
+ if (row.lastSeen < cursor.lastSeen) return true;
7877
+ return row.lastSeen === cursor.lastSeen && row.id < cursor.trackId;
6507
7878
  }
6508
7879
  //#endregion
6509
7880
  //#region src/pipeline-analytics/store/track-store.ts
@@ -7595,6 +8966,11 @@ var MediaStore = class {
7595
8966
  * and no duplicate snapshot/lastFrame pair. The `lastFrame` write lands BEFORE
7596
8967
  * the snapshot delete, so the view is never momentarily absent. Returns the new
7597
8968
  * `lastFrame` key.
8969
+ *
8970
+ * `keepSource: true` (short-track keyFrameSmall fallback, 2026-07-23): copy
8971
+ * the bytes into the `lastFrame` slot WITHOUT deleting the source row — used
8972
+ * when the source is the track's `keyFrameSmall`, which must keep serving its
8973
+ * own kind.
7598
8974
  */
7599
8975
  async promoteToLastFrame(input) {
7600
8976
  const data = Buffer.from(input.snapshot.base64, "base64");
@@ -7606,7 +8982,7 @@ var MediaStore = class {
7606
8982
  timestamp: input.snapshot.timestamp,
7607
8983
  data
7608
8984
  });
7609
- await this.deleteByKey(input.snapshot.key);
8985
+ if (input.keepSource !== true) await this.deleteByKey(input.snapshot.key);
7610
8986
  return newKey;
7611
8987
  }
7612
8988
  /**
@@ -9184,15 +10560,19 @@ async function ingestSensorStateChange(deps, data, timestamp) {
9184
10560
  const makeId = deps.makeId ?? (() => `pa-sensor-${(0, node_crypto.randomUUID)()}`);
9185
10561
  let inserted = 0;
9186
10562
  for (const cameraId of cameraIds) {
9187
- await deps.sink.insert({
10563
+ const ev = {
9188
10564
  id: makeId(),
9189
10565
  deviceId: cameraId,
9190
10566
  sourceDeviceId: data.deviceId,
9191
10567
  kind: descriptor.kind,
9192
10568
  value,
9193
10569
  timestamp
9194
- });
10570
+ };
10571
+ await deps.sink.insert(ev);
9195
10572
  inserted++;
10573
+ if (deps.onPersisted !== void 0) try {
10574
+ deps.onPersisted(ev);
10575
+ } catch {}
9196
10576
  }
9197
10577
  return inserted;
9198
10578
  }
@@ -9580,7 +10960,7 @@ async function composeWideCentralSquareThumbnail(slab, layout) {
9580
10960
  const leftPad = Math.max(0, Math.round(layout.slabOffsetX * scale));
9581
10961
  const rightPad = Math.max(0, canvasNativeW - nativeW - leftPad);
9582
10962
  if (leftPad === 0 && rightPad === 0) return slab;
9583
- return (0, sharp.default)(await (0, sharp.default)(slab).resize(canvasNativeW, nativeH, { fit: "fill" }).blur(LETTERBOX_BLUR_SIGMA).modulate({ brightness: LETTERBOX_BLUR_BRIGHTNESS }).toBuffer()).composite([{
10963
+ return (0, sharp.default)(await (0, sharp.default)(slab).resize(canvasNativeW, nativeH, { fit: "cover" }).blur(LETTERBOX_BLUR_SIGMA).modulate({ brightness: LETTERBOX_BLUR_BRIGHTNESS }).toBuffer()).composite([{
9584
10964
  input: slab,
9585
10965
  left: leftPad,
9586
10966
  top: 0
@@ -9747,8 +11127,11 @@ function isUniformRgbFrame(data, width, height) {
9747
11127
  *
9748
11128
  * The detection frame is resolved ONCE per call (blank-frame guard); native full
9749
11129
  * frames + subject crops are fetched per target by handle. Frames recycle in
9750
- * milliseconds, so the caller must invoke this immediately after producing the
9751
- * events while the handle is still live.
11130
+ * milliseconds, so the caller either invokes this while the handle is still
11131
+ * live, or — when the call is queued behind a capture lane (the S3 batch
11132
+ * dispatch) — pre-resolves the ≤640 frame in the live window via
11133
+ * {@link resolvePinnedFrame} and passes it as `pinnedFrame`, which skips the
11134
+ * by-handle resolve for the ≤640 tier entirely.
9752
11135
  */
9753
11136
  var EventMediaDispatcher = class {
9754
11137
  deps;
@@ -9768,6 +11151,61 @@ var EventMediaDispatcher = class {
9768
11151
  this.deps = deps;
9769
11152
  this.sharedNativeFullFrame = createSharedFrameResolver((handle) => this.deps.getNativeFullFrameRgb(handle, SNAPSHOT_MAX_WIDTH));
9770
11153
  }
11154
+ /**
11155
+ * Resolve the ≤640 detection frame by handle NOW — in the caller's LIVE frame
11156
+ * window — and COPY its pixel data (`Buffer.from`) so ring recycling can never
11157
+ * touch it. This is the seam the per-frame batch dispatch uses to pin the
11158
+ * frame BEFORE queueing on the CaptureScheduler: since S3 the batch is one
11159
+ * scheduler request against a 3-slot device lane and can start seconds late,
11160
+ * by which time the handle's ring slot is usually recycled and the by-handle
11161
+ * resolve in {@link captureForFrame} finds nothing (the 2026-07-23 boxed-tile
11162
+ * regression). ~0.7MB per pinned frame, freed with the batch — bounded by the
11163
+ * lane. Returns `null` on a genuine live-window miss (logged at debug — the
11164
+ * caller then omits `pinnedFrame` and captureForFrame's by-handle resolve,
11165
+ * WARN-logged on failure, remains the fallback).
11166
+ */
11167
+ async resolvePinnedFrame(deviceId, frameHandle) {
11168
+ try {
11169
+ const decoded = await resolveFrame(frameHandle, { getRemoteFrame: this.deps.getRemoteFrame });
11170
+ if (!decoded) return null;
11171
+ return {
11172
+ ...decoded,
11173
+ data: Buffer.from(decoded.data)
11174
+ };
11175
+ } catch (err) {
11176
+ this.deps.logger.debug("event media: pinned-frame resolve threw (live window)", {
11177
+ tags: { deviceId },
11178
+ meta: {
11179
+ deviceId,
11180
+ shmId: frameHandle.shmId,
11181
+ error: String(err)
11182
+ }
11183
+ });
11184
+ return null;
11185
+ }
11186
+ }
11187
+ /**
11188
+ * WARN-log a whole-batch capture abort: every early return in
11189
+ * {@link captureForFrame} loses ALL of the frame's targets at once —
11190
+ * firstFrame + snapshot/lastFrame/thumbnail + event child crops — which
11191
+ * previously died with DEBUG-only logs (invisible in production, the silent
11192
+ * half of the boxed-tile regression). The per-kind lost-target counts make
11193
+ * the blast radius visible.
11194
+ */
11195
+ warnBatchLost(reason, input, extraMeta = {}) {
11196
+ this.deps.logger.warn(`event media: capture batch lost — ${reason}`, {
11197
+ tags: { deviceId: input.deviceId },
11198
+ meta: {
11199
+ deviceId: input.deviceId,
11200
+ shmId: input.frameHandle.shmId,
11201
+ pinned: input.pinnedFrame !== void 0,
11202
+ lostEventTargets: input.events.length,
11203
+ lostFirstFrameTargets: input.trackFrames.length,
11204
+ lostSnapshotTargets: input.snapshots?.length ?? 0,
11205
+ ...extraMeta
11206
+ }
11207
+ });
11208
+ }
9771
11209
  async captureForFrame(input) {
9772
11210
  const { deviceId, frameHandle, events, trackFrames } = input;
9773
11211
  const snapshots = input.snapshots ?? [];
@@ -9781,51 +11219,28 @@ var EventMediaDispatcher = class {
9781
11219
  const extraCandidateCount = input.rasterFallbackCandidates?.length ?? 0;
9782
11220
  if (events.length === 0 && trackFrames.length === 0 && snapshots.length === 0 && extraCandidateCount === 0) return empty;
9783
11221
  let decoded;
9784
- try {
11222
+ if (input.pinnedFrame !== void 0) decoded = input.pinnedFrame;
11223
+ else try {
9785
11224
  decoded = await resolveFrame(frameHandle, { getRemoteFrame: this.deps.getRemoteFrame });
9786
11225
  } catch (err) {
9787
- this.deps.logger.debug("event media: resolveFrame threw", {
9788
- tags: { deviceId },
9789
- meta: {
9790
- deviceId,
9791
- shmId: frameHandle.shmId,
9792
- error: String(err)
9793
- }
9794
- });
11226
+ this.warnBatchLost("resolveFrame threw", input, { error: String(err) });
9795
11227
  return empty;
9796
11228
  }
9797
11229
  if (!decoded) {
9798
- this.deps.logger.debug("event media: frame recycled before resolve", {
9799
- tags: { deviceId },
9800
- meta: {
9801
- deviceId,
9802
- shmId: frameHandle.shmId
9803
- }
9804
- });
11230
+ this.warnBatchLost("frame recycled before resolve", input);
9805
11231
  return empty;
9806
11232
  }
9807
11233
  if (decoded.format !== "rgb") {
9808
- this.deps.logger.debug("event media: resolved frame is not RGB", {
9809
- tags: { deviceId },
9810
- meta: {
9811
- deviceId,
9812
- format: decoded.format
9813
- }
9814
- });
11234
+ this.warnBatchLost("resolved frame is not RGB", input, { format: decoded.format });
9815
11235
  return empty;
9816
11236
  }
9817
- const frameData = Buffer.from(decoded.data);
11237
+ const frameData = input.pinnedFrame !== void 0 ? decoded.data : Buffer.from(decoded.data);
9818
11238
  const fw = decoded.width;
9819
11239
  const fh = decoded.height;
9820
11240
  if (isUniformRgbFrame(frameData, fw, fh)) {
9821
- this.deps.logger.debug("event media: resolved frame uniform (blank/hwaccel) — skipping crop", {
9822
- tags: { deviceId },
9823
- meta: {
9824
- deviceId,
9825
- shmId: frameHandle.shmId,
9826
- width: fw,
9827
- height: fh
9828
- }
11241
+ this.warnBatchLost("resolved frame uniform (blank/hwaccel)", input, {
11242
+ width: fw,
11243
+ height: fh
9829
11244
  });
9830
11245
  return empty;
9831
11246
  }
@@ -10638,6 +12053,7 @@ var ZoneAnalyticsProvider = class {
10638
12053
  }
10639
12054
  this.snapshots.set(input.deviceId, snapshot);
10640
12055
  this.appendHistory(input.deviceId, snapshot);
12056
+ this.emitSnapshot(input.deviceId, snapshot);
10641
12057
  this.sliceThrottle.push(input.deviceId, snapshot);
10642
12058
  }
10643
12059
  /** Drop a device's snapshot + history. Called on device removal. */
@@ -10702,10 +12118,24 @@ var ZoneAnalyticsProvider = class {
10702
12118
  });
10703
12119
  if (snapshot) {
10704
12120
  this.appendHistory(deviceId, snapshot);
12121
+ this.emitSnapshot(deviceId, snapshot);
10705
12122
  this.sliceThrottle.push(deviceId, snapshot);
10706
12123
  }
10707
12124
  }
10708
12125
  }
12126
+ /** Push one snapshot to the occupancy tap, isolating any consumer throw from
12127
+ * the frame path (occupancy notifications must never break analytics). */
12128
+ emitSnapshot(deviceId, snapshot) {
12129
+ if (!this.ctx.onSnapshot) return;
12130
+ try {
12131
+ this.ctx.onSnapshot(deviceId, snapshot);
12132
+ } catch (err) {
12133
+ this.ctx.logger.debug("zone-analytics occupancy tap failed", {
12134
+ tags: { deviceId },
12135
+ meta: { error: err instanceof Error ? err.message : String(err) }
12136
+ });
12137
+ }
12138
+ }
10709
12139
  appendHistory(deviceId, snapshot) {
10710
12140
  const ring = this.history.get(deviceId) ?? [];
10711
12141
  const cutoff = snapshot.ts - HISTORY_WINDOW_MS;
@@ -11615,21 +13045,47 @@ var FaceSettingsSchema = require_dist.object({
11615
13045
  * stripped from the per-device schema).
11616
13046
  */
11617
13047
  enabled: require_dist.boolean().default(true),
11618
- /** Cosine similarity (on L2-normalized arcface vectors) required to match. */
11619
- similarityThreshold: require_dist.number().min(0).max(1).default(.55),
11620
- /** Reject ambiguous matches: require best secondBest ≥ margin. */
11621
- margin: require_dist.number().min(0).max(1).default(.1),
13048
+ /** Cosine similarity (on L2-normalized arcface vectors) required to match.
13049
+ * Raised 0.55→0.62 (2026-07-23 face-quality batch): 0.55 was implausibly
13050
+ * loose against thin/low-quality galleries and produced ~25-40% visually
13051
+ * wrong auto-assignments in the 12h audit. */
13052
+ similarityThreshold: require_dist.number().min(0).max(1).default(.62),
13053
+ /** Reject ambiguous matches: require best − secondBest ≥ margin. Raised
13054
+ * 0.10→0.15 (2026-07-23 face-quality batch) to widen the confusion gap. */
13055
+ margin: require_dist.number().min(0).max(1).default(.15),
11622
13056
  /** Minimum face-detection confidence for a face to be considered. */
11623
13057
  minFaceConfidence: require_dist.number().min(0).max(1).default(.5),
11624
13058
  /**
11625
- * Minimum face bbox size (px, shorter side of the face box in detection-frame
11626
- * space) for a face to be eligible for embedding-based auto-matching. Below
11627
- * this, ArcFace resolution is unreliable and auto-assignment produces the
11628
- * observed false positives (tiny/distant faces collapsing onto one identity).
11629
- * Such faces are dropped BEFORE matching/enrolment (#26.1).
13059
+ * Minimum face bbox size (px, shorter side, NATIVE scale when the runner
13060
+ * measured it, else detection-frame space) for a face to be DETECTED and
13061
+ * COLLECTED into the recent-faces buffer. Below this the face is dropped
13062
+ * BEFORE ingest/enrolment (#26.1). This is the DETECTION/collection floor —
13063
+ * NOT the auto-assignment floor (see {@link recognitionMinFacePx}).
11630
13064
  */
11631
13065
  minFacePx: require_dist.number().min(0).default(30),
11632
13066
  /**
13067
+ * Minimum face short side (px, NATIVE scale when the runner measured it, else
13068
+ * detection-frame space) for a collected face to be eligible for AUTO-MATCH
13069
+ * (identity assignment). Separate from — and ≥ — {@link minFacePx}: faces
13070
+ * between `minFacePx` and this floor are still detected, cropped, and stored
13071
+ * in the buffer (available for MANUAL assignment), but are NEVER auto-assigned
13072
+ * an identity. ArcFace embeddings below ~48px are unreliable and drove the
13073
+ * observed false positives (2026-07-23 face-quality batch). A face below this
13074
+ * floor keeps `recognizedIdentityId` UNSET (fail-safe).
13075
+ */
13076
+ recognitionMinFacePx: require_dist.number().min(0).default(48),
13077
+ /**
13078
+ * Lower cosine bound of the SUGGESTION band (2026-07-24). A face whose best
13079
+ * gallery match MISSES auto-assignment but is still plausible surfaces as a
13080
+ * SUGGESTION (persisted `suggestedIdentityId`/`suggestedMatchScore`, never an
13081
+ * assignment) when EITHER: its match cosine is in [`suggestionMinCosine`,
13082
+ * `similarityThreshold`) AND its face clears the recognition size floor; OR its
13083
+ * cosine is ≥ `similarityThreshold` but the face is below the recognition floor
13084
+ * (blocked ONLY by size). Below this cosine nothing is suggested. Operator-
13085
+ * overridable per field, like the other face thresholds.
13086
+ */
13087
+ suggestionMinCosine: require_dist.number().min(0).max(1).default(.5),
13088
+ /**
11633
13089
  * Minimum enrolled-sample count an identity must have before it can be an
11634
13090
  * AUTO-MATCH target. A single-sample identity is an unreliable sink (one noisy
11635
13091
  * enrolment attracted 81% of matches); identities below this are excluded from
@@ -11657,6 +13113,8 @@ function resolveFaceSettings(raw) {
11657
13113
  margin: pick("margin"),
11658
13114
  minFaceConfidence: pick("minFaceConfidence"),
11659
13115
  minFacePx: pick("minFacePx"),
13116
+ recognitionMinFacePx: pick("recognitionMinFacePx"),
13117
+ suggestionMinCosine: pick("suggestionMinCosine"),
11660
13118
  minIdentitySamples: pick("minIdentitySamples"),
11661
13119
  confirmFrames: pick("confirmFrames"),
11662
13120
  bufferRetentionDays: pick("bufferRetentionDays"),
@@ -13067,13 +14525,33 @@ function buildGlobalSettingsSchema() {
13067
14525
  {
13068
14526
  type: "number",
13069
14527
  key: "minFacePx",
13070
- label: "Min face size",
13071
- 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.",
14528
+ label: "Min face size (detect)",
14529
+ 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.",
13072
14530
  min: 0,
13073
14531
  step: 1,
13074
14532
  default: FACE_DEFAULTS.minFacePx,
13075
14533
  unit: "px"
13076
14534
  },
14535
+ {
14536
+ type: "number",
14537
+ key: "recognitionMinFacePx",
14538
+ label: "Min face size (recognize)",
14539
+ 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.",
14540
+ min: 0,
14541
+ step: 1,
14542
+ default: FACE_DEFAULTS.recognitionMinFacePx,
14543
+ unit: "px"
14544
+ },
14545
+ {
14546
+ type: "number",
14547
+ key: "suggestionMinCosine",
14548
+ label: "Suggestion threshold",
14549
+ 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.",
14550
+ min: 0,
14551
+ max: 1,
14552
+ step: .05,
14553
+ default: FACE_DEFAULTS.suggestionMinCosine
14554
+ },
13077
14555
  {
13078
14556
  type: "number",
13079
14557
  key: "minIdentitySamples",
@@ -13198,6 +14676,7 @@ var PackageDropDetector = class {
13198
14676
  importance: PACKAGE_IMPORTANCE
13199
14677
  };
13200
14678
  await this.deps.events.insertObject(ev);
14679
+ this.deps.onPersisted?.(ev, "delivered");
13201
14680
  this.deps.emit.delivered({
13202
14681
  deviceId: entry.deviceId,
13203
14682
  entryId: entry.id,
@@ -13248,6 +14727,7 @@ var PackageDropDetector = class {
13248
14727
  importance: PACKAGE_IMPORTANCE
13249
14728
  };
13250
14729
  await this.deps.events.insertObject(ev);
14730
+ this.deps.onPersisted?.(ev, "picked-up");
13251
14731
  this.deps.emit.pickedUp({
13252
14732
  deviceId: entry.deviceId,
13253
14733
  entryId: entry.id,
@@ -13733,6 +15213,22 @@ var FACE_COLUMNS = [
13733
15213
  {
13734
15214
  name: "faceBbox",
13735
15215
  type: "JSON"
15216
+ },
15217
+ {
15218
+ name: "bestMatchScore",
15219
+ type: "REAL"
15220
+ },
15221
+ {
15222
+ name: "nativeFaceShortSidePx",
15223
+ type: "REAL"
15224
+ },
15225
+ {
15226
+ name: "suggestedIdentityId",
15227
+ type: "TEXT"
15228
+ },
15229
+ {
15230
+ name: "suggestedMatchScore",
15231
+ type: "REAL"
13736
15232
  }
13737
15233
  ];
13738
15234
  var FACE_INDEXES = [{
@@ -13807,7 +15303,11 @@ var FaceStore = class {
13807
15303
  recognizedIdentityId: data.recognizedIdentityId ?? void 0,
13808
15304
  assignedSampleId: data.assignedSampleId ?? void 0,
13809
15305
  keyFrameMediaKey: data.keyFrameMediaKey ?? void 0,
13810
- faceBbox: data.faceBbox ?? void 0
15306
+ faceBbox: data.faceBbox ?? void 0,
15307
+ bestMatchScore: data.bestMatchScore ?? void 0,
15308
+ nativeFaceShortSidePx: data.nativeFaceShortSidePx ?? void 0,
15309
+ suggestedIdentityId: data.suggestedIdentityId ?? void 0,
15310
+ suggestedMatchScore: data.suggestedMatchScore ?? void 0
13811
15311
  };
13812
15312
  }).filter((f) => !f.assigned);
13813
15313
  }
@@ -14007,7 +15507,11 @@ var FaceStore = class {
14007
15507
  recognizedIdentityId: data.recognizedIdentityId ?? void 0,
14008
15508
  assignedSampleId: data.assignedSampleId ?? void 0,
14009
15509
  keyFrameMediaKey: data.keyFrameMediaKey ?? void 0,
14010
- faceBbox: data.faceBbox ?? void 0
15510
+ faceBbox: data.faceBbox ?? void 0,
15511
+ bestMatchScore: data.bestMatchScore ?? void 0,
15512
+ nativeFaceShortSidePx: data.nativeFaceShortSidePx ?? void 0,
15513
+ suggestedIdentityId: data.suggestedIdentityId ?? void 0,
15514
+ suggestedMatchScore: data.suggestedMatchScore ?? void 0
14011
15515
  };
14012
15516
  }
14013
15517
  /**
@@ -14053,7 +15557,11 @@ var FaceStore = class {
14053
15557
  recognizedIdentityId: data.recognizedIdentityId ?? void 0,
14054
15558
  assignedSampleId: data.assignedSampleId ?? void 0,
14055
15559
  keyFrameMediaKey: data.keyFrameMediaKey ?? void 0,
14056
- faceBbox: data.faceBbox ?? void 0
15560
+ faceBbox: data.faceBbox ?? void 0,
15561
+ bestMatchScore: data.bestMatchScore ?? void 0,
15562
+ nativeFaceShortSidePx: data.nativeFaceShortSidePx ?? void 0,
15563
+ suggestedIdentityId: data.suggestedIdentityId ?? void 0,
15564
+ suggestedMatchScore: data.suggestedMatchScore ?? void 0
14057
15565
  };
14058
15566
  });
14059
15567
  const filterMode = input.filter ?? "all";
@@ -14285,6 +15793,40 @@ function eligibleIdentities(gallery, probeModelId, probeDim, minIdentitySamples)
14285
15793
  return eligible;
14286
15794
  }
14287
15795
  /**
15796
+ * Match a probe embedding against the gallery. Only samples with the same
15797
+ * `modelId` AND the same dimension are compared (model-version safety). Returns
15798
+ * the best identity when its score ≥ threshold and it beats the best OTHER
15799
+ * identity by ≥ margin; otherwise null.
15800
+ */
15801
+ function matchEmbedding(probe, gallery, opts) {
15802
+ const probeVec = new Float32Array(probe.embedding);
15803
+ const eligible = eligibleIdentities(gallery, probe.modelId, probe.embedding.length, opts.minIdentitySamples ?? 1);
15804
+ const bestByIdentity = /* @__PURE__ */ new Map();
15805
+ for (const s of gallery) {
15806
+ if (s.modelId !== probe.modelId) continue;
15807
+ if (s.embedding.length !== probe.embedding.length) continue;
15808
+ if (!eligible.has(s.identityId)) continue;
15809
+ const score = require_dist.cosineSimilarity(probeVec, new Float32Array(s.embedding));
15810
+ const prev = bestByIdentity.get(s.identityId);
15811
+ if (prev === void 0 || score > prev) bestByIdentity.set(s.identityId, score);
15812
+ }
15813
+ if (bestByIdentity.size === 0) return null;
15814
+ let bestId = null;
15815
+ let bestScore = -Infinity;
15816
+ let secondScore = -Infinity;
15817
+ for (const [id, score] of bestByIdentity) if (score > bestScore) {
15818
+ secondScore = bestScore;
15819
+ bestScore = score;
15820
+ bestId = id;
15821
+ } else if (score > secondScore) secondScore = score;
15822
+ if (bestId === null || bestScore < opts.threshold) return null;
15823
+ if (secondScore > -Infinity && bestScore - secondScore < opts.margin) return null;
15824
+ return {
15825
+ identityId: bestId,
15826
+ score: bestScore
15827
+ };
15828
+ }
15829
+ /**
14288
15830
  * Assign at most one identity per track AND at most one track per identity for
14289
15831
  * a single frame. Greedy by score: compute every candidate's full ranked match
14290
15832
  * list, then repeatedly take the globally-highest (track, identity) pair whose
@@ -14391,6 +15933,24 @@ var FaceRecognizer = class {
14391
15933
  names = /* @__PURE__ */ new Map();
14392
15934
  aggregates = /* @__PURE__ */ new Map();
14393
15935
  bestFace = /* @__PURE__ */ new Map();
15936
+ /**
15937
+ * Peak identity-MATCH cosine per track for the currently-assigned identity
15938
+ * (NOT the detection confidence held in {@link bestFace}). Reset on an
15939
+ * identity switch so a track carries the confidence of the label it ends up
15940
+ * with. Read synchronously at close (NC `minLabelConfidence`) via
15941
+ * {@link bestLabelMatchConfidence}, then dropped in {@link onTrackEnd}.
15942
+ */
15943
+ bestMatchScore = /* @__PURE__ */ new Map();
15944
+ /**
15945
+ * Peak SUGGESTION (identity + cosine) per track: the best plausible-but-not-
15946
+ * confident match that MISSED auto-assignment (cosine in the suggestion band
15947
+ * with a large-enough face, OR ≥ threshold but below the recognition size
15948
+ * floor). Accumulated across frames exactly like {@link bestMatchScore},
15949
+ * read at close, then dropped in {@link onTrackEnd}. A track that is ever
15950
+ * auto-assigned discards this at close (mutual exclusivity: assigned rows
15951
+ * carry `recognizedIdentityId`, suggestion rows carry `suggested*`).
15952
+ */
15953
+ bestSuggestion = /* @__PURE__ */ new Map();
14394
15954
  /** Best-face-per-track ranking via the shared tracker primitive, constructed
14395
15955
  * WITH the DECLARED face policy (`BEST_FACE_POLICY` in
14396
15956
  * `best-selection-policies.ts`): confidence-only, no hysteresis, no rate
@@ -14460,7 +16020,9 @@ var FaceRecognizer = class {
14460
16020
  gallerySize: this.gallery.length
14461
16021
  }
14462
16022
  });
14463
- const matches = this.gallery.length > 0 ? assignUniquePerFrame(candidates.map((c) => ({
16023
+ const meetsRecognitionFloor = (t) => (t.nativeFaceShortSidePx ?? (t.faceBbox ? Math.min(t.faceBbox.w, t.faceBbox.h) : void 0) ?? Number.POSITIVE_INFINITY) >= settings.recognitionMinFacePx;
16024
+ const matchCandidates = candidates.filter(meetsRecognitionFloor);
16025
+ const matches = this.gallery.length > 0 && matchCandidates.length > 0 ? assignUniquePerFrame(matchCandidates.map((c) => ({
14464
16026
  trackId: c.trackId,
14465
16027
  embedding: c.embedding,
14466
16028
  modelId: c.embeddingModelId
@@ -14469,16 +16031,42 @@ var FaceRecognizer = class {
14469
16031
  margin: settings.margin,
14470
16032
  minIdentitySamples: settings.minIdentitySamples
14471
16033
  }) : /* @__PURE__ */ new Map();
16034
+ if (this.gallery.length > 0) for (const c of candidates) {
16035
+ const suggestion = matchEmbedding({
16036
+ embedding: c.embedding,
16037
+ modelId: c.embeddingModelId
16038
+ }, this.gallery, {
16039
+ threshold: settings.suggestionMinCosine,
16040
+ margin: settings.margin,
16041
+ minIdentitySamples: settings.minIdentitySamples
16042
+ });
16043
+ if (suggestion === null) continue;
16044
+ const meetsRecognitionFloor = (c.nativeFaceShortSidePx ?? (c.faceBbox ? Math.min(c.faceBbox.w, c.faceBbox.h) : void 0) ?? Number.POSITIVE_INFINITY) >= settings.recognitionMinFacePx;
16045
+ const inNearMissBand = meetsRecognitionFloor && suggestion.score < settings.similarityThreshold;
16046
+ const inSizeBlockedBand = !meetsRecognitionFloor && suggestion.score >= settings.similarityThreshold;
16047
+ if (!inNearMissBand && !inSizeBlockedBand) continue;
16048
+ const prev = this.bestSuggestion.get(c.trackId);
16049
+ if (prev === void 0 || suggestion.score > prev.score) this.bestSuggestion.set(c.trackId, {
16050
+ identityId: suggestion.identityId,
16051
+ score: suggestion.score
16052
+ });
16053
+ }
14472
16054
  const labelWork = [];
14473
16055
  for (const c of candidates) {
14474
16056
  const match = matches.get(c.trackId) ?? null;
14475
16057
  const { state, changed } = updateTrackAggregate(this.aggregates.get(c.trackId) ?? EMPTY_AGGREGATE, match, { confirmFrames: settings.confirmFrames });
14476
16058
  this.aggregates.set(c.trackId, state);
14477
- if (changed && state.assignedIdentityId !== null) labelWork.push({
14478
- trackId: c.trackId,
14479
- assignedIdentityId: state.assignedIdentityId,
14480
- matchScore: match?.score ?? null
14481
- });
16059
+ if (changed && state.assignedIdentityId !== null) {
16060
+ labelWork.push({
16061
+ trackId: c.trackId,
16062
+ assignedIdentityId: state.assignedIdentityId,
16063
+ matchScore: match?.score ?? null
16064
+ });
16065
+ if (match?.score !== void 0) this.bestMatchScore.set(c.trackId, match.score);
16066
+ } else if (match !== null && state.assignedIdentityId !== null && match.identityId === state.assignedIdentityId) {
16067
+ const prev = this.bestMatchScore.get(c.trackId);
16068
+ if (prev === void 0 || match.score > prev) this.bestMatchScore.set(c.trackId, match.score);
16069
+ }
14482
16070
  const recognizedIdentityId = state.assignedIdentityId ?? match?.identityId ?? void 0;
14483
16071
  const held = this.bestFace.get(c.trackId);
14484
16072
  const isNewBest = this.bestTracker.observe(c.trackId, c.confidence, input.timestamp);
@@ -14507,7 +16095,8 @@ var FaceRecognizer = class {
14507
16095
  bbox: cropBbox,
14508
16096
  timestamp: input.timestamp,
14509
16097
  ...bestCrop !== void 0 ? { crop: bestCrop } : {},
14510
- ...recognizedIdentityId !== void 0 ? { recognizedIdentityId } : {}
16098
+ ...recognizedIdentityId !== void 0 ? { recognizedIdentityId } : {},
16099
+ ...c.nativeFaceShortSidePx !== void 0 ? { nativeFaceShortSidePx: c.nativeFaceShortSidePx } : {}
14511
16100
  });
14512
16101
  } else if (crop !== void 0 && held !== void 0) this.bestFace.set(c.trackId, {
14513
16102
  ...held,
@@ -14584,11 +16173,23 @@ var FaceRecognizer = class {
14584
16173
  * entry (crop -> MediaStore under ownerKind 'face'), then drop the track's
14585
16174
  * in-memory state. Best-effort; a track with no held face is a no-op.
14586
16175
  */
16176
+ /**
16177
+ * Peak identity-match cosine for the track's assigned identity, or undefined
16178
+ * when no identity was ever confirmed. Read at close BEFORE {@link onTrackEnd}
16179
+ * drops the per-track state (the NC `minLabelConfidence` seam).
16180
+ */
16181
+ bestLabelMatchConfidence(trackId) {
16182
+ return this.bestMatchScore.get(trackId);
16183
+ }
14587
16184
  async onTrackEnd(deviceId, trackId) {
14588
16185
  const held = this.bestFace.get(trackId);
16186
+ const bestMatchScore = this.bestMatchScore.get(trackId);
16187
+ const bestSuggestion = this.bestSuggestion.get(trackId);
14589
16188
  this.aggregates.delete(trackId);
14590
16189
  this.bestFace.delete(trackId);
14591
16190
  this.bestTracker.delete(trackId);
16191
+ this.bestMatchScore.delete(trackId);
16192
+ this.bestSuggestion.delete(trackId);
14592
16193
  if (held === void 0) {
14593
16194
  this.deps.logger.debug("face: track ended without a held face", { tags: {
14594
16195
  deviceId,
@@ -14652,7 +16253,13 @@ var FaceRecognizer = class {
14652
16253
  ...held.recognizedIdentityId !== void 0 ? { recognizedIdentityId: held.recognizedIdentityId } : {},
14653
16254
  assigned: false,
14654
16255
  faceBbox: held.bbox,
14655
- ...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {}
16256
+ ...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {},
16257
+ ...bestMatchScore !== void 0 ? { bestMatchScore } : {},
16258
+ ...held.nativeFaceShortSidePx !== void 0 ? { nativeFaceShortSidePx: held.nativeFaceShortSidePx } : {},
16259
+ ...held.recognizedIdentityId === void 0 && bestSuggestion !== void 0 ? {
16260
+ suggestedIdentityId: bestSuggestion.identityId,
16261
+ suggestedMatchScore: bestSuggestion.score
16262
+ } : {}
14656
16263
  });
14657
16264
  this.deps.logger.info("face: buffered to gallery", {
14658
16265
  tags: {
@@ -15975,11 +17582,13 @@ var PlateRecognizer = class {
15975
17582
  } : null;
15976
17583
  }
15977
17584
  /** Live label for a plate read: the recognized vehicle NAME when matched,
15978
- * else the raw OCR text. Returns `null` for an implausible read (junk OCR
15979
- * off a distant/oblique plate) the caller must NOT stamp a label then. */
17585
+ * else the NORMALIZED (uppercase, alnum-only) OCR text never the raw read,
17586
+ * so lowercase/separator residue can't reach track labels. Returns `null`
17587
+ * for an implausible read (junk OCR off a distant/oblique plate) — the
17588
+ * caller must NOT stamp a label then. */
15980
17589
  resolveLabel(text, score) {
15981
17590
  if (!isPlausiblePlateRead(text, score)) return null;
15982
- return this.matchVehicle(text, score)?.name ?? text;
17591
+ return this.matchVehicle(text, score)?.name ?? normalizePlate$1(text);
15983
17592
  }
15984
17593
  async processFrame(input) {
15985
17594
  const minConfidence = input.minConfidence ?? 0;
@@ -16040,6 +17649,15 @@ var PlateRecognizer = class {
16040
17649
  ...crop !== void 0 ? { crop } : {}
16041
17650
  });
16042
17651
  }
17652
+ /**
17653
+ * Best OCR read score for the track's held plate (`plateText.confidence`),
17654
+ * or undefined when no plausible plate was read. Read at close BEFORE
17655
+ * {@link onTrackEnd} drops the per-track state (the NC `minLabelConfidence`
17656
+ * seam — mirrors the face recognizer's identity-match confidence).
17657
+ */
17658
+ bestLabelMatchConfidence(trackId) {
17659
+ return this.bestPlate.get(trackId)?.score;
17660
+ }
16043
17661
  /** Persist the held best plate for a finished track as one PlateStore row
16044
17662
  * (crop → MediaStore under ownerKind 'plate'), then drop in-memory state. */
16045
17663
  async onTrackEnd(deviceId, trackId) {
@@ -16145,6 +17763,47 @@ var PlateRecognizer = class {
16145
17763
  }
16146
17764
  };
16147
17765
  //#endregion
17766
+ //#region src/shared/frame/keyframe-wide-thumbnail.ts
17767
+ /**
17768
+ * 16:9 central-square subject crop out of a DECODED (raw RGB) full frame — the
17769
+ * close-time keyFrame→`thumbnail` derive (#27-A degenerate-retry fix, part 3).
17770
+ *
17771
+ * The live best-shot path fetches the 16:9 central-square WINDOW from the
17772
+ * runner's retained NATIVE surface ({@link wideCentralSquareLayout} +
17773
+ * {@link composeWideCentralSquareThumbnail} — see
17774
+ * `services/event-media-dispatcher.cropSubjectVariants`). At track close that
17775
+ * surface is long gone; the only durable full frame is the persisted native
17776
+ * `keyFrame` JPEG. This helper applies the SAME framing to that decoded frame:
17777
+ * normalized subject bbox → pixel bbox in the frame's OWN space → central-square
17778
+ * 16:9 layout → extract the in-frame slab → compose (lateral ambience fill for
17779
+ * any out-of-frame part). Same containment guarantee, same variants downstream
17780
+ * (`deriveThumbnailSmall` runs on the returned window).
17781
+ *
17782
+ * Shaped as a `cropRegionToJpeg` drop-in for {@link createKeyFrameCrop} (same
17783
+ * signature as the plain `extractCrop` composition used for plate crops), so
17784
+ * the close-time derive reuses the EXISTING keyframe-crop utility — media
17785
+ * fetch, temporal-skew guard and decode included — with only the framing
17786
+ * swapped.
17787
+ */
17788
+ /**
17789
+ * Compose the 16:9 central-square `thumbnail` window for a normalized subject
17790
+ * region of a raw RGB frame. Returns the composed JPEG (never upscaled — the
17791
+ * window is at the frame's native scale).
17792
+ */
17793
+ async function cropWideCentralSquareFromRgb(rgb, frameWidth, frameHeight, norm) {
17794
+ const layout = wideCentralSquareLayout({
17795
+ x: norm.x * frameWidth,
17796
+ y: norm.y * frameHeight,
17797
+ w: norm.w * frameWidth,
17798
+ h: norm.h * frameHeight
17799
+ }, {
17800
+ W: frameWidth,
17801
+ H: frameHeight
17802
+ });
17803
+ const { crop: slab } = await extractCrop(rgb, frameWidth, frameHeight, layout.fetch);
17804
+ return composeWideCentralSquareThumbnail(slab, layout);
17805
+ }
17806
+ //#endregion
16148
17807
  //#region src/pipeline-analytics/pipeline/event-child-crops.ts
16149
17808
  /**
16150
17809
  * Compute Intersection-over-Union between two (x,y,w,h) bounding boxes.
@@ -16710,6 +18369,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
16710
18369
  * path). Null on non-post-processing nodes or when disabled. */
16711
18370
  embeddingDispatcher = null;
16712
18371
  bindingCache = null;
18372
+ /** Per-device throttle for the inactive-binding frame-drop warn (5 min). */
18373
+ inactiveBindingDropWarnAt = /* @__PURE__ */ new Map();
16713
18374
  zoneAnalytics = null;
16714
18375
  audioMetrics = null;
16715
18376
  /**
@@ -16842,40 +18503,67 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
16842
18503
  return this._captureScheduler;
16843
18504
  }
16844
18505
  get trackCloser() {
16845
- if (!this._trackCloser) this._trackCloser = new TrackCloser({
16846
- logger: this.ctx.logger,
16847
- residents: this.residents,
16848
- trackStore: () => this.trackStore,
16849
- mediaStore: () => this.mediaStore,
16850
- eventStore: () => this.eventStore,
16851
- faceRecognizer: () => this.faceRecognizer,
16852
- plateRecognizer: () => this.plateRecognizer,
16853
- detailDispatcher: () => this.detailDispatcher,
16854
- overlayState: this.overlayState,
16855
- mediaCaptureLog: this.mediaCaptureLog,
16856
- frameDims: (deviceId) => this.lastFrameDimsByDevice.get(deviceId),
16857
- isShuttingDown: () => this.shuttingDown,
16858
- emitTrackEnded: (data, timestampMs) => {
16859
- this.ctx.eventBus.emit({
16860
- id: `pa-end-${data.trackId}`,
16861
- timestamp: new Date(timestampMs),
16862
- source: {
16863
- type: "addon",
16864
- id: "pipeline-analytics",
16865
- addonId: "pipeline-analytics"
16866
- },
16867
- category: require_dist.EventCategory.PipelineAnalyticsTrackEnded,
16868
- data: {
16869
- deviceId: data.deviceId,
16870
- trackId: data.trackId,
16871
- className: data.className,
16872
- durationMs: data.durationMs
16873
- }
16874
- });
16875
- },
16876
- emitTrackLifecycle: (payload, timestampMs) => this.emitTrackLifecycle(payload, timestampMs),
16877
- onTrackClosed: (track, ownedMedia, info) => this.notificationCenter?.onTrackClosed(track, ownedMedia, info)
16878
- });
18506
+ if (!this._trackCloser) {
18507
+ const deriveKeyFrameThumbnailJpeg = createKeyFrameCrop({
18508
+ getMedia: async (mediaKey) => {
18509
+ const m = await this.mediaStore?.getByKey(mediaKey);
18510
+ return m ? {
18511
+ base64: m.base64,
18512
+ timestamp: m.timestamp
18513
+ } : null;
18514
+ },
18515
+ decodeJpegToRgb: (base64) => decodeJpegToRgb(base64),
18516
+ cropRegionToJpeg: (bytes, w, h, norm) => cropWideCentralSquareFromRgb(bytes, w, h, norm),
18517
+ maxSkewMs: KEYFRAME_CROP_MAX_SKEW_MS,
18518
+ logger: this.ctx.logger.child("CloseKeyFrameThumbnail")
18519
+ });
18520
+ this._trackCloser = new TrackCloser({
18521
+ logger: this.ctx.logger,
18522
+ residents: this.residents,
18523
+ trackStore: () => this.trackStore,
18524
+ mediaStore: () => this.mediaStore,
18525
+ eventStore: () => this.eventStore,
18526
+ faceRecognizer: () => this.faceRecognizer,
18527
+ plateRecognizer: () => this.plateRecognizer,
18528
+ detailDispatcher: () => this.detailDispatcher,
18529
+ overlayState: this.overlayState,
18530
+ mediaCaptureLog: this.mediaCaptureLog,
18531
+ frameDims: (deviceId) => this.lastFrameDimsByDevice.get(deviceId),
18532
+ isShuttingDown: () => this.shuttingDown,
18533
+ emitTrackEnded: (data, timestampMs) => {
18534
+ this.ctx.eventBus.emit({
18535
+ id: `pa-end-${data.trackId}`,
18536
+ timestamp: new Date(timestampMs),
18537
+ source: {
18538
+ type: "addon",
18539
+ id: "pipeline-analytics",
18540
+ addonId: "pipeline-analytics"
18541
+ },
18542
+ category: require_dist.EventCategory.PipelineAnalyticsTrackEnded,
18543
+ data: {
18544
+ deviceId: data.deviceId,
18545
+ trackId: data.trackId,
18546
+ className: data.className,
18547
+ durationMs: data.durationMs
18548
+ }
18549
+ });
18550
+ },
18551
+ emitTrackLifecycle: (payload, timestampMs) => this.emitTrackLifecycle(payload, timestampMs),
18552
+ onTrackClosed: (track, ownedMedia, info) => this.notificationCenter?.onTrackClosed(track, ownedMedia, info),
18553
+ deriveThumbnailFromKeyFrame: async (input) => {
18554
+ const derived = await deriveKeyFrameThumbnailJpeg({
18555
+ ...input,
18556
+ padding: 0
18557
+ });
18558
+ if (!derived) return null;
18559
+ return {
18560
+ thumbnail: derived.jpeg,
18561
+ thumbnailSmall: await deriveThumbnailSmall(derived.jpeg),
18562
+ skewMs: derived.skewMs
18563
+ };
18564
+ }
18565
+ });
18566
+ }
16879
18567
  return this._trackCloser;
16880
18568
  }
16881
18569
  /** Master toggle for the migrated object/face EmbeddingDispatcher (default
@@ -16953,7 +18641,33 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
16953
18641
  if (this.isPostProcessingNode) await this.startEmbeddingDispatcher(logger, transport);
16954
18642
  this.startSweepTimers();
16955
18643
  this.ctx.logger.info("pipeline-analytics subscribers installed");
16956
- return this.buildProviderRegistrations(api, stores, capProviders);
18644
+ const providers = this.buildProviderRegistrations(api, stores, capProviders);
18645
+ const isHub = ownNodeId === "hub";
18646
+ const ncHandlers = isHub ? this.buildNcActionHandlers(api) : void 0;
18647
+ return {
18648
+ providers,
18649
+ ...isHub && ncHandlers !== void 0 ? {
18650
+ customActions: ncActions,
18651
+ actionHandlers: ncHandlers
18652
+ } : {}
18653
+ };
18654
+ }
18655
+ /**
18656
+ * Build the `nc.*` bridge handlers over the rule store, resolving the
18657
+ * caller-owned target set from the notifiers target catalog (a target is
18658
+ * owned when its open `config.ownerUserId` blob matches the caller). Returns
18659
+ * `undefined` when the Notification Center is absent (never built).
18660
+ */
18661
+ buildNcActionHandlers(api) {
18662
+ const center = this.notificationCenter;
18663
+ if (center === null) return void 0;
18664
+ return makeNcActionHandlers({
18665
+ ruleStore: center.ruleStore,
18666
+ logger: this.ctx.logger.child("nc-actions"),
18667
+ listCallerTargetIds: async (userId) => {
18668
+ return (await api.notificationOutput.listTargets.query({})).filter((t) => t.config["ownerUserId"] === userId).map((t) => t.id);
18669
+ }
18670
+ });
16957
18671
  }
16958
18672
  /** Declare typed collections up-front so the first insert doesn't race with
16959
18673
  * a CREATE TABLE. Idempotent. */
@@ -16981,7 +18695,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
16981
18695
  let storage = this.ctx.kernel.storage;
16982
18696
  const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
16983
18697
  if (mediaRoot) {
16984
- const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-AdCpDGpz.js"));
18698
+ const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-DsYWuKgE.js"));
16985
18699
  storage = new FilesystemStorageProvider(mediaRoot);
16986
18700
  logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
16987
18701
  }
@@ -17078,6 +18792,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
17078
18792
  resolveZones: (deviceId) => this.resolveDeviceZones(deviceId),
17079
18793
  resolvePackageRules: (deviceId) => this.resolveDevicePackageRules(deviceId),
17080
18794
  resolveSettings: (deviceId) => this.resolveDevicePackageDropSettings(deviceId),
18795
+ onPersisted: (ev, phase) => this.notificationCenter?.onPackageEventPersisted(ev, phase),
17081
18796
  onError: (scope, err) => logger.warn("package-drop detector error", { meta: {
17082
18797
  scope,
17083
18798
  error: require_dist.errMsg(err)
@@ -17236,7 +18951,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
17236
18951
  fetchDevice: (deviceId) => this.ctx.fetchDevice(deviceId),
17237
18952
  listStationaryObjects: (deviceId) => this.stationaryRegistry?.listViews(deviceId) ?? [],
17238
18953
  listStationaryDeviceIds: () => this.stationaryRegistry?.deviceIds() ?? [],
17239
- resolveZones: (deviceId) => this.resolveDeviceZones(deviceId)
18954
+ resolveZones: (deviceId) => this.resolveDeviceZones(deviceId),
18955
+ onSnapshot: (deviceId, snapshot) => this.notificationCenter?.observeOccupancy(deviceId, snapshot)
17240
18956
  });
17241
18957
  this.zoneAnalytics = zoneAnalytics;
17242
18958
  const audioMetrics = new AudioMetricsProvider({
@@ -17583,7 +19299,20 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
17583
19299
  */
17584
19300
  async processFrame(deviceId, frame, source, frameHandle, detailSteps) {
17585
19301
  if (this.shuttingDown) return;
17586
- if (!await this.bindingCache.isActive(deviceId)) return;
19302
+ if (!await this.bindingCache.isActive(deviceId)) {
19303
+ const now = Date.now();
19304
+ if (now - (this.inactiveBindingDropWarnAt.get(deviceId) ?? 0) >= 3e5) {
19305
+ this.inactiveBindingDropWarnAt.set(deviceId, now);
19306
+ this.ctx.logger.warn("inference frames dropped — pipeline-analytics binding inactive", {
19307
+ tags: { deviceId },
19308
+ meta: {
19309
+ source,
19310
+ detections: frame.detections.length
19311
+ }
19312
+ });
19313
+ }
19314
+ return;
19315
+ }
17587
19316
  const key = this.procKey(deviceId, source);
17588
19317
  const trk = await this.resolveDeviceTrackingSettings(deviceId);
17589
19318
  const activeCount = this.lastActiveTrackIds.get(key)?.size ?? 0;
@@ -17689,7 +19418,10 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
17689
19418
  source,
17690
19419
  resurrected: true
17691
19420
  } });
17692
- if (this.eventMediaDispatcher) this.residents.setLastFrameAt(deviceId, id, result.timestamp);
19421
+ if (this.eventMediaDispatcher) {
19422
+ this.residents.setLastFrameAt(deviceId, id, result.timestamp);
19423
+ if (!this.residents.isFirstFrameLanded(id) && !this.residents.isFirstFramePending(id)) this.residents.markFirstFramePending(deviceId, id);
19424
+ }
17693
19425
  continue;
17694
19426
  }
17695
19427
  bornCandidates.push({
@@ -17876,8 +19608,6 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
17876
19608
  else plateCrops += 1;
17877
19609
  const snapshotTargets = this.buildSnapshotTargets(deviceId, result.tracked, result.timestamp, mediaSettings, result.frameWidth, result.frameHeight);
17878
19610
  for (const retry of this.collectFirstFrameRetries(deviceId, result.tracked, result.timestamp, firstFrameTargets)) firstFrameTargets.push(retry);
17879
- const keyFrameTrackIds = selectKeyFrameTrackIds(snapshotTargets);
17880
- if (keyFrameTrackIds.length > 0) this.persistKeyFrames(deviceId, result.timestamp, keyFrameTrackIds, frameHandle);
17881
19611
  const targetedThisFrame = new Set([...firstFrameTargets.map((t) => t.trackId), ...snapshotTargets.map((s) => s.trackId)]);
17882
19612
  const rasterFallbackCandidates = [];
17883
19613
  const widenedRasterWantedIds = /* @__PURE__ */ new Set();
@@ -17923,6 +19653,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
17923
19653
  for (const t of firstFrameTargets) if (!this.residents.closure(t.trackId)?.rasterFallback) rasterFallbackWantedTrackIds.add(t.trackId);
17924
19654
  for (const s of snapshotTargets) if (!this.residents.closure(s.trackId)?.rasterFallback) rasterFallbackWantedTrackIds.add(s.trackId);
17925
19655
  const dispatcher = this.eventMediaDispatcher;
19656
+ const pinnedFramePromise = dispatcher.resolvePinnedFrame(deviceId, frameHandle);
17926
19657
  this.captureScheduler.request({
17927
19658
  deviceId,
17928
19659
  kind: deriveFrameDispatchKind({
@@ -17932,7 +19663,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
17932
19663
  hasSnapshot: snapshotTargets.some((t) => t.appendSnapshot)
17933
19664
  }),
17934
19665
  holdKeys: heldCaptureKeys,
17935
- exec: () => dispatcher.captureForFrame({
19666
+ exec: () => pinnedFramePromise.then((pinnedFrame) => dispatcher.captureForFrame({
17936
19667
  deviceId,
17937
19668
  frameHandle,
17938
19669
  events: eventTargets,
@@ -17940,8 +19671,9 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
17940
19671
  snapshots: snapshotTargets,
17941
19672
  cropPadding: mediaSettings.cropPadding,
17942
19673
  rasterFallbackWantedTrackIds,
17943
- rasterFallbackCandidates
17944
- }).then((res) => {
19674
+ rasterFallbackCandidates,
19675
+ ...pinnedFrame !== null ? { pinnedFrame } : {}
19676
+ })).then((res) => {
17945
19677
  for (const rf of res.rasterFallbacks) this.residents.retainRasterFallback(deviceId, rf.trackId, {
17946
19678
  jpeg: rf.jpeg,
17947
19679
  timestamp: rf.timestamp
@@ -17957,11 +19689,16 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
17957
19689
  mediaKey: s.mediaKey
17958
19690
  });
17959
19691
  for (const trackId of res.thumbnailTrackIds) this.residents.markThumbnailLanded(deviceId, trackId);
17960
- for (const trackId of res.firstFrameTrackIds) this.residents.clearFirstFramePending(trackId);
19692
+ for (const trackId of res.firstFrameTrackIds) {
19693
+ this.residents.clearFirstFramePending(trackId);
19694
+ this.residents.markFirstFrameLanded(deviceId, trackId);
19695
+ }
17961
19696
  for (const trackId of res.lastFrameTrackIds) this.residents.setLastFrameAt(deviceId, trackId, dispatchTimestamp);
17962
19697
  })
17963
19698
  }).catch(() => {});
17964
19699
  }
19700
+ const keyFrameTrackIds = selectKeyFrameTrackIds(snapshotTargets);
19701
+ if (keyFrameTrackIds.length > 0) this.persistKeyFrames(deviceId, result.timestamp, keyFrameTrackIds, frameHandle);
17965
19702
  }
17966
19703
  if (this.faceRecognizer && faceGloballyEnabled && faceSettings) this.faceRecognizer.processFrame({
17967
19704
  deviceId,
@@ -18307,7 +20044,18 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
18307
20044
  if (!await this.resolveGlobalFaceEnabled()) return;
18308
20045
  const [settings, media] = await Promise.all([this.resolveDeviceFaceSettings(deviceId), this.resolveDeviceMediaSettings(deviceId)]);
18309
20046
  const faceShortSidePx = detail.nativeFaceShortSidePx ?? (detail.bbox !== void 0 ? Math.min(detail.bbox.w, detail.bbox.h) : void 0);
18310
- if (faceShortSidePx !== void 0 && faceShortSidePx < settings.minFacePx) return;
20047
+ if (faceShortSidePx !== void 0 && faceShortSidePx < settings.minFacePx) {
20048
+ this.ctx.logger.debug("face detail below minFacePx — dropped", {
20049
+ tags: { deviceId },
20050
+ meta: {
20051
+ trackId,
20052
+ faceShortSidePx: Math.round(faceShortSidePx),
20053
+ minFacePx: settings.minFacePx,
20054
+ gatedOn: detail.nativeFaceShortSidePx !== void 0 ? "native" : "bbox"
20055
+ }
20056
+ });
20057
+ return;
20058
+ }
18311
20059
  await this.faceRecognizer.ingestFaceDetail({
18312
20060
  deviceId,
18313
20061
  trackId,
@@ -18318,6 +20066,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
18318
20066
  embedding: decodeEmbeddingBase64(detail.embedding),
18319
20067
  parentBbox: { ...frame.bbox },
18320
20068
  ...detail.bbox !== void 0 ? { faceBbox: { ...detail.bbox } } : {},
20069
+ ...detail.nativeFaceShortSidePx !== void 0 ? { nativeFaceShortSidePx: detail.nativeFaceShortSidePx } : {},
18321
20070
  ...detail.alignedCropJpeg !== void 0 ? { alignedCropJpeg: detail.alignedCropJpeg } : {},
18322
20071
  settings,
18323
20072
  cropPadding: media.cropPadding,
@@ -18680,8 +20429,18 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
18680
20429
  });
18681
20430
  const rollingLastFrame = plan.rollingLastFrame && !this.captureScheduler.isKeyActive(captureCoalesceKey(deviceId, "lastFrame", t.trackId));
18682
20431
  if (plan.appendSnapshot) this.trackStore.markSnapshotPending(t.trackId, timestamp, t.bbox);
18683
- const plausibleBox = isPlausibleThumbnailBox(t.bbox, frameWidth, frameHeight);
18684
- const bestThumbnail = plan.bestThumbnail && plausibleBox && !this.captureScheduler.isKeyActive(captureCoalesceKey(deviceId, "thumbnail", t.trackId));
20432
+ const plausibleBox = isPlausibleThumbnailBox(t.bbox, frameWidth, frameHeight, t.confidence);
20433
+ if (isNewBest && plausibleBox) this.residents.recordBestSeenBbox(deviceId, t.trackId, {
20434
+ x: t.bbox.x,
20435
+ y: t.bbox.y,
20436
+ w: t.bbox.w,
20437
+ h: t.bbox.h,
20438
+ frameWidth,
20439
+ frameHeight,
20440
+ timestamp
20441
+ });
20442
+ const retryBoxPlausible = isNewBest || isPlausibleRetryBox(t.bbox, this.residents.bestSeenBbox(t.trackId) ?? null);
20443
+ const bestThumbnail = plan.bestThumbnail && plausibleBox && retryBoxPlausible && !this.captureScheduler.isKeyActive(captureCoalesceKey(deviceId, "thumbnail", t.trackId));
18685
20444
  const keyFrame = isNewBest && plausibleBox;
18686
20445
  if (!plan.appendSnapshot && !rollingLastFrame && !bestThumbnail && !keyFrame) continue;
18687
20446
  targets.push({
@@ -18748,6 +20507,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
18748
20507
  atMs: timestamp
18749
20508
  });
18750
20509
  await this.eventStore.insertAudio(ev);
20510
+ this.notificationCenter?.onAudioEventPersisted(ev);
18751
20511
  this.trackStore?.addAudioLabelEpisode(deviceId, route.className, topClassification.score, timestamp);
18752
20512
  this.ctx.eventBus.emit({
18753
20513
  id: `pa-${ev.id}`,
@@ -19381,7 +21141,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
19381
21141
  try {
19382
21142
  await ingestSensorStateChange({
19383
21143
  sink: store,
19384
- cache
21144
+ cache,
21145
+ onPersisted: (ev) => this.notificationCenter?.onSensorEventPersisted(ev)
19385
21146
  }, data, timestamp);
19386
21147
  } catch (err) {
19387
21148
  this.ctx.logger.warn("sensor-event ingest failed", {
@@ -19901,7 +21662,9 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
19901
21662
  };
19902
21663
  //#endregion
19903
21664
  exports.DETECTION_PIPELINE_SECTION_IDS = DETECTION_PIPELINE_SECTION_IDS;
21665
+ exports.customActions = ncActions;
19904
21666
  exports.default = PipelineAnalyticsAddon;
21667
+ exports.ncActions = ncActions;
19905
21668
  exports.pickCleanMedia = pickCleanMedia;
19906
21669
  exports.retagDetectionSections = retagDetectionSections;
19907
21670
  exports.stripGlobalOnlyFields = stripGlobalOnlyFields;