@camstack/addon-post-analysis 1.1.29 → 1.1.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{dist-AFLbpmAs.js → dist-Cy4Tp-yp.js} +4897 -4646
- package/dist/{dist-CFjLqX2m.mjs → dist-D1cY_vlY.mjs} +4892 -4647
- package/dist/embedding-encoder/index.js +2 -2
- package/dist/embedding-encoder/index.mjs +2 -2
- package/dist/{node-DtltlqrH.js → node-qtD7WAqr.js} +1 -1
- package/dist/pipeline-analytics/_stub.js +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-D0V4LPAq.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-jTZY8fyP.mjs} +3 -3
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-D6C5R_2c.mjs +26 -0
- package/dist/pipeline-analytics/{hostInit-B71dWJNT.mjs → hostInit-TIRXUvf0.mjs} +3 -3
- package/dist/pipeline-analytics/index.js +1323 -73
- package/dist/pipeline-analytics/index.mjs +1322 -73
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DBgBsiUM.mjs +0 -26
|
@@ -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-Cy4Tp-yp.js");
|
|
6
6
|
let node_crypto = require("node:crypto");
|
|
7
7
|
let sharp = require("sharp");
|
|
8
8
|
sharp = require_dist.__toESM(sharp);
|
|
@@ -165,13 +165,13 @@ var CLASS_RANK_VEHICLE = .8;
|
|
|
165
165
|
var CLASS_RANK_ANIMAL = .5;
|
|
166
166
|
var CLASS_RANK_DEFAULT = .25;
|
|
167
167
|
var PERSON_CLASSES = new Set(["person", "face"]);
|
|
168
|
-
var VEHICLE_CLASSES = new Set([
|
|
168
|
+
var VEHICLE_CLASSES$1 = new Set([
|
|
169
169
|
"vehicle",
|
|
170
170
|
"car",
|
|
171
171
|
"truck",
|
|
172
172
|
"bus"
|
|
173
173
|
]);
|
|
174
|
-
var ANIMAL_CLASSES = new Set([
|
|
174
|
+
var ANIMAL_CLASSES$1 = new Set([
|
|
175
175
|
"animal",
|
|
176
176
|
"dog",
|
|
177
177
|
"cat"
|
|
@@ -187,8 +187,8 @@ function clamp01(x) {
|
|
|
187
187
|
function classRank(className) {
|
|
188
188
|
const c = className.toLowerCase();
|
|
189
189
|
if (PERSON_CLASSES.has(c)) return 1;
|
|
190
|
-
if (VEHICLE_CLASSES.has(c)) return CLASS_RANK_VEHICLE;
|
|
191
|
-
if (ANIMAL_CLASSES.has(c)) return CLASS_RANK_ANIMAL;
|
|
190
|
+
if (VEHICLE_CLASSES$1.has(c)) return CLASS_RANK_VEHICLE;
|
|
191
|
+
if (ANIMAL_CLASSES$1.has(c)) return CLASS_RANK_ANIMAL;
|
|
192
192
|
return CLASS_RANK_DEFAULT;
|
|
193
193
|
}
|
|
194
194
|
/**
|
|
@@ -1086,7 +1086,7 @@ function resolveDetectionLabel(input) {
|
|
|
1086
1086
|
//#endregion
|
|
1087
1087
|
//#region src/pipeline-analytics/pipeline/zones/geometry.ts
|
|
1088
1088
|
/** Ray-casting point-in-polygon test */
|
|
1089
|
-
function pointInPolygon(point, polygon) {
|
|
1089
|
+
function pointInPolygon$1(point, polygon) {
|
|
1090
1090
|
if (polygon.length < 3) return false;
|
|
1091
1091
|
let inside = false;
|
|
1092
1092
|
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
|
@@ -1599,7 +1599,7 @@ function bboxPolygonOverlap(bbox, polygon) {
|
|
|
1599
1599
|
const gridSize = 8;
|
|
1600
1600
|
let inside = 0;
|
|
1601
1601
|
const total = gridSize * gridSize;
|
|
1602
|
-
for (let row = 0; row < gridSize; row++) for (let col = 0; col < gridSize; col++) if (pointInPolygon({
|
|
1602
|
+
for (let row = 0; row < gridSize; row++) for (let col = 0; col < gridSize; col++) if (pointInPolygon$1({
|
|
1603
1603
|
x: bbox.x + (col + .5) * (bbox.w / gridSize),
|
|
1604
1604
|
y: bbox.y + (row + .5) * (bbox.h / gridSize)
|
|
1605
1605
|
}, polygon)) inside++;
|
|
@@ -1618,7 +1618,7 @@ function maskPolygonOverlap(mask, maskWidth, maskHeight, bbox, polygon, _frameWi
|
|
|
1618
1618
|
for (let my = 0; my < maskHeight; my++) for (let mx = 0; mx < maskWidth; mx++) {
|
|
1619
1619
|
if (mask[my * maskWidth + mx] === 0) continue;
|
|
1620
1620
|
totalMaskPixels++;
|
|
1621
|
-
if (pointInPolygon({
|
|
1621
|
+
if (pointInPolygon$1({
|
|
1622
1622
|
x: bbox.x + mx / maskWidth * bbox.w,
|
|
1623
1623
|
y: bbox.y + my / maskHeight * bbox.h
|
|
1624
1624
|
}, polygon)) insidePolygon++;
|
|
@@ -2501,6 +2501,14 @@ var StationaryObjectRegistry = class {
|
|
|
2501
2501
|
count(deviceId) {
|
|
2502
2502
|
return this.byDevice.get(deviceId)?.size ?? 0;
|
|
2503
2503
|
}
|
|
2504
|
+
/** Device ids that currently hold at least one parked entry — drives the
|
|
2505
|
+
* occupancy baseline sampler (a detached camera with parked cars still
|
|
2506
|
+
* gets a flat history baseline). */
|
|
2507
|
+
deviceIds() {
|
|
2508
|
+
const ids = [];
|
|
2509
|
+
for (const [deviceId, m] of this.byDevice) if (m.size > 0) ids.push(deviceId);
|
|
2510
|
+
return ids;
|
|
2511
|
+
}
|
|
2504
2512
|
/** Record that a frame was processed for a device — advances the OBSERVED
|
|
2505
2513
|
* clock that drives entry expiry in {@link sweep}. */
|
|
2506
2514
|
noteFrame(deviceId, timestamp) {
|
|
@@ -2720,6 +2728,26 @@ function rowToEntry(id, data) {
|
|
|
2720
2728
|
};
|
|
2721
2729
|
}
|
|
2722
2730
|
//#endregion
|
|
2731
|
+
//#region src/pipeline-analytics/pipeline/stationary/stationary-zones.ts
|
|
2732
|
+
/**
|
|
2733
|
+
* Zone ids whose 0–1 polygon contains the entry's normalised bbox centroid.
|
|
2734
|
+
* Empty when the entry has no frame dims (can't normalise) or no zone matches.
|
|
2735
|
+
*/
|
|
2736
|
+
function computeStationaryEntryZones(entry, zones) {
|
|
2737
|
+
if (entry.frameWidth <= 0 || entry.frameHeight <= 0 || zones.length === 0) return [];
|
|
2738
|
+
const centroidPx = bboxCentroid(entry.bbox);
|
|
2739
|
+
const point = {
|
|
2740
|
+
x: centroidPx.x / entry.frameWidth,
|
|
2741
|
+
y: centroidPx.y / entry.frameHeight
|
|
2742
|
+
};
|
|
2743
|
+
const matched = [];
|
|
2744
|
+
for (const zone of zones) {
|
|
2745
|
+
if (zone.polygon.length < 3) continue;
|
|
2746
|
+
if (pointInPolygon$1(point, zone.polygon)) matched.push(zone.id);
|
|
2747
|
+
}
|
|
2748
|
+
return matched;
|
|
2749
|
+
}
|
|
2750
|
+
//#endregion
|
|
2723
2751
|
//#region src/pipeline-analytics/pipeline/track-appearance.ts
|
|
2724
2752
|
/**
|
|
2725
2753
|
* Pure: no side effects. `continuing` = still active from last frame;
|
|
@@ -2932,11 +2960,196 @@ var BindingCache = class {
|
|
|
2932
2960
|
}
|
|
2933
2961
|
};
|
|
2934
2962
|
//#endregion
|
|
2963
|
+
//#region src/pipeline-analytics/store/recent-cursor.ts
|
|
2964
|
+
function encodeRecentCursor(cursor) {
|
|
2965
|
+
return Buffer.from(JSON.stringify({
|
|
2966
|
+
l: cursor.lastSeen,
|
|
2967
|
+
i: cursor.trackId
|
|
2968
|
+
}), "utf8").toString("base64url");
|
|
2969
|
+
}
|
|
2970
|
+
/**
|
|
2971
|
+
* Decode + validate an opaque cursor. Fails fast with a clear error on any
|
|
2972
|
+
* malformed input (bad base64, bad JSON, wrong field types) — a garbage
|
|
2973
|
+
* cursor must never silently degrade into a full-history first page.
|
|
2974
|
+
*/
|
|
2975
|
+
function decodeRecentCursor(raw) {
|
|
2976
|
+
let parsed;
|
|
2977
|
+
try {
|
|
2978
|
+
parsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
|
|
2979
|
+
} catch {
|
|
2980
|
+
throw new Error("listRecentTracks: malformed cursor");
|
|
2981
|
+
}
|
|
2982
|
+
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");
|
|
2983
|
+
return {
|
|
2984
|
+
lastSeen: parsed.l,
|
|
2985
|
+
trackId: parsed.i
|
|
2986
|
+
};
|
|
2987
|
+
}
|
|
2988
|
+
/** Comparator for the (lastSeen DESC, id DESC) total order. */
|
|
2989
|
+
function compareRecentDesc(a, b) {
|
|
2990
|
+
if (a.lastSeen !== b.lastSeen) return b.lastSeen - a.lastSeen;
|
|
2991
|
+
if (a.id === b.id) return 0;
|
|
2992
|
+
return a.id < b.id ? 1 : -1;
|
|
2993
|
+
}
|
|
2994
|
+
/** True when `row` sits strictly AFTER the cursor position in DESC order
|
|
2995
|
+
* (i.e. belongs to the next page). */
|
|
2996
|
+
function isAfterCursor(row, cursor) {
|
|
2997
|
+
if (row.lastSeen < cursor.lastSeen) return true;
|
|
2998
|
+
return row.lastSeen === cursor.lastSeen && row.id < cursor.trackId;
|
|
2999
|
+
}
|
|
3000
|
+
//#endregion
|
|
3001
|
+
//#region src/pipeline-analytics/store/zone-geometry.ts
|
|
3002
|
+
/**
|
|
3003
|
+
* Normalized min/max envelope over every position's bbox. Returns `null`
|
|
3004
|
+
* when the frame dimensions are unknown/degenerate or there are no
|
|
3005
|
+
* positions — the caller persists NULL envelope columns in that case.
|
|
3006
|
+
*/
|
|
3007
|
+
function computeTrackEnvelope(positions, frameWidth, frameHeight) {
|
|
3008
|
+
if (frameWidth === void 0 || frameHeight === void 0 || !(frameWidth > 0) || !(frameHeight > 0) || positions.length === 0) return null;
|
|
3009
|
+
let minX = Number.POSITIVE_INFINITY;
|
|
3010
|
+
let minY = Number.POSITIVE_INFINITY;
|
|
3011
|
+
let maxX = Number.NEGATIVE_INFINITY;
|
|
3012
|
+
let maxY = Number.NEGATIVE_INFINITY;
|
|
3013
|
+
for (const p of positions) {
|
|
3014
|
+
const x0 = p.bbox.x / frameWidth;
|
|
3015
|
+
const y0 = p.bbox.y / frameHeight;
|
|
3016
|
+
const x1 = (p.bbox.x + p.bbox.w) / frameWidth;
|
|
3017
|
+
const y1 = (p.bbox.y + p.bbox.h) / frameHeight;
|
|
3018
|
+
if (x0 < minX) minX = x0;
|
|
3019
|
+
if (y0 < minY) minY = y0;
|
|
3020
|
+
if (x1 > maxX) maxX = x1;
|
|
3021
|
+
if (y1 > maxY) maxY = y1;
|
|
3022
|
+
}
|
|
3023
|
+
return {
|
|
3024
|
+
minX,
|
|
3025
|
+
minY,
|
|
3026
|
+
maxX,
|
|
3027
|
+
maxY
|
|
3028
|
+
};
|
|
3029
|
+
}
|
|
3030
|
+
/**
|
|
3031
|
+
* Axis-aligned bounds of a zone filter (rect: itself; polygon: vertex
|
|
3032
|
+
* min/max). A degenerate polygon (< 3 points) yields the full frame so the
|
|
3033
|
+
* SQL prefilter never silently drops rows the precise test would keep.
|
|
3034
|
+
*/
|
|
3035
|
+
function zoneBounds(zone) {
|
|
3036
|
+
if (zone.kind === "rect") return {
|
|
3037
|
+
minX: zone.x,
|
|
3038
|
+
minY: zone.y,
|
|
3039
|
+
maxX: zone.x + zone.width,
|
|
3040
|
+
maxY: zone.y + zone.height
|
|
3041
|
+
};
|
|
3042
|
+
if (zone.points.length < 3) return {
|
|
3043
|
+
minX: 0,
|
|
3044
|
+
minY: 0,
|
|
3045
|
+
maxX: 1,
|
|
3046
|
+
maxY: 1
|
|
3047
|
+
};
|
|
3048
|
+
let minX = Number.POSITIVE_INFINITY;
|
|
3049
|
+
let minY = Number.POSITIVE_INFINITY;
|
|
3050
|
+
let maxX = Number.NEGATIVE_INFINITY;
|
|
3051
|
+
let maxY = Number.NEGATIVE_INFINITY;
|
|
3052
|
+
for (const p of zone.points) {
|
|
3053
|
+
if (p.x < minX) minX = p.x;
|
|
3054
|
+
if (p.y < minY) minY = p.y;
|
|
3055
|
+
if (p.x > maxX) maxX = p.x;
|
|
3056
|
+
if (p.y > maxY) maxY = p.y;
|
|
3057
|
+
}
|
|
3058
|
+
return {
|
|
3059
|
+
minX,
|
|
3060
|
+
minY,
|
|
3061
|
+
maxX,
|
|
3062
|
+
maxY
|
|
3063
|
+
};
|
|
3064
|
+
}
|
|
3065
|
+
/** Whether two axis-aligned envelopes overlap (touching edges count). */
|
|
3066
|
+
function envelopesOverlap(a, b) {
|
|
3067
|
+
return a.minX <= b.maxX && a.maxX >= b.minX && a.minY <= b.maxY && a.maxY >= b.minY;
|
|
3068
|
+
}
|
|
3069
|
+
/**
|
|
3070
|
+
* Ray-casting point-in-polygon (even-odd rule). Points on an edge may
|
|
3071
|
+
* resolve either way — acceptable for zone filtering. A polygon with
|
|
3072
|
+
* fewer than 3 vertices contains nothing.
|
|
3073
|
+
*/
|
|
3074
|
+
function pointInPolygon(point, polygon) {
|
|
3075
|
+
if (polygon.length < 3) return false;
|
|
3076
|
+
let inside = false;
|
|
3077
|
+
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
|
3078
|
+
const a = polygon[i];
|
|
3079
|
+
const b = polygon[j];
|
|
3080
|
+
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;
|
|
3081
|
+
}
|
|
3082
|
+
return inside;
|
|
3083
|
+
}
|
|
3084
|
+
/**
|
|
3085
|
+
* Precise per-position zone test.
|
|
3086
|
+
*
|
|
3087
|
+
* - rect zone → any position bbox (normalized) intersects the rect.
|
|
3088
|
+
* - polygon zone → any position CENTER (normalized `x`/`y` — positions
|
|
3089
|
+
* store the bbox center) falls inside the polygon.
|
|
3090
|
+
*
|
|
3091
|
+
* Unknown/degenerate frame dims → `true` (the track cannot be tested, so it
|
|
3092
|
+
* PASSES — mirroring the NULL-envelope-matches rule).
|
|
3093
|
+
*/
|
|
3094
|
+
function positionsIntersectZone(positions, frameWidth, frameHeight, zone) {
|
|
3095
|
+
if (frameWidth === void 0 || frameHeight === void 0 || !(frameWidth > 0) || !(frameHeight > 0)) return true;
|
|
3096
|
+
if (zone.kind === "rect") {
|
|
3097
|
+
const rect = zoneBounds(zone);
|
|
3098
|
+
for (const p of positions) if (envelopesOverlap({
|
|
3099
|
+
minX: p.bbox.x / frameWidth,
|
|
3100
|
+
minY: p.bbox.y / frameHeight,
|
|
3101
|
+
maxX: (p.bbox.x + p.bbox.w) / frameWidth,
|
|
3102
|
+
maxY: (p.bbox.y + p.bbox.h) / frameHeight
|
|
3103
|
+
}, rect)) return true;
|
|
3104
|
+
return false;
|
|
3105
|
+
}
|
|
3106
|
+
for (const p of positions) if (pointInPolygon({
|
|
3107
|
+
x: p.x / frameWidth,
|
|
3108
|
+
y: p.y / frameHeight
|
|
3109
|
+
}, zone.points)) return true;
|
|
3110
|
+
return false;
|
|
3111
|
+
}
|
|
3112
|
+
//#endregion
|
|
2935
3113
|
//#region src/pipeline-analytics/store/track-store.ts
|
|
2936
3114
|
var DEFAULT_CONFIG = {
|
|
2937
3115
|
ttlMs: 3e4,
|
|
2938
3116
|
maxPositionHistory: 300
|
|
2939
3117
|
};
|
|
3118
|
+
/** `queryRecent` page-size defaults (mirrors the cap input's bounds). */
|
|
3119
|
+
var RECENT_DEFAULT_LIMIT = 200;
|
|
3120
|
+
var RECENT_MAX_LIMIT = 1e3;
|
|
3121
|
+
/** Wide inclusive bound for the envelope-overlap `BETWEEN` prefilter.
|
|
3122
|
+
* Envelope values are normalized ~0..1 but a bbox can spill slightly past
|
|
3123
|
+
* the frame edge; ±1e6 keeps every real value inside the range while the
|
|
3124
|
+
* opposing bound does the actual overlap cut. */
|
|
3125
|
+
var ENV_RANGE_SLACK = 1e6;
|
|
3126
|
+
function rowMatchesZone(data, zone) {
|
|
3127
|
+
const fw = data["frameWidth"];
|
|
3128
|
+
const fh = data["frameHeight"];
|
|
3129
|
+
if (typeof fw !== "number" || typeof fh !== "number") return true;
|
|
3130
|
+
const positions = data["positions"];
|
|
3131
|
+
if (!Array.isArray(positions)) return true;
|
|
3132
|
+
const positionRows = [];
|
|
3133
|
+
for (const p of positions) {
|
|
3134
|
+
if (p === null || typeof p !== "object") continue;
|
|
3135
|
+
if (!("x" in p) || typeof p.x !== "number" || !("y" in p) || typeof p.y !== "number") continue;
|
|
3136
|
+
if (!("bbox" in p) || p.bbox === null || typeof p.bbox !== "object") continue;
|
|
3137
|
+
const b = p.bbox;
|
|
3138
|
+
if (!("x" in b) || typeof b.x !== "number" || !("y" in b) || typeof b.y !== "number") continue;
|
|
3139
|
+
if (!("w" in b) || typeof b.w !== "number" || !("h" in b) || typeof b.h !== "number") continue;
|
|
3140
|
+
positionRows.push({
|
|
3141
|
+
x: p.x,
|
|
3142
|
+
y: p.y,
|
|
3143
|
+
bbox: {
|
|
3144
|
+
x: b.x,
|
|
3145
|
+
y: b.y,
|
|
3146
|
+
w: b.w,
|
|
3147
|
+
h: b.h
|
|
3148
|
+
}
|
|
3149
|
+
});
|
|
3150
|
+
}
|
|
3151
|
+
return positionsIntersectZone(positionRows, fw, fh, zone);
|
|
3152
|
+
}
|
|
2940
3153
|
var TRACKS_COLLECTION = "pipeline-analytics:tracks";
|
|
2941
3154
|
var TRACKS_COLUMNS = [
|
|
2942
3155
|
{
|
|
@@ -3008,6 +3221,30 @@ var TRACKS_COLUMNS = [
|
|
|
3008
3221
|
{
|
|
3009
3222
|
name: "audioLabels",
|
|
3010
3223
|
type: "JSON"
|
|
3224
|
+
},
|
|
3225
|
+
{
|
|
3226
|
+
name: "envMinX",
|
|
3227
|
+
type: "REAL"
|
|
3228
|
+
},
|
|
3229
|
+
{
|
|
3230
|
+
name: "envMinY",
|
|
3231
|
+
type: "REAL"
|
|
3232
|
+
},
|
|
3233
|
+
{
|
|
3234
|
+
name: "envMaxX",
|
|
3235
|
+
type: "REAL"
|
|
3236
|
+
},
|
|
3237
|
+
{
|
|
3238
|
+
name: "envMaxY",
|
|
3239
|
+
type: "REAL"
|
|
3240
|
+
},
|
|
3241
|
+
{
|
|
3242
|
+
name: "frameWidth",
|
|
3243
|
+
type: "INTEGER"
|
|
3244
|
+
},
|
|
3245
|
+
{
|
|
3246
|
+
name: "frameHeight",
|
|
3247
|
+
type: "INTEGER"
|
|
3011
3248
|
}
|
|
3012
3249
|
];
|
|
3013
3250
|
var TRACKS_INDEXES = [{
|
|
@@ -3063,6 +3300,7 @@ var TrackStore = class {
|
|
|
3063
3300
|
config;
|
|
3064
3301
|
logger;
|
|
3065
3302
|
store;
|
|
3303
|
+
frameDims;
|
|
3066
3304
|
constructor(deps) {
|
|
3067
3305
|
this.logger = deps.logger;
|
|
3068
3306
|
this.store = deps.store;
|
|
@@ -3070,6 +3308,7 @@ var TrackStore = class {
|
|
|
3070
3308
|
...DEFAULT_CONFIG,
|
|
3071
3309
|
...deps.config
|
|
3072
3310
|
};
|
|
3311
|
+
this.frameDims = deps.frameDims;
|
|
3073
3312
|
}
|
|
3074
3313
|
/** One-time collection declaration. Call from addon onInitialize. */
|
|
3075
3314
|
static async declare(store) {
|
|
@@ -3416,21 +3655,200 @@ var TrackStore = class {
|
|
|
3416
3655
|
}
|
|
3417
3656
|
return [...seenDevices];
|
|
3418
3657
|
}
|
|
3419
|
-
/** Historical query — hits the persisted collection.
|
|
3658
|
+
/** Historical query — hits the persisted collection. With `zone` set,
|
|
3659
|
+
* candidates are SQL-prefiltered on the envelope columns (overlap test via
|
|
3660
|
+
* `whereBetween`), NULL-envelope rows are re-fetched separately (they must
|
|
3661
|
+
* still MATCH — `BETWEEN` excludes NULL), and survivors run the precise
|
|
3662
|
+
* per-position test against the zone. `projection: 'slim'` drops the heavy
|
|
3663
|
+
* `positions[]` / `snapshots[]` JSON from the returned rows (empty arrays);
|
|
3664
|
+
* the zone test still runs on the stored positions before the drop. */
|
|
3420
3665
|
async queryHistorical(params) {
|
|
3421
|
-
const
|
|
3422
|
-
|
|
3423
|
-
return (await this.store.query.query({
|
|
3666
|
+
const limit = params.limit ?? 50;
|
|
3667
|
+
const timeBetween = params.since !== void 0 || params.until !== void 0 ? { firstSeen: [params.since ?? 0, params.until ?? Date.now()] } : {};
|
|
3668
|
+
if (params.zone === void 0) return (await this.store.query.query({
|
|
3669
|
+
collection: TRACKS_COLLECTION,
|
|
3670
|
+
filter: {
|
|
3671
|
+
where: { deviceId: params.deviceId },
|
|
3672
|
+
...Object.keys(timeBetween).length > 0 ? { whereBetween: timeBetween } : {},
|
|
3673
|
+
orderBy: {
|
|
3674
|
+
field: "firstSeen",
|
|
3675
|
+
direction: "desc"
|
|
3676
|
+
},
|
|
3677
|
+
limit
|
|
3678
|
+
}
|
|
3679
|
+
})).map((r) => this.rowToTrack(r.id, r.data, params.projection));
|
|
3680
|
+
const zone = params.zone;
|
|
3681
|
+
const bounds = zoneBounds(zone);
|
|
3682
|
+
const overlapQuery = this.store.query.query({
|
|
3683
|
+
collection: TRACKS_COLLECTION,
|
|
3684
|
+
filter: {
|
|
3685
|
+
where: { deviceId: params.deviceId },
|
|
3686
|
+
whereBetween: {
|
|
3687
|
+
...timeBetween,
|
|
3688
|
+
envMinX: [-1e6, bounds.maxX],
|
|
3689
|
+
envMaxX: [bounds.minX, ENV_RANGE_SLACK],
|
|
3690
|
+
envMinY: [-1e6, bounds.maxY],
|
|
3691
|
+
envMaxY: [bounds.minY, ENV_RANGE_SLACK]
|
|
3692
|
+
},
|
|
3693
|
+
orderBy: {
|
|
3694
|
+
field: "firstSeen",
|
|
3695
|
+
direction: "desc"
|
|
3696
|
+
},
|
|
3697
|
+
limit
|
|
3698
|
+
}
|
|
3699
|
+
});
|
|
3700
|
+
const nullEnvQuery = this.store.query.query({
|
|
3424
3701
|
collection: TRACKS_COLLECTION,
|
|
3425
3702
|
filter: {
|
|
3426
|
-
|
|
3703
|
+
where: { deviceId: params.deviceId },
|
|
3704
|
+
...Object.keys(timeBetween).length > 0 ? { whereBetween: timeBetween } : {},
|
|
3427
3705
|
orderBy: {
|
|
3428
3706
|
field: "firstSeen",
|
|
3429
3707
|
direction: "desc"
|
|
3430
3708
|
},
|
|
3431
|
-
limit
|
|
3709
|
+
limit
|
|
3710
|
+
}
|
|
3711
|
+
});
|
|
3712
|
+
const [overlapRows, windowRows] = await Promise.all([overlapQuery, nullEnvQuery]);
|
|
3713
|
+
const candidates = /* @__PURE__ */ new Map();
|
|
3714
|
+
for (const r of overlapRows) if (typeof r.data["envMinX"] === "number") candidates.set(r.id, r.data);
|
|
3715
|
+
for (const r of windowRows) if (r.data["envMinX"] === null || r.data["envMinX"] === void 0) candidates.set(r.id, r.data);
|
|
3716
|
+
const matched = [];
|
|
3717
|
+
for (const [id, data] of candidates) if (rowMatchesZone(data, zone)) matched.push({
|
|
3718
|
+
id,
|
|
3719
|
+
data
|
|
3720
|
+
});
|
|
3721
|
+
matched.sort((a, b) => Number(b.data["firstSeen"] ?? 0) - Number(a.data["firstSeen"] ?? 0));
|
|
3722
|
+
return matched.slice(0, limit).map((r) => this.rowToTrack(r.id, r.data, params.projection));
|
|
3723
|
+
}
|
|
3724
|
+
/**
|
|
3725
|
+
* Batched multi-device recent-tracks page (`listRecentTracks`): the
|
|
3726
|
+
* persisted completed tracks of every requested device, merged and ordered
|
|
3727
|
+
* by (`lastSeen` DESC, `trackId` DESC) with a stable opaque cursor.
|
|
3728
|
+
*
|
|
3729
|
+
* Approach — per-device indexed page + k-way merge (documented for the
|
|
3730
|
+
* cap): each device is fetched with ONE indexed query on
|
|
3731
|
+
* `idx_tracks_device_lastSeen` (`WHERE deviceId = ? AND lastSeen BETWEEN
|
|
3732
|
+
* ? AND ? ORDER BY lastSeen DESC LIMIT limit+1`), then the pages are
|
|
3733
|
+
* merged in memory and cut to `limit`. At events-page cardinalities
|
|
3734
|
+
* (≤ dozens of devices × ≤ 1000 rows) the in-memory merge is negligible
|
|
3735
|
+
* next to the row I/O, and every row fetched is a candidate (no scan
|
|
3736
|
+
* waste). The +1 overfetch makes `nextCursor` exact: when the merged
|
|
3737
|
+
* candidate set exceeds `limit` more rows are KNOWN to exist; when it
|
|
3738
|
+
* does not, every device returned fewer rows than asked for and is
|
|
3739
|
+
* therefore exhausted — so the final page always ends with
|
|
3740
|
+
* `nextCursor: null` (no empty trailing page).
|
|
3741
|
+
*
|
|
3742
|
+
* Cursor correctness: SQL can only bound `lastSeen`, and rows sharing the
|
|
3743
|
+
* cursor's exact millisecond have no defined SQL order — so a cursor page
|
|
3744
|
+
* runs TWO ranges per device: an exhaustive same-millisecond tie query
|
|
3745
|
+
* (`lastSeen = cursor.lastSeen`, id tie-break applied in memory) plus the
|
|
3746
|
+
* strictly-older indexed page (`lastSeen ≤ cursor.lastSeen - 1`). Tie
|
|
3747
|
+
* clusters are same-ms track expiries on one camera — physically tiny —
|
|
3748
|
+
* so the unbounded tie query stays O(1) in practice. Known accepted edge:
|
|
3749
|
+
* on a NON-cursor page, a same-ms tie cluster straddling a device's
|
|
3750
|
+
* `limit+1` SQL cut could omit a tie row (needs > limit rows sharing one
|
|
3751
|
+
* millisecond on one camera — unreachable at these cardinalities).
|
|
3752
|
+
*
|
|
3753
|
+
* Errors propagate (no partial merges): a failed device query fails the
|
|
3754
|
+
* page rather than silently returning an incomplete window.
|
|
3755
|
+
*/
|
|
3756
|
+
async queryRecent(params) {
|
|
3757
|
+
const limit = Math.min(Math.max(params.limit ?? RECENT_DEFAULT_LIMIT, 1), RECENT_MAX_LIMIT);
|
|
3758
|
+
const deviceIds = [...new Set(params.deviceIds)];
|
|
3759
|
+
if (deviceIds.length === 0) return {
|
|
3760
|
+
tracks: [],
|
|
3761
|
+
nextCursor: null
|
|
3762
|
+
};
|
|
3763
|
+
const cursor = params.cursor !== void 0 ? decodeRecentCursor(params.cursor) : null;
|
|
3764
|
+
const lo = params.since ?? 0;
|
|
3765
|
+
const winHi = params.until ?? Number.MAX_SAFE_INTEGER;
|
|
3766
|
+
const hi = cursor !== null ? Math.min(cursor.lastSeen, winHi) : winHi;
|
|
3767
|
+
if (hi < lo) return {
|
|
3768
|
+
tracks: [],
|
|
3769
|
+
nextCursor: null
|
|
3770
|
+
};
|
|
3771
|
+
const fetchRange = async (deviceId, range, pageLimit) => this.store.query.query({
|
|
3772
|
+
collection: TRACKS_COLLECTION,
|
|
3773
|
+
filter: {
|
|
3774
|
+
where: { deviceId },
|
|
3775
|
+
whereBetween: { lastSeen: range },
|
|
3776
|
+
orderBy: {
|
|
3777
|
+
field: "lastSeen",
|
|
3778
|
+
direction: "desc"
|
|
3779
|
+
},
|
|
3780
|
+
...pageLimit !== void 0 ? { limit: pageLimit } : {}
|
|
3432
3781
|
}
|
|
3433
|
-
})
|
|
3782
|
+
});
|
|
3783
|
+
const perDevice = await Promise.all(deviceIds.map(async (deviceId) => {
|
|
3784
|
+
if (cursor === null || cursor.lastSeen > hi) return fetchRange(deviceId, [lo, hi], limit + 1);
|
|
3785
|
+
const tieRange = [cursor.lastSeen, cursor.lastSeen];
|
|
3786
|
+
const belowHi = cursor.lastSeen - 1;
|
|
3787
|
+
const [ties, below] = await Promise.all([cursor.lastSeen >= lo ? fetchRange(deviceId, tieRange) : Promise.resolve([]), belowHi >= lo ? fetchRange(deviceId, [lo, belowHi], limit + 1) : Promise.resolve([])]);
|
|
3788
|
+
return [...ties, ...below];
|
|
3789
|
+
}));
|
|
3790
|
+
const candidates = [];
|
|
3791
|
+
for (const rows of perDevice) for (const r of rows) {
|
|
3792
|
+
const lastSeen = Number(r.data["lastSeen"] ?? 0);
|
|
3793
|
+
if (cursor !== null && !isAfterCursor({
|
|
3794
|
+
lastSeen,
|
|
3795
|
+
id: r.id
|
|
3796
|
+
}, cursor)) continue;
|
|
3797
|
+
candidates.push({
|
|
3798
|
+
id: r.id,
|
|
3799
|
+
lastSeen,
|
|
3800
|
+
data: r.data
|
|
3801
|
+
});
|
|
3802
|
+
}
|
|
3803
|
+
candidates.sort(compareRecentDesc);
|
|
3804
|
+
const page = candidates.slice(0, limit);
|
|
3805
|
+
const tracks = page.map((r) => this.rowToTrack(r.id, r.data, params.projection));
|
|
3806
|
+
const last = page[page.length - 1];
|
|
3807
|
+
return {
|
|
3808
|
+
tracks,
|
|
3809
|
+
nextCursor: candidates.length > limit && last !== void 0 ? encodeRecentCursor({
|
|
3810
|
+
lastSeen: last.lastSeen,
|
|
3811
|
+
trackId: last.id
|
|
3812
|
+
}) : null
|
|
3813
|
+
};
|
|
3814
|
+
}
|
|
3815
|
+
/**
|
|
3816
|
+
* Deduplicated detector class names observed on a device's RECENT persisted
|
|
3817
|
+
* tracks (one indexed page, `lastSeen` desc). Feeds `listEventKinds` — a
|
|
3818
|
+
* representative "what has this camera actually seen" set, not an exhaustive
|
|
3819
|
+
* all-time DISTINCT (the query cap has none). Unions the primary `className`
|
|
3820
|
+
* with the accumulated `classes` array. Best-effort: [] on error.
|
|
3821
|
+
*/
|
|
3822
|
+
async observedClassNames(deviceId, limit = 500) {
|
|
3823
|
+
try {
|
|
3824
|
+
const rows = await this.store.query.query({
|
|
3825
|
+
collection: TRACKS_COLLECTION,
|
|
3826
|
+
filter: {
|
|
3827
|
+
where: { deviceId },
|
|
3828
|
+
orderBy: {
|
|
3829
|
+
field: "lastSeen",
|
|
3830
|
+
direction: "desc"
|
|
3831
|
+
},
|
|
3832
|
+
limit
|
|
3833
|
+
}
|
|
3834
|
+
});
|
|
3835
|
+
const names = /* @__PURE__ */ new Set();
|
|
3836
|
+
for (const r of rows) {
|
|
3837
|
+
const className = r.data["className"];
|
|
3838
|
+
if (typeof className === "string" && className.length > 0) names.add(className);
|
|
3839
|
+
const classes = r.data["classes"];
|
|
3840
|
+
if (Array.isArray(classes)) {
|
|
3841
|
+
for (const c of classes) if (typeof c === "string" && c.length > 0) names.add(c);
|
|
3842
|
+
}
|
|
3843
|
+
}
|
|
3844
|
+
return [...names];
|
|
3845
|
+
} catch (err) {
|
|
3846
|
+
this.logger.warn("TrackStore.observedClassNames failed", { meta: {
|
|
3847
|
+
deviceId,
|
|
3848
|
+
error: String(err)
|
|
3849
|
+
} });
|
|
3850
|
+
return [];
|
|
3851
|
+
}
|
|
3434
3852
|
}
|
|
3435
3853
|
async getPersistedByTrackId(trackId) {
|
|
3436
3854
|
const records = await this.store.query.query({
|
|
@@ -3445,6 +3863,8 @@ var TrackStore = class {
|
|
|
3445
3863
|
return this.rowToTrack(row.id, row.data);
|
|
3446
3864
|
}
|
|
3447
3865
|
async persistCompleted(t) {
|
|
3866
|
+
const dims = this.frameDims?.(t.deviceId);
|
|
3867
|
+
const envelope = computeTrackEnvelope(t.positions, dims?.w, dims?.h);
|
|
3448
3868
|
await this.store.set.mutate({
|
|
3449
3869
|
collection: TRACKS_COLLECTION,
|
|
3450
3870
|
key: t.trackId,
|
|
@@ -3463,13 +3883,30 @@ var TrackStore = class {
|
|
|
3463
3883
|
...t.importance !== void 0 ? { importance: t.importance } : {},
|
|
3464
3884
|
...t.bestEventId !== void 0 ? { bestEventId: t.bestEventId } : {},
|
|
3465
3885
|
...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {},
|
|
3466
|
-
...t.audioLabels !== void 0 ? { audioLabels: [...t.audioLabels] } : {}
|
|
3886
|
+
...t.audioLabels !== void 0 ? { audioLabels: [...t.audioLabels] } : {},
|
|
3887
|
+
...envelope !== null && dims !== void 0 ? {
|
|
3888
|
+
envMinX: envelope.minX,
|
|
3889
|
+
envMinY: envelope.minY,
|
|
3890
|
+
envMaxX: envelope.maxX,
|
|
3891
|
+
envMaxY: envelope.maxY,
|
|
3892
|
+
frameWidth: dims.w,
|
|
3893
|
+
frameHeight: dims.h
|
|
3894
|
+
} : {}
|
|
3467
3895
|
}
|
|
3468
3896
|
});
|
|
3469
3897
|
}
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
3898
|
+
/**
|
|
3899
|
+
* Map a persisted row onto the cap `Track` shape. `projection: 'slim'`
|
|
3900
|
+
* drops the heavy `positions[]` / `snapshots[]` JSON (returned as empty
|
|
3901
|
+
* arrays — they are required on the schema) while keeping every scalar
|
|
3902
|
+
* the list surfaces render; `full` (default) is byte-compatible with the
|
|
3903
|
+
* pre-projection behaviour. The persisted envelope columns surface as the
|
|
3904
|
+
* optional `envelope` object in BOTH projections (four light numbers).
|
|
3905
|
+
*/
|
|
3906
|
+
rowToTrack(id, data, projection) {
|
|
3907
|
+
const slim = projection === "slim";
|
|
3908
|
+
const positions = slim ? [] : data["positions"] ?? [];
|
|
3909
|
+
const snapshots = slim ? [] : data["snapshots"] ?? [];
|
|
3473
3910
|
const zones = data["zonesVisited"] ?? [];
|
|
3474
3911
|
const classes = data["classes"];
|
|
3475
3912
|
const label = data["label"];
|
|
@@ -3477,6 +3914,16 @@ var TrackStore = class {
|
|
|
3477
3914
|
const bestEventId = data["bestEventId"];
|
|
3478
3915
|
const importanceReason = data["importanceReason"];
|
|
3479
3916
|
const audioLabels = data["audioLabels"];
|
|
3917
|
+
const envMinX = data["envMinX"];
|
|
3918
|
+
const envMinY = data["envMinY"];
|
|
3919
|
+
const envMaxX = data["envMaxX"];
|
|
3920
|
+
const envMaxY = data["envMaxY"];
|
|
3921
|
+
const envelope = typeof envMinX === "number" && typeof envMinY === "number" && typeof envMaxX === "number" && typeof envMaxY === "number" ? {
|
|
3922
|
+
minX: envMinX,
|
|
3923
|
+
minY: envMinY,
|
|
3924
|
+
maxX: envMaxX,
|
|
3925
|
+
maxY: envMaxY
|
|
3926
|
+
} : null;
|
|
3480
3927
|
return {
|
|
3481
3928
|
trackId: id,
|
|
3482
3929
|
deviceId: Number(data["deviceId"]),
|
|
@@ -3494,7 +3941,8 @@ var TrackStore = class {
|
|
|
3494
3941
|
...typeof importance === "number" ? { importance } : {},
|
|
3495
3942
|
...typeof bestEventId === "string" ? { bestEventId } : {},
|
|
3496
3943
|
...typeof importanceReason === "string" ? { importanceReason } : {},
|
|
3497
|
-
...Array.isArray(audioLabels) && audioLabels.length > 0 ? { audioLabels } : {}
|
|
3944
|
+
...Array.isArray(audioLabels) && audioLabels.length > 0 ? { audioLabels } : {},
|
|
3945
|
+
...envelope !== null ? { envelope } : {}
|
|
3498
3946
|
};
|
|
3499
3947
|
}
|
|
3500
3948
|
};
|
|
@@ -4605,6 +5053,384 @@ function stripNulls(data) {
|
|
|
4605
5053
|
return out;
|
|
4606
5054
|
}
|
|
4607
5055
|
//#endregion
|
|
5056
|
+
//#region src/pipeline-analytics/store/sensor-event-store.ts
|
|
5057
|
+
var SENSOR_EVENTS_COLLECTION = "pipeline-analytics:sensor-events";
|
|
5058
|
+
var SENSOR_EVENT_COLUMNS = [
|
|
5059
|
+
{
|
|
5060
|
+
name: "id",
|
|
5061
|
+
type: "TEXT",
|
|
5062
|
+
primaryKey: true,
|
|
5063
|
+
notNull: true
|
|
5064
|
+
},
|
|
5065
|
+
(
|
|
5066
|
+
/** The CAMERA the event is attributed to. */
|
|
5067
|
+
{
|
|
5068
|
+
name: "deviceId",
|
|
5069
|
+
type: "INTEGER",
|
|
5070
|
+
notNull: true
|
|
5071
|
+
}),
|
|
5072
|
+
(
|
|
5073
|
+
/** The linked sensor device whose state changed. */
|
|
5074
|
+
{
|
|
5075
|
+
name: "sourceDeviceId",
|
|
5076
|
+
type: "INTEGER",
|
|
5077
|
+
notNull: true
|
|
5078
|
+
}),
|
|
5079
|
+
(
|
|
5080
|
+
/** Event kind id (matches an `EventKindDescriptor.kind`). */
|
|
5081
|
+
{
|
|
5082
|
+
name: "kind",
|
|
5083
|
+
type: "TEXT",
|
|
5084
|
+
notNull: true
|
|
5085
|
+
}),
|
|
5086
|
+
(
|
|
5087
|
+
/** Snapshot of the sensor cap's runtime-state slice at the change. */
|
|
5088
|
+
{
|
|
5089
|
+
name: "value",
|
|
5090
|
+
type: "JSON"
|
|
5091
|
+
}),
|
|
5092
|
+
{
|
|
5093
|
+
name: "timestamp",
|
|
5094
|
+
type: "INTEGER",
|
|
5095
|
+
notNull: true
|
|
5096
|
+
}
|
|
5097
|
+
];
|
|
5098
|
+
var SENSOR_EVENT_INDEXES = [{
|
|
5099
|
+
name: "idx_sensor_events_device_ts",
|
|
5100
|
+
columns: ["deviceId", "timestamp"]
|
|
5101
|
+
}];
|
|
5102
|
+
var DEFAULT_QUERY_LIMIT = 1e3;
|
|
5103
|
+
var SensorEventStore = class {
|
|
5104
|
+
store;
|
|
5105
|
+
logger;
|
|
5106
|
+
constructor(deps) {
|
|
5107
|
+
this.store = deps.store;
|
|
5108
|
+
this.logger = deps.logger;
|
|
5109
|
+
}
|
|
5110
|
+
/** One-time collection declaration. Call from addon onInitialize. */
|
|
5111
|
+
static async declare(store) {
|
|
5112
|
+
await store.declareCollection.mutate({
|
|
5113
|
+
collection: SENSOR_EVENTS_COLLECTION,
|
|
5114
|
+
columns: [...SENSOR_EVENT_COLUMNS],
|
|
5115
|
+
indexes: [...SENSOR_EVENT_INDEXES]
|
|
5116
|
+
});
|
|
5117
|
+
}
|
|
5118
|
+
/** Insert one attributed sensor event. Best-effort (telemetry-lossy). */
|
|
5119
|
+
async insert(ev) {
|
|
5120
|
+
try {
|
|
5121
|
+
await this.store.insert.mutate({
|
|
5122
|
+
collection: SENSOR_EVENTS_COLLECTION,
|
|
5123
|
+
record: {
|
|
5124
|
+
id: ev.id,
|
|
5125
|
+
data: {
|
|
5126
|
+
deviceId: ev.deviceId,
|
|
5127
|
+
sourceDeviceId: ev.sourceDeviceId,
|
|
5128
|
+
kind: ev.kind,
|
|
5129
|
+
value: ev.value,
|
|
5130
|
+
timestamp: ev.timestamp
|
|
5131
|
+
}
|
|
5132
|
+
}
|
|
5133
|
+
});
|
|
5134
|
+
} catch (err) {
|
|
5135
|
+
this.logger.warn("SensorEventStore.insert failed", {
|
|
5136
|
+
tags: { deviceId: ev.deviceId },
|
|
5137
|
+
meta: {
|
|
5138
|
+
eventId: ev.id,
|
|
5139
|
+
error: String(err)
|
|
5140
|
+
}
|
|
5141
|
+
});
|
|
5142
|
+
}
|
|
5143
|
+
}
|
|
5144
|
+
/** Per-camera sensor-event history, newest first. Mirrors the
|
|
5145
|
+
* motion/object/audio query semantics; `kinds` narrows via `whereIn`. */
|
|
5146
|
+
async query(q) {
|
|
5147
|
+
const filter = {
|
|
5148
|
+
where: { deviceId: q.deviceId },
|
|
5149
|
+
orderBy: {
|
|
5150
|
+
field: "timestamp",
|
|
5151
|
+
direction: "desc"
|
|
5152
|
+
},
|
|
5153
|
+
limit: q.limit ?? DEFAULT_QUERY_LIMIT
|
|
5154
|
+
};
|
|
5155
|
+
if (q.since !== void 0 || q.until !== void 0) filter["whereBetween"] = { timestamp: [q.since ?? 0, q.until ?? Date.now()] };
|
|
5156
|
+
if (q.kinds !== void 0 && q.kinds.length > 0) filter["whereIn"] = { kind: [...q.kinds] };
|
|
5157
|
+
return (await this.store.query.query({
|
|
5158
|
+
collection: SENSOR_EVENTS_COLLECTION,
|
|
5159
|
+
filter
|
|
5160
|
+
})).map((r) => rowToSensorEvent(r.id, r.data));
|
|
5161
|
+
}
|
|
5162
|
+
/**
|
|
5163
|
+
* Delete every row with `timestamp ≤ cutoffMs`, draining a page at a time
|
|
5164
|
+
* (mirrors `EventStore.evictBefore`, including the infinite-loop guard).
|
|
5165
|
+
* Returns the number of rows deleted. Rides the analytics retention sweep.
|
|
5166
|
+
*/
|
|
5167
|
+
async evictBefore(cutoffMs) {
|
|
5168
|
+
let deleted = 0;
|
|
5169
|
+
for (;;) {
|
|
5170
|
+
const rows = await this.store.query.query({
|
|
5171
|
+
collection: SENSOR_EVENTS_COLLECTION,
|
|
5172
|
+
filter: {
|
|
5173
|
+
whereBetween: { timestamp: [0, cutoffMs] },
|
|
5174
|
+
limit: EVICT_PAGE_SIZE
|
|
5175
|
+
}
|
|
5176
|
+
});
|
|
5177
|
+
if (rows.length === 0) break;
|
|
5178
|
+
let deletedInPage = 0;
|
|
5179
|
+
for (const row of rows) {
|
|
5180
|
+
if (typeof row.id !== "string") continue;
|
|
5181
|
+
try {
|
|
5182
|
+
await this.store.delete.mutate({
|
|
5183
|
+
collection: SENSOR_EVENTS_COLLECTION,
|
|
5184
|
+
key: row.id
|
|
5185
|
+
});
|
|
5186
|
+
deleted++;
|
|
5187
|
+
deletedInPage++;
|
|
5188
|
+
} catch {}
|
|
5189
|
+
}
|
|
5190
|
+
if (deletedInPage === 0) break;
|
|
5191
|
+
}
|
|
5192
|
+
return deleted;
|
|
5193
|
+
}
|
|
5194
|
+
};
|
|
5195
|
+
/** Page size for the eviction drain loop (mirrors EventStore.PRUNE_PAGE_SIZE). */
|
|
5196
|
+
var EVICT_PAGE_SIZE = 500;
|
|
5197
|
+
function rowToSensorEvent(id, data) {
|
|
5198
|
+
const value = data["value"];
|
|
5199
|
+
return {
|
|
5200
|
+
id,
|
|
5201
|
+
deviceId: Number(data["deviceId"]),
|
|
5202
|
+
sourceDeviceId: Number(data["sourceDeviceId"]),
|
|
5203
|
+
kind: String(data["kind"]),
|
|
5204
|
+
value: isRecord(value) ? value : null,
|
|
5205
|
+
timestamp: Number(data["timestamp"])
|
|
5206
|
+
};
|
|
5207
|
+
}
|
|
5208
|
+
function isRecord(x) {
|
|
5209
|
+
return x !== null && typeof x === "object" && !Array.isArray(x);
|
|
5210
|
+
}
|
|
5211
|
+
//#endregion
|
|
5212
|
+
//#region src/pipeline-analytics/services/event-kinds.ts
|
|
5213
|
+
/**
|
|
5214
|
+
* Extensible per-device event kinds (Part B).
|
|
5215
|
+
*
|
|
5216
|
+
* `composeEventKinds` builds the `listEventKinds` payload for a camera:
|
|
5217
|
+
* (a) built-ins — motion + audio, always present;
|
|
5218
|
+
* (b) detection classes actually OBSERVED on the device (track history);
|
|
5219
|
+
* (c) sensor kinds contributed by LINKED devices (device-manager
|
|
5220
|
+
* `getLinkedDevices`), one descriptor per bound sensor cap present in
|
|
5221
|
+
* the static `EVENT_KIND_BY_CAP` map. Binding-driven per linked device
|
|
5222
|
+
* (`getBindings`) — never a global cap enumeration (D12).
|
|
5223
|
+
*
|
|
5224
|
+
* `LinkedCamerasCache` is the ingest-side reverse index (sensor device →
|
|
5225
|
+
* linked camera ids) with a TTL, so the `DeviceStateChanged` handler stays
|
|
5226
|
+
* cheap at bus rate.
|
|
5227
|
+
*/
|
|
5228
|
+
var MOTION_COLOR = "#f59e0b";
|
|
5229
|
+
var AUDIO_COLOR = "#06b6d4";
|
|
5230
|
+
var PERSON_COLOR = "#22c55e";
|
|
5231
|
+
var VEHICLE_COLOR = "#3b82f6";
|
|
5232
|
+
var ANIMAL_COLOR = "#f97316";
|
|
5233
|
+
var GENERIC_DETECTION_COLOR = "#64748b";
|
|
5234
|
+
var VEHICLE_CLASSES = new Set([
|
|
5235
|
+
"vehicle",
|
|
5236
|
+
"car",
|
|
5237
|
+
"truck",
|
|
5238
|
+
"bus",
|
|
5239
|
+
"motorcycle",
|
|
5240
|
+
"bicycle",
|
|
5241
|
+
"boat",
|
|
5242
|
+
"train"
|
|
5243
|
+
]);
|
|
5244
|
+
var ANIMAL_CLASSES = new Set([
|
|
5245
|
+
"animal",
|
|
5246
|
+
"dog",
|
|
5247
|
+
"cat",
|
|
5248
|
+
"bird",
|
|
5249
|
+
"horse",
|
|
5250
|
+
"cow",
|
|
5251
|
+
"sheep"
|
|
5252
|
+
]);
|
|
5253
|
+
function detectionIcon(className) {
|
|
5254
|
+
if (className === "person") return "person";
|
|
5255
|
+
if (VEHICLE_CLASSES.has(className)) return "vehicle";
|
|
5256
|
+
if (ANIMAL_CLASSES.has(className)) return "animal";
|
|
5257
|
+
return "generic";
|
|
5258
|
+
}
|
|
5259
|
+
function detectionColor(className) {
|
|
5260
|
+
if (className === "person") return PERSON_COLOR;
|
|
5261
|
+
if (VEHICLE_CLASSES.has(className)) return VEHICLE_COLOR;
|
|
5262
|
+
if (ANIMAL_CLASSES.has(className)) return ANIMAL_COLOR;
|
|
5263
|
+
return GENERIC_DETECTION_COLOR;
|
|
5264
|
+
}
|
|
5265
|
+
function titleCase(s) {
|
|
5266
|
+
return s.length > 0 ? s.charAt(0).toUpperCase() + s.slice(1) : s;
|
|
5267
|
+
}
|
|
5268
|
+
/**
|
|
5269
|
+
* Full event-kind list for a camera. Sensor kinds are deduped per
|
|
5270
|
+
* (kind, source deviceId) — two linked contact sensors each contribute
|
|
5271
|
+
* their own entry, distinguishable by `source.deviceId`.
|
|
5272
|
+
*/
|
|
5273
|
+
async function composeEventKinds(deps, deviceId) {
|
|
5274
|
+
const out = [{
|
|
5275
|
+
kind: "motion",
|
|
5276
|
+
label: "Motion",
|
|
5277
|
+
color: MOTION_COLOR,
|
|
5278
|
+
icon: "motion",
|
|
5279
|
+
category: "motion",
|
|
5280
|
+
source: {
|
|
5281
|
+
capName: "pipeline-analytics",
|
|
5282
|
+
deviceId
|
|
5283
|
+
}
|
|
5284
|
+
}, {
|
|
5285
|
+
kind: "audio",
|
|
5286
|
+
label: "Audio",
|
|
5287
|
+
color: AUDIO_COLOR,
|
|
5288
|
+
icon: "audio",
|
|
5289
|
+
category: "audio",
|
|
5290
|
+
source: {
|
|
5291
|
+
capName: "pipeline-analytics",
|
|
5292
|
+
deviceId
|
|
5293
|
+
}
|
|
5294
|
+
}];
|
|
5295
|
+
try {
|
|
5296
|
+
const classNames = await deps.observedClassNames(deviceId);
|
|
5297
|
+
for (const className of [...classNames].sort()) out.push({
|
|
5298
|
+
kind: className,
|
|
5299
|
+
label: titleCase(className),
|
|
5300
|
+
color: detectionColor(className),
|
|
5301
|
+
icon: detectionIcon(className),
|
|
5302
|
+
category: "detection",
|
|
5303
|
+
source: {
|
|
5304
|
+
capName: "pipeline-analytics",
|
|
5305
|
+
deviceId
|
|
5306
|
+
}
|
|
5307
|
+
});
|
|
5308
|
+
} catch (err) {
|
|
5309
|
+
deps.onError?.("observedClassNames", err);
|
|
5310
|
+
}
|
|
5311
|
+
try {
|
|
5312
|
+
const { devices } = await deps.linkedDevices.getLinkedDevices({ deviceId });
|
|
5313
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5314
|
+
for (const linked of devices) {
|
|
5315
|
+
let capNames;
|
|
5316
|
+
try {
|
|
5317
|
+
const { entries } = await deps.bindings.getBindings({ deviceId: linked.deviceId });
|
|
5318
|
+
capNames = entries.map((e) => e.capName);
|
|
5319
|
+
} catch (err) {
|
|
5320
|
+
deps.onError?.("getBindings", err);
|
|
5321
|
+
continue;
|
|
5322
|
+
}
|
|
5323
|
+
for (const capName of capNames) {
|
|
5324
|
+
const descriptor = require_dist.EVENT_KIND_BY_CAP[capName];
|
|
5325
|
+
if (descriptor === void 0) continue;
|
|
5326
|
+
const dedupeKey = `${descriptor.kind}:${linked.deviceId}`;
|
|
5327
|
+
if (seen.has(dedupeKey)) continue;
|
|
5328
|
+
seen.add(dedupeKey);
|
|
5329
|
+
out.push({
|
|
5330
|
+
kind: descriptor.kind,
|
|
5331
|
+
label: descriptor.label,
|
|
5332
|
+
color: descriptor.color,
|
|
5333
|
+
icon: descriptor.icon,
|
|
5334
|
+
category: descriptor.category,
|
|
5335
|
+
source: {
|
|
5336
|
+
capName,
|
|
5337
|
+
deviceId: linked.deviceId
|
|
5338
|
+
}
|
|
5339
|
+
});
|
|
5340
|
+
}
|
|
5341
|
+
}
|
|
5342
|
+
} catch (err) {
|
|
5343
|
+
deps.onError?.("getLinkedDevices", err);
|
|
5344
|
+
}
|
|
5345
|
+
return out;
|
|
5346
|
+
}
|
|
5347
|
+
var DEFAULT_CACHE_TTL_MS = 6e4;
|
|
5348
|
+
/**
|
|
5349
|
+
* TTL-cached reverse index: source deviceId → camera ids it is linked to.
|
|
5350
|
+
* Rebuilds lazily (single-flight) when stale, so the `DeviceStateChanged`
|
|
5351
|
+
* handler pays one map lookup per event in the common case.
|
|
5352
|
+
*/
|
|
5353
|
+
var LinkedCamerasCache = class {
|
|
5354
|
+
deps;
|
|
5355
|
+
ttlMs;
|
|
5356
|
+
index = /* @__PURE__ */ new Map();
|
|
5357
|
+
/** Ms timestamp of the last build; null = never built / invalidated. */
|
|
5358
|
+
builtAt = null;
|
|
5359
|
+
building = null;
|
|
5360
|
+
constructor(deps) {
|
|
5361
|
+
this.deps = deps;
|
|
5362
|
+
this.ttlMs = deps.ttlMs ?? DEFAULT_CACHE_TTL_MS;
|
|
5363
|
+
}
|
|
5364
|
+
/** Camera ids linked to `sourceDeviceId` ([] when none). */
|
|
5365
|
+
async camerasFor(sourceDeviceId, nowMs = Date.now()) {
|
|
5366
|
+
if (this.builtAt === null || nowMs - this.builtAt >= this.ttlMs) {
|
|
5367
|
+
this.building ??= this.rebuild(nowMs).finally(() => {
|
|
5368
|
+
this.building = null;
|
|
5369
|
+
});
|
|
5370
|
+
await this.building;
|
|
5371
|
+
}
|
|
5372
|
+
return this.index.get(sourceDeviceId) ?? [];
|
|
5373
|
+
}
|
|
5374
|
+
/** Drop the cached index (e.g. on link-topology change events). */
|
|
5375
|
+
invalidate() {
|
|
5376
|
+
this.builtAt = null;
|
|
5377
|
+
}
|
|
5378
|
+
/** Test/maintenance hook: replace the index directly. */
|
|
5379
|
+
seed(index, builtAt) {
|
|
5380
|
+
this.index = new Map(index);
|
|
5381
|
+
this.builtAt = builtAt;
|
|
5382
|
+
}
|
|
5383
|
+
async rebuild(nowMs) {
|
|
5384
|
+
try {
|
|
5385
|
+
const cameraIds = await this.deps.cameras.listCameraIds();
|
|
5386
|
+
const next = /* @__PURE__ */ new Map();
|
|
5387
|
+
for (const cameraId of cameraIds) try {
|
|
5388
|
+
const { devices } = await this.deps.linkedDevices.getLinkedDevices({ deviceId: cameraId });
|
|
5389
|
+
for (const d of devices) {
|
|
5390
|
+
const list = next.get(d.deviceId);
|
|
5391
|
+
if (list === void 0) next.set(d.deviceId, [cameraId]);
|
|
5392
|
+
else if (!list.includes(cameraId)) list.push(cameraId);
|
|
5393
|
+
}
|
|
5394
|
+
} catch (err) {
|
|
5395
|
+
this.deps.onError?.("getLinkedDevices", err);
|
|
5396
|
+
}
|
|
5397
|
+
this.index = next;
|
|
5398
|
+
this.builtAt = nowMs;
|
|
5399
|
+
} catch (err) {
|
|
5400
|
+
this.deps.onError?.("listCameraIds", err);
|
|
5401
|
+
this.builtAt = nowMs;
|
|
5402
|
+
}
|
|
5403
|
+
}
|
|
5404
|
+
};
|
|
5405
|
+
/**
|
|
5406
|
+
* One `DeviceStateChanged` → N history rows (one per linked camera). The
|
|
5407
|
+
* EVENT_KIND_BY_CAP gate exits first so non-sensor cap churn costs one map
|
|
5408
|
+
* lookup. Returns the number of rows inserted (0 when unmapped/unlinked).
|
|
5409
|
+
* Telemetry-lossy by design (D8) — inserts are best-effort.
|
|
5410
|
+
*/
|
|
5411
|
+
async function ingestSensorStateChange(deps, data, timestamp) {
|
|
5412
|
+
const descriptor = require_dist.EVENT_KIND_BY_CAP[data.capName];
|
|
5413
|
+
if (descriptor === void 0) return 0;
|
|
5414
|
+
const cameraIds = await deps.cache.camerasFor(data.deviceId);
|
|
5415
|
+
if (cameraIds.length === 0) return 0;
|
|
5416
|
+
const slice = data.slice;
|
|
5417
|
+
const value = slice !== null && slice !== void 0 && typeof slice === "object" && !Array.isArray(slice) ? slice : null;
|
|
5418
|
+
const makeId = deps.makeId ?? (() => `pa-sensor-${(0, node_crypto.randomUUID)()}`);
|
|
5419
|
+
let inserted = 0;
|
|
5420
|
+
for (const cameraId of cameraIds) {
|
|
5421
|
+
await deps.sink.insert({
|
|
5422
|
+
id: makeId(),
|
|
5423
|
+
deviceId: cameraId,
|
|
5424
|
+
sourceDeviceId: data.deviceId,
|
|
5425
|
+
kind: descriptor.kind,
|
|
5426
|
+
value,
|
|
5427
|
+
timestamp
|
|
5428
|
+
});
|
|
5429
|
+
inserted++;
|
|
5430
|
+
}
|
|
5431
|
+
return inserted;
|
|
5432
|
+
}
|
|
5433
|
+
//#endregion
|
|
4608
5434
|
//#region src/shared/frame/resolve-frame.ts
|
|
4609
5435
|
/**
|
|
4610
5436
|
* Resolve the pixels a `FrameHandle` refers to via the node-routed fetch.
|
|
@@ -4838,22 +5664,26 @@ var EventMediaDispatcher = class {
|
|
|
4838
5664
|
for (const tf of trackFrames) await this.writeTrackFrame(deviceId, frameData, fw, fh, tf);
|
|
4839
5665
|
const storedSnapshots = [];
|
|
4840
5666
|
for (const sn of snapshots) {
|
|
4841
|
-
const stored = await this.writeTrackSnapshot(deviceId, frameData, fw, fh, sn);
|
|
5667
|
+
const stored = await this.writeTrackSnapshot(deviceId, frameData, fw, fh, sn, input.cropPadding);
|
|
4842
5668
|
if (stored) storedSnapshots.push(stored);
|
|
4843
5669
|
}
|
|
4844
5670
|
return { storedSnapshots };
|
|
4845
5671
|
}
|
|
4846
5672
|
/**
|
|
4847
|
-
* Periodic per-track media (§5).
|
|
4848
|
-
*
|
|
4849
|
-
* (
|
|
4850
|
-
*
|
|
4851
|
-
*
|
|
5673
|
+
* Periodic per-track media (§5). The boxed FULL frame is encoded once and
|
|
5674
|
+
* shared by the appended `snapshot` (timeline filmstrip) and the rolling
|
|
5675
|
+
* `lastFrame` (overwrite). The best `thumbnail` is DIFFERENT: a clean
|
|
5676
|
+
* subject-centered crop (same output contract as the object-event `crop`
|
|
5677
|
+
* kind) — it is the gallery/reel fallback for tracks that never produced an
|
|
5678
|
+
* object event, and a full frame there shows the scene (e.g. a foreground
|
|
5679
|
+
* parked car), not the track's subject. Returns the appended snapshot for
|
|
5680
|
+
* TrackStore wiring (null when `appendSnapshot` is false or the encode
|
|
5681
|
+
* failed).
|
|
4852
5682
|
*/
|
|
4853
|
-
async writeTrackSnapshot(deviceId, frameData, fw, fh, sn) {
|
|
5683
|
+
async writeTrackSnapshot(deviceId, frameData, fw, fh, sn, cropPadding) {
|
|
4854
5684
|
if (!sn.appendSnapshot && !sn.rollingLastFrame && !sn.bestThumbnail) return null;
|
|
4855
|
-
let boxed;
|
|
4856
|
-
try {
|
|
5685
|
+
let boxed = null;
|
|
5686
|
+
if (sn.appendSnapshot || sn.rollingLastFrame) try {
|
|
4857
5687
|
boxed = await drawBoxedFrame(frameData, fw, fh, [{
|
|
4858
5688
|
...sn.bbox,
|
|
4859
5689
|
...sn.label ? { label: sn.label } : {}
|
|
@@ -4867,10 +5697,9 @@ var EventMediaDispatcher = class {
|
|
|
4867
5697
|
error: err instanceof Error ? err.message : String(err)
|
|
4868
5698
|
}
|
|
4869
5699
|
});
|
|
4870
|
-
return null;
|
|
4871
5700
|
}
|
|
4872
5701
|
let stored = null;
|
|
4873
|
-
if (sn.appendSnapshot) try {
|
|
5702
|
+
if (sn.appendSnapshot && boxed) try {
|
|
4874
5703
|
const mediaKey = await this.deps.mediaStore.put({
|
|
4875
5704
|
deviceId,
|
|
4876
5705
|
ownerKind: "track",
|
|
@@ -4886,10 +5715,49 @@ var EventMediaDispatcher = class {
|
|
|
4886
5715
|
bbox: sn.bbox
|
|
4887
5716
|
};
|
|
4888
5717
|
} catch {}
|
|
4889
|
-
if (sn.rollingLastFrame) await this.replaceKind(deviceId, sn.trackId, "lastFrame", sn.timestamp, boxed);
|
|
4890
|
-
if (sn.bestThumbnail)
|
|
5718
|
+
if (sn.rollingLastFrame && boxed) await this.replaceKind(deviceId, sn.trackId, "lastFrame", sn.timestamp, boxed);
|
|
5719
|
+
if (sn.bestThumbnail) try {
|
|
5720
|
+
const crop = await this.cropSubjectRegion(frameData, fw, fh, sn.bbox, cropPadding);
|
|
5721
|
+
await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, crop);
|
|
5722
|
+
} catch (err) {
|
|
5723
|
+
this.deps.logger.warn("event media: track thumbnail crop failed", {
|
|
5724
|
+
tags: { deviceId },
|
|
5725
|
+
meta: {
|
|
5726
|
+
deviceId,
|
|
5727
|
+
trackId: sn.trackId,
|
|
5728
|
+
error: err instanceof Error ? err.message : String(err)
|
|
5729
|
+
}
|
|
5730
|
+
});
|
|
5731
|
+
if (boxed) await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, boxed);
|
|
5732
|
+
}
|
|
4891
5733
|
return stored;
|
|
4892
5734
|
}
|
|
5735
|
+
/**
|
|
5736
|
+
* Clean subject-centered crop of `bbox` out of the raw frame — the shared
|
|
5737
|
+
* output contract of the object-event `crop` kind and the track `thumbnail`:
|
|
5738
|
+
* square-safe 16:9 region around the bbox, extracted from the ORIGINAL frame
|
|
5739
|
+
* (no box drawn), resized to 640×360, JPEG q80.
|
|
5740
|
+
*/
|
|
5741
|
+
async cropSubjectRegion(frameData, fw, fh, bbox, cropPadding) {
|
|
5742
|
+
const region = squareSafeCropRegion(bbox, {
|
|
5743
|
+
W: fw,
|
|
5744
|
+
H: fh
|
|
5745
|
+
}, cropPadding);
|
|
5746
|
+
const left = Math.max(0, Math.min(region.x, fw - 1));
|
|
5747
|
+
const top = Math.max(0, Math.min(region.y, fh - 1));
|
|
5748
|
+
const width = Math.max(1, Math.min(region.w, fw - left));
|
|
5749
|
+
const height = Math.max(1, Math.min(region.h, fh - top));
|
|
5750
|
+
return await (0, sharp.default)(frameData, { raw: {
|
|
5751
|
+
width: fw,
|
|
5752
|
+
height: fh,
|
|
5753
|
+
channels: 3
|
|
5754
|
+
} }).extract({
|
|
5755
|
+
left,
|
|
5756
|
+
top,
|
|
5757
|
+
width,
|
|
5758
|
+
height
|
|
5759
|
+
}).resize(CROP_WIDTH, CROP_HEIGHT).jpeg({ quality: CROP_QUALITY }).toBuffer();
|
|
5760
|
+
}
|
|
4893
5761
|
async replaceKind(deviceId, trackId, kind, timestamp, data) {
|
|
4894
5762
|
try {
|
|
4895
5763
|
await this.deps.mediaStore.putReplacing({
|
|
@@ -4917,24 +5785,7 @@ var EventMediaDispatcher = class {
|
|
|
4917
5785
|
label: caption(ev.className, ev.confidence, ev.label)
|
|
4918
5786
|
};
|
|
4919
5787
|
try {
|
|
4920
|
-
const
|
|
4921
|
-
W: fw,
|
|
4922
|
-
H: fh
|
|
4923
|
-
}, cropPadding);
|
|
4924
|
-
const left = Math.max(0, Math.min(region.x, fw - 1));
|
|
4925
|
-
const top = Math.max(0, Math.min(region.y, fh - 1));
|
|
4926
|
-
const width = Math.max(1, Math.min(region.w, fw - left));
|
|
4927
|
-
const height = Math.max(1, Math.min(region.h, fh - top));
|
|
4928
|
-
const crop = await (0, sharp.default)(frameData, { raw: {
|
|
4929
|
-
width: fw,
|
|
4930
|
-
height: fh,
|
|
4931
|
-
channels: 3
|
|
4932
|
-
} }).extract({
|
|
4933
|
-
left,
|
|
4934
|
-
top,
|
|
4935
|
-
width,
|
|
4936
|
-
height
|
|
4937
|
-
}).resize(CROP_WIDTH, CROP_HEIGHT).jpeg({ quality: CROP_QUALITY }).toBuffer();
|
|
5788
|
+
const crop = await this.cropSubjectRegion(frameData, fw, fh, ev.bbox, cropPadding);
|
|
4938
5789
|
await this.deps.mediaStore.put({
|
|
4939
5790
|
deviceId,
|
|
4940
5791
|
ownerKind: "event",
|
|
@@ -5408,6 +6259,17 @@ var RESOLUTION_MS = {
|
|
|
5408
6259
|
* latest state is never lost.
|
|
5409
6260
|
*/
|
|
5410
6261
|
var SLICE_WRITE_INTERVAL_MS$1 = 1e3;
|
|
6262
|
+
/**
|
|
6263
|
+
* Cadence of the synthetic occupancy baseline. When a camera is detached (no
|
|
6264
|
+
* inference frames) but has persisted parked objects, the history ring would
|
|
6265
|
+
* otherwise stay empty and the chart would read "No occupancy history yet". A
|
|
6266
|
+
* device WITH parked entries gets one hydrated sample per this interval — a
|
|
6267
|
+
* flat baseline of the parked count — so the graph shows the parking lot's
|
|
6268
|
+
* standing occupancy instead of a gap. No sample is emitted for a device
|
|
6269
|
+
* without entries, and a real `recordFrame` in the same window suppresses the
|
|
6270
|
+
* baseline (it already appended a richer sample).
|
|
6271
|
+
*/
|
|
6272
|
+
var BASELINE_SAMPLE_INTERVAL_MS = 6e4;
|
|
5411
6273
|
var ZoneAnalyticsProvider = class {
|
|
5412
6274
|
ctx;
|
|
5413
6275
|
snapshots = /* @__PURE__ */ new Map();
|
|
@@ -5424,8 +6286,17 @@ var ZoneAnalyticsProvider = class {
|
|
|
5424
6286
|
/** Last logged frame-wide occupancy total per device — so the occupancy log
|
|
5425
6287
|
* fires only when the count actually changes, not every inference frame. */
|
|
5426
6288
|
lastOccupancyTotal = /* @__PURE__ */ new Map();
|
|
6289
|
+
/** Low-cadence baseline sampler — appends a hydrated occupancy sample for any
|
|
6290
|
+
* device with parked objects but no live frames. `null` when disabled. */
|
|
6291
|
+
baselineTimer = null;
|
|
5427
6292
|
constructor(ctx) {
|
|
5428
6293
|
this.ctx = ctx;
|
|
6294
|
+
if (ctx.listStationaryDeviceIds && ctx.listStationaryObjects) {
|
|
6295
|
+
this.baselineTimer = setInterval(() => {
|
|
6296
|
+
this.appendBaselineSamples();
|
|
6297
|
+
}, BASELINE_SAMPLE_INTERVAL_MS);
|
|
6298
|
+
this.baselineTimer.unref?.();
|
|
6299
|
+
}
|
|
5429
6300
|
this.sliceThrottle = new SliceThrottler({
|
|
5430
6301
|
intervalMs: SLICE_WRITE_INTERVAL_MS$1,
|
|
5431
6302
|
equalsIgnoringTs: snapshotEqualsIgnoringTs,
|
|
@@ -5447,9 +6318,10 @@ var ZoneAnalyticsProvider = class {
|
|
|
5447
6318
|
/** Stop pending throttle timers — called from addon shutdown. */
|
|
5448
6319
|
destroy() {
|
|
5449
6320
|
this.sliceThrottle.destroy();
|
|
6321
|
+
if (this.baselineTimer) clearInterval(this.baselineTimer);
|
|
5450
6322
|
}
|
|
5451
6323
|
async getCurrentSnapshot({ deviceId }) {
|
|
5452
|
-
return this.snapshots.get(deviceId) ??
|
|
6324
|
+
return this.snapshots.get(deviceId) ?? await this.hydrateFromRegistry(deviceId);
|
|
5453
6325
|
}
|
|
5454
6326
|
async getZoneHistory(input) {
|
|
5455
6327
|
return this.bucketize(input.deviceId, input.from, input.to, input.resolution, (snap) => {
|
|
@@ -5502,6 +6374,65 @@ var ZoneAnalyticsProvider = class {
|
|
|
5502
6374
|
this.lastOccupancyTotal.delete(deviceId);
|
|
5503
6375
|
this.sliceThrottle.forgetDevice(deviceId);
|
|
5504
6376
|
}
|
|
6377
|
+
/**
|
|
6378
|
+
* Build an occupancy snapshot for a device purely from its parked-object
|
|
6379
|
+
* registry (no live frame). Returns `null` when hydration is unavailable or
|
|
6380
|
+
* the device has no parked objects — a device with neither frames nor entries
|
|
6381
|
+
* legitimately reports `null`. The snapshot's `ts` is the most recent
|
|
6382
|
+
* `lastConfirmedAt` across entries, falling back to the current tick.
|
|
6383
|
+
*/
|
|
6384
|
+
async hydrateFromRegistry(deviceId) {
|
|
6385
|
+
const listStationary = this.ctx.listStationaryObjects;
|
|
6386
|
+
if (!listStationary) return null;
|
|
6387
|
+
const entries = listStationary(deviceId);
|
|
6388
|
+
if (entries.length === 0) return null;
|
|
6389
|
+
let zones = [];
|
|
6390
|
+
try {
|
|
6391
|
+
zones = await this.ctx.resolveZones?.(deviceId) ?? [];
|
|
6392
|
+
} catch (err) {
|
|
6393
|
+
this.ctx.logger.debug("zone-analytics hydrate zone resolve failed", {
|
|
6394
|
+
tags: { deviceId },
|
|
6395
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
6396
|
+
});
|
|
6397
|
+
}
|
|
6398
|
+
const ts = mostRecentStationaryConfirmedAt(entries) || Date.now();
|
|
6399
|
+
return buildStationarySnapshot({
|
|
6400
|
+
deviceId,
|
|
6401
|
+
entries,
|
|
6402
|
+
zones,
|
|
6403
|
+
timestamp: ts
|
|
6404
|
+
});
|
|
6405
|
+
}
|
|
6406
|
+
/**
|
|
6407
|
+
* Baseline sampler tick: for every device with parked objects, append a
|
|
6408
|
+
* hydrated sample to the history ring at the CURRENT time — but only when a
|
|
6409
|
+
* real frame hasn't already appended a sample within this interval (frames
|
|
6410
|
+
* flowing = richer samples, no synthetic baseline needed).
|
|
6411
|
+
*/
|
|
6412
|
+
async appendBaselineSamples() {
|
|
6413
|
+
const deviceIds = this.ctx.listStationaryDeviceIds?.() ?? [];
|
|
6414
|
+
const now = Date.now();
|
|
6415
|
+
for (const deviceId of deviceIds) {
|
|
6416
|
+
const ring = this.history.get(deviceId);
|
|
6417
|
+
if (now - (ring && ring.length > 0 ? ring[ring.length - 1].ts : 0) < BASELINE_SAMPLE_INTERVAL_MS) continue;
|
|
6418
|
+
const entries = this.ctx.listStationaryObjects?.(deviceId) ?? [];
|
|
6419
|
+
if (entries.length === 0) continue;
|
|
6420
|
+
let zones = [];
|
|
6421
|
+
try {
|
|
6422
|
+
zones = await this.ctx.resolveZones?.(deviceId) ?? [];
|
|
6423
|
+
} catch {}
|
|
6424
|
+
const snapshot = buildStationarySnapshot({
|
|
6425
|
+
deviceId,
|
|
6426
|
+
entries,
|
|
6427
|
+
zones,
|
|
6428
|
+
timestamp: now
|
|
6429
|
+
});
|
|
6430
|
+
if (snapshot) {
|
|
6431
|
+
this.appendHistory(deviceId, snapshot);
|
|
6432
|
+
this.sliceThrottle.push(deviceId, snapshot);
|
|
6433
|
+
}
|
|
6434
|
+
}
|
|
6435
|
+
}
|
|
5505
6436
|
appendHistory(deviceId, snapshot) {
|
|
5506
6437
|
const ring = this.history.get(deviceId) ?? [];
|
|
5507
6438
|
const cutoff = snapshot.ts - HISTORY_WINDOW_MS;
|
|
@@ -5592,6 +6523,39 @@ function computeSnapshot(input) {
|
|
|
5592
6523
|
...input.stationaryObjects !== void 0 && input.stationaryObjects.length > 0 ? { stationaryObjects: input.stationaryObjects } : {}
|
|
5593
6524
|
};
|
|
5594
6525
|
}
|
|
6526
|
+
/** Most recent `lastConfirmedAt` across parked entries (0 when none). */
|
|
6527
|
+
function mostRecentStationaryConfirmedAt(entries) {
|
|
6528
|
+
let max = 0;
|
|
6529
|
+
for (const e of entries) if (e.lastConfirmedAt > max) max = e.lastConfirmedAt;
|
|
6530
|
+
return max;
|
|
6531
|
+
}
|
|
6532
|
+
/**
|
|
6533
|
+
* Build an occupancy snapshot from parked-object registry entries alone —
|
|
6534
|
+
* used when no live inference frame is available (fresh respawn, camera
|
|
6535
|
+
* detached). Each entry is folded into the frame aggregate AND attributed to
|
|
6536
|
+
* the zones its normalised bbox centroid falls inside (via
|
|
6537
|
+
* {@link computeStationaryEntryZones}), so a zone drawn over a parked car
|
|
6538
|
+
* reports a count of 1. Returns `null` for an empty entry list. Reuses
|
|
6539
|
+
* {@link computeSnapshot} — the SAME aggregation the live frame path runs.
|
|
6540
|
+
*/
|
|
6541
|
+
function buildStationarySnapshot(input) {
|
|
6542
|
+
if (input.entries.length === 0) return null;
|
|
6543
|
+
const tracked = input.entries.map((e) => ({
|
|
6544
|
+
trackId: `stationary:${e.id}`,
|
|
6545
|
+
className: e.className,
|
|
6546
|
+
zones: computeStationaryEntryZones(e, input.zones)
|
|
6547
|
+
}));
|
|
6548
|
+
const first = input.entries[0];
|
|
6549
|
+
return computeSnapshot({
|
|
6550
|
+
deviceId: input.deviceId,
|
|
6551
|
+
timestamp: input.timestamp,
|
|
6552
|
+
frameWidth: first.frameWidth,
|
|
6553
|
+
frameHeight: first.frameHeight,
|
|
6554
|
+
tracked,
|
|
6555
|
+
zones: input.zones,
|
|
6556
|
+
stationaryObjects: input.entries
|
|
6557
|
+
});
|
|
6558
|
+
}
|
|
5595
6559
|
//#endregion
|
|
5596
6560
|
//#region src/pipeline-analytics/audio-metrics-provider.ts
|
|
5597
6561
|
var AUDIO_METRICS_CAP_NAME = "audio-metrics";
|
|
@@ -7669,6 +8633,17 @@ var DEFAULT_MIN_INTERVAL_MS = 1e3;
|
|
|
7669
8633
|
/** Default fire cap for a `once` step when its announce omits `maxPerTrack`. */
|
|
7670
8634
|
var DEFAULT_ONCE_MAX_PER_TRACK = 3;
|
|
7671
8635
|
/**
|
|
8636
|
+
* Consecutive frame-plane misses ("frame + crop both missed") after which a step
|
|
8637
|
+
* is ABANDONED for the track. The decode worker serves native crops from a RAM
|
|
8638
|
+
* lease store with a ~500ms TTL, so a retry that arrives seconds later re-cuts
|
|
8639
|
+
* from an evicted handle and is a guaranteed miss forever. Retrying a
|
|
8640
|
+
* permanently-gone frame just burns cross-process RPC + CPU + log lines. Three
|
|
8641
|
+
* consecutive misses (each ≥ one tick apart) confidently means the frame is gone
|
|
8642
|
+
* for good, while still tolerating a single transient decode-worker hiccup /
|
|
8643
|
+
* respawn on a genuinely live track (the counter resets on any resolved result).
|
|
8644
|
+
*/
|
|
8645
|
+
var MAX_CONSECUTIVE_FRAME_MISSES = 3;
|
|
8646
|
+
/**
|
|
7672
8647
|
* Pure per-(track, step) scheduling state machine for detail-subtree
|
|
7673
8648
|
* dispatch. Given a camera's announced child steps (`DetailStepAnnounce[]`,
|
|
7674
8649
|
* read off `PipelineInferenceResultPayload.detailSteps`), decides WHEN each
|
|
@@ -7689,7 +8664,9 @@ var DetailScheduler = class {
|
|
|
7689
8664
|
firedCount: 1,
|
|
7690
8665
|
lastFiredAt: nowMs,
|
|
7691
8666
|
sticky: false,
|
|
7692
|
-
retryPending: false
|
|
8667
|
+
retryPending: false,
|
|
8668
|
+
consecutiveFrameMisses: 0,
|
|
8669
|
+
abandoned: false
|
|
7693
8670
|
};
|
|
7694
8671
|
steps.set(stepAnnounce.stepId, state);
|
|
7695
8672
|
requests.push({
|
|
@@ -7722,7 +8699,7 @@ var DetailScheduler = class {
|
|
|
7722
8699
|
tick(nowMs) {
|
|
7723
8700
|
const requests = [];
|
|
7724
8701
|
for (const [trackId, steps] of this.tracks) for (const state of steps.values()) {
|
|
7725
|
-
if (state.sticky) continue;
|
|
8702
|
+
if (state.sticky || state.abandoned) continue;
|
|
7726
8703
|
if (state.retryPending) {
|
|
7727
8704
|
if (!this.intervalElapsed(state, nowMs)) continue;
|
|
7728
8705
|
if (!this.underMaxPerTrack(state)) {
|
|
@@ -7760,7 +8737,8 @@ var DetailScheduler = class {
|
|
|
7760
8737
|
if (!steps) return;
|
|
7761
8738
|
const state = steps.get(stepId);
|
|
7762
8739
|
if (!state) return;
|
|
7763
|
-
if (state.sticky) return;
|
|
8740
|
+
if (state.sticky || state.abandoned) return;
|
|
8741
|
+
state.consecutiveFrameMisses = 0;
|
|
7764
8742
|
const { stickyOnConfidence } = state.announce.cadence;
|
|
7765
8743
|
if (confidence !== null && stickyOnConfidence !== void 0 && confidence >= stickyOnConfidence) {
|
|
7766
8744
|
state.sticky = true;
|
|
@@ -7775,11 +8753,37 @@ var DetailScheduler = class {
|
|
|
7775
8753
|
if (this.underMaxPerTrack(state)) state.retryPending = true;
|
|
7776
8754
|
}
|
|
7777
8755
|
}
|
|
8756
|
+
/**
|
|
8757
|
+
* A dispatched request could not resolve a frame AT ALL — the frame handle
|
|
8758
|
+
* lease was evicted AND the crop fallback was unavailable (the "frame + crop
|
|
8759
|
+
* both missed" outcome). This is fundamentally different from `onResult(null)`:
|
|
8760
|
+
* there the frame plane WORKED and the model merely returned nothing (worth a
|
|
8761
|
+
* retry on a fresh frame). A frame-plane miss re-cuts from the SAME evicted
|
|
8762
|
+
* handle every time, so it can never recover from this request. It is
|
|
8763
|
+
* retry-eligible only for a bounded number of CONSECUTIVE attempts; after
|
|
8764
|
+
* {@link MAX_CONSECUTIVE_FRAME_MISSES} in a row the step is abandoned for the
|
|
8765
|
+
* track — this is the give-up that breaks the permanent-retry loop. `_nowMs`
|
|
8766
|
+
* is accepted for signature symmetry (backoff is anchored to `lastFiredAt`).
|
|
8767
|
+
*/
|
|
8768
|
+
onFrameMiss(trackId, stepId, _nowMs) {
|
|
8769
|
+
const steps = this.tracks.get(trackId);
|
|
8770
|
+
if (!steps) return;
|
|
8771
|
+
const state = steps.get(stepId);
|
|
8772
|
+
if (!state) return;
|
|
8773
|
+
if (state.sticky || state.abandoned) return;
|
|
8774
|
+
state.consecutiveFrameMisses += 1;
|
|
8775
|
+
if (state.consecutiveFrameMisses >= MAX_CONSECUTIVE_FRAME_MISSES) {
|
|
8776
|
+
state.abandoned = true;
|
|
8777
|
+
state.retryPending = false;
|
|
8778
|
+
return;
|
|
8779
|
+
}
|
|
8780
|
+
if (this.underMaxPerTrack(state)) state.retryPending = true;
|
|
8781
|
+
}
|
|
7778
8782
|
onTrackEnded(trackId) {
|
|
7779
8783
|
this.tracks.delete(trackId);
|
|
7780
8784
|
}
|
|
7781
8785
|
canFire(state, nowMs) {
|
|
7782
|
-
if (state.sticky) return false;
|
|
8786
|
+
if (state.sticky || state.abandoned) return false;
|
|
7783
8787
|
if (!this.underMaxPerTrack(state)) return false;
|
|
7784
8788
|
return this.intervalElapsed(state, nowMs);
|
|
7785
8789
|
}
|
|
@@ -7960,8 +8964,12 @@ var TrackDetailDispatcher = class {
|
|
|
7960
8964
|
}
|
|
7961
8965
|
async dispatch(deviceId, dev, req, frame) {
|
|
7962
8966
|
const details = await this.runOnce(deviceId, dev, req, frame);
|
|
8967
|
+
if (details === null) {
|
|
8968
|
+
dev.scheduler.onFrameMiss(req.trackId, req.stepId, Date.now());
|
|
8969
|
+
return;
|
|
8970
|
+
}
|
|
7963
8971
|
let topScore = null;
|
|
7964
|
-
if (details
|
|
8972
|
+
if (details.length > 0) {
|
|
7965
8973
|
topScore = details.reduce((max, d) => d.score > max ? d.score : max, Number.NEGATIVE_INFINITY);
|
|
7966
8974
|
const steps = composeDetailSteps(req, (id) => this.deps.hasTrackLabel?.(id) ?? false);
|
|
7967
8975
|
if (isEnrichmentChain(steps) && !detailsCarryEnrichment(details)) this.warnEnrichmentMissThrottled(deviceId, dev, req, steps);
|
|
@@ -9294,6 +10302,40 @@ function classifyAudioFrame(top, cfg) {
|
|
|
9294
10302
|
//#endregion
|
|
9295
10303
|
//#region src/pipeline-analytics/event-media-handler.ts
|
|
9296
10304
|
var CACHE_CONTROL = "public, max-age=31536000, immutable";
|
|
10305
|
+
/** Default / clamp bounds for the `thumb` variant edge (px). Mirrors
|
|
10306
|
+
* `shared/frame/square-thumb.ts`; kept here so query parsing stays pure. */
|
|
10307
|
+
var THUMB_DEFAULT_SIZE = 160;
|
|
10308
|
+
var THUMB_MIN_SIZE$1 = 64;
|
|
10309
|
+
var THUMB_MAX_SIZE$1 = 320;
|
|
10310
|
+
/**
|
|
10311
|
+
* Parse the `?kind=…` query into a preferred stored media kind. Returns null
|
|
10312
|
+
* when unset. The value is a free-form kind token (e.g. `crop`); the resolver
|
|
10313
|
+
* validates it against the known kinds.
|
|
10314
|
+
*/
|
|
10315
|
+
function parseEventMediaKind(query) {
|
|
10316
|
+
const kind = new URLSearchParams(query).get("kind");
|
|
10317
|
+
return kind !== null && kind.length > 0 ? kind : null;
|
|
10318
|
+
}
|
|
10319
|
+
/**
|
|
10320
|
+
* Parse the `?variant=…` query into an {@link EventMediaVariant}. Returns null
|
|
10321
|
+
* when no small-square rendering was requested (the caller then serves the
|
|
10322
|
+
* stored blob). Accepts `variant=thumb` or `square=1`; the edge comes from
|
|
10323
|
+
* `size` / `w` / `h` (clamped to [64, 320], default 160).
|
|
10324
|
+
*/
|
|
10325
|
+
function parseEventMediaVariant(query) {
|
|
10326
|
+
const params = new URLSearchParams(query);
|
|
10327
|
+
if (!(params.get("variant") === "thumb" || params.get("square") === "1")) return null;
|
|
10328
|
+
const sizeRaw = params.get("size") ?? params.get("w") ?? params.get("h");
|
|
10329
|
+
let size = THUMB_DEFAULT_SIZE;
|
|
10330
|
+
if (sizeRaw !== null) {
|
|
10331
|
+
const n = Number.parseInt(sizeRaw, 10);
|
|
10332
|
+
if (Number.isFinite(n)) size = Math.max(THUMB_MIN_SIZE$1, Math.min(THUMB_MAX_SIZE$1, n));
|
|
10333
|
+
}
|
|
10334
|
+
return {
|
|
10335
|
+
kind: "thumb",
|
|
10336
|
+
size
|
|
10337
|
+
};
|
|
10338
|
+
}
|
|
9297
10339
|
/**
|
|
9298
10340
|
* Create a data-plane handler that serves event thumbnails as JPEG images.
|
|
9299
10341
|
*
|
|
@@ -9307,14 +10349,20 @@ function createEventMediaHandler(deps) {
|
|
|
9307
10349
|
res.writeHead(405, { allow: "GET, HEAD" }).end();
|
|
9308
10350
|
return;
|
|
9309
10351
|
}
|
|
9310
|
-
const
|
|
10352
|
+
const url = req.url ?? "/";
|
|
10353
|
+
const qIdx = url.indexOf("?");
|
|
10354
|
+
const rawPath = qIdx === -1 ? url : url.slice(0, qIdx);
|
|
10355
|
+
const query = qIdx === -1 ? "" : url.slice(qIdx + 1);
|
|
10356
|
+
const eventId = rawPath.replace(/^\/+/, "");
|
|
9311
10357
|
if (!eventId || eventId.includes("/")) {
|
|
9312
10358
|
res.writeHead(404).end();
|
|
9313
10359
|
return;
|
|
9314
10360
|
}
|
|
10361
|
+
const variant = parseEventMediaVariant(query);
|
|
10362
|
+
const preferKind = parseEventMediaKind(query);
|
|
9315
10363
|
let media = null;
|
|
9316
10364
|
try {
|
|
9317
|
-
media = await deps.getMedia(eventId);
|
|
10365
|
+
media = await deps.getMedia(eventId, variant ?? void 0, preferKind ?? void 0);
|
|
9318
10366
|
} catch {
|
|
9319
10367
|
const body = "Internal server error";
|
|
9320
10368
|
res.writeHead(500, {
|
|
@@ -9347,6 +10395,27 @@ function createEventMediaHandler(deps) {
|
|
|
9347
10395
|
else res.end(Buffer.from(media.bytes));
|
|
9348
10396
|
};
|
|
9349
10397
|
}
|
|
10398
|
+
/** JPEG quality for the small square thumbnail (visibly fine at ≤192px, tiny). */
|
|
10399
|
+
var THUMB_QUALITY = 70;
|
|
10400
|
+
/**
|
|
10401
|
+
* Produce a SMALL SQUARE JPEG from an already-encoded image (typically the
|
|
10402
|
+
* stored 640×360 `crop`). Center-crop cover to a square then downscale to
|
|
10403
|
+
* `size`×`size` at JPEG q70 — the reel/list surfaces want a compact square tile
|
|
10404
|
+
* showing the object, not the full 16:9 crop. Output is a fraction of the source
|
|
10405
|
+
* (~3–8 KB at 144–192 px vs ~65 KB for the crop), so a fleet-wide reel renders
|
|
10406
|
+
* from tiny HTTP-cached tiles instead of full base64 payloads.
|
|
10407
|
+
*
|
|
10408
|
+
* `fit: 'cover'` + `position: 'centre'` scales the shorter side to `size` and
|
|
10409
|
+
* crops the overflow symmetrically — the center square of a square-safe crop
|
|
10410
|
+
* fully contains the detector bbox, so the object stays framed.
|
|
10411
|
+
*/
|
|
10412
|
+
async function makeSquareThumb(bytes, size) {
|
|
10413
|
+
const edge = Math.max(64, Math.min(320, Math.round(size)));
|
|
10414
|
+
return (0, sharp.default)(Buffer.from(bytes)).resize(edge, edge, {
|
|
10415
|
+
fit: "cover",
|
|
10416
|
+
position: "centre"
|
|
10417
|
+
}).jpeg({ quality: THUMB_QUALITY }).toBuffer();
|
|
10418
|
+
}
|
|
9350
10419
|
//#endregion
|
|
9351
10420
|
//#region src/pipeline-analytics/index.ts
|
|
9352
10421
|
/**
|
|
@@ -9402,6 +10471,34 @@ var OVERLAY_SYNTHESIS_WARN_THROTTLE_MS = 6e4;
|
|
|
9402
10471
|
var AUDIO_EVENT_HEARTBEAT_MS = 5e3;
|
|
9403
10472
|
var MOTION_EVENT_HEARTBEAT_MS = 5e3;
|
|
9404
10473
|
/**
|
|
10474
|
+
* Stored media kinds that carry NO drawn bounding box, in fallback preference
|
|
10475
|
+
* order. The reel forces `?kind=crop`; when a track has no crop the endpoint may
|
|
10476
|
+
* degrade only to one of these CLEAN frames — never `fullFrameBoxed` /
|
|
10477
|
+
* `thumbnail` / `lastFrame` / `firstFrame` / `snapshot` (all server-boxed).
|
|
10478
|
+
*/
|
|
10479
|
+
var CLEAN_MEDIA_KINDS = [
|
|
10480
|
+
"crop",
|
|
10481
|
+
"fullFrame",
|
|
10482
|
+
"keyFrame"
|
|
10483
|
+
];
|
|
10484
|
+
/**
|
|
10485
|
+
* Pick a CLEAN (never boxed) media file for a crop-forced request: the exact
|
|
10486
|
+
* `preferKind` if it is itself clean and present, else the first available
|
|
10487
|
+
* {@link CLEAN_MEDIA_KINDS} frame. Returns undefined when only boxed / no media
|
|
10488
|
+
* exists (caller 404s → the viewer shows an icon).
|
|
10489
|
+
*/
|
|
10490
|
+
function pickCleanMedia(files, preferKind) {
|
|
10491
|
+
const isClean = (k) => CLEAN_MEDIA_KINDS.includes(k);
|
|
10492
|
+
if (isClean(preferKind)) {
|
|
10493
|
+
const exact = files.find((f) => f.kind === preferKind);
|
|
10494
|
+
if (exact) return exact;
|
|
10495
|
+
}
|
|
10496
|
+
for (const kind of CLEAN_MEDIA_KINDS) {
|
|
10497
|
+
const found = files.find((f) => f.kind === kind);
|
|
10498
|
+
if (found) return found;
|
|
10499
|
+
}
|
|
10500
|
+
}
|
|
10501
|
+
/**
|
|
9405
10502
|
* Decode a base64 little-endian float32 vector (the `DetailResult.embedding`
|
|
9406
10503
|
* wire encoding produced by `runDetailSubtree`) back into a plain number[].
|
|
9407
10504
|
*/
|
|
@@ -9458,6 +10555,11 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
9458
10555
|
stationaryRegistry = null;
|
|
9459
10556
|
mediaStore = null;
|
|
9460
10557
|
eventStore = null;
|
|
10558
|
+
/** Per-camera history of LINKED-device sensor state changes (Part B). */
|
|
10559
|
+
sensorEventStore = null;
|
|
10560
|
+
/** Ingest-side reverse index (sensor device → linked camera ids), TTL-cached
|
|
10561
|
+
* so the DeviceStateChanged handler stays cheap. */
|
|
10562
|
+
linkedCamerasCache = null;
|
|
9461
10563
|
identityStore = null;
|
|
9462
10564
|
faceStore = null;
|
|
9463
10565
|
faceRecognizer = null;
|
|
@@ -9508,6 +10610,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
9508
10610
|
unsubNativeDetection = null;
|
|
9509
10611
|
unsubBindings = null;
|
|
9510
10612
|
unsubDeviceUnreg = null;
|
|
10613
|
+
/** DeviceStateChanged subscription feeding the sensor-event history. */
|
|
10614
|
+
unsubDeviceState = null;
|
|
9511
10615
|
ttlSweepTimer = null;
|
|
9512
10616
|
retentionSweepTimer = null;
|
|
9513
10617
|
/** Handle for the event-media data-plane listener (dispose on shutdown). */
|
|
@@ -9603,6 +10707,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
9603
10707
|
await TrackStore.declare(api.settingsStore);
|
|
9604
10708
|
await MediaStore.declare(api.settingsStore);
|
|
9605
10709
|
await EventStore.declare(api.settingsStore);
|
|
10710
|
+
await SensorEventStore.declare(api.settingsStore);
|
|
9606
10711
|
await IdentityStore.declare(api.settingsStore);
|
|
9607
10712
|
await FaceStore.declare(api.settingsStore);
|
|
9608
10713
|
await PlateStore.declare(api.settingsStore);
|
|
@@ -9613,14 +10718,15 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
9613
10718
|
let storage = this.ctx.kernel.storage;
|
|
9614
10719
|
const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
|
|
9615
10720
|
if (mediaRoot) {
|
|
9616
|
-
const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-
|
|
10721
|
+
const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-qtD7WAqr.js"));
|
|
9617
10722
|
storage = new FilesystemStorageProvider(mediaRoot);
|
|
9618
10723
|
logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
|
|
9619
10724
|
}
|
|
9620
10725
|
if (!storage) throw new Error("pipeline-analytics requires ctx.kernel.storage");
|
|
9621
10726
|
this.trackStore = new TrackStore({
|
|
9622
10727
|
store: api.settingsStore,
|
|
9623
|
-
logger: logger.child("TrackStore")
|
|
10728
|
+
logger: logger.child("TrackStore"),
|
|
10729
|
+
frameDims: (deviceId) => this.lastFrameDimsByDevice.get(deviceId)
|
|
9624
10730
|
});
|
|
9625
10731
|
this.stationaryRegistry = new StationaryObjectRegistry({
|
|
9626
10732
|
store: api.settingsStore,
|
|
@@ -9656,6 +10762,20 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
9656
10762
|
logger: logger.child("EventStore"),
|
|
9657
10763
|
media: this.mediaStore
|
|
9658
10764
|
});
|
|
10765
|
+
this.sensorEventStore = new SensorEventStore({
|
|
10766
|
+
store: api.settingsStore,
|
|
10767
|
+
logger: logger.child("SensorEventStore")
|
|
10768
|
+
});
|
|
10769
|
+
this.linkedCamerasCache = new LinkedCamerasCache({
|
|
10770
|
+
cameras: { listCameraIds: async () => {
|
|
10771
|
+
return (await api.deviceManager.listAll.query({})).filter((d) => d.isCamera).map((d) => d.id);
|
|
10772
|
+
} },
|
|
10773
|
+
linkedDevices: { getLinkedDevices: (input) => api.deviceManager.getLinkedDevices.query(input) },
|
|
10774
|
+
onError: (scope, err) => logger.warn("linked-cameras cache refresh failed", { meta: {
|
|
10775
|
+
scope,
|
|
10776
|
+
error: require_dist.errMsg(err)
|
|
10777
|
+
} })
|
|
10778
|
+
});
|
|
9659
10779
|
this.identityStore = new IdentityStore({
|
|
9660
10780
|
store: api.settingsStore,
|
|
9661
10781
|
logger: logger.child("IdentityStore")
|
|
@@ -9820,7 +10940,10 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
9820
10940
|
});
|
|
9821
10941
|
this.zoneAnalytics = new ZoneAnalyticsProvider({
|
|
9822
10942
|
logger: logger.child("ZoneAnalytics"),
|
|
9823
|
-
fetchDevice: (deviceId) => this.ctx.fetchDevice(deviceId)
|
|
10943
|
+
fetchDevice: (deviceId) => this.ctx.fetchDevice(deviceId),
|
|
10944
|
+
listStationaryObjects: (deviceId) => this.stationaryRegistry?.listViews(deviceId) ?? [],
|
|
10945
|
+
listStationaryDeviceIds: () => this.stationaryRegistry?.deviceIds() ?? [],
|
|
10946
|
+
resolveZones: (deviceId) => this.resolveDeviceZones(deviceId)
|
|
9824
10947
|
});
|
|
9825
10948
|
this.audioMetrics = new AudioMetricsProvider({
|
|
9826
10949
|
logger: logger.child("AudioMetrics"),
|
|
@@ -9836,9 +10959,9 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
9836
10959
|
}
|
|
9837
10960
|
});
|
|
9838
10961
|
try {
|
|
9839
|
-
const handler = createEventMediaHandler({ getMedia: async (id) => {
|
|
10962
|
+
const handler = createEventMediaHandler({ getMedia: async (id, variant, preferKind) => {
|
|
9840
10963
|
try {
|
|
9841
|
-
return await this.readMediaByEventOrKey(id);
|
|
10964
|
+
return await this.readMediaByEventOrKey(id, variant, preferKind);
|
|
9842
10965
|
} catch (err) {
|
|
9843
10966
|
this.ctx.logger.warn("readEventThumbnail failed", { meta: {
|
|
9844
10967
|
eventId: id,
|
|
@@ -9879,6 +11002,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
9879
11002
|
const data = ev.data;
|
|
9880
11003
|
this.handleNativeDetection(data);
|
|
9881
11004
|
});
|
|
11005
|
+
this.unsubDeviceState = this.ctx.eventBus.subscribe({ category: require_dist.EventCategory.DeviceStateChanged }, (ev) => {
|
|
11006
|
+
const data = ev.data;
|
|
11007
|
+
if (require_dist.EVENT_KIND_BY_CAP[data.capName] === void 0) return;
|
|
11008
|
+
const timestamp = ev.timestamp instanceof Date ? ev.timestamp.getTime() : Date.now();
|
|
11009
|
+
this.handleSensorStateChanged(data, timestamp);
|
|
11010
|
+
});
|
|
9882
11011
|
if (await this.embeddingEnabledState.get()) {
|
|
9883
11012
|
const encoderClient = {
|
|
9884
11013
|
encode: (crop, width, height) => this.ctx.api.embeddingEncoder.encode.query({
|
|
@@ -10244,6 +11373,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
10244
11373
|
this.unsubBindings = null;
|
|
10245
11374
|
this.unsubDeviceUnreg?.();
|
|
10246
11375
|
this.unsubDeviceUnreg = null;
|
|
11376
|
+
this.unsubDeviceState?.();
|
|
11377
|
+
this.unsubDeviceState = null;
|
|
10247
11378
|
await this.embeddingDispatcher?.stop();
|
|
10248
11379
|
this.embeddingDispatcher = null;
|
|
10249
11380
|
for (const id of this.proxies.keys()) this.releaseProxy(id);
|
|
@@ -10341,7 +11472,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
10341
11472
|
const stationaryAsTracked = stationaryViews.map((v) => ({
|
|
10342
11473
|
trackId: `stationary:${v.id}`,
|
|
10343
11474
|
className: v.className,
|
|
10344
|
-
zones:
|
|
11475
|
+
zones: computeStationaryEntryZones(v, liveZones)
|
|
10345
11476
|
}));
|
|
10346
11477
|
this.zoneAnalytics?.recordFrame({
|
|
10347
11478
|
deviceId,
|
|
@@ -11506,6 +12637,15 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
11506
12637
|
} });
|
|
11507
12638
|
}
|
|
11508
12639
|
await this.mediaStore.evictBefore(now - 31 * day);
|
|
12640
|
+
if (this.sensorEventStore) try {
|
|
12641
|
+
const sensorDeleted = await this.sensorEventStore.evictBefore(objectCutoffMs);
|
|
12642
|
+
if (sensorDeleted > 0) this.ctx.logger.info("sensor-event retention prune", { meta: {
|
|
12643
|
+
deleted: sensorDeleted,
|
|
12644
|
+
cutoffMs: objectCutoffMs
|
|
12645
|
+
} });
|
|
12646
|
+
} catch (err) {
|
|
12647
|
+
this.ctx.logger.debug("sensor-event prune failed", { meta: { error: String(err) } });
|
|
12648
|
+
}
|
|
11509
12649
|
if (this.faceStore) try {
|
|
11510
12650
|
const faceCutoffMs = now - FACE_DEFAULTS.bufferRetentionDays * day;
|
|
11511
12651
|
const deletedFaceIds = await this.faceStore.pruneAll({
|
|
@@ -11777,6 +12917,21 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
11777
12917
|
return null;
|
|
11778
12918
|
}
|
|
11779
12919
|
}
|
|
12920
|
+
/**
|
|
12921
|
+
* Resolve a device's current 0–1 zone catalogue independent of the live
|
|
12922
|
+
* frame path — used by zone-analytics snapshot hydration + the occupancy
|
|
12923
|
+
* baseline sampler when the camera is detached (no frames). Warms the proxy
|
|
12924
|
+
* (cold read via `fetchDevice`) and, when the cached slice is empty, forces
|
|
12925
|
+
* one `refresh()` round-trip so a just-created proxy returns real zones.
|
|
12926
|
+
*/
|
|
12927
|
+
async resolveDeviceZones(deviceId) {
|
|
12928
|
+
const proxy = await this.ensureProxy(deviceId);
|
|
12929
|
+
if (!proxy) return [];
|
|
12930
|
+
const cached = proxy.state.zones.value?.zones;
|
|
12931
|
+
if (cached && cached.length > 0) return cached;
|
|
12932
|
+
await proxy.state.zones.refresh().catch(() => void 0);
|
|
12933
|
+
return proxy.state.zones.value?.zones ?? [];
|
|
12934
|
+
}
|
|
11780
12935
|
releaseProxy(deviceId) {
|
|
11781
12936
|
const unsubs = this.proxyUnsubs.get(deviceId);
|
|
11782
12937
|
if (unsubs) for (const u of unsubs) try {
|
|
@@ -11796,6 +12951,68 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
11796
12951
|
async listTracks(input) {
|
|
11797
12952
|
return this.trackStore?.queryHistorical(input) ?? [];
|
|
11798
12953
|
}
|
|
12954
|
+
/**
|
|
12955
|
+
* Batched cluster-wide track listing — one merged (`lastSeen` DESC,
|
|
12956
|
+
* `trackId` DESC) page across the requested devices with a stable opaque
|
|
12957
|
+
* cursor. Replaces the per-camera `listTracks` fan-out for the events page
|
|
12958
|
+
* first paint + the reel. See `TrackStore.queryRecent` for the per-device
|
|
12959
|
+
* indexed page + k-way merge and the cursor encoding.
|
|
12960
|
+
*/
|
|
12961
|
+
async listRecentTracks(input) {
|
|
12962
|
+
return this.trackStore?.queryRecent(input) ?? {
|
|
12963
|
+
tracks: [],
|
|
12964
|
+
nextCursor: null
|
|
12965
|
+
};
|
|
12966
|
+
}
|
|
12967
|
+
/**
|
|
12968
|
+
* Every event kind the device can produce: built-ins (motion + audio),
|
|
12969
|
+
* detection classes actually observed on the device, and sensor kinds
|
|
12970
|
+
* from LINKED devices (device-manager `getLinkedDevices`, binding-driven
|
|
12971
|
+
* per linked device). Degrades to the built-ins on error.
|
|
12972
|
+
*/
|
|
12973
|
+
async listEventKinds(input) {
|
|
12974
|
+
const api = this.ctx.api;
|
|
12975
|
+
return composeEventKinds({
|
|
12976
|
+
linkedDevices: { getLinkedDevices: (i) => api.deviceManager.getLinkedDevices.query(i) },
|
|
12977
|
+
bindings: { getBindings: (i) => api.deviceManager.getBindings.query(i) },
|
|
12978
|
+
observedClassNames: async (deviceId) => this.trackStore?.observedClassNames(deviceId) ?? [],
|
|
12979
|
+
onError: (scope, err) => this.ctx.logger.warn("listEventKinds: partial compose", {
|
|
12980
|
+
tags: { deviceId: input.deviceId },
|
|
12981
|
+
meta: {
|
|
12982
|
+
scope,
|
|
12983
|
+
error: require_dist.errMsg(err)
|
|
12984
|
+
}
|
|
12985
|
+
})
|
|
12986
|
+
}, input.deviceId);
|
|
12987
|
+
}
|
|
12988
|
+
/** Per-camera sensor-event history (state changes of linked devices). */
|
|
12989
|
+
async getSensorEvents(input) {
|
|
12990
|
+
return this.sensorEventStore?.query(input) ?? [];
|
|
12991
|
+
}
|
|
12992
|
+
/**
|
|
12993
|
+
* Sensor-event ingest handler — `DeviceStateChanged` of a device exposing a
|
|
12994
|
+
* mapped sensor cap. Resolves the linked-camera set through the TTL cache
|
|
12995
|
+
* and inserts ONE row per linked camera. Best-effort (telemetry-lossy).
|
|
12996
|
+
*/
|
|
12997
|
+
async handleSensorStateChanged(data, timestamp) {
|
|
12998
|
+
const store = this.sensorEventStore;
|
|
12999
|
+
const cache = this.linkedCamerasCache;
|
|
13000
|
+
if (store === null || cache === null) return;
|
|
13001
|
+
try {
|
|
13002
|
+
await ingestSensorStateChange({
|
|
13003
|
+
sink: store,
|
|
13004
|
+
cache
|
|
13005
|
+
}, data, timestamp);
|
|
13006
|
+
} catch (err) {
|
|
13007
|
+
this.ctx.logger.warn("sensor-event ingest failed", {
|
|
13008
|
+
tags: { deviceId: data.deviceId },
|
|
13009
|
+
meta: {
|
|
13010
|
+
capName: data.capName,
|
|
13011
|
+
error: require_dist.errMsg(err)
|
|
13012
|
+
}
|
|
13013
|
+
});
|
|
13014
|
+
}
|
|
13015
|
+
}
|
|
11799
13016
|
async clearTracks(input) {
|
|
11800
13017
|
this.trackStore?.clearDevice(input.deviceId);
|
|
11801
13018
|
this.stationaryRegistry?.clearDevice(input.deviceId);
|
|
@@ -12152,19 +13369,51 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
12152
13369
|
* points its thumbnail at the embedding row's crop key (a `track:…:crop:…`
|
|
12153
13370
|
* key), so this resolves that crop; event ids stay on the event-crop path.
|
|
12154
13371
|
*/
|
|
12155
|
-
async readMediaByEventOrKey(id) {
|
|
12156
|
-
|
|
12157
|
-
|
|
12158
|
-
|
|
13372
|
+
async readMediaByEventOrKey(id, variant, preferKind) {
|
|
13373
|
+
const base = id.includes(":") ? await this.readMediaByKey(id) : await this.readEventThumbnail(id, preferKind);
|
|
13374
|
+
if (base === null || variant === void 0) return base;
|
|
13375
|
+
return this.applyThumbVariant(base, variant);
|
|
13376
|
+
}
|
|
13377
|
+
async readMediaByKey(id) {
|
|
13378
|
+
const file = await (this.mediaStore?.getByKey(id) ?? Promise.resolve(null));
|
|
13379
|
+
if (!file) return null;
|
|
13380
|
+
return {
|
|
13381
|
+
bytes: Buffer.from(file.base64, "base64"),
|
|
13382
|
+
key: file.key
|
|
13383
|
+
};
|
|
13384
|
+
}
|
|
13385
|
+
/**
|
|
13386
|
+
* Render a small center-cropped square from a resolved event media blob for
|
|
13387
|
+
* the reel / list surfaces. The returned `key` is variant-distinct so the
|
|
13388
|
+
* data-plane ETag never collides with the full-size blob's. On any encode
|
|
13389
|
+
* failure the full blob is served (a thumb must never 500 / blank a tile).
|
|
13390
|
+
*/
|
|
13391
|
+
async applyThumbVariant(media, variant) {
|
|
13392
|
+
try {
|
|
12159
13393
|
return {
|
|
12160
|
-
bytes:
|
|
12161
|
-
key:
|
|
13394
|
+
bytes: await makeSquareThumb(media.bytes, variant.size),
|
|
13395
|
+
key: `${media.key}|t${variant.size}`
|
|
12162
13396
|
};
|
|
13397
|
+
} catch (err) {
|
|
13398
|
+
this.ctx.logger.debug("event media: thumb variant failed — serving full", { meta: {
|
|
13399
|
+
key: media.key,
|
|
13400
|
+
size: variant.size,
|
|
13401
|
+
error: require_dist.errMsg(err)
|
|
13402
|
+
} });
|
|
13403
|
+
return media;
|
|
12163
13404
|
}
|
|
12164
|
-
return this.readEventThumbnail(id);
|
|
12165
13405
|
}
|
|
12166
|
-
async readEventThumbnail(id) {
|
|
13406
|
+
async readEventThumbnail(id, preferKind) {
|
|
12167
13407
|
const eventFiles = await (this.mediaStore?.listByOwner("event", id) ?? Promise.resolve([]));
|
|
13408
|
+
if (preferKind !== void 0 && preferKind.length > 0) {
|
|
13409
|
+
const trackFiles = await (this.mediaStore?.listByOwner("track", id) ?? Promise.resolve([]));
|
|
13410
|
+
const clean = pickCleanMedia([...eventFiles, ...trackFiles], preferKind);
|
|
13411
|
+
if (!clean) return null;
|
|
13412
|
+
return {
|
|
13413
|
+
bytes: Buffer.from(clean.base64, "base64"),
|
|
13414
|
+
key: clean.key
|
|
13415
|
+
};
|
|
13416
|
+
}
|
|
12168
13417
|
const chosenEvent = eventFiles.find((f) => f.kind === "crop") ?? eventFiles.find((f) => f.kind === "fullFrameBoxed") ?? eventFiles[0];
|
|
12169
13418
|
if (chosenEvent) return {
|
|
12170
13419
|
bytes: Buffer.from(chosenEvent.base64, "base64"),
|
|
@@ -12750,5 +13999,6 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
12750
13999
|
};
|
|
12751
14000
|
//#endregion
|
|
12752
14001
|
exports.default = PipelineAnalyticsAddon;
|
|
14002
|
+
exports.pickCleanMedia = pickCleanMedia;
|
|
12753
14003
|
exports.stripGlobalOnlyFields = stripGlobalOnlyFields;
|
|
12754
14004
|
exports.toAnalyticsDeviceSections = toAnalyticsDeviceSections;
|