@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.
- package/dist/{dist-BW-IoQDL.js → dist-Bezh0l-m.js} +1905 -1457
- package/dist/{dist-D5fcvkof.mjs → dist-Dm1I_IVp.mjs} +1388 -988
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/{node-AdCpDGpz.js → node-DsYWuKgE.js} +1 -1
- package/dist/pipeline-analytics/_stub.js +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-6_8G5GUI.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-DX9SlV1u.mjs} +2 -2
- package/dist/pipeline-analytics/{_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CDgDsFf_.mjs → _virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CbYZbG3H.mjs} +1 -1
- package/dist/pipeline-analytics/{hostInit-DAKHmSht.mjs → hostInit-DQ6-5P8q.mjs} +2 -2
- package/dist/pipeline-analytics/index.js +2475 -712
- package/dist/pipeline-analytics/index.mjs +2473 -712
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
|
@@ -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-
|
|
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
|
|
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/
|
|
3046
|
+
//#region src/pipeline-analytics/store/zone-geometry.ts
|
|
3025
3047
|
/**
|
|
3026
|
-
*
|
|
3027
|
-
*
|
|
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
|
|
3034
|
-
|
|
3035
|
-
let
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
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
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
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
|
-
*
|
|
3053
|
-
*
|
|
3054
|
-
*
|
|
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
|
|
3058
|
-
if (
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
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
|
-
|
|
3074
|
-
|
|
3104
|
+
minX,
|
|
3105
|
+
minY,
|
|
3106
|
+
maxX,
|
|
3107
|
+
maxY
|
|
3075
3108
|
};
|
|
3076
3109
|
}
|
|
3077
|
-
/**
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
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.
|
|
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
|
-
*
|
|
3304
|
-
*
|
|
3305
|
-
*
|
|
3306
|
-
*
|
|
3307
|
-
*
|
|
3308
|
-
*
|
|
3309
|
-
*
|
|
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
|
|
3312
|
-
const
|
|
3313
|
-
if (!
|
|
3314
|
-
|
|
3315
|
-
|
|
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
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
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
|
-
|
|
3323
|
-
this.deps.
|
|
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 (
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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/
|
|
3646
|
-
var
|
|
3647
|
-
var
|
|
3648
|
-
|
|
3649
|
-
|
|
3650
|
-
|
|
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
|
-
|
|
4198
|
+
targetCacheTtlMs;
|
|
3693
4199
|
constructor(deps) {
|
|
3694
|
-
this.
|
|
3695
|
-
this.logger = deps.logger;
|
|
4200
|
+
this.deps = deps;
|
|
3696
4201
|
this.now = deps.now ?? (() => Date.now());
|
|
3697
|
-
this.
|
|
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
|
-
|
|
3708
|
-
|
|
3709
|
-
|
|
3710
|
-
|
|
3711
|
-
|
|
3712
|
-
|
|
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
|
|
3715
|
-
|
|
3716
|
-
|
|
4231
|
+
const result = await this.deps.send({
|
|
4232
|
+
addonId: target.addonId,
|
|
4233
|
+
targetId: target.id,
|
|
4234
|
+
notification
|
|
3717
4235
|
});
|
|
3718
|
-
|
|
3719
|
-
|
|
3720
|
-
|
|
3721
|
-
|
|
3722
|
-
|
|
3723
|
-
|
|
3724
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4252
|
+
return {
|
|
4253
|
+
ok: false,
|
|
4254
|
+
error: String(err),
|
|
4255
|
+
permanent: false
|
|
4256
|
+
};
|
|
3734
4257
|
}
|
|
3735
4258
|
}
|
|
3736
|
-
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
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
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
|
|
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
|
-
|
|
3760
|
-
|
|
3761
|
-
const
|
|
3762
|
-
|
|
3763
|
-
const
|
|
3764
|
-
|
|
3765
|
-
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
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
|
-
|
|
3777
|
-
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
|
|
3781
|
-
|
|
3782
|
-
|
|
3783
|
-
|
|
3784
|
-
|
|
3785
|
-
|
|
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.
|
|
3789
|
-
|
|
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
|
|
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/
|
|
4235
|
-
|
|
4236
|
-
var
|
|
4237
|
-
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
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
|
-
/**
|
|
4248
|
-
|
|
4249
|
-
|
|
4250
|
-
|
|
4251
|
-
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
|
|
4256
|
-
|
|
4257
|
-
|
|
4258
|
-
|
|
4259
|
-
|
|
4260
|
-
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
|
|
4264
|
-
|
|
4265
|
-
|
|
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
|
-
|
|
4291
|
-
|
|
4292
|
-
|
|
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
|
|
4295
|
-
|
|
4296
|
-
|
|
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.
|
|
4299
|
-
|
|
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
|
-
|
|
4309
|
-
|
|
4310
|
-
|
|
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
|
-
*
|
|
4329
|
-
*
|
|
4330
|
-
*
|
|
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
|
|
4334
|
-
|
|
4335
|
-
|
|
4336
|
-
|
|
4337
|
-
|
|
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
|
-
|
|
4347
|
-
|
|
4348
|
-
|
|
4349
|
-
|
|
4350
|
-
|
|
4351
|
-
|
|
4352
|
-
|
|
4353
|
-
|
|
4354
|
-
|
|
4355
|
-
|
|
4356
|
-
|
|
4357
|
-
|
|
4358
|
-
|
|
4359
|
-
|
|
4360
|
-
|
|
4361
|
-
|
|
4362
|
-
|
|
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
|
|
5201
|
+
return pruned;
|
|
4373
5202
|
}
|
|
4374
5203
|
};
|
|
4375
|
-
|
|
4376
|
-
|
|
5204
|
+
/** The persisted column map for a row (the `key` PK is passed separately). */
|
|
5205
|
+
function rowToValue(row) {
|
|
4377
5206
|
return {
|
|
4378
|
-
|
|
4379
|
-
|
|
4380
|
-
|
|
4381
|
-
|
|
4382
|
-
|
|
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
|
-
/**
|
|
4389
|
-
|
|
4390
|
-
|
|
4391
|
-
|
|
4392
|
-
|
|
4393
|
-
|
|
4394
|
-
|
|
4395
|
-
|
|
4396
|
-
|
|
4397
|
-
const
|
|
4398
|
-
|
|
4399
|
-
|
|
4400
|
-
|
|
4401
|
-
|
|
4402
|
-
const
|
|
4403
|
-
if (
|
|
4404
|
-
|
|
4405
|
-
|
|
4406
|
-
|
|
4407
|
-
|
|
4408
|
-
|
|
4409
|
-
|
|
4410
|
-
|
|
4411
|
-
|
|
4412
|
-
|
|
4413
|
-
|
|
4414
|
-
|
|
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.
|
|
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
|
|
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" : "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
...
|
|
4616
|
-
className: subject.classNames[0] ?? "
|
|
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)
|
|
4654
|
-
|
|
4655
|
-
|
|
4656
|
-
|
|
4657
|
-
|
|
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 === "
|
|
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
|
-
|
|
4722
|
-
|
|
4723
|
-
|
|
4724
|
-
*
|
|
4725
|
-
*
|
|
4726
|
-
|
|
4727
|
-
|
|
4728
|
-
|
|
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
|
-
*
|
|
4742
|
-
*
|
|
4743
|
-
*
|
|
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
|
|
6217
|
+
var RETRY_MIN_FRACTION_OF_BEST = .45;
|
|
4746
6218
|
/**
|
|
4747
|
-
*
|
|
4748
|
-
*
|
|
4749
|
-
*
|
|
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
|
|
4752
|
-
if (
|
|
4753
|
-
|
|
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.
|
|
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,
|
|
7452
|
+
const { api, logger } = deps;
|
|
5970
7453
|
const pipelineRunnerApi = api.pipelineRunner;
|
|
5971
|
-
const
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
*
|
|
6467
|
-
*
|
|
6468
|
-
*
|
|
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
|
|
6471
|
-
|
|
6472
|
-
|
|
6473
|
-
|
|
6474
|
-
|
|
6475
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6482
|
-
|
|
6483
|
-
|
|
6484
|
-
|
|
6485
|
-
|
|
6486
|
-
|
|
6487
|
-
*
|
|
6488
|
-
|
|
6489
|
-
|
|
6490
|
-
|
|
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
|
-
|
|
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: "
|
|
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
|
|
9751
|
-
*
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
9822
|
-
|
|
9823
|
-
|
|
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
|
-
|
|
11620
|
-
|
|
11621
|
-
|
|
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
|
|
11626
|
-
* space) for a face to be
|
|
11627
|
-
*
|
|
11628
|
-
*
|
|
11629
|
-
*
|
|
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
|
|
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
|
|
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)
|
|
14478
|
-
|
|
14479
|
-
|
|
14480
|
-
|
|
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
|
|
15979
|
-
*
|
|
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)
|
|
16846
|
-
|
|
16847
|
-
|
|
16848
|
-
|
|
16849
|
-
|
|
16850
|
-
|
|
16851
|
-
|
|
16852
|
-
|
|
16853
|
-
|
|
16854
|
-
|
|
16855
|
-
|
|
16856
|
-
|
|
16857
|
-
|
|
16858
|
-
|
|
16859
|
-
|
|
16860
|
-
|
|
16861
|
-
|
|
16862
|
-
|
|
16863
|
-
|
|
16864
|
-
|
|
16865
|
-
|
|
16866
|
-
|
|
16867
|
-
|
|
16868
|
-
|
|
16869
|
-
|
|
16870
|
-
|
|
16871
|
-
|
|
16872
|
-
|
|
16873
|
-
|
|
16874
|
-
|
|
16875
|
-
|
|
16876
|
-
|
|
16877
|
-
|
|
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
|
-
|
|
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-
|
|
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))
|
|
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)
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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
|
-
|
|
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;
|