@camstack/addon-post-analysis 1.1.30 → 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-U51kCBdm.js → dist-Cy4Tp-yp.js} +251 -8
- package/dist/{dist-yPsKFcJL.mjs → dist-D1cY_vlY.mjs} +246 -9
- package/dist/embedding-encoder/index.js +2 -2
- package/dist/embedding-encoder/index.mjs +2 -2
- package/dist/{node-BFF5_uIc.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-DeoVBjEB.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-Bd2d1PYo.mjs → hostInit-TIRXUvf0.mjs} +3 -3
- package/dist/pipeline-analytics/index.js +979 -54
- package/dist/pipeline-analytics/index.mjs +978 -53
- 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++;
|
|
@@ -2743,7 +2743,7 @@ function computeStationaryEntryZones(entry, zones) {
|
|
|
2743
2743
|
const matched = [];
|
|
2744
2744
|
for (const zone of zones) {
|
|
2745
2745
|
if (zone.polygon.length < 3) continue;
|
|
2746
|
-
if (pointInPolygon(point, zone.polygon)) matched.push(zone.id);
|
|
2746
|
+
if (pointInPolygon$1(point, zone.polygon)) matched.push(zone.id);
|
|
2747
2747
|
}
|
|
2748
2748
|
return matched;
|
|
2749
2749
|
}
|
|
@@ -2960,11 +2960,196 @@ var BindingCache = class {
|
|
|
2960
2960
|
}
|
|
2961
2961
|
};
|
|
2962
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
|
|
2963
3113
|
//#region src/pipeline-analytics/store/track-store.ts
|
|
2964
3114
|
var DEFAULT_CONFIG = {
|
|
2965
3115
|
ttlMs: 3e4,
|
|
2966
3116
|
maxPositionHistory: 300
|
|
2967
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
|
+
}
|
|
2968
3153
|
var TRACKS_COLLECTION = "pipeline-analytics:tracks";
|
|
2969
3154
|
var TRACKS_COLUMNS = [
|
|
2970
3155
|
{
|
|
@@ -3036,6 +3221,30 @@ var TRACKS_COLUMNS = [
|
|
|
3036
3221
|
{
|
|
3037
3222
|
name: "audioLabels",
|
|
3038
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"
|
|
3039
3248
|
}
|
|
3040
3249
|
];
|
|
3041
3250
|
var TRACKS_INDEXES = [{
|
|
@@ -3091,6 +3300,7 @@ var TrackStore = class {
|
|
|
3091
3300
|
config;
|
|
3092
3301
|
logger;
|
|
3093
3302
|
store;
|
|
3303
|
+
frameDims;
|
|
3094
3304
|
constructor(deps) {
|
|
3095
3305
|
this.logger = deps.logger;
|
|
3096
3306
|
this.store = deps.store;
|
|
@@ -3098,6 +3308,7 @@ var TrackStore = class {
|
|
|
3098
3308
|
...DEFAULT_CONFIG,
|
|
3099
3309
|
...deps.config
|
|
3100
3310
|
};
|
|
3311
|
+
this.frameDims = deps.frameDims;
|
|
3101
3312
|
}
|
|
3102
3313
|
/** One-time collection declaration. Call from addon onInitialize. */
|
|
3103
3314
|
static async declare(store) {
|
|
@@ -3444,21 +3655,200 @@ var TrackStore = class {
|
|
|
3444
3655
|
}
|
|
3445
3656
|
return [...seenDevices];
|
|
3446
3657
|
}
|
|
3447
|
-
/** 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. */
|
|
3448
3665
|
async queryHistorical(params) {
|
|
3449
|
-
const
|
|
3450
|
-
|
|
3451
|
-
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({
|
|
3452
3683
|
collection: TRACKS_COLLECTION,
|
|
3453
3684
|
filter: {
|
|
3454
|
-
|
|
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
|
+
},
|
|
3455
3693
|
orderBy: {
|
|
3456
3694
|
field: "firstSeen",
|
|
3457
3695
|
direction: "desc"
|
|
3458
3696
|
},
|
|
3459
|
-
limit
|
|
3697
|
+
limit
|
|
3698
|
+
}
|
|
3699
|
+
});
|
|
3700
|
+
const nullEnvQuery = this.store.query.query({
|
|
3701
|
+
collection: TRACKS_COLLECTION,
|
|
3702
|
+
filter: {
|
|
3703
|
+
where: { deviceId: params.deviceId },
|
|
3704
|
+
...Object.keys(timeBetween).length > 0 ? { whereBetween: timeBetween } : {},
|
|
3705
|
+
orderBy: {
|
|
3706
|
+
field: "firstSeen",
|
|
3707
|
+
direction: "desc"
|
|
3708
|
+
},
|
|
3709
|
+
limit
|
|
3460
3710
|
}
|
|
3461
|
-
})
|
|
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 } : {}
|
|
3781
|
+
}
|
|
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
|
+
}
|
|
3462
3852
|
}
|
|
3463
3853
|
async getPersistedByTrackId(trackId) {
|
|
3464
3854
|
const records = await this.store.query.query({
|
|
@@ -3473,6 +3863,8 @@ var TrackStore = class {
|
|
|
3473
3863
|
return this.rowToTrack(row.id, row.data);
|
|
3474
3864
|
}
|
|
3475
3865
|
async persistCompleted(t) {
|
|
3866
|
+
const dims = this.frameDims?.(t.deviceId);
|
|
3867
|
+
const envelope = computeTrackEnvelope(t.positions, dims?.w, dims?.h);
|
|
3476
3868
|
await this.store.set.mutate({
|
|
3477
3869
|
collection: TRACKS_COLLECTION,
|
|
3478
3870
|
key: t.trackId,
|
|
@@ -3491,13 +3883,30 @@ var TrackStore = class {
|
|
|
3491
3883
|
...t.importance !== void 0 ? { importance: t.importance } : {},
|
|
3492
3884
|
...t.bestEventId !== void 0 ? { bestEventId: t.bestEventId } : {},
|
|
3493
3885
|
...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {},
|
|
3494
|
-
...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
|
+
} : {}
|
|
3495
3895
|
}
|
|
3496
3896
|
});
|
|
3497
3897
|
}
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
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"] ?? [];
|
|
3501
3910
|
const zones = data["zonesVisited"] ?? [];
|
|
3502
3911
|
const classes = data["classes"];
|
|
3503
3912
|
const label = data["label"];
|
|
@@ -3505,6 +3914,16 @@ var TrackStore = class {
|
|
|
3505
3914
|
const bestEventId = data["bestEventId"];
|
|
3506
3915
|
const importanceReason = data["importanceReason"];
|
|
3507
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;
|
|
3508
3927
|
return {
|
|
3509
3928
|
trackId: id,
|
|
3510
3929
|
deviceId: Number(data["deviceId"]),
|
|
@@ -3522,7 +3941,8 @@ var TrackStore = class {
|
|
|
3522
3941
|
...typeof importance === "number" ? { importance } : {},
|
|
3523
3942
|
...typeof bestEventId === "string" ? { bestEventId } : {},
|
|
3524
3943
|
...typeof importanceReason === "string" ? { importanceReason } : {},
|
|
3525
|
-
...Array.isArray(audioLabels) && audioLabels.length > 0 ? { audioLabels } : {}
|
|
3944
|
+
...Array.isArray(audioLabels) && audioLabels.length > 0 ? { audioLabels } : {},
|
|
3945
|
+
...envelope !== null ? { envelope } : {}
|
|
3526
3946
|
};
|
|
3527
3947
|
}
|
|
3528
3948
|
};
|
|
@@ -4633,6 +5053,384 @@ function stripNulls(data) {
|
|
|
4633
5053
|
return out;
|
|
4634
5054
|
}
|
|
4635
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
|
|
4636
5434
|
//#region src/shared/frame/resolve-frame.ts
|
|
4637
5435
|
/**
|
|
4638
5436
|
* Resolve the pixels a `FrameHandle` refers to via the node-routed fetch.
|
|
@@ -4866,22 +5664,26 @@ var EventMediaDispatcher = class {
|
|
|
4866
5664
|
for (const tf of trackFrames) await this.writeTrackFrame(deviceId, frameData, fw, fh, tf);
|
|
4867
5665
|
const storedSnapshots = [];
|
|
4868
5666
|
for (const sn of snapshots) {
|
|
4869
|
-
const stored = await this.writeTrackSnapshot(deviceId, frameData, fw, fh, sn);
|
|
5667
|
+
const stored = await this.writeTrackSnapshot(deviceId, frameData, fw, fh, sn, input.cropPadding);
|
|
4870
5668
|
if (stored) storedSnapshots.push(stored);
|
|
4871
5669
|
}
|
|
4872
5670
|
return { storedSnapshots };
|
|
4873
5671
|
}
|
|
4874
5672
|
/**
|
|
4875
|
-
* Periodic per-track media (§5).
|
|
4876
|
-
*
|
|
4877
|
-
* (
|
|
4878
|
-
*
|
|
4879
|
-
*
|
|
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).
|
|
4880
5682
|
*/
|
|
4881
|
-
async writeTrackSnapshot(deviceId, frameData, fw, fh, sn) {
|
|
5683
|
+
async writeTrackSnapshot(deviceId, frameData, fw, fh, sn, cropPadding) {
|
|
4882
5684
|
if (!sn.appendSnapshot && !sn.rollingLastFrame && !sn.bestThumbnail) return null;
|
|
4883
|
-
let boxed;
|
|
4884
|
-
try {
|
|
5685
|
+
let boxed = null;
|
|
5686
|
+
if (sn.appendSnapshot || sn.rollingLastFrame) try {
|
|
4885
5687
|
boxed = await drawBoxedFrame(frameData, fw, fh, [{
|
|
4886
5688
|
...sn.bbox,
|
|
4887
5689
|
...sn.label ? { label: sn.label } : {}
|
|
@@ -4895,10 +5697,9 @@ var EventMediaDispatcher = class {
|
|
|
4895
5697
|
error: err instanceof Error ? err.message : String(err)
|
|
4896
5698
|
}
|
|
4897
5699
|
});
|
|
4898
|
-
return null;
|
|
4899
5700
|
}
|
|
4900
5701
|
let stored = null;
|
|
4901
|
-
if (sn.appendSnapshot) try {
|
|
5702
|
+
if (sn.appendSnapshot && boxed) try {
|
|
4902
5703
|
const mediaKey = await this.deps.mediaStore.put({
|
|
4903
5704
|
deviceId,
|
|
4904
5705
|
ownerKind: "track",
|
|
@@ -4914,10 +5715,49 @@ var EventMediaDispatcher = class {
|
|
|
4914
5715
|
bbox: sn.bbox
|
|
4915
5716
|
};
|
|
4916
5717
|
} catch {}
|
|
4917
|
-
if (sn.rollingLastFrame) await this.replaceKind(deviceId, sn.trackId, "lastFrame", sn.timestamp, boxed);
|
|
4918
|
-
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
|
+
}
|
|
4919
5733
|
return stored;
|
|
4920
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
|
+
}
|
|
4921
5761
|
async replaceKind(deviceId, trackId, kind, timestamp, data) {
|
|
4922
5762
|
try {
|
|
4923
5763
|
await this.deps.mediaStore.putReplacing({
|
|
@@ -4945,24 +5785,7 @@ var EventMediaDispatcher = class {
|
|
|
4945
5785
|
label: caption(ev.className, ev.confidence, ev.label)
|
|
4946
5786
|
};
|
|
4947
5787
|
try {
|
|
4948
|
-
const
|
|
4949
|
-
W: fw,
|
|
4950
|
-
H: fh
|
|
4951
|
-
}, cropPadding);
|
|
4952
|
-
const left = Math.max(0, Math.min(region.x, fw - 1));
|
|
4953
|
-
const top = Math.max(0, Math.min(region.y, fh - 1));
|
|
4954
|
-
const width = Math.max(1, Math.min(region.w, fw - left));
|
|
4955
|
-
const height = Math.max(1, Math.min(region.h, fh - top));
|
|
4956
|
-
const crop = await (0, sharp.default)(frameData, { raw: {
|
|
4957
|
-
width: fw,
|
|
4958
|
-
height: fh,
|
|
4959
|
-
channels: 3
|
|
4960
|
-
} }).extract({
|
|
4961
|
-
left,
|
|
4962
|
-
top,
|
|
4963
|
-
width,
|
|
4964
|
-
height
|
|
4965
|
-
}).resize(CROP_WIDTH, CROP_HEIGHT).jpeg({ quality: CROP_QUALITY }).toBuffer();
|
|
5788
|
+
const crop = await this.cropSubjectRegion(frameData, fw, fh, ev.bbox, cropPadding);
|
|
4966
5789
|
await this.deps.mediaStore.put({
|
|
4967
5790
|
deviceId,
|
|
4968
5791
|
ownerKind: "event",
|
|
@@ -9732,6 +10555,11 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
9732
10555
|
stationaryRegistry = null;
|
|
9733
10556
|
mediaStore = null;
|
|
9734
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;
|
|
9735
10563
|
identityStore = null;
|
|
9736
10564
|
faceStore = null;
|
|
9737
10565
|
faceRecognizer = null;
|
|
@@ -9782,6 +10610,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
9782
10610
|
unsubNativeDetection = null;
|
|
9783
10611
|
unsubBindings = null;
|
|
9784
10612
|
unsubDeviceUnreg = null;
|
|
10613
|
+
/** DeviceStateChanged subscription feeding the sensor-event history. */
|
|
10614
|
+
unsubDeviceState = null;
|
|
9785
10615
|
ttlSweepTimer = null;
|
|
9786
10616
|
retentionSweepTimer = null;
|
|
9787
10617
|
/** Handle for the event-media data-plane listener (dispose on shutdown). */
|
|
@@ -9877,6 +10707,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
9877
10707
|
await TrackStore.declare(api.settingsStore);
|
|
9878
10708
|
await MediaStore.declare(api.settingsStore);
|
|
9879
10709
|
await EventStore.declare(api.settingsStore);
|
|
10710
|
+
await SensorEventStore.declare(api.settingsStore);
|
|
9880
10711
|
await IdentityStore.declare(api.settingsStore);
|
|
9881
10712
|
await FaceStore.declare(api.settingsStore);
|
|
9882
10713
|
await PlateStore.declare(api.settingsStore);
|
|
@@ -9887,14 +10718,15 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
9887
10718
|
let storage = this.ctx.kernel.storage;
|
|
9888
10719
|
const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
|
|
9889
10720
|
if (mediaRoot) {
|
|
9890
|
-
const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-
|
|
10721
|
+
const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-qtD7WAqr.js"));
|
|
9891
10722
|
storage = new FilesystemStorageProvider(mediaRoot);
|
|
9892
10723
|
logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
|
|
9893
10724
|
}
|
|
9894
10725
|
if (!storage) throw new Error("pipeline-analytics requires ctx.kernel.storage");
|
|
9895
10726
|
this.trackStore = new TrackStore({
|
|
9896
10727
|
store: api.settingsStore,
|
|
9897
|
-
logger: logger.child("TrackStore")
|
|
10728
|
+
logger: logger.child("TrackStore"),
|
|
10729
|
+
frameDims: (deviceId) => this.lastFrameDimsByDevice.get(deviceId)
|
|
9898
10730
|
});
|
|
9899
10731
|
this.stationaryRegistry = new StationaryObjectRegistry({
|
|
9900
10732
|
store: api.settingsStore,
|
|
@@ -9930,6 +10762,20 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
9930
10762
|
logger: logger.child("EventStore"),
|
|
9931
10763
|
media: this.mediaStore
|
|
9932
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
|
+
});
|
|
9933
10779
|
this.identityStore = new IdentityStore({
|
|
9934
10780
|
store: api.settingsStore,
|
|
9935
10781
|
logger: logger.child("IdentityStore")
|
|
@@ -10156,6 +11002,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
10156
11002
|
const data = ev.data;
|
|
10157
11003
|
this.handleNativeDetection(data);
|
|
10158
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
|
+
});
|
|
10159
11011
|
if (await this.embeddingEnabledState.get()) {
|
|
10160
11012
|
const encoderClient = {
|
|
10161
11013
|
encode: (crop, width, height) => this.ctx.api.embeddingEncoder.encode.query({
|
|
@@ -10521,6 +11373,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
10521
11373
|
this.unsubBindings = null;
|
|
10522
11374
|
this.unsubDeviceUnreg?.();
|
|
10523
11375
|
this.unsubDeviceUnreg = null;
|
|
11376
|
+
this.unsubDeviceState?.();
|
|
11377
|
+
this.unsubDeviceState = null;
|
|
10524
11378
|
await this.embeddingDispatcher?.stop();
|
|
10525
11379
|
this.embeddingDispatcher = null;
|
|
10526
11380
|
for (const id of this.proxies.keys()) this.releaseProxy(id);
|
|
@@ -11783,6 +12637,15 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
11783
12637
|
} });
|
|
11784
12638
|
}
|
|
11785
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
|
+
}
|
|
11786
12649
|
if (this.faceStore) try {
|
|
11787
12650
|
const faceCutoffMs = now - FACE_DEFAULTS.bufferRetentionDays * day;
|
|
11788
12651
|
const deletedFaceIds = await this.faceStore.pruneAll({
|
|
@@ -12088,6 +12951,68 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
12088
12951
|
async listTracks(input) {
|
|
12089
12952
|
return this.trackStore?.queryHistorical(input) ?? [];
|
|
12090
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
|
+
}
|
|
12091
13016
|
async clearTracks(input) {
|
|
12092
13017
|
this.trackStore?.clearDevice(input.deviceId);
|
|
12093
13018
|
this.stationaryRegistry?.clearDevice(input.deviceId);
|