@camstack/addon-post-analysis 1.1.28 → 1.1.29
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-Bnb58pyL.js → dist-AFLbpmAs.js} +94 -6
- package/dist/{dist-Blpsv-M0.mjs → dist-CFjLqX2m.mjs} +94 -6
- package/dist/embedding-encoder/index.js +2 -2
- package/dist/embedding-encoder/index.mjs +2 -2
- package/dist/{node-Cvhwrf43.js → node-DtltlqrH.js} +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-CZZpqsjV.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-D0V4LPAq.mjs} +1 -1
- package/dist/pipeline-analytics/{hostInit-D-KSUwyU.mjs → hostInit-B71dWJNT.mjs} +1 -1
- package/dist/pipeline-analytics/index.js +1391 -187
- package/dist/pipeline-analytics/index.mjs +1390 -186
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -2
|
@@ -2,11 +2,10 @@ 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-AFLbpmAs.js");
|
|
6
6
|
let node_crypto = require("node:crypto");
|
|
7
7
|
let sharp = require("sharp");
|
|
8
8
|
sharp = require_dist.__toESM(sharp);
|
|
9
|
-
let _camstack_shm_ring = require("@camstack/shm-ring");
|
|
10
9
|
//#region src/pipeline-analytics/videoclips-provider.ts
|
|
11
10
|
var SOURCE = "analytics";
|
|
12
11
|
function clipIdFor(eventId, startMs, endMs) {
|
|
@@ -151,6 +150,17 @@ var WEIGHT_IDENTITY = .05;
|
|
|
151
150
|
var DWELL_FULL_MS = 6e4;
|
|
152
151
|
/** Peak bbox area (as a fraction of frame area) that saturates the size term. */
|
|
153
152
|
var SIZE_FULL = .15;
|
|
153
|
+
/** A track whose net centroid displacement stays below this fraction of the
|
|
154
|
+
* frame diagonal over its whole life counts as stationary on the net signal. */
|
|
155
|
+
var STATIC_DISPLACEMENT_FRAC = .02;
|
|
156
|
+
/** ...and whose entire centroid path fits within this fraction of the frame
|
|
157
|
+
* diagonal counts as stationary on the span signal. Both must hold. */
|
|
158
|
+
var STATIC_SPAN_FRAC = .04;
|
|
159
|
+
/** Importance multiplier applied to a stationary track that has NO resolved
|
|
160
|
+
* identity — strongly demotes "ghost" detections on static objects (a pile of
|
|
161
|
+
* clothes read as a person, a kitchen object read as an animal) below the
|
|
162
|
+
* key-event threshold. Identified tracks (face/plate) are never suppressed. */
|
|
163
|
+
var STATIC_IMPORTANCE_MULTIPLIER = .1;
|
|
154
164
|
var CLASS_RANK_VEHICLE = .8;
|
|
155
165
|
var CLASS_RANK_ANIMAL = .5;
|
|
156
166
|
var CLASS_RANK_DEFAULT = .25;
|
|
@@ -225,6 +235,11 @@ function computeImportance(input) {
|
|
|
225
235
|
sum += term.value;
|
|
226
236
|
if (term.value > best.value) best = term;
|
|
227
237
|
}
|
|
238
|
+
const identified = input.label !== void 0 && input.label.length > 0;
|
|
239
|
+
if (input.netDisplacementFrac !== void 0 && input.pathSpanFrac !== void 0 && input.netDisplacementFrac < .02 && input.pathSpanFrac < .04 && !identified) return {
|
|
240
|
+
importance: clamp01(sum * STATIC_IMPORTANCE_MULTIPLIER),
|
|
241
|
+
reason: best.reason
|
|
242
|
+
};
|
|
228
243
|
return {
|
|
229
244
|
importance: clamp01(sum),
|
|
230
245
|
reason: best.reason
|
|
@@ -618,6 +633,20 @@ function normalizePlate(text) {
|
|
|
618
633
|
return text.toUpperCase().replace(/[^A-Z0-9]/g, "");
|
|
619
634
|
}
|
|
620
635
|
/**
|
|
636
|
+
* Quality gate for a raw OCR plate read BEFORE it becomes a track label or a
|
|
637
|
+
* gallery row. Distant/oblique parked plates produce junk reads ("N", "Idag",
|
|
638
|
+
* "@em") that otherwise flood track labels (live-observed on the parking
|
|
639
|
+
* camera, 2026-07-16). A plausible European plate is ≥{@link PLATE_MIN_LENGTH}
|
|
640
|
+
* alphanumerics and mixes letters AND digits; anything else — or a read below
|
|
641
|
+
* {@link PLATE_MIN_SCORE} — is discarded, not stored.
|
|
642
|
+
*/
|
|
643
|
+
function isPlausiblePlateRead(text, score) {
|
|
644
|
+
if (score < .4) return false;
|
|
645
|
+
const norm = normalizePlate(text);
|
|
646
|
+
if (norm.length < 4) return false;
|
|
647
|
+
return /[0-9]/.test(norm) && /[A-Z]/.test(norm);
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
621
650
|
* Fold characters that OCR routinely confuses onto a single canonical symbol, so
|
|
622
651
|
* "AB0" and "ABO" compare as equal. Applied to BOTH operands of a distance
|
|
623
652
|
* comparison only — never stored. Conservative set covering the common Latin
|
|
@@ -1102,7 +1131,7 @@ var MAX_PATH_LENGTH = 300;
|
|
|
1102
1131
|
function clamp(value, min, max) {
|
|
1103
1132
|
return Math.max(min, Math.min(max, value));
|
|
1104
1133
|
}
|
|
1105
|
-
function iou$
|
|
1134
|
+
function iou$2(a, b) {
|
|
1106
1135
|
const ax1 = a.x, ay1 = a.y, ax2 = a.x + a.w, ay2 = a.y + a.h;
|
|
1107
1136
|
const bx1 = b.x, by1 = b.y, bx2 = b.x + b.w, by2 = b.y + b.h;
|
|
1108
1137
|
const ix1 = Math.max(ax1, bx1), iy1 = Math.max(ay1, by1);
|
|
@@ -1157,7 +1186,7 @@ var SortTracker = class {
|
|
|
1157
1186
|
*/
|
|
1158
1187
|
looseMatch(track, det) {
|
|
1159
1188
|
if (track.class !== det.class) return false;
|
|
1160
|
-
if (iou$
|
|
1189
|
+
if (iou$2(track.bbox, det.bbox) >= this.config.rescueIouThreshold) return true;
|
|
1161
1190
|
const tc = bboxCentroid(track.bbox);
|
|
1162
1191
|
const dc = bboxCentroid(det.bbox);
|
|
1163
1192
|
const dist = Math.hypot(tc.x - dc.x, tc.y - dc.y);
|
|
@@ -1185,7 +1214,7 @@ var SortTracker = class {
|
|
|
1185
1214
|
for (let di = 0; di < detections.length; di++) {
|
|
1186
1215
|
const det = detections[di];
|
|
1187
1216
|
if (this.config.classGating && track.class !== det.class) continue;
|
|
1188
|
-
const score = iou$
|
|
1217
|
+
const score = iou$2(pbox, det.bbox);
|
|
1189
1218
|
if (score >= this.config.iouThreshold) pairs.push({
|
|
1190
1219
|
track,
|
|
1191
1220
|
detIdx: di,
|
|
@@ -1210,7 +1239,7 @@ var SortTracker = class {
|
|
|
1210
1239
|
rescuePairs.push({
|
|
1211
1240
|
track,
|
|
1212
1241
|
detIdx: di,
|
|
1213
|
-
score: iou$
|
|
1242
|
+
score: iou$2(track.bbox, det.bbox)
|
|
1214
1243
|
});
|
|
1215
1244
|
}
|
|
1216
1245
|
}
|
|
@@ -1275,7 +1304,7 @@ var SortTracker = class {
|
|
|
1275
1304
|
if (!lost.resurrectable) continue;
|
|
1276
1305
|
if (timestamp - lost.lostAt > this.config.resurrectionWindowMs) continue;
|
|
1277
1306
|
if (!this.looseMatch(lost, det)) continue;
|
|
1278
|
-
const score = iou$
|
|
1307
|
+
const score = iou$2(lost.bbox, det.bbox);
|
|
1279
1308
|
if (score > bestScore) {
|
|
1280
1309
|
best = lost;
|
|
1281
1310
|
bestScore = score;
|
|
@@ -1336,6 +1365,16 @@ var SortTracker = class {
|
|
|
1336
1365
|
path: [...t.path]
|
|
1337
1366
|
}));
|
|
1338
1367
|
}
|
|
1368
|
+
/**
|
|
1369
|
+
* Remove a track from BOTH the live and graveyard sets so it neither emits
|
|
1370
|
+
* nor resurrects. Used when a track is PROMOTED to a stationary-object
|
|
1371
|
+
* registry entry: the registry now owns that parked object, and the tracker
|
|
1372
|
+
* must forget it so the object's detections don't re-spawn a duplicate track.
|
|
1373
|
+
*/
|
|
1374
|
+
dropTrack(trackId) {
|
|
1375
|
+
this.tracks = this.tracks.filter((t) => t.id !== trackId);
|
|
1376
|
+
this.lostTracks = this.lostTracks.filter((t) => t.id !== trackId);
|
|
1377
|
+
}
|
|
1339
1378
|
getActiveTracks() {
|
|
1340
1379
|
return this.tracks;
|
|
1341
1380
|
}
|
|
@@ -1749,6 +1788,9 @@ var FrameProcessor = class {
|
|
|
1749
1788
|
*/
|
|
1750
1789
|
detectionRules;
|
|
1751
1790
|
zoneEngine = new ZoneEngine();
|
|
1791
|
+
/** Optional stationary-object gate (parked-object suppression). Null until
|
|
1792
|
+
* the addon wires it via {@link setStationaryGate}. */
|
|
1793
|
+
stationaryGate = null;
|
|
1752
1794
|
constructor(deviceId, trackerConfig = {}, stateConfig = {}, emitterConfig = {}, source = "pipeline") {
|
|
1753
1795
|
this.deviceId = deviceId;
|
|
1754
1796
|
this.source = source;
|
|
@@ -1764,6 +1806,16 @@ var FrameProcessor = class {
|
|
|
1764
1806
|
setDetectionRules(rules) {
|
|
1765
1807
|
this.detectionRules = rules;
|
|
1766
1808
|
}
|
|
1809
|
+
/** Wire (or clear) the stationary-object gate. Called by the addon per
|
|
1810
|
+
* device so parked-object suppression applies from the next frame. */
|
|
1811
|
+
setStationaryGate(gate) {
|
|
1812
|
+
this.stationaryGate = gate;
|
|
1813
|
+
}
|
|
1814
|
+
/** Forget a track in the underlying tracker (used when the addon promotes it
|
|
1815
|
+
* to a stationary-object registry entry). */
|
|
1816
|
+
dropTrack(trackId) {
|
|
1817
|
+
this.tracker.dropTrack(trackId);
|
|
1818
|
+
}
|
|
1767
1819
|
process(input) {
|
|
1768
1820
|
const { timestamp, frame } = input;
|
|
1769
1821
|
const frameWidth = frame.width;
|
|
@@ -1842,7 +1894,17 @@ var FrameProcessor = class {
|
|
|
1842
1894
|
}
|
|
1843
1895
|
const { passed } = this.zoneEngine.filterDetections(flatDetections, this.zones, this.detectionRules, frameWidth, frameHeight, (fd) => fd.detection.class);
|
|
1844
1896
|
const filteredDetections = passed.map((fd) => fd.detection);
|
|
1845
|
-
const
|
|
1897
|
+
const gate = this.stationaryGate ? this.stationaryGate.filter({
|
|
1898
|
+
detections: filteredDetections,
|
|
1899
|
+
frameWidth,
|
|
1900
|
+
frameHeight
|
|
1901
|
+
}) : {
|
|
1902
|
+
suppressedIndices: /* @__PURE__ */ new Set(),
|
|
1903
|
+
confirmed: [],
|
|
1904
|
+
wokenEntryIds: []
|
|
1905
|
+
};
|
|
1906
|
+
const trackerInput = gate.suppressedIndices.size > 0 ? filteredDetections.filter((_, i) => !gate.suppressedIndices.has(i)) : filteredDetections;
|
|
1907
|
+
const trackedDetections = this.tracker.update(trackerInput, timestamp);
|
|
1846
1908
|
const objectStates = this.stateAnalyzer.analyze(trackedDetections, timestamp);
|
|
1847
1909
|
const rawEvents = this.eventEmitter.emit(trackedDetections, objectStates, [], [], String(this.deviceId));
|
|
1848
1910
|
const zonesByTrack = /* @__PURE__ */ new Map();
|
|
@@ -1924,7 +1986,9 @@ var FrameProcessor = class {
|
|
|
1924
1986
|
frameHeight,
|
|
1925
1987
|
tracked,
|
|
1926
1988
|
objectEvents,
|
|
1927
|
-
rawTrackedDetections: trackedDetections
|
|
1989
|
+
rawTrackedDetections: trackedDetections,
|
|
1990
|
+
stationaryConfirmed: gate.confirmed,
|
|
1991
|
+
stationaryWoken: gate.wokenEntryIds
|
|
1928
1992
|
};
|
|
1929
1993
|
}
|
|
1930
1994
|
};
|
|
@@ -2001,6 +2065,7 @@ function buildTrackLifecyclePayload(input) {
|
|
|
2001
2065
|
...input.positionsCount !== void 0 ? { positionsCount: input.positionsCount } : {},
|
|
2002
2066
|
...input.importance !== void 0 ? { importance: input.importance } : {},
|
|
2003
2067
|
...input.importanceReason !== void 0 ? { importanceReason: input.importanceReason } : {},
|
|
2068
|
+
...input.audioLabels !== void 0 && input.audioLabels.length > 0 ? { audioLabels: input.audioLabels } : {},
|
|
2004
2069
|
...input.embeddingId !== void 0 ? { embeddingId: input.embeddingId } : {},
|
|
2005
2070
|
...input.embeddingModelId !== void 0 ? { embeddingModelId: input.embeddingModelId } : {},
|
|
2006
2071
|
...hasMedia ? { media } : {}
|
|
@@ -2117,6 +2182,555 @@ function resolveSearchThumbnailUrl(input) {
|
|
|
2117
2182
|
return `${input.baseUrl}/${encodeURIComponent(id)}`;
|
|
2118
2183
|
}
|
|
2119
2184
|
//#endregion
|
|
2185
|
+
//#region src/pipeline-analytics/pipeline/static-track-gate.ts
|
|
2186
|
+
/**
|
|
2187
|
+
* Net displacement + path span for a track's centroid path, normalized to
|
|
2188
|
+
* `referenceDiagonalPx`. Returns undefined when there is nothing meaningful to
|
|
2189
|
+
* measure (fewer than two points, or a degenerate ≤0 reference) so the caller
|
|
2190
|
+
* leaves the importance score untouched.
|
|
2191
|
+
*/
|
|
2192
|
+
function computeStaticTrackMetrics(centroids, referenceDiagonalPx) {
|
|
2193
|
+
if (!(referenceDiagonalPx > 0) || centroids.length < 2) return void 0;
|
|
2194
|
+
const first = centroids[0];
|
|
2195
|
+
const last = centroids[centroids.length - 1];
|
|
2196
|
+
const netDisplacementFrac = Math.hypot(last.x - first.x, last.y - first.y) / referenceDiagonalPx;
|
|
2197
|
+
let minX = Infinity;
|
|
2198
|
+
let minY = Infinity;
|
|
2199
|
+
let maxX = -Infinity;
|
|
2200
|
+
let maxY = -Infinity;
|
|
2201
|
+
for (const c of centroids) {
|
|
2202
|
+
if (c.x < minX) minX = c.x;
|
|
2203
|
+
if (c.x > maxX) maxX = c.x;
|
|
2204
|
+
if (c.y < minY) minY = c.y;
|
|
2205
|
+
if (c.y > maxY) maxY = c.y;
|
|
2206
|
+
}
|
|
2207
|
+
return {
|
|
2208
|
+
netDisplacementFrac,
|
|
2209
|
+
pathSpanFrac: Math.hypot(maxX - minX, maxY - minY) / referenceDiagonalPx
|
|
2210
|
+
};
|
|
2211
|
+
}
|
|
2212
|
+
/** Average bbox diagonal (px) across a track's positions — the scale reference
|
|
2213
|
+
* when frame dimensions aren't available. Returns 0 for an empty list. */
|
|
2214
|
+
function averageBboxDiagonal(boxes) {
|
|
2215
|
+
if (boxes.length === 0) return 0;
|
|
2216
|
+
let sum = 0;
|
|
2217
|
+
for (const b of boxes) sum += Math.hypot(b.w, b.h);
|
|
2218
|
+
return sum / boxes.length;
|
|
2219
|
+
}
|
|
2220
|
+
//#endregion
|
|
2221
|
+
//#region src/pipeline-analytics/pipeline/stationary/stationary-types.ts
|
|
2222
|
+
function entryToView(e) {
|
|
2223
|
+
return {
|
|
2224
|
+
id: e.id,
|
|
2225
|
+
className: e.className,
|
|
2226
|
+
bbox: { ...e.bbox },
|
|
2227
|
+
frameWidth: e.frameWidth,
|
|
2228
|
+
frameHeight: e.frameHeight,
|
|
2229
|
+
firstSeenAt: e.firstSeenAt,
|
|
2230
|
+
becameStationaryAt: e.becameStationaryAt,
|
|
2231
|
+
lastConfirmedAt: e.lastConfirmedAt,
|
|
2232
|
+
...e.label !== void 0 ? { label: e.label } : {},
|
|
2233
|
+
...e.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: e.keyFrameMediaKey } : {}
|
|
2234
|
+
};
|
|
2235
|
+
}
|
|
2236
|
+
/** IoU at/above which a detection is "the same parked object" → suppress its
|
|
2237
|
+
* spawn and refresh the entry's `lastConfirmedAt`. Matches decision #2 (0.6). */
|
|
2238
|
+
var SUPPRESS_IOU = .6;
|
|
2239
|
+
/** Centroid move (fraction of the frame diagonal) beyond which a near
|
|
2240
|
+
* same-class detection means the parked object actually MOVED → wake. */
|
|
2241
|
+
var WAKE_MOVE_FRAC = .08;
|
|
2242
|
+
/** How near (fraction of frame diagonal) a same-class detection's centroid must
|
|
2243
|
+
* be to an entry to be considered "this entry's object" when testing for a
|
|
2244
|
+
* wake. Keeps an unrelated object elsewhere in the frame from waking it. */
|
|
2245
|
+
var WAKE_SEARCH_FRAC = .5;
|
|
2246
|
+
/**
|
|
2247
|
+
* Look-back window over which a track must have stayed put to be PROMOTED. A
|
|
2248
|
+
* car that drives in then parks has a large whole-life displacement but a tiny
|
|
2249
|
+
* last-`windowMs` displacement — so promotion is judged on the recent window,
|
|
2250
|
+
* not the full path. Also the minimum age (the track must have EXISTED this
|
|
2251
|
+
* long) so a freshly-spawned static blob isn't promoted instantly.
|
|
2252
|
+
*/
|
|
2253
|
+
var PROMOTION_WINDOW_MS = 3e4;
|
|
2254
|
+
var DEFAULT_MATCH_CONFIG = {
|
|
2255
|
+
suppressIou: SUPPRESS_IOU,
|
|
2256
|
+
wakeMoveFrac: WAKE_MOVE_FRAC,
|
|
2257
|
+
wakeSearchFrac: WAKE_SEARCH_FRAC
|
|
2258
|
+
};
|
|
2259
|
+
//#endregion
|
|
2260
|
+
//#region src/pipeline-analytics/pipeline/stationary/stationary-match.ts
|
|
2261
|
+
/**
|
|
2262
|
+
* stationary-match — PURE geometry for the stationary-object registry.
|
|
2263
|
+
*
|
|
2264
|
+
* Two decisions, both unit-testable in isolation:
|
|
2265
|
+
*
|
|
2266
|
+
* 1. `partitionDetectionsAgainstRegistry` — given the current registry entries
|
|
2267
|
+
* and this frame's (zone-filtered) detections, decides which detections are
|
|
2268
|
+
* suppressed (they keep confirming a known parked object → no track spawns),
|
|
2269
|
+
* which entries are confirmed present, and which entries WOKE (their object
|
|
2270
|
+
* moved → retire the entry and let the detection spawn a normal track).
|
|
2271
|
+
*
|
|
2272
|
+
* 2. `evaluateStationaryPromotion` — given a track's recent centroid path,
|
|
2273
|
+
* decides whether it has stayed put long enough to become a stationary
|
|
2274
|
+
* entry. Reuses the static-track-gate metrics (net displacement + path span
|
|
2275
|
+
* normalised to the frame diagonal) over the recent look-back window.
|
|
2276
|
+
*/
|
|
2277
|
+
/** Default promotion tunables — static thresholds shared with the key-event
|
|
2278
|
+
* static gate so "stationary" means the same thing in both places. */
|
|
2279
|
+
var DEFAULT_PROMOTION_CONFIG = {
|
|
2280
|
+
windowMs: PROMOTION_WINDOW_MS,
|
|
2281
|
+
netFracMax: STATIC_DISPLACEMENT_FRAC,
|
|
2282
|
+
spanFracMax: STATIC_SPAN_FRAC,
|
|
2283
|
+
minPoints: 4
|
|
2284
|
+
};
|
|
2285
|
+
function iou$1(a, b) {
|
|
2286
|
+
const ax2 = a.x + a.w;
|
|
2287
|
+
const ay2 = a.y + a.h;
|
|
2288
|
+
const bx2 = b.x + b.w;
|
|
2289
|
+
const by2 = b.y + b.h;
|
|
2290
|
+
const ix1 = Math.max(a.x, b.x);
|
|
2291
|
+
const iy1 = Math.max(a.y, b.y);
|
|
2292
|
+
const ix2 = Math.min(ax2, bx2);
|
|
2293
|
+
const iy2 = Math.min(ay2, by2);
|
|
2294
|
+
const inter = Math.max(0, ix2 - ix1) * Math.max(0, iy2 - iy1);
|
|
2295
|
+
const union = a.w * a.h + b.w * b.h - inter;
|
|
2296
|
+
return union > 0 ? inter / union : 0;
|
|
2297
|
+
}
|
|
2298
|
+
function centroid(b) {
|
|
2299
|
+
return {
|
|
2300
|
+
x: b.x + b.w / 2,
|
|
2301
|
+
y: b.y + b.h / 2
|
|
2302
|
+
};
|
|
2303
|
+
}
|
|
2304
|
+
function diagonalOf(width, height) {
|
|
2305
|
+
return Math.hypot(width, height);
|
|
2306
|
+
}
|
|
2307
|
+
/**
|
|
2308
|
+
* Decide, per stationary entry, whether the current frame confirms it, wakes
|
|
2309
|
+
* it, or misses it (no matching detection — leave it for the TTL sweep).
|
|
2310
|
+
*
|
|
2311
|
+
* Per entry, over same-class detections:
|
|
2312
|
+
* - best IoU ≥ `suppressIou` → SUPPRESS the best-overlap detection (it is the
|
|
2313
|
+
* parked object, unmoved) and mark the entry confirmed.
|
|
2314
|
+
* - else if a same-class detection sits within `wakeSearchFrac × diag` of the
|
|
2315
|
+
* entry centroid but has moved > `wakeMoveFrac × diag` → WAKE the entry (the
|
|
2316
|
+
* object slid out of its parked box). The detection is NOT suppressed, so it
|
|
2317
|
+
* spawns a fresh moving track.
|
|
2318
|
+
* - else → MISS (occlusion / brief absence): neither suppress nor wake.
|
|
2319
|
+
*
|
|
2320
|
+
* A detection can suppress at most one spawn even if it overlaps two entries
|
|
2321
|
+
* (`suppressedIndices` is a set).
|
|
2322
|
+
*/
|
|
2323
|
+
function partitionDetectionsAgainstRegistry(input) {
|
|
2324
|
+
const { entries, detections, referenceDiagonalPx, config } = input;
|
|
2325
|
+
const suppressed = /* @__PURE__ */ new Set();
|
|
2326
|
+
const confirmed = [];
|
|
2327
|
+
const woken = [];
|
|
2328
|
+
const diag = referenceDiagonalPx;
|
|
2329
|
+
for (const entry of entries) {
|
|
2330
|
+
const ec = centroid(entry.bbox);
|
|
2331
|
+
let bestIou = 0;
|
|
2332
|
+
let bestIdx = -1;
|
|
2333
|
+
let wakeCandidate = false;
|
|
2334
|
+
for (let di = 0; di < detections.length; di++) {
|
|
2335
|
+
const det = detections[di];
|
|
2336
|
+
if (det.className !== entry.className) continue;
|
|
2337
|
+
const o = iou$1(entry.bbox, det.bbox);
|
|
2338
|
+
if (o > bestIou) {
|
|
2339
|
+
bestIou = o;
|
|
2340
|
+
bestIdx = di;
|
|
2341
|
+
}
|
|
2342
|
+
if (diag > 0) {
|
|
2343
|
+
const dc = centroid(det.bbox);
|
|
2344
|
+
const dist = Math.hypot(dc.x - ec.x, dc.y - ec.y);
|
|
2345
|
+
if (dist <= config.wakeSearchFrac * diag && dist > config.wakeMoveFrac * diag) wakeCandidate = true;
|
|
2346
|
+
}
|
|
2347
|
+
}
|
|
2348
|
+
if (bestIou >= config.suppressIou && bestIdx >= 0) {
|
|
2349
|
+
suppressed.add(bestIdx);
|
|
2350
|
+
confirmed.push({
|
|
2351
|
+
entryId: entry.id,
|
|
2352
|
+
className: entry.className,
|
|
2353
|
+
bbox: { ...entry.bbox }
|
|
2354
|
+
});
|
|
2355
|
+
} else if (wakeCandidate) woken.push(entry.id);
|
|
2356
|
+
}
|
|
2357
|
+
return {
|
|
2358
|
+
suppressedIndices: suppressed,
|
|
2359
|
+
confirmed,
|
|
2360
|
+
wokenEntryIds: woken
|
|
2361
|
+
};
|
|
2362
|
+
}
|
|
2363
|
+
/**
|
|
2364
|
+
* A track is promoted when, over the recent `windowMs`, its centroid barely
|
|
2365
|
+
* moved (both net displacement and path span below the static thresholds) AND
|
|
2366
|
+
* the track has actually EXISTED for at least `windowMs` (so a car that just
|
|
2367
|
+
* arrived isn't parked yet). Judging on the recent window — not the whole life
|
|
2368
|
+
* — is what lets a car that drove in then parked be recognised as stationary.
|
|
2369
|
+
*/
|
|
2370
|
+
function evaluateStationaryPromotion(input) {
|
|
2371
|
+
const { positions, referenceDiagonalPx, now, config } = input;
|
|
2372
|
+
if (!(referenceDiagonalPx > 0) || positions.length === 0) return { promote: false };
|
|
2373
|
+
if (now - positions[0].timestamp < config.windowMs) return { promote: false };
|
|
2374
|
+
const cutoff = now - config.windowMs;
|
|
2375
|
+
const window = positions.filter((p) => p.timestamp >= cutoff);
|
|
2376
|
+
if (window.length < config.minPoints) return { promote: false };
|
|
2377
|
+
const metrics = computeStaticTrackMetrics(window.map((p) => ({
|
|
2378
|
+
x: p.x,
|
|
2379
|
+
y: p.y
|
|
2380
|
+
})), referenceDiagonalPx);
|
|
2381
|
+
if (metrics === void 0) return { promote: false };
|
|
2382
|
+
return {
|
|
2383
|
+
promote: metrics.netDisplacementFrac < config.netFracMax && metrics.pathSpanFrac < config.spanFracMax,
|
|
2384
|
+
netFrac: metrics.netDisplacementFrac,
|
|
2385
|
+
spanFrac: metrics.pathSpanFrac
|
|
2386
|
+
};
|
|
2387
|
+
}
|
|
2388
|
+
//#endregion
|
|
2389
|
+
//#region src/pipeline-analytics/pipeline/stationary/stationary-registry.ts
|
|
2390
|
+
var STATIONARY_COLLECTION = "pipeline-analytics:stationary-objects";
|
|
2391
|
+
var STATIONARY_COLUMNS = [
|
|
2392
|
+
{
|
|
2393
|
+
name: "id",
|
|
2394
|
+
type: "TEXT",
|
|
2395
|
+
primaryKey: true,
|
|
2396
|
+
notNull: true
|
|
2397
|
+
},
|
|
2398
|
+
{
|
|
2399
|
+
name: "deviceId",
|
|
2400
|
+
type: "INTEGER",
|
|
2401
|
+
notNull: true
|
|
2402
|
+
},
|
|
2403
|
+
{
|
|
2404
|
+
name: "className",
|
|
2405
|
+
type: "TEXT",
|
|
2406
|
+
notNull: true
|
|
2407
|
+
},
|
|
2408
|
+
{
|
|
2409
|
+
name: "bbox",
|
|
2410
|
+
type: "JSON"
|
|
2411
|
+
},
|
|
2412
|
+
{
|
|
2413
|
+
name: "frameWidth",
|
|
2414
|
+
type: "INTEGER"
|
|
2415
|
+
},
|
|
2416
|
+
{
|
|
2417
|
+
name: "frameHeight",
|
|
2418
|
+
type: "INTEGER"
|
|
2419
|
+
},
|
|
2420
|
+
{
|
|
2421
|
+
name: "firstSeenAt",
|
|
2422
|
+
type: "INTEGER"
|
|
2423
|
+
},
|
|
2424
|
+
{
|
|
2425
|
+
name: "becameStationaryAt",
|
|
2426
|
+
type: "INTEGER"
|
|
2427
|
+
},
|
|
2428
|
+
{
|
|
2429
|
+
name: "lastConfirmedAt",
|
|
2430
|
+
type: "INTEGER"
|
|
2431
|
+
},
|
|
2432
|
+
{
|
|
2433
|
+
name: "sourceTrackId",
|
|
2434
|
+
type: "TEXT"
|
|
2435
|
+
},
|
|
2436
|
+
{
|
|
2437
|
+
name: "label",
|
|
2438
|
+
type: "TEXT"
|
|
2439
|
+
},
|
|
2440
|
+
{
|
|
2441
|
+
name: "keyFrameMediaKey",
|
|
2442
|
+
type: "TEXT"
|
|
2443
|
+
}
|
|
2444
|
+
];
|
|
2445
|
+
var STATIONARY_INDEXES = [{
|
|
2446
|
+
name: "idx_stationary_device",
|
|
2447
|
+
columns: ["deviceId"]
|
|
2448
|
+
}];
|
|
2449
|
+
var StationaryObjectRegistry = class {
|
|
2450
|
+
byDevice = /* @__PURE__ */ new Map();
|
|
2451
|
+
dirty = /* @__PURE__ */ new Set();
|
|
2452
|
+
store;
|
|
2453
|
+
logger;
|
|
2454
|
+
matchConfig;
|
|
2455
|
+
entryTtlMs;
|
|
2456
|
+
onChange;
|
|
2457
|
+
/** Latest processed-frame timestamp per device — expiry counts OBSERVED
|
|
2458
|
+
* time, not wall-clock. A session-dispatch camera produces no frames
|
|
2459
|
+
* between motion sessions; that silence is not evidence the object left,
|
|
2460
|
+
* so quiet minutes must not age the entries (see {@link sweep}). */
|
|
2461
|
+
lastFrameAtByDevice = /* @__PURE__ */ new Map();
|
|
2462
|
+
constructor(deps) {
|
|
2463
|
+
this.store = deps.store;
|
|
2464
|
+
this.logger = deps.logger;
|
|
2465
|
+
this.matchConfig = deps.matchConfig ?? DEFAULT_MATCH_CONFIG;
|
|
2466
|
+
this.entryTtlMs = deps.entryTtlMs ?? 3e5;
|
|
2467
|
+
this.onChange = deps.onChange;
|
|
2468
|
+
}
|
|
2469
|
+
static async declare(store) {
|
|
2470
|
+
await store.declareCollection.mutate({
|
|
2471
|
+
collection: STATIONARY_COLLECTION,
|
|
2472
|
+
columns: [...STATIONARY_COLUMNS],
|
|
2473
|
+
indexes: [...STATIONARY_INDEXES]
|
|
2474
|
+
});
|
|
2475
|
+
}
|
|
2476
|
+
/** Hydrate all persisted entries into memory (call once at boot, after
|
|
2477
|
+
* `declare`). Best-effort — a query failure leaves the registry empty. */
|
|
2478
|
+
async load() {
|
|
2479
|
+
try {
|
|
2480
|
+
const rows = await this.store.query.query({
|
|
2481
|
+
collection: STATIONARY_COLLECTION,
|
|
2482
|
+
filter: { limit: 1e5 }
|
|
2483
|
+
});
|
|
2484
|
+
for (const row of rows) {
|
|
2485
|
+
const entry = rowToEntry(row.id, row.data);
|
|
2486
|
+
if (!entry) continue;
|
|
2487
|
+
this.deviceMap(entry.deviceId).set(entry.id, entry);
|
|
2488
|
+
}
|
|
2489
|
+
this.logger.info("stationary registry loaded", { meta: { entries: rows.length } });
|
|
2490
|
+
} catch (err) {
|
|
2491
|
+
this.logger.warn("stationary registry load failed", { meta: { error: String(err) } });
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
list(deviceId) {
|
|
2495
|
+
const m = this.byDevice.get(deviceId);
|
|
2496
|
+
return m ? [...m.values()] : [];
|
|
2497
|
+
}
|
|
2498
|
+
listViews(deviceId) {
|
|
2499
|
+
return this.list(deviceId).map(entryToView);
|
|
2500
|
+
}
|
|
2501
|
+
count(deviceId) {
|
|
2502
|
+
return this.byDevice.get(deviceId)?.size ?? 0;
|
|
2503
|
+
}
|
|
2504
|
+
/** Record that a frame was processed for a device — advances the OBSERVED
|
|
2505
|
+
* clock that drives entry expiry in {@link sweep}. */
|
|
2506
|
+
noteFrame(deviceId, timestamp) {
|
|
2507
|
+
if (timestamp > (this.lastFrameAtByDevice.get(deviceId) ?? 0)) this.lastFrameAtByDevice.set(deviceId, timestamp);
|
|
2508
|
+
}
|
|
2509
|
+
/**
|
|
2510
|
+
* Per-frame gate: partition this frame's detections against the device's
|
|
2511
|
+
* entries. PURE with respect to registry state — apply the outcome with
|
|
2512
|
+
* {@link applyFrameOutcome} once the frame result is assembled.
|
|
2513
|
+
*/
|
|
2514
|
+
filter(input) {
|
|
2515
|
+
const entries = this.list(input.deviceId);
|
|
2516
|
+
if (entries.length === 0) return {
|
|
2517
|
+
suppressedIndices: /* @__PURE__ */ new Set(),
|
|
2518
|
+
confirmed: [],
|
|
2519
|
+
wokenEntryIds: []
|
|
2520
|
+
};
|
|
2521
|
+
return partitionDetectionsAgainstRegistry({
|
|
2522
|
+
entries,
|
|
2523
|
+
detections: input.detections,
|
|
2524
|
+
referenceDiagonalPx: diagonalOf(input.frameWidth, input.frameHeight),
|
|
2525
|
+
config: this.matchConfig
|
|
2526
|
+
});
|
|
2527
|
+
}
|
|
2528
|
+
/** Fold a frame's gate result back into state: advance confirmed entries'
|
|
2529
|
+
* `lastConfirmedAt` and retire woken entries (their object departed). */
|
|
2530
|
+
applyFrameOutcome(input) {
|
|
2531
|
+
const m = this.byDevice.get(input.deviceId);
|
|
2532
|
+
if (!m) return;
|
|
2533
|
+
for (const c of input.confirmed) {
|
|
2534
|
+
const e = m.get(c.entryId);
|
|
2535
|
+
if (!e) continue;
|
|
2536
|
+
m.set(c.entryId, {
|
|
2537
|
+
...e,
|
|
2538
|
+
lastConfirmedAt: input.timestamp
|
|
2539
|
+
});
|
|
2540
|
+
this.dirty.add(c.entryId);
|
|
2541
|
+
}
|
|
2542
|
+
for (const id of input.wokenEntryIds) {
|
|
2543
|
+
const e = m.get(id);
|
|
2544
|
+
if (!e) continue;
|
|
2545
|
+
m.delete(id);
|
|
2546
|
+
this.dirty.delete(id);
|
|
2547
|
+
this.deletePersisted(id);
|
|
2548
|
+
this.onChange?.({
|
|
2549
|
+
phase: "departed",
|
|
2550
|
+
entry: e,
|
|
2551
|
+
timestamp: input.timestamp
|
|
2552
|
+
});
|
|
2553
|
+
}
|
|
2554
|
+
}
|
|
2555
|
+
/** Promote a parked track into a persisted stationary entry. */
|
|
2556
|
+
async promote(entry) {
|
|
2557
|
+
this.deviceMap(entry.deviceId).set(entry.id, entry);
|
|
2558
|
+
this.dirty.delete(entry.id);
|
|
2559
|
+
try {
|
|
2560
|
+
await this.persist(entry);
|
|
2561
|
+
} catch (err) {
|
|
2562
|
+
this.logger.warn("stationary promote persist failed", {
|
|
2563
|
+
tags: { deviceId: entry.deviceId },
|
|
2564
|
+
meta: {
|
|
2565
|
+
entryId: entry.id,
|
|
2566
|
+
error: String(err)
|
|
2567
|
+
}
|
|
2568
|
+
});
|
|
2569
|
+
}
|
|
2570
|
+
this.onChange?.({
|
|
2571
|
+
phase: "appeared",
|
|
2572
|
+
entry,
|
|
2573
|
+
timestamp: entry.becameStationaryAt
|
|
2574
|
+
});
|
|
2575
|
+
}
|
|
2576
|
+
/**
|
|
2577
|
+
* Retire entries unconfirmed for longer than the TTL of OBSERVED time, and
|
|
2578
|
+
* flush any advanced `lastConfirmedAt`s to the store. Returns retired
|
|
2579
|
+
* entries (for logging).
|
|
2580
|
+
*
|
|
2581
|
+
* Expiry is measured against the device's latest processed-frame timestamp
|
|
2582
|
+
* ({@link noteFrame}), NOT the wall clock: a session-dispatch camera emits
|
|
2583
|
+
* no frames between motion sessions, and that silence says nothing about
|
|
2584
|
+
* the object. Only when the camera has actually been WATCHING for `ttl`
|
|
2585
|
+
* beyond the last confirmation (frames flowed, object never matched) is the
|
|
2586
|
+
* object considered removed. A device with no recorded frame yet never
|
|
2587
|
+
* expires its entries. `now` only stamps the departed telemetry.
|
|
2588
|
+
*/
|
|
2589
|
+
async sweep(now) {
|
|
2590
|
+
const retired = [];
|
|
2591
|
+
for (const [deviceId, m] of this.byDevice) {
|
|
2592
|
+
const observedAt = this.lastFrameAtByDevice.get(deviceId);
|
|
2593
|
+
if (observedAt === void 0) continue;
|
|
2594
|
+
for (const [id, e] of m) if (observedAt - e.lastConfirmedAt > this.entryTtlMs) {
|
|
2595
|
+
m.delete(id);
|
|
2596
|
+
this.dirty.delete(id);
|
|
2597
|
+
retired.push(e);
|
|
2598
|
+
this.deletePersisted(id);
|
|
2599
|
+
this.onChange?.({
|
|
2600
|
+
phase: "departed",
|
|
2601
|
+
entry: e,
|
|
2602
|
+
timestamp: now
|
|
2603
|
+
});
|
|
2604
|
+
}
|
|
2605
|
+
if (m.size === 0) this.byDevice.delete(deviceId);
|
|
2606
|
+
}
|
|
2607
|
+
for (const id of [...this.dirty]) {
|
|
2608
|
+
this.dirty.delete(id);
|
|
2609
|
+
const entry = this.findById(id);
|
|
2610
|
+
if (!entry) continue;
|
|
2611
|
+
try {
|
|
2612
|
+
await this.store.update.mutate({
|
|
2613
|
+
collection: STATIONARY_COLLECTION,
|
|
2614
|
+
id,
|
|
2615
|
+
data: { lastConfirmedAt: entry.lastConfirmedAt }
|
|
2616
|
+
});
|
|
2617
|
+
} catch (err) {
|
|
2618
|
+
this.logger.debug("stationary lastConfirmedAt flush failed", { meta: {
|
|
2619
|
+
entryId: id,
|
|
2620
|
+
error: String(err)
|
|
2621
|
+
} });
|
|
2622
|
+
}
|
|
2623
|
+
}
|
|
2624
|
+
return retired;
|
|
2625
|
+
}
|
|
2626
|
+
/** Drop a device's entries from memory WITHOUT deleting persisted rows.
|
|
2627
|
+
* Used on device unbind; a rebind reloads from the store. */
|
|
2628
|
+
forgetDevice(deviceId) {
|
|
2629
|
+
this.lastFrameAtByDevice.delete(deviceId);
|
|
2630
|
+
const m = this.byDevice.get(deviceId);
|
|
2631
|
+
if (!m) return;
|
|
2632
|
+
for (const id of m.keys()) this.dirty.delete(id);
|
|
2633
|
+
this.byDevice.delete(deviceId);
|
|
2634
|
+
}
|
|
2635
|
+
/** Delete every persisted + in-memory entry for a device (operator wipe). */
|
|
2636
|
+
async clearDevice(deviceId) {
|
|
2637
|
+
this.lastFrameAtByDevice.delete(deviceId);
|
|
2638
|
+
const m = this.byDevice.get(deviceId);
|
|
2639
|
+
if (m) {
|
|
2640
|
+
for (const id of [...m.keys()]) {
|
|
2641
|
+
this.dirty.delete(id);
|
|
2642
|
+
this.deletePersisted(id);
|
|
2643
|
+
}
|
|
2644
|
+
this.byDevice.delete(deviceId);
|
|
2645
|
+
}
|
|
2646
|
+
}
|
|
2647
|
+
deviceMap(deviceId) {
|
|
2648
|
+
let m = this.byDevice.get(deviceId);
|
|
2649
|
+
if (!m) {
|
|
2650
|
+
m = /* @__PURE__ */ new Map();
|
|
2651
|
+
this.byDevice.set(deviceId, m);
|
|
2652
|
+
}
|
|
2653
|
+
return m;
|
|
2654
|
+
}
|
|
2655
|
+
findById(id) {
|
|
2656
|
+
for (const m of this.byDevice.values()) {
|
|
2657
|
+
const e = m.get(id);
|
|
2658
|
+
if (e) return e;
|
|
2659
|
+
}
|
|
2660
|
+
}
|
|
2661
|
+
async persist(e) {
|
|
2662
|
+
await this.store.set.mutate({
|
|
2663
|
+
collection: STATIONARY_COLLECTION,
|
|
2664
|
+
key: e.id,
|
|
2665
|
+
value: {
|
|
2666
|
+
deviceId: e.deviceId,
|
|
2667
|
+
className: e.className,
|
|
2668
|
+
bbox: { ...e.bbox },
|
|
2669
|
+
frameWidth: e.frameWidth,
|
|
2670
|
+
frameHeight: e.frameHeight,
|
|
2671
|
+
firstSeenAt: e.firstSeenAt,
|
|
2672
|
+
becameStationaryAt: e.becameStationaryAt,
|
|
2673
|
+
lastConfirmedAt: e.lastConfirmedAt,
|
|
2674
|
+
...e.sourceTrackId !== void 0 ? { sourceTrackId: e.sourceTrackId } : {},
|
|
2675
|
+
...e.label !== void 0 ? { label: e.label } : {},
|
|
2676
|
+
...e.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: e.keyFrameMediaKey } : {}
|
|
2677
|
+
}
|
|
2678
|
+
});
|
|
2679
|
+
}
|
|
2680
|
+
async deletePersisted(id) {
|
|
2681
|
+
try {
|
|
2682
|
+
await this.store.delete.mutate({
|
|
2683
|
+
collection: STATIONARY_COLLECTION,
|
|
2684
|
+
key: id
|
|
2685
|
+
});
|
|
2686
|
+
} catch (err) {
|
|
2687
|
+
this.logger.debug("stationary delete failed", { meta: {
|
|
2688
|
+
entryId: id,
|
|
2689
|
+
error: String(err)
|
|
2690
|
+
} });
|
|
2691
|
+
}
|
|
2692
|
+
}
|
|
2693
|
+
};
|
|
2694
|
+
function rowToEntry(id, data) {
|
|
2695
|
+
const deviceId = Number(data["deviceId"]);
|
|
2696
|
+
const className = data["className"];
|
|
2697
|
+
const bbox = data["bbox"];
|
|
2698
|
+
if (!Number.isFinite(deviceId) || typeof className !== "string" || !bbox) return null;
|
|
2699
|
+
const sourceTrackId = data["sourceTrackId"];
|
|
2700
|
+
const label = data["label"];
|
|
2701
|
+
const keyFrameMediaKey = data["keyFrameMediaKey"];
|
|
2702
|
+
return {
|
|
2703
|
+
id,
|
|
2704
|
+
deviceId,
|
|
2705
|
+
className,
|
|
2706
|
+
bbox: {
|
|
2707
|
+
x: Number(bbox.x),
|
|
2708
|
+
y: Number(bbox.y),
|
|
2709
|
+
w: Number(bbox.w),
|
|
2710
|
+
h: Number(bbox.h)
|
|
2711
|
+
},
|
|
2712
|
+
frameWidth: Number(data["frameWidth"] ?? 0),
|
|
2713
|
+
frameHeight: Number(data["frameHeight"] ?? 0),
|
|
2714
|
+
firstSeenAt: Number(data["firstSeenAt"] ?? 0),
|
|
2715
|
+
becameStationaryAt: Number(data["becameStationaryAt"] ?? 0),
|
|
2716
|
+
lastConfirmedAt: Number(data["lastConfirmedAt"] ?? 0),
|
|
2717
|
+
...typeof sourceTrackId === "string" ? { sourceTrackId } : {},
|
|
2718
|
+
...typeof label === "string" ? { label } : {},
|
|
2719
|
+
...typeof keyFrameMediaKey === "string" ? { keyFrameMediaKey } : {}
|
|
2720
|
+
};
|
|
2721
|
+
}
|
|
2722
|
+
//#endregion
|
|
2723
|
+
//#region src/pipeline-analytics/pipeline/track-appearance.ts
|
|
2724
|
+
/**
|
|
2725
|
+
* Pure: no side effects. `continuing` = still active from last frame;
|
|
2726
|
+
* `birth` = a brand-new track's first sighting; `resurrection` = a known track
|
|
2727
|
+
* re-entering the active set after being lost.
|
|
2728
|
+
*/
|
|
2729
|
+
function classifyTrackAppearance(input) {
|
|
2730
|
+
if (input.inPrevActive) return "continuing";
|
|
2731
|
+
return input.positionsCount > 1 ? "resurrection" : "birth";
|
|
2732
|
+
}
|
|
2733
|
+
//#endregion
|
|
2120
2734
|
//#region src/pipeline-analytics/pipeline/key-event-query.ts
|
|
2121
2735
|
async function rankKeyEvents(candidates, options, peakLookup) {
|
|
2122
2736
|
const scored = [];
|
|
@@ -2126,6 +2740,10 @@ async function rankKeyEvents(candidates, options, peakLookup) {
|
|
|
2126
2740
|
let bestEventId = t.bestEventId;
|
|
2127
2741
|
if (importance === void 0) {
|
|
2128
2742
|
const peak = await peakLookup(t.trackId);
|
|
2743
|
+
const staticMetrics = computeStaticTrackMetrics(t.positions.map((p) => ({
|
|
2744
|
+
x: p.x,
|
|
2745
|
+
y: p.y
|
|
2746
|
+
})), averageBboxDiagonal(t.positions.map((p) => p.bbox)));
|
|
2129
2747
|
importance = computeImportance({
|
|
2130
2748
|
peakConfidence: peak.peakConfidence,
|
|
2131
2749
|
className: t.className,
|
|
@@ -2133,7 +2751,11 @@ async function rankKeyEvents(candidates, options, peakLookup) {
|
|
|
2133
2751
|
peakBboxAreaFrac: peak.peakBboxAreaFrac,
|
|
2134
2752
|
totalDistance: t.totalDistance,
|
|
2135
2753
|
zonesVisited: t.zonesVisited,
|
|
2136
|
-
...t.label !== void 0 ? { label: t.label } : {}
|
|
2754
|
+
...t.label !== void 0 ? { label: t.label } : {},
|
|
2755
|
+
...staticMetrics ? {
|
|
2756
|
+
netDisplacementFrac: staticMetrics.netDisplacementFrac,
|
|
2757
|
+
pathSpanFrac: staticMetrics.pathSpanFrac
|
|
2758
|
+
} : {}
|
|
2137
2759
|
}).importance;
|
|
2138
2760
|
bestEventId = bestEventId ?? peak.bestEventId;
|
|
2139
2761
|
}
|
|
@@ -2145,7 +2767,7 @@ async function rankKeyEvents(candidates, options, peakLookup) {
|
|
|
2145
2767
|
className: t.className,
|
|
2146
2768
|
...t.label !== void 0 ? { label: t.label } : {},
|
|
2147
2769
|
importance,
|
|
2148
|
-
bestEventId: bestEventId ??
|
|
2770
|
+
bestEventId: bestEventId ?? t.trackId,
|
|
2149
2771
|
windowMs: t.lastSeen - t.firstSeen
|
|
2150
2772
|
});
|
|
2151
2773
|
}
|
|
@@ -2382,6 +3004,10 @@ var TRACKS_COLUMNS = [
|
|
|
2382
3004
|
{
|
|
2383
3005
|
name: "importanceReason",
|
|
2384
3006
|
type: "TEXT"
|
|
3007
|
+
},
|
|
3008
|
+
{
|
|
3009
|
+
name: "audioLabels",
|
|
3010
|
+
type: "JSON"
|
|
2385
3011
|
}
|
|
2386
3012
|
];
|
|
2387
3013
|
var TRACKS_INDEXES = [{
|
|
@@ -2391,6 +3017,17 @@ var TRACKS_INDEXES = [{
|
|
|
2391
3017
|
name: "idx_tracks_device_firstSeen",
|
|
2392
3018
|
columns: ["deviceId", "firstSeen"]
|
|
2393
3019
|
}];
|
|
3020
|
+
/** Serialize the per-label aggregate map into the `Track.audioLabels`
|
|
3021
|
+
* array shape, most-frequent label first. */
|
|
3022
|
+
function audioLabelsToArray(agg) {
|
|
3023
|
+
return [...agg.entries()].map(([label, a]) => ({
|
|
3024
|
+
label,
|
|
3025
|
+
peakScore: a.peakScore,
|
|
3026
|
+
count: a.count,
|
|
3027
|
+
firstAt: a.firstAt,
|
|
3028
|
+
lastAt: a.lastAt
|
|
3029
|
+
})).sort((a, b) => b.count - a.count);
|
|
3030
|
+
}
|
|
2394
3031
|
function cloneTrack(t) {
|
|
2395
3032
|
return {
|
|
2396
3033
|
trackId: t.trackId,
|
|
@@ -2417,7 +3054,8 @@ function cloneTrack(t) {
|
|
|
2417
3054
|
active: t.active,
|
|
2418
3055
|
...t.importance !== void 0 ? { importance: t.importance } : {},
|
|
2419
3056
|
...t.bestEventId !== void 0 ? { bestEventId: t.bestEventId } : {},
|
|
2420
|
-
...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {}
|
|
3057
|
+
...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {},
|
|
3058
|
+
...t.audioLabels !== void 0 && t.audioLabels.size > 0 ? { audioLabels: audioLabelsToArray(t.audioLabels) } : {}
|
|
2421
3059
|
};
|
|
2422
3060
|
}
|
|
2423
3061
|
var TrackStore = class {
|
|
@@ -2478,25 +3116,81 @@ var TrackStore = class {
|
|
|
2478
3116
|
this.active.set(params.trackId, fresh);
|
|
2479
3117
|
return fresh;
|
|
2480
3118
|
}
|
|
3119
|
+
/**
|
|
3120
|
+
* Record one audio-classification EPISODE against every track currently
|
|
3121
|
+
* active on the device — "what was heard on this camera while the track
|
|
3122
|
+
* was alive". Called from the confident-classification audio-event insert
|
|
3123
|
+
* (score ≥ device `classificationMinScore`, class-change-or-heartbeat
|
|
3124
|
+
* coalesced), so counts stay episode-scaled rather than 30 Hz chunk-scaled.
|
|
3125
|
+
*/
|
|
3126
|
+
addAudioLabelEpisode(deviceId, label, score, timestamp) {
|
|
3127
|
+
for (const t of this.active.values()) {
|
|
3128
|
+
if (t.deviceId !== deviceId || !t.active) continue;
|
|
3129
|
+
const agg = t.audioLabels ??= /* @__PURE__ */ new Map();
|
|
3130
|
+
const entry = agg.get(label);
|
|
3131
|
+
if (entry) {
|
|
3132
|
+
entry.peakScore = Math.max(entry.peakScore, score);
|
|
3133
|
+
entry.count += 1;
|
|
3134
|
+
entry.lastAt = timestamp;
|
|
3135
|
+
} else agg.set(label, {
|
|
3136
|
+
peakScore: score,
|
|
3137
|
+
count: 1,
|
|
3138
|
+
firstAt: timestamp,
|
|
3139
|
+
lastAt: timestamp
|
|
3140
|
+
});
|
|
3141
|
+
}
|
|
3142
|
+
}
|
|
2481
3143
|
/** Attach a snapshot reference to an active track. */
|
|
2482
3144
|
addSnapshot(trackId, snapshot) {
|
|
2483
3145
|
const t = this.active.get(trackId);
|
|
2484
3146
|
if (!t) return;
|
|
2485
3147
|
t.snapshots.push(snapshot);
|
|
2486
3148
|
t.lastSnapshotAt = snapshot.timestamp;
|
|
3149
|
+
t.lastSnapshotBbox = { ...snapshot.position.bbox };
|
|
3150
|
+
}
|
|
3151
|
+
/**
|
|
3152
|
+
* Synchronously advance the periodic-snapshot gate reference (clock + bbox) at
|
|
3153
|
+
* the MOMENT a snapshot write is DECIDED — before the async encode/dispatch
|
|
3154
|
+
* lands the real snapshot via {@link addSnapshot}. Without it, `lastSnapshotAt`
|
|
3155
|
+
* only advances when the dispatcher round-trip returns (~50-200ms of sharp
|
|
3156
|
+
* encode), so at 10-25fps several consecutive frames pass
|
|
3157
|
+
* `evaluatePeriodicSnapshot` before the clock moves → a burst of near-identical
|
|
3158
|
+
* snapshots. Mirrors the synchronous `lastFrameAtByTrack` advance for the
|
|
3159
|
+
* rolling `lastFrame`.
|
|
3160
|
+
*
|
|
3161
|
+
* No rollback: if the write later fails the slot is simply lost (a rare dropped
|
|
3162
|
+
* snapshot is preferable to a burst). `addSnapshot` re-stamps the same
|
|
3163
|
+
* clock/bbox when the real snapshot lands, so the two stay consistent. No-op for
|
|
3164
|
+
* an unknown/expired track (the active entry is dropped on expiry, so there is
|
|
3165
|
+
* no separate map to leak).
|
|
3166
|
+
*/
|
|
3167
|
+
markSnapshotPending(trackId, timestamp, bbox) {
|
|
3168
|
+
const t = this.active.get(trackId);
|
|
3169
|
+
if (!t) return;
|
|
3170
|
+
t.lastSnapshotAt = timestamp;
|
|
3171
|
+
t.lastSnapshotBbox = { ...bbox };
|
|
2487
3172
|
}
|
|
2488
3173
|
lastSnapshotAt(trackId) {
|
|
2489
3174
|
return this.active.get(trackId)?.lastSnapshotAt ?? 0;
|
|
2490
3175
|
}
|
|
3176
|
+
/** Bbox reference of the last captured snapshot (or the seed bbox), for the
|
|
3177
|
+
* periodic-snapshot movement gate. Undefined until the clock is seeded. */
|
|
3178
|
+
lastSnapshotBbox(trackId) {
|
|
3179
|
+
const b = this.active.get(trackId)?.lastSnapshotBbox;
|
|
3180
|
+
return b ? { ...b } : void 0;
|
|
3181
|
+
}
|
|
2491
3182
|
/**
|
|
2492
3183
|
* Seed the snapshot cadence clock (once, at track start) WITHOUT appending a
|
|
2493
3184
|
* snapshot, so the first periodic snapshot fires ~snapshotIntervalMs after the
|
|
2494
3185
|
* track begins rather than immediately — the `firstFrame` already covers the
|
|
2495
3186
|
* track's start. No-op if a snapshot was already taken (clock already set).
|
|
2496
3187
|
*/
|
|
2497
|
-
seedSnapshotClock(trackId, timestamp) {
|
|
3188
|
+
seedSnapshotClock(trackId, timestamp, bbox) {
|
|
2498
3189
|
const t = this.active.get(trackId);
|
|
2499
|
-
if (t && t.lastSnapshotAt === 0)
|
|
3190
|
+
if (t && t.lastSnapshotAt === 0) {
|
|
3191
|
+
t.lastSnapshotAt = timestamp;
|
|
3192
|
+
if (bbox) t.lastSnapshotBbox = { ...bbox };
|
|
3193
|
+
}
|
|
2500
3194
|
}
|
|
2501
3195
|
getActive(deviceId) {
|
|
2502
3196
|
const out = [];
|
|
@@ -2507,6 +3201,21 @@ var TrackStore = class {
|
|
|
2507
3201
|
const t = this.active.get(trackId);
|
|
2508
3202
|
return t && t.active ? cloneTrack(t) : null;
|
|
2509
3203
|
}
|
|
3204
|
+
/**
|
|
3205
|
+
* Cheap read of an active track's promotion-relevant fields WITHOUT the deep
|
|
3206
|
+
* clone `getActiveByTrack` does — the returned `positions` is the live
|
|
3207
|
+
* internal array (read-only; callers must not mutate). Feeds the per-frame
|
|
3208
|
+
* stationary-promotion check, called at inference fps. Null if unknown/expired.
|
|
3209
|
+
*/
|
|
3210
|
+
peekActive(trackId) {
|
|
3211
|
+
const t = this.active.get(trackId);
|
|
3212
|
+
if (!t || !t.active) return null;
|
|
3213
|
+
return {
|
|
3214
|
+
firstSeen: t.firstSeen,
|
|
3215
|
+
positions: t.positions,
|
|
3216
|
+
...t.label !== void 0 ? { label: t.label } : {}
|
|
3217
|
+
};
|
|
3218
|
+
}
|
|
2510
3219
|
/** Expire tracks whose `lastSeen` is older than TTL. Persists each
|
|
2511
3220
|
* expired track to the declared collection and returns them. */
|
|
2512
3221
|
async expireStale(nowMs) {
|
|
@@ -2612,6 +3321,15 @@ var TrackStore = class {
|
|
|
2612
3321
|
clearAll() {
|
|
2613
3322
|
this.active.clear();
|
|
2614
3323
|
}
|
|
3324
|
+
/**
|
|
3325
|
+
* Drop a single active track WITHOUT persisting it as a historical row. Used
|
|
3326
|
+
* when a track is PROMOTED to a stationary-object registry entry: the durable
|
|
3327
|
+
* record for a parked object is the registry entry, not a Track, so the track
|
|
3328
|
+
* must NOT land in the key-event feed. No-op for an unknown/expired track.
|
|
3329
|
+
*/
|
|
3330
|
+
dropActive(trackId) {
|
|
3331
|
+
this.active.delete(trackId);
|
|
3332
|
+
}
|
|
2615
3333
|
/** Delete the persisted track row (keyed by trackId) and drop the in-RAM
|
|
2616
3334
|
* active entry if present. Used by the whole-track deletion cascade. */
|
|
2617
3335
|
async deletePersisted(trackId) {
|
|
@@ -2744,7 +3462,8 @@ var TrackStore = class {
|
|
|
2744
3462
|
state: t.state,
|
|
2745
3463
|
...t.importance !== void 0 ? { importance: t.importance } : {},
|
|
2746
3464
|
...t.bestEventId !== void 0 ? { bestEventId: t.bestEventId } : {},
|
|
2747
|
-
...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {}
|
|
3465
|
+
...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {},
|
|
3466
|
+
...t.audioLabels !== void 0 ? { audioLabels: [...t.audioLabels] } : {}
|
|
2748
3467
|
}
|
|
2749
3468
|
});
|
|
2750
3469
|
}
|
|
@@ -2757,6 +3476,7 @@ var TrackStore = class {
|
|
|
2757
3476
|
const importance = data["importance"];
|
|
2758
3477
|
const bestEventId = data["bestEventId"];
|
|
2759
3478
|
const importanceReason = data["importanceReason"];
|
|
3479
|
+
const audioLabels = data["audioLabels"];
|
|
2760
3480
|
return {
|
|
2761
3481
|
trackId: id,
|
|
2762
3482
|
deviceId: Number(data["deviceId"]),
|
|
@@ -2773,7 +3493,8 @@ var TrackStore = class {
|
|
|
2773
3493
|
active: false,
|
|
2774
3494
|
...typeof importance === "number" ? { importance } : {},
|
|
2775
3495
|
...typeof bestEventId === "string" ? { bestEventId } : {},
|
|
2776
|
-
...typeof importanceReason === "string" ? { importanceReason } : {}
|
|
3496
|
+
...typeof importanceReason === "string" ? { importanceReason } : {},
|
|
3497
|
+
...Array.isArray(audioLabels) && audioLabels.length > 0 ? { audioLabels } : {}
|
|
2777
3498
|
};
|
|
2778
3499
|
}
|
|
2779
3500
|
};
|
|
@@ -3886,13 +4607,10 @@ function stripNulls(data) {
|
|
|
3886
4607
|
//#endregion
|
|
3887
4608
|
//#region src/shared/frame/resolve-frame.ts
|
|
3888
4609
|
/**
|
|
3889
|
-
* Resolve the pixels a `FrameHandle` refers to
|
|
3890
|
-
*
|
|
3891
|
-
* `deps.getRemoteFrame`. Returns `null` when the frame is no longer
|
|
3892
|
-
* available (slot recycled locally, or the remote node reports no frame).
|
|
4610
|
+
* Resolve the pixels a `FrameHandle` refers to via the node-routed fetch.
|
|
4611
|
+
* Returns `null` when the frame is no longer available.
|
|
3893
4612
|
*/
|
|
3894
4613
|
async function resolveFrame(handle, deps) {
|
|
3895
|
-
if (handle.nodeId === deps.ownNodeId) return deps.readers.read(handle);
|
|
3896
4614
|
return deps.getRemoteFrame(handle);
|
|
3897
4615
|
}
|
|
3898
4616
|
//#endregion
|
|
@@ -4069,11 +4787,7 @@ var EventMediaDispatcher = class {
|
|
|
4069
4787
|
if (events.length === 0 && trackFrames.length === 0 && snapshots.length === 0) return empty;
|
|
4070
4788
|
let decoded;
|
|
4071
4789
|
try {
|
|
4072
|
-
decoded = await resolveFrame(frameHandle, {
|
|
4073
|
-
ownNodeId: this.deps.ownNodeId,
|
|
4074
|
-
readers: this.deps.readers,
|
|
4075
|
-
getRemoteFrame: this.deps.getRemoteFrame
|
|
4076
|
-
});
|
|
4790
|
+
decoded = await resolveFrame(frameHandle, { getRemoteFrame: this.deps.getRemoteFrame });
|
|
4077
4791
|
} catch (err) {
|
|
4078
4792
|
this.deps.logger.debug("event media: resolveFrame threw", {
|
|
4079
4793
|
tags: { deviceId },
|
|
@@ -4384,8 +5098,6 @@ var EmbeddingDispatcher = class {
|
|
|
4384
5098
|
encoder;
|
|
4385
5099
|
eventBus;
|
|
4386
5100
|
logger;
|
|
4387
|
-
ownNodeId;
|
|
4388
|
-
readers;
|
|
4389
5101
|
getRemoteFrame;
|
|
4390
5102
|
lastEmbedTime = /* @__PURE__ */ new Map();
|
|
4391
5103
|
pendingCrops = /* @__PURE__ */ new Map();
|
|
@@ -4398,8 +5110,6 @@ var EmbeddingDispatcher = class {
|
|
|
4398
5110
|
this.encoder = deps.encoder;
|
|
4399
5111
|
this.eventBus = deps.eventBus;
|
|
4400
5112
|
this.logger = deps.logger;
|
|
4401
|
-
this.ownNodeId = deps.ownNodeId;
|
|
4402
|
-
this.readers = deps.readers;
|
|
4403
5113
|
this.getRemoteFrame = deps.getRemoteFrame;
|
|
4404
5114
|
}
|
|
4405
5115
|
async start() {
|
|
@@ -4451,11 +5161,7 @@ var EmbeddingDispatcher = class {
|
|
|
4451
5161
|
}
|
|
4452
5162
|
let decoded;
|
|
4453
5163
|
try {
|
|
4454
|
-
decoded = await resolveFrame(handle, {
|
|
4455
|
-
ownNodeId: this.ownNodeId,
|
|
4456
|
-
readers: this.readers,
|
|
4457
|
-
getRemoteFrame: this.getRemoteFrame
|
|
4458
|
-
});
|
|
5164
|
+
decoded = await resolveFrame(handle, { getRemoteFrame: this.getRemoteFrame });
|
|
4459
5165
|
} catch (err) {
|
|
4460
5166
|
this.logger.debug("skip: resolveFrame threw", {
|
|
4461
5167
|
tags: { deviceId: Number(deviceId) },
|
|
@@ -4882,7 +5588,8 @@ function computeSnapshot(input) {
|
|
|
4882
5588
|
unzoned: {
|
|
4883
5589
|
totalObjects: unzonedTotal,
|
|
4884
5590
|
byClass: unzonedByClass
|
|
4885
|
-
}
|
|
5591
|
+
},
|
|
5592
|
+
...input.stationaryObjects !== void 0 && input.stationaryObjects.length > 0 ? { stationaryObjects: input.stationaryObjects } : {}
|
|
4886
5593
|
};
|
|
4887
5594
|
}
|
|
4888
5595
|
//#endregion
|
|
@@ -5458,7 +6165,18 @@ var MediaSettingsSchema = require_dist.object({
|
|
|
5458
6165
|
/** Cadence (ms) for the periodic per-track `snapshot` + rolling `lastFrame`.
|
|
5459
6166
|
* A snapshot is captured for an active track only after this much wall-clock
|
|
5460
6167
|
* has elapsed since its previous one. */
|
|
5461
|
-
snapshotIntervalMs: require_dist.number().int().min(500).max(6e4).default(5e3)
|
|
6168
|
+
snapshotIntervalMs: require_dist.number().int().min(500).max(6e4).default(5e3),
|
|
6169
|
+
/** Movement gate for the periodic `snapshot`: once `snapshotIntervalMs` has
|
|
6170
|
+
* elapsed, a fresh snapshot is only captured when the track's centroid moved
|
|
6171
|
+
* at least this fraction of the frame DIAGONAL since the last captured
|
|
6172
|
+
* snapshot. Suppresses near-identical frames from a long-lived / stationary
|
|
6173
|
+
* track. 0 disables the gate (pure-time behaviour). Default 0.03 ≈ 3% of the
|
|
6174
|
+
* frame diagonal (~66px on 1080p). */
|
|
6175
|
+
snapshotMovementThreshold: require_dist.number().min(0).max(1).default(.03),
|
|
6176
|
+
/** Loiterer fallback (ms): force a periodic `snapshot` for a stationary but
|
|
6177
|
+
* still-present track after this much wall-clock without one, so its
|
|
6178
|
+
* filmstrip is never empty. Effectively clamped to ≥ `snapshotIntervalMs`. */
|
|
6179
|
+
snapshotMaxIdleMs: require_dist.number().int().min(1e3).max(6e5).default(3e4)
|
|
5462
6180
|
});
|
|
5463
6181
|
var MEDIA_DEFAULTS = MediaSettingsSchema.parse({});
|
|
5464
6182
|
/**
|
|
@@ -5473,7 +6191,148 @@ function resolveMediaSettings(raw) {
|
|
|
5473
6191
|
return {
|
|
5474
6192
|
cropPadding: pick("cropPadding"),
|
|
5475
6193
|
saveThumbnails: pick("saveThumbnails"),
|
|
5476
|
-
snapshotIntervalMs: pick("snapshotIntervalMs")
|
|
6194
|
+
snapshotIntervalMs: pick("snapshotIntervalMs"),
|
|
6195
|
+
snapshotMovementThreshold: pick("snapshotMovementThreshold"),
|
|
6196
|
+
snapshotMaxIdleMs: pick("snapshotMaxIdleMs")
|
|
6197
|
+
};
|
|
6198
|
+
}
|
|
6199
|
+
function centroidOf(b) {
|
|
6200
|
+
return {
|
|
6201
|
+
x: b.x + b.w / 2,
|
|
6202
|
+
y: b.y + b.h / 2
|
|
6203
|
+
};
|
|
6204
|
+
}
|
|
6205
|
+
/** Centroid displacement between two boxes as a fraction of the frame diagonal.
|
|
6206
|
+
* Returns 0 for a degenerate (≤0) frame diagonal so the caller can fall back
|
|
6207
|
+
* to pure-time behaviour instead of dividing by zero. */
|
|
6208
|
+
function centroidMovedFraction(a, b, frameWidth, frameHeight) {
|
|
6209
|
+
const diag = Math.hypot(frameWidth, frameHeight);
|
|
6210
|
+
if (diag <= 0) return 0;
|
|
6211
|
+
const ca = centroidOf(a);
|
|
6212
|
+
const cb = centroidOf(b);
|
|
6213
|
+
return Math.hypot(cb.x - ca.x, cb.y - ca.y) / diag;
|
|
6214
|
+
}
|
|
6215
|
+
/**
|
|
6216
|
+
* Decide whether the periodic `snapshot` should be captured for a track THIS
|
|
6217
|
+
* frame. Pure: no side effects. The caller keeps the `saveThumbnails` master
|
|
6218
|
+
* switch and advances `lastSnapshotAt`/`lastSnapshotBbox` only when a capture
|
|
6219
|
+
* actually lands — so a skipped (stationary) frame leaves the clock untouched,
|
|
6220
|
+
* which naturally lets `maxIdleMs` fire and re-evaluates movement every frame
|
|
6221
|
+
* until the object moves.
|
|
6222
|
+
*/
|
|
6223
|
+
function evaluatePeriodicSnapshot(input) {
|
|
6224
|
+
const { lastSnapshotAt, lastSnapshotBbox, currentBbox, now, frameWidth, frameHeight, intervalMs, movementThreshold, maxIdleMs } = input;
|
|
6225
|
+
if (lastSnapshotAt <= 0 || now - lastSnapshotAt < intervalMs) {
|
|
6226
|
+
if (lastSnapshotAt > 0 && lastSnapshotBbox !== void 0 && now - lastSnapshotAt >= 1500) {
|
|
6227
|
+
const fastMoved = centroidMovedFraction(lastSnapshotBbox, currentBbox, frameWidth, frameHeight);
|
|
6228
|
+
if (fastMoved >= .08) return {
|
|
6229
|
+
capture: true,
|
|
6230
|
+
reason: "fast-mover",
|
|
6231
|
+
movedFraction: fastMoved
|
|
6232
|
+
};
|
|
6233
|
+
}
|
|
6234
|
+
return {
|
|
6235
|
+
capture: false,
|
|
6236
|
+
reason: "interval-not-elapsed",
|
|
6237
|
+
movedFraction: 0
|
|
6238
|
+
};
|
|
6239
|
+
}
|
|
6240
|
+
if (lastSnapshotBbox === void 0) return {
|
|
6241
|
+
capture: true,
|
|
6242
|
+
reason: "no-reference",
|
|
6243
|
+
movedFraction: 0
|
|
6244
|
+
};
|
|
6245
|
+
const movedFraction = centroidMovedFraction(lastSnapshotBbox, currentBbox, frameWidth, frameHeight);
|
|
6246
|
+
if (movedFraction >= movementThreshold) return {
|
|
6247
|
+
capture: true,
|
|
6248
|
+
reason: "moved",
|
|
6249
|
+
movedFraction
|
|
6250
|
+
};
|
|
6251
|
+
const idleLimit = Math.max(maxIdleMs, intervalMs);
|
|
6252
|
+
if (now - lastSnapshotAt >= idleLimit) return {
|
|
6253
|
+
capture: true,
|
|
6254
|
+
reason: "idle-forced",
|
|
6255
|
+
movedFraction
|
|
6256
|
+
};
|
|
6257
|
+
return {
|
|
6258
|
+
capture: false,
|
|
6259
|
+
reason: "stationary-skip",
|
|
6260
|
+
movedFraction
|
|
6261
|
+
};
|
|
6262
|
+
}
|
|
6263
|
+
//#endregion
|
|
6264
|
+
//#region src/pipeline-analytics/periodic-media-plan.ts
|
|
6265
|
+
/**
|
|
6266
|
+
* Decide the periodic media writes for one track on one frame. Pure: no side
|
|
6267
|
+
* effects. The caller advances its own `lastFrameAt` clock only when the
|
|
6268
|
+
* returned `rollingLastFrame` is true.
|
|
6269
|
+
*
|
|
6270
|
+
* INVARIANT: `appendSnapshot` and `rollingLastFrame` are never both true — the
|
|
6271
|
+
* rolling `lastFrame` is never the same frame as an appended `snapshot`, so it
|
|
6272
|
+
* can never duplicate one.
|
|
6273
|
+
*/
|
|
6274
|
+
function planPeriodicMedia(input) {
|
|
6275
|
+
const appendSnapshot = input.dueSnapshot;
|
|
6276
|
+
return {
|
|
6277
|
+
appendSnapshot,
|
|
6278
|
+
rollingLastFrame: input.saveThumbnails && input.now - input.lastFrameAt >= input.intervalMs && !appendSnapshot,
|
|
6279
|
+
bestThumbnail: input.isNewBest
|
|
6280
|
+
};
|
|
6281
|
+
}
|
|
6282
|
+
//#endregion
|
|
6283
|
+
//#region src/pipeline-analytics/pipeline/key-frame-capture.ts
|
|
6284
|
+
/**
|
|
6285
|
+
* Pure helpers for the per-track NATIVE-resolution `keyFrame` capture
|
|
6286
|
+
* (Design B — one native full-frame per track at its best-detection moment).
|
|
6287
|
+
*
|
|
6288
|
+
* ## Why this exists (the missing native keyFrame)
|
|
6289
|
+
*
|
|
6290
|
+
* `keyFrame` was historically captured ONLY inside the CLIP object-embedding
|
|
6291
|
+
* best path (`persistObjectEmbeddingBests`, gated by `isClipObjectEmbedding`).
|
|
6292
|
+
* Under the two-plane pipeline the root frame carries NO CLIP embedding (clip is
|
|
6293
|
+
* a per-track DETAIL served via `runDetailSubtree`, and is disabled cluster-
|
|
6294
|
+
* wide), so that gate was never satisfied and the native `keyFrame` was NEVER
|
|
6295
|
+
* produced — every stored frame stayed at the ≤640×360 detection resolution.
|
|
6296
|
+
*
|
|
6297
|
+
* The fix decouples the `keyFrame` from the clip path: it is captured on the
|
|
6298
|
+
* GENERAL best-frame signal (the same `bestThumbnail` decision that drives the
|
|
6299
|
+
* `thumbnail`), reusing the WORKING native crop path (`captureCrop` →
|
|
6300
|
+
* `pipelineRunner.getNativeCrop`, which cuts the ROI from the decode worker's
|
|
6301
|
+
* retained NATIVE surface and only falls back to the detection frame on a miss).
|
|
6302
|
+
* A full-frame ROI at {@link KEYFRAME_NATIVE_MAX_WIDTH} therefore yields a frame
|
|
6303
|
+
* LARGER than the detection raster (up to the cap), which is the whole point of
|
|
6304
|
+
* the `keyFrame` kind.
|
|
6305
|
+
*/
|
|
6306
|
+
/** Cap (px) on the width of the native KEY FRAME (full-frame native capture).
|
|
6307
|
+
* Native resolution is the point, but a full 4K RGB surface over the transport
|
|
6308
|
+
* per new-best is wasteful for a web detail view — 1920px keeps a sharp native
|
|
6309
|
+
* frame while bounding the copy (a miss falls back to the detection-res frame,
|
|
6310
|
+
* which is already ≤640px). */
|
|
6311
|
+
var KEYFRAME_NATIVE_MAX_WIDTH = 1920;
|
|
6312
|
+
/**
|
|
6313
|
+
* The tracks whose native `keyFrame` should be (re)captured THIS frame: exactly
|
|
6314
|
+
* the tracks that hit a new best-frame moment (`bestThumbnail`). `putReplacing`
|
|
6315
|
+
* downstream keeps one `keyFrame` per track (the current peak).
|
|
6316
|
+
*/
|
|
6317
|
+
function selectKeyFrameTrackIds(targets) {
|
|
6318
|
+
return targets.filter((t) => t.bestThumbnail).map((t) => t.trackId);
|
|
6319
|
+
}
|
|
6320
|
+
/**
|
|
6321
|
+
* Build the `captureCrop` request for a track's native `keyFrame`: the FULL
|
|
6322
|
+
* frame (no padding) at the native width cap. The full-frame box is what makes
|
|
6323
|
+
* the capture route through the native surface at native resolution instead of
|
|
6324
|
+
* a tight ≤640 detection crop.
|
|
6325
|
+
*/
|
|
6326
|
+
function buildKeyFrameCaptureRequest(frameWidth, frameHeight) {
|
|
6327
|
+
return {
|
|
6328
|
+
bbox: {
|
|
6329
|
+
x: 0,
|
|
6330
|
+
y: 0,
|
|
6331
|
+
w: frameWidth,
|
|
6332
|
+
h: frameHeight
|
|
6333
|
+
},
|
|
6334
|
+
padding: 0,
|
|
6335
|
+
maxWidth: KEYFRAME_NATIVE_MAX_WIDTH
|
|
5477
6336
|
};
|
|
5478
6337
|
}
|
|
5479
6338
|
//#endregion
|
|
@@ -6943,7 +7802,7 @@ var DetailScheduler = class {
|
|
|
6943
7802
|
//#region src/pipeline-analytics/detail-dispatcher.ts
|
|
6944
7803
|
/**
|
|
6945
7804
|
* Compose the `steps` list sent to `runDetailSubtree` for one request —
|
|
6946
|
-
*
|
|
7805
|
+
* chain-aware for the multi-step detail subtrees.
|
|
6947
7806
|
*
|
|
6948
7807
|
* A `face-detection` request ALSO includes `'face-embedding'` (the full
|
|
6949
7808
|
* detect→recognize chain) EXCEPT when it is a PERIODIC geometry refresh on a
|
|
@@ -6952,13 +7811,35 @@ var DetailScheduler = class {
|
|
|
6952
7811
|
* that case runs the detector geometry ALONE. Every recognition-bearing reason
|
|
6953
7812
|
* (new-track / improve / retry) keeps the embedding regardless of the label.
|
|
6954
7813
|
*
|
|
6955
|
-
*
|
|
6956
|
-
*
|
|
6957
|
-
* `
|
|
7814
|
+
* A `plate-detection` request ALWAYS includes `'plate-ocr'` — symmetric to the
|
|
7815
|
+
* face chain. Without `'plate-ocr'` in the array the pipeline's strict-`steps`
|
|
7816
|
+
* pruning (`pruneChildStepsToRequested`) drops the OCR child, so a detected
|
|
7817
|
+
* plate never gets read and no plate text is ever produced. (The dispatcher
|
|
7818
|
+
* cannot import the pipeline catalog to derive the child chain — this hardcode
|
|
7819
|
+
* mirrors it; keep the two in sync when the catalog's plate subtree changes.)
|
|
7820
|
+
*
|
|
7821
|
+
* Other steps are unchanged (`[req.stepId]`). Pairs with the pipeline's
|
|
7822
|
+
* strict-`steps` pruning — naming the nested child here is what keeps it in the
|
|
7823
|
+
* executed chain.
|
|
6958
7824
|
*/
|
|
6959
7825
|
function composeDetailSteps(req, hasTrackLabel) {
|
|
6960
|
-
if (req.stepId
|
|
6961
|
-
|
|
7826
|
+
if (req.stepId === "face-detection") return req.reason === "periodic" && hasTrackLabel(req.trackId) ? ["face-detection"] : ["face-detection", "face-embedding"];
|
|
7827
|
+
if (req.stepId === "plate-detection") return ["plate-detection", "plate-ocr"];
|
|
7828
|
+
return [req.stepId];
|
|
7829
|
+
}
|
|
7830
|
+
/**
|
|
7831
|
+
* Does `steps` name a nested-enrichment chain (root detector + a child that
|
|
7832
|
+
* produces a `label`/`embedding`), i.e. more than the bare root step? Used to
|
|
7833
|
+
* surface a silent enrichment miss (BUG C): a plate detected but never read, a
|
|
7834
|
+
* face detected but never embedded — the root detail still routes so the miss
|
|
7835
|
+
* is otherwise invisible. `['plate-detection']` alone is NOT a chain.
|
|
7836
|
+
*/
|
|
7837
|
+
function isEnrichmentChain(steps) {
|
|
7838
|
+
return steps.length > 1;
|
|
7839
|
+
}
|
|
7840
|
+
/** Does any returned detail carry the enrichment a chain request asked for? */
|
|
7841
|
+
function detailsCarryEnrichment(details) {
|
|
7842
|
+
return details.some((d) => d.label !== void 0 || d.embedding !== void 0);
|
|
6962
7843
|
}
|
|
6963
7844
|
/** Throttle for the per-device "detail call failed" warn — one line / minute. */
|
|
6964
7845
|
var FAIL_WARN_THROTTLE_MS = 6e4;
|
|
@@ -7035,7 +7916,8 @@ var TrackDetailDispatcher = class {
|
|
|
7035
7916
|
queue: [],
|
|
7036
7917
|
inFlight: 0,
|
|
7037
7918
|
timer: null,
|
|
7038
|
-
lastFailWarnAt: 0
|
|
7919
|
+
lastFailWarnAt: 0,
|
|
7920
|
+
lastEnrichWarnAt: 0
|
|
7039
7921
|
};
|
|
7040
7922
|
this.devices.set(deviceId, dev);
|
|
7041
7923
|
}
|
|
@@ -7081,6 +7963,8 @@ var TrackDetailDispatcher = class {
|
|
|
7081
7963
|
let topScore = null;
|
|
7082
7964
|
if (details !== null && details.length > 0) {
|
|
7083
7965
|
topScore = details.reduce((max, d) => d.score > max ? d.score : max, Number.NEGATIVE_INFINITY);
|
|
7966
|
+
const steps = composeDetailSteps(req, (id) => this.deps.hasTrackLabel?.(id) ?? false);
|
|
7967
|
+
if (isEnrichmentChain(steps) && !detailsCarryEnrichment(details)) this.warnEnrichmentMissThrottled(deviceId, dev, req, steps);
|
|
7084
7968
|
try {
|
|
7085
7969
|
await this.deps.routeResults(deviceId, req.trackId, details, frame);
|
|
7086
7970
|
} catch (err) {
|
|
@@ -7162,6 +8046,20 @@ var TrackDetailDispatcher = class {
|
|
|
7162
8046
|
}
|
|
7163
8047
|
});
|
|
7164
8048
|
}
|
|
8049
|
+
warnEnrichmentMissThrottled(deviceId, dev, req, steps) {
|
|
8050
|
+
const now = Date.now();
|
|
8051
|
+
if (now - dev.lastEnrichWarnAt < FAIL_WARN_THROTTLE_MS) return;
|
|
8052
|
+
dev.lastEnrichWarnAt = now;
|
|
8053
|
+
this.deps.logger.warn("detail chain ran but produced no enrichment (root detected, child yielded no label/embedding)", {
|
|
8054
|
+
tags: { deviceId },
|
|
8055
|
+
meta: {
|
|
8056
|
+
trackId: req.trackId,
|
|
8057
|
+
stepId: req.stepId,
|
|
8058
|
+
reason: req.reason,
|
|
8059
|
+
steps
|
|
8060
|
+
}
|
|
8061
|
+
});
|
|
8062
|
+
}
|
|
7165
8063
|
};
|
|
7166
8064
|
//#endregion
|
|
7167
8065
|
//#region src/pipeline-analytics/overlay-state.ts
|
|
@@ -7999,38 +8897,68 @@ var PlateRecognizer = class {
|
|
|
7999
8897
|
name
|
|
8000
8898
|
} : null;
|
|
8001
8899
|
}
|
|
8002
|
-
/** Live label for a plate read: the recognized vehicle NAME when matched,
|
|
8003
|
-
* the raw OCR text
|
|
8900
|
+
/** Live label for a plate read: the recognized vehicle NAME when matched,
|
|
8901
|
+
* else the raw OCR text. Returns `null` for an implausible read (junk OCR
|
|
8902
|
+
* off a distant/oblique plate) — the caller must NOT stamp a label then. */
|
|
8004
8903
|
resolveLabel(text, score) {
|
|
8904
|
+
if (!isPlausiblePlateRead(text, score)) return null;
|
|
8005
8905
|
return this.matchVehicle(text, score)?.name ?? text;
|
|
8006
8906
|
}
|
|
8007
8907
|
async processFrame(input) {
|
|
8008
8908
|
const minConfidence = input.minConfidence ?? 0;
|
|
8009
8909
|
const candidates = input.tracked.filter((t) => typeof t.plateText === "string" && t.plateText.length > 0 && typeof t.plateScore === "number" && t.plateScore >= minConfidence && t.plateBbox !== void 0);
|
|
8010
8910
|
if (candidates.length === 0) return;
|
|
8011
|
-
for (const c of candidates) {
|
|
8012
|
-
|
|
8013
|
-
|
|
8014
|
-
|
|
8015
|
-
|
|
8016
|
-
|
|
8017
|
-
|
|
8018
|
-
|
|
8019
|
-
|
|
8020
|
-
|
|
8021
|
-
|
|
8022
|
-
|
|
8023
|
-
|
|
8024
|
-
|
|
8025
|
-
|
|
8026
|
-
|
|
8027
|
-
|
|
8028
|
-
|
|
8029
|
-
|
|
8030
|
-
|
|
8031
|
-
|
|
8911
|
+
for (const c of candidates) await this.holdBest({
|
|
8912
|
+
deviceId: input.deviceId,
|
|
8913
|
+
trackId: c.trackId,
|
|
8914
|
+
text: c.plateText,
|
|
8915
|
+
score: c.plateScore,
|
|
8916
|
+
bbox: c.plateBbox,
|
|
8917
|
+
timestamp: input.timestamp,
|
|
8918
|
+
frameWidth: input.frameWidth,
|
|
8919
|
+
frameHeight: input.frameHeight,
|
|
8920
|
+
cropPadding: input.cropPadding,
|
|
8921
|
+
...input.frameHandle !== void 0 ? { frameHandle: input.frameHandle } : {}
|
|
8922
|
+
});
|
|
8923
|
+
}
|
|
8924
|
+
/**
|
|
8925
|
+
* Detail-plane entry point (two-plane design): plate-ocr runs on demand per
|
|
8926
|
+
* track via `pipelineRunner.runDetailSubtree`, NOT per frame, so the OCR read
|
|
8927
|
+
* never lands on the per-frame `tracked[]` that {@link processFrame} scans.
|
|
8928
|
+
* The dispatcher's result router calls this with each plate detail so the
|
|
8929
|
+
* gallery still collects the best read + tight crop (persisted on
|
|
8930
|
+
* {@link onTrackEnd}). Without it the plate label rides the event but the
|
|
8931
|
+
* gallery stays empty (0 plateCrop) — the observed live gap.
|
|
8932
|
+
*/
|
|
8933
|
+
async observePlateRead(input) {
|
|
8934
|
+
if (!isPlausiblePlateRead(input.text, input.score)) return;
|
|
8935
|
+
if (input.score < (input.minConfidence ?? 0)) return;
|
|
8936
|
+
await this.holdBest(input);
|
|
8937
|
+
}
|
|
8938
|
+
/** Hold the highest-scoring plate read per track, capturing a tight crop the
|
|
8939
|
+
* first time a new best is seen (shared by the per-frame + detail-plane paths). */
|
|
8940
|
+
async holdBest(input) {
|
|
8941
|
+
const held = this.bestPlate.get(input.trackId);
|
|
8942
|
+
if (held !== void 0 && input.score <= held.score) return;
|
|
8943
|
+
let crop;
|
|
8944
|
+
if (input.frameHandle !== void 0) try {
|
|
8945
|
+
crop = await this.deps.captureCrop(input.frameHandle, input.bbox, input.frameWidth, input.frameHeight, input.cropPadding) ?? void 0;
|
|
8946
|
+
} catch (err) {
|
|
8947
|
+
this.deps.logger.debug("PlateRecognizer crop capture failed", {
|
|
8948
|
+
tags: { deviceId: input.deviceId },
|
|
8949
|
+
meta: {
|
|
8950
|
+
trackId: input.trackId,
|
|
8951
|
+
error: String(err)
|
|
8952
|
+
}
|
|
8032
8953
|
});
|
|
8033
8954
|
}
|
|
8955
|
+
this.bestPlate.set(input.trackId, {
|
|
8956
|
+
text: input.text,
|
|
8957
|
+
score: input.score,
|
|
8958
|
+
bbox: input.bbox,
|
|
8959
|
+
timestamp: input.timestamp,
|
|
8960
|
+
...crop !== void 0 ? { crop } : {}
|
|
8961
|
+
});
|
|
8034
8962
|
}
|
|
8035
8963
|
/** Persist the held best plate for a finished track as one PlateStore row
|
|
8036
8964
|
* (crop → MediaStore under ownerKind 'plate'), then drop in-memory state. */
|
|
@@ -8441,18 +9369,17 @@ var DETAIL_FALLBACK_CROP_PADDING = .15;
|
|
|
8441
9369
|
* before re-reading. */
|
|
8442
9370
|
var CLIP_MODEL_ID_CACHE_TTL_MS = 6e4;
|
|
8443
9371
|
var SETTINGS_CACHE_TTL_MS = 5e3;
|
|
9372
|
+
/** Reconcile cadence for the per-device `zones` / `zoneRules` slice handles.
|
|
9373
|
+
* The `device.state-changed` push doesn't reliably reach a forked child, so
|
|
9374
|
+
* each cached proxy re-pulls both slices on this timer (see ensureProxy) —
|
|
9375
|
+
* a zone drawn in the editor shows up in per-zone stats within one tick. */
|
|
9376
|
+
var ZONE_SLICE_RECONCILE_MS = 3e4;
|
|
8444
9377
|
/** §5 best-frame: a track's `thumbnail` is overwritten only when the current
|
|
8445
9378
|
* detection confidence beats the held best by at least this margin (hysteresis
|
|
8446
9379
|
* so jitter around a plateau doesn't churn the write). */
|
|
8447
9380
|
var BEST_FRAME_HYSTERESIS = .05;
|
|
8448
9381
|
/** §5 best-frame: at most one best-thumbnail capture per this interval per track. */
|
|
8449
9382
|
var BEST_FRAME_MIN_GAP_MS = 2e3;
|
|
8450
|
-
/** Design B: cap (px) on the width of the native KEY FRAME (full-frame native
|
|
8451
|
-
* capture). Native resolution is the point, but a full 4K RGB surface over the
|
|
8452
|
-
* transport per new-best is wasteful for a web detail view — 1920px keeps a
|
|
8453
|
-
* sharp native frame while bounding the copy (a miss falls back to the
|
|
8454
|
-
* detection-res frame, which is already ≤640px). */
|
|
8455
|
-
var KEYFRAME_NATIVE_MAX_WIDTH = 1920;
|
|
8456
9383
|
/** getKeyEvents: max completed tracks pulled from a window before importance
|
|
8457
9384
|
* ranking. Ordering is by importance (not firstSeen) and legacy rows score on
|
|
8458
9385
|
* read, so we over-fetch candidates and trim to `limit` after sorting. */
|
|
@@ -8525,6 +9452,10 @@ function stripGlobalOnlyFields(sections) {
|
|
|
8525
9452
|
var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
8526
9453
|
processors = /* @__PURE__ */ new Map();
|
|
8527
9454
|
trackStore = null;
|
|
9455
|
+
/** Parked-object registry: promotes a track that stopped moving into a
|
|
9456
|
+
* lightweight entry, suppresses its detections from re-spawning tracks, and
|
|
9457
|
+
* wakes it when the object departs. Null until onInitialize. */
|
|
9458
|
+
stationaryRegistry = null;
|
|
8528
9459
|
mediaStore = null;
|
|
8529
9460
|
eventStore = null;
|
|
8530
9461
|
identityStore = null;
|
|
@@ -8550,9 +9481,6 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
8550
9481
|
* detection-pipeline DECODED frame — the ONLY image source (never the
|
|
8551
9482
|
* snapshot cap). Null when shm frame access is unavailable. */
|
|
8552
9483
|
eventMediaDispatcher = null;
|
|
8553
|
-
/** Shared shm-ring reader cache for resolving `frameHandle`s to pixels.
|
|
8554
|
-
* Owned here so segments stay open across frames; closed once on shutdown. */
|
|
8555
|
-
frameReaders = null;
|
|
8556
9484
|
/** Object/face embedding dispatcher — migrated from the retired
|
|
8557
9485
|
* enrichment-engine addon. Runs ONLY on the post-processing node; on each
|
|
8558
9486
|
* detection it resolves the frame, crops the ROI, and calls the
|
|
@@ -8589,6 +9517,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
8589
9517
|
* dataPlane facility in the current environment). */
|
|
8590
9518
|
eventMediaBaseUrl = null;
|
|
8591
9519
|
lastActiveTrackIds = /* @__PURE__ */ new Map();
|
|
9520
|
+
lastFrameDimsByDevice = /* @__PURE__ */ new Map();
|
|
8592
9521
|
lastAudioInsertByDevice = /* @__PURE__ */ new Map();
|
|
8593
9522
|
lastMotionInsertByDevice = /* @__PURE__ */ new Map();
|
|
8594
9523
|
levelStateByDevice = /* @__PURE__ */ new Map();
|
|
@@ -8620,10 +9549,15 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
8620
9549
|
* cross-restart "best" gate stays in `ObjectEmbeddingStore.upsertIfBetter`. */
|
|
8621
9550
|
objectEmbeddingBestSelector = new TrackBestSelector();
|
|
8622
9551
|
/** Design B: the track's shared native key-frame media key, captured at the
|
|
8623
|
-
* best-detection moment (
|
|
8624
|
-
*
|
|
8625
|
-
* track end. */
|
|
9552
|
+
* best-detection moment (general best-frame path). Read by the face / plate /
|
|
9553
|
+
* object-embedding rows so they LINK the SAME single native key frame.
|
|
9554
|
+
* Cleared on track end. */
|
|
8626
9555
|
keyFrameKeyByTrackId = /* @__PURE__ */ new Map();
|
|
9556
|
+
/** Wall-clock of each track's last ACTUALLY-written rolling `lastFrame`. The
|
|
9557
|
+
* rolling `lastFrame` runs on its OWN pure-time cadence and only on frames
|
|
9558
|
+
* where no `snapshot` is appended, so it is never byte-identical to a stored
|
|
9559
|
+
* `snapshot` (kills the end-of-track duplicate). Cleared on track end. */
|
|
9560
|
+
lastFrameAtByTrack = /* @__PURE__ */ new Map();
|
|
8627
9561
|
/** Per-track update-gate memory for `PipelineAnalyticsTrackLifecycle`
|
|
8628
9562
|
* `phase:'update'` — the last-emitted best (confidence / label / crop
|
|
8629
9563
|
* area) + emit time, so a material improvement is measured against the
|
|
@@ -8674,11 +9608,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
8674
9608
|
await PlateStore.declare(api.settingsStore);
|
|
8675
9609
|
await VehicleStore.declare(api.settingsStore);
|
|
8676
9610
|
await ObjectEmbeddingStore.declare(api.settingsStore);
|
|
9611
|
+
await StationaryObjectRegistry.declare(api.settingsStore);
|
|
8677
9612
|
const logger = this.ctx.logger;
|
|
8678
9613
|
let storage = this.ctx.kernel.storage;
|
|
8679
9614
|
const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
|
|
8680
9615
|
if (mediaRoot) {
|
|
8681
|
-
const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-
|
|
9616
|
+
const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-DtltlqrH.js"));
|
|
8682
9617
|
storage = new FilesystemStorageProvider(mediaRoot);
|
|
8683
9618
|
logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
|
|
8684
9619
|
}
|
|
@@ -8687,6 +9622,30 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
8687
9622
|
store: api.settingsStore,
|
|
8688
9623
|
logger: logger.child("TrackStore")
|
|
8689
9624
|
});
|
|
9625
|
+
this.stationaryRegistry = new StationaryObjectRegistry({
|
|
9626
|
+
store: api.settingsStore,
|
|
9627
|
+
logger: logger.child("StationaryRegistry"),
|
|
9628
|
+
onChange: ({ phase, entry, timestamp }) => {
|
|
9629
|
+
this.ctx.eventBus.emit({
|
|
9630
|
+
id: `pa-stationary-${entry.id}-${phase}`,
|
|
9631
|
+
timestamp: new Date(timestamp),
|
|
9632
|
+
source: {
|
|
9633
|
+
type: "addon",
|
|
9634
|
+
id: "pipeline-analytics",
|
|
9635
|
+
addonId: "pipeline-analytics"
|
|
9636
|
+
},
|
|
9637
|
+
category: require_dist.EventCategory.PipelineAnalyticsStationaryChanged,
|
|
9638
|
+
data: {
|
|
9639
|
+
deviceId: entry.deviceId,
|
|
9640
|
+
entryId: entry.id,
|
|
9641
|
+
className: entry.className,
|
|
9642
|
+
phase,
|
|
9643
|
+
timestamp
|
|
9644
|
+
}
|
|
9645
|
+
});
|
|
9646
|
+
}
|
|
9647
|
+
});
|
|
9648
|
+
await this.stationaryRegistry.load();
|
|
8690
9649
|
this.mediaStore = new MediaStore({
|
|
8691
9650
|
storage,
|
|
8692
9651
|
store: api.settingsStore,
|
|
@@ -8723,33 +9682,33 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
8723
9682
|
designatedNode: designated
|
|
8724
9683
|
} });
|
|
8725
9684
|
}
|
|
8726
|
-
|
|
8727
|
-
const decoderApi = api.decoder;
|
|
9685
|
+
const pipelineRunnerApi = api.pipelineRunner;
|
|
8728
9686
|
const getRemoteFrame = async (handle) => {
|
|
8729
|
-
if (!
|
|
8730
|
-
const
|
|
9687
|
+
if (!pipelineRunnerApi?.getNativeCrop) return null;
|
|
9688
|
+
const full = await pipelineRunnerApi.getNativeCrop.query({
|
|
8731
9689
|
handle,
|
|
8732
|
-
|
|
8733
|
-
|
|
8734
|
-
|
|
9690
|
+
bbox: {
|
|
9691
|
+
x: 0,
|
|
9692
|
+
y: 0,
|
|
9693
|
+
w: 1,
|
|
9694
|
+
h: 1
|
|
9695
|
+
},
|
|
9696
|
+
maxWidth: handle.width
|
|
9697
|
+
}, require_dist.nodePin(handle.nodeId));
|
|
9698
|
+
if (!full || full.width <= 0 || full.height <= 0) return null;
|
|
8735
9699
|
return {
|
|
8736
|
-
data: Buffer.from(
|
|
8737
|
-
width:
|
|
8738
|
-
height:
|
|
8739
|
-
format:
|
|
8740
|
-
timestamp:
|
|
9700
|
+
data: Buffer.from(full.bytes),
|
|
9701
|
+
width: full.width,
|
|
9702
|
+
height: full.height,
|
|
9703
|
+
format: "rgb",
|
|
9704
|
+
timestamp: 0
|
|
8741
9705
|
};
|
|
8742
9706
|
};
|
|
8743
9707
|
this.eventMediaDispatcher = new EventMediaDispatcher({
|
|
8744
|
-
ownNodeId,
|
|
8745
|
-
readers: this.frameReaders,
|
|
8746
9708
|
getRemoteFrame,
|
|
8747
9709
|
mediaStore: this.mediaStore,
|
|
8748
9710
|
logger: logger.child("EventMediaDispatcher")
|
|
8749
9711
|
});
|
|
8750
|
-
const ownNodeIdForFaces = ownNodeId;
|
|
8751
|
-
const frameReadersForFaces = this.frameReaders;
|
|
8752
|
-
const pipelineRunnerApi = api.pipelineRunner;
|
|
8753
9712
|
const cropMetricLogger = logger.child("NativeCrop");
|
|
8754
9713
|
let nativeHits = 0;
|
|
8755
9714
|
let nativeFallbacks = 0;
|
|
@@ -8781,11 +9740,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
8781
9740
|
return null;
|
|
8782
9741
|
}
|
|
8783
9742
|
};
|
|
8784
|
-
const resolveFrameShared = createSharedFrameResolver((frameHandle) => resolveFrame(frameHandle, {
|
|
8785
|
-
ownNodeId: ownNodeIdForFaces,
|
|
8786
|
-
readers: frameReadersForFaces,
|
|
8787
|
-
getRemoteFrame
|
|
8788
|
-
}));
|
|
9743
|
+
const resolveFrameShared = createSharedFrameResolver((frameHandle) => resolveFrame(frameHandle, { getRemoteFrame }));
|
|
8789
9744
|
const captureCrop = async (frameHandle, bbox, frameWidth, frameHeight, padding, maxWidth) => {
|
|
8790
9745
|
const paddedNorm = padBbox({
|
|
8791
9746
|
x: bbox.x / frameWidth,
|
|
@@ -8941,8 +9896,6 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
8941
9896
|
encoder: encoderClient,
|
|
8942
9897
|
eventBus: this.ctx.eventBus,
|
|
8943
9898
|
logger: logger.child("EmbeddingDispatcher"),
|
|
8944
|
-
ownNodeId,
|
|
8945
|
-
readers: frameReadersForFaces,
|
|
8946
9899
|
getRemoteFrame
|
|
8947
9900
|
});
|
|
8948
9901
|
await this.embeddingDispatcher.start();
|
|
@@ -8953,6 +9906,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
8953
9906
|
this.bindingCache?.onBindingsChanged(data);
|
|
8954
9907
|
if (data.capName === "pipeline-analytics" && data.reason === "wrapper-deactivated") {
|
|
8955
9908
|
this.trackStore?.clearDevice(data.deviceId);
|
|
9909
|
+
this.stationaryRegistry?.forgetDevice(data.deviceId);
|
|
8956
9910
|
this.overlayState.clearDevice(data.deviceId);
|
|
8957
9911
|
this.overlaySynthesisWarnAt.delete(data.deviceId);
|
|
8958
9912
|
this.forgetDeviceProcessors(data.deviceId);
|
|
@@ -8967,6 +9921,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
8967
9921
|
this.unsubDeviceUnreg = this.ctx.eventBus.subscribe({ category: require_dist.EventCategory.DeviceUnregistered }, (ev) => {
|
|
8968
9922
|
const { deviceId } = ev.data;
|
|
8969
9923
|
this.trackStore?.clearDevice(deviceId);
|
|
9924
|
+
this.stationaryRegistry?.forgetDevice(deviceId);
|
|
8970
9925
|
this.overlayState.clearDevice(deviceId);
|
|
8971
9926
|
this.overlaySynthesisWarnAt.delete(deviceId);
|
|
8972
9927
|
this.forgetDeviceProcessors(deviceId);
|
|
@@ -8987,6 +9942,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
8987
9942
|
this.retentionSweepTimer = setInterval(() => {
|
|
8988
9943
|
this.sweepRetention();
|
|
8989
9944
|
this.runTrackRetentionSweep();
|
|
9945
|
+
this.stationaryRegistry?.sweep(Date.now());
|
|
8990
9946
|
}, RETENTION_SWEEP_INTERVAL_MS);
|
|
8991
9947
|
this.ctx.logger.info("pipeline-analytics subscribers installed");
|
|
8992
9948
|
const widgetsProvider = { listWidgets: async () => [
|
|
@@ -9307,8 +10263,10 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
9307
10263
|
this.overlaySynthesisWarnAt.clear();
|
|
9308
10264
|
this.processors.clear();
|
|
9309
10265
|
this.lastActiveTrackIds.clear();
|
|
10266
|
+
this.lastFrameDimsByDevice.clear();
|
|
9310
10267
|
this.dropoutSkipsByKey.clear();
|
|
9311
10268
|
this.bestFrameTracker.clear();
|
|
10269
|
+
this.lastFrameAtByTrack.clear();
|
|
9312
10270
|
this.trackLifecycleUpdateMem.clear();
|
|
9313
10271
|
this.objectEmbeddingBestSelector.clear();
|
|
9314
10272
|
this.levelStateByDevice.clear();
|
|
@@ -9319,13 +10277,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
9319
10277
|
this.faceGlobalEnabledCache = null;
|
|
9320
10278
|
this.mediaCacheByDevice.clear();
|
|
9321
10279
|
this.trackStore?.clearAll();
|
|
10280
|
+
this.stationaryRegistry = null;
|
|
9322
10281
|
this.bindingCache?.clearAll();
|
|
9323
10282
|
await this.eventMediaDataPlane?.dispose();
|
|
9324
10283
|
this.eventMediaDataPlane = null;
|
|
9325
10284
|
this.eventMediaBaseUrl = null;
|
|
9326
10285
|
this.eventMediaDispatcher = null;
|
|
9327
|
-
this.frameReaders?.close();
|
|
9328
|
-
this.frameReaders = null;
|
|
9329
10286
|
}
|
|
9330
10287
|
async handleInferenceResult(data) {
|
|
9331
10288
|
if (this.shuttingDown) return;
|
|
@@ -9373,22 +10330,41 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
9373
10330
|
timestamp: frame.timestamp,
|
|
9374
10331
|
frame
|
|
9375
10332
|
});
|
|
10333
|
+
if (source === "pipeline") this.stationaryRegistry?.noteFrame(deviceId, result.timestamp);
|
|
10334
|
+
if (this.stationaryRegistry && (result.stationaryConfirmed.length > 0 || result.stationaryWoken.length > 0)) this.stationaryRegistry.applyFrameOutcome({
|
|
10335
|
+
deviceId,
|
|
10336
|
+
confirmed: result.stationaryConfirmed,
|
|
10337
|
+
wokenEntryIds: result.stationaryWoken,
|
|
10338
|
+
timestamp: result.timestamp
|
|
10339
|
+
});
|
|
10340
|
+
const stationaryViews = this.stationaryRegistry?.listViews(deviceId) ?? [];
|
|
10341
|
+
const stationaryAsTracked = stationaryViews.map((v) => ({
|
|
10342
|
+
trackId: `stationary:${v.id}`,
|
|
10343
|
+
className: v.className,
|
|
10344
|
+
zones: []
|
|
10345
|
+
}));
|
|
9376
10346
|
this.zoneAnalytics?.recordFrame({
|
|
9377
10347
|
deviceId,
|
|
9378
10348
|
timestamp: result.timestamp,
|
|
9379
10349
|
frameWidth: result.frameWidth,
|
|
9380
10350
|
frameHeight: result.frameHeight,
|
|
9381
|
-
tracked: result.tracked,
|
|
9382
|
-
zones: liveZones
|
|
10351
|
+
tracked: stationaryAsTracked.length > 0 ? [...result.tracked, ...stationaryAsTracked] : result.tracked,
|
|
10352
|
+
zones: liveZones,
|
|
10353
|
+
...stationaryViews.length > 0 ? { stationaryObjects: stationaryViews } : {}
|
|
10354
|
+
});
|
|
10355
|
+
if (result.frameWidth > 0 && result.frameHeight > 0) this.lastFrameDimsByDevice.set(deviceId, {
|
|
10356
|
+
w: result.frameWidth,
|
|
10357
|
+
h: result.frameHeight
|
|
9383
10358
|
});
|
|
9384
10359
|
const currentTrackIds = /* @__PURE__ */ new Set();
|
|
10360
|
+
const positionsCountById = /* @__PURE__ */ new Map();
|
|
9385
10361
|
for (const t of result.tracked) {
|
|
9386
10362
|
currentTrackIds.add(t.trackId);
|
|
9387
10363
|
const center = {
|
|
9388
10364
|
x: t.bbox.x + t.bbox.w / 2,
|
|
9389
10365
|
y: t.bbox.y + t.bbox.h / 2
|
|
9390
10366
|
};
|
|
9391
|
-
this.trackStore.upsert({
|
|
10367
|
+
const upserted = this.trackStore.upsert({
|
|
9392
10368
|
trackId: t.trackId,
|
|
9393
10369
|
deviceId,
|
|
9394
10370
|
className: t.className,
|
|
@@ -9403,6 +10379,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
9403
10379
|
zones: t.zones,
|
|
9404
10380
|
state: t.state
|
|
9405
10381
|
});
|
|
10382
|
+
positionsCountById.set(t.trackId, upserted.positions.length);
|
|
9406
10383
|
}
|
|
9407
10384
|
const log = this.ctx.logger.withTags({ deviceId });
|
|
9408
10385
|
const prevIds = this.lastActiveTrackIds.get(key) ?? /* @__PURE__ */ new Set();
|
|
@@ -9411,6 +10388,18 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
9411
10388
|
for (const id of currentTrackIds) if (!prevIds.has(id)) {
|
|
9412
10389
|
const t = result.tracked.find((x) => x.trackId === id);
|
|
9413
10390
|
if (t) {
|
|
10391
|
+
if (classifyTrackAppearance({
|
|
10392
|
+
inPrevActive: false,
|
|
10393
|
+
positionsCount: positionsCountById.get(id) ?? 1
|
|
10394
|
+
}) === "resurrection") {
|
|
10395
|
+
log.info("track resumed", { meta: {
|
|
10396
|
+
trackId: id,
|
|
10397
|
+
className: t.className,
|
|
10398
|
+
source,
|
|
10399
|
+
resurrected: true
|
|
10400
|
+
} });
|
|
10401
|
+
continue;
|
|
10402
|
+
}
|
|
9414
10403
|
newTrackCount += 1;
|
|
9415
10404
|
log.info("track started", { meta: {
|
|
9416
10405
|
trackId: id,
|
|
@@ -9424,7 +10413,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
9424
10413
|
bbox: { ...t.bbox },
|
|
9425
10414
|
...t.label ? { label: t.label } : {}
|
|
9426
10415
|
});
|
|
9427
|
-
this.trackStore.seedSnapshotClock(id, result.timestamp);
|
|
10416
|
+
this.trackStore.seedSnapshotClock(id, result.timestamp, t.bbox);
|
|
9428
10417
|
}
|
|
9429
10418
|
this.ctx.eventBus.emit({
|
|
9430
10419
|
id: `pa-${(0, node_crypto.randomUUID)()}`,
|
|
@@ -9470,6 +10459,34 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
9470
10459
|
} });
|
|
9471
10460
|
}
|
|
9472
10461
|
this.lastActiveTrackIds.set(key, currentTrackIds);
|
|
10462
|
+
if (source === "pipeline" && this.stationaryRegistry) {
|
|
10463
|
+
const dims = this.lastFrameDimsByDevice.get(deviceId);
|
|
10464
|
+
if (dims && dims.w > 0 && dims.h > 0) {
|
|
10465
|
+
const refDiag = Math.hypot(dims.w, dims.h);
|
|
10466
|
+
for (const t of result.tracked) {
|
|
10467
|
+
const active = this.trackStore.peekActive(t.trackId);
|
|
10468
|
+
if (!active) continue;
|
|
10469
|
+
const { promote } = evaluateStationaryPromotion({
|
|
10470
|
+
positions: active.positions,
|
|
10471
|
+
referenceDiagonalPx: refDiag,
|
|
10472
|
+
now: result.timestamp,
|
|
10473
|
+
config: DEFAULT_PROMOTION_CONFIG
|
|
10474
|
+
});
|
|
10475
|
+
if (!promote) continue;
|
|
10476
|
+
this.promoteToStationary({
|
|
10477
|
+
deviceId,
|
|
10478
|
+
key,
|
|
10479
|
+
processor,
|
|
10480
|
+
track: t,
|
|
10481
|
+
firstSeen: active.firstSeen,
|
|
10482
|
+
label: active.label,
|
|
10483
|
+
frameWidth: result.frameWidth,
|
|
10484
|
+
frameHeight: result.frameHeight,
|
|
10485
|
+
timestamp: result.timestamp
|
|
10486
|
+
});
|
|
10487
|
+
}
|
|
10488
|
+
}
|
|
10489
|
+
}
|
|
9473
10490
|
if (this.detailDispatcher && detailSteps && detailSteps.length > 0) {
|
|
9474
10491
|
const dispatcher = this.detailDispatcher;
|
|
9475
10492
|
const steps = detailSteps;
|
|
@@ -9538,7 +10555,9 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
9538
10555
|
let plateCrops = 0;
|
|
9539
10556
|
for (const crops of childCropsByEvent.values()) for (const c of crops) if (c.kind === "faceCrop") faceCrops += 1;
|
|
9540
10557
|
else plateCrops += 1;
|
|
9541
|
-
const snapshotTargets = this.buildSnapshotTargets(deviceId, result.tracked, result.timestamp, mediaSettings);
|
|
10558
|
+
const snapshotTargets = this.buildSnapshotTargets(deviceId, result.tracked, result.timestamp, mediaSettings, result.frameWidth, result.frameHeight);
|
|
10559
|
+
const keyFrameTrackIds = selectKeyFrameTrackIds(snapshotTargets);
|
|
10560
|
+
if (keyFrameTrackIds.length > 0) this.persistKeyFrames(deviceId, result.timestamp, keyFrameTrackIds, frameHandle, result.frameWidth, result.frameHeight);
|
|
9542
10561
|
if (eventTargets.length > 0 || firstFrameTargets.length > 0 || snapshotTargets.length > 0) {
|
|
9543
10562
|
const captureCounts = {
|
|
9544
10563
|
events: eventTargets.length,
|
|
@@ -9753,14 +10772,36 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
9753
10772
|
if (isFaceDetail && d.embedding !== void 0) await this.routeFaceDetail(deviceId, trackId, d, frame);
|
|
9754
10773
|
else if (d.embedding !== void 0) await this.routeClipDetail(deviceId, trackId, d, frame.timestamp);
|
|
9755
10774
|
else if (d.label !== void 0 && d.label.length > 0) {
|
|
9756
|
-
if (d.className === "plate" && d.bbox !== void 0)
|
|
9757
|
-
|
|
9758
|
-
|
|
9759
|
-
|
|
9760
|
-
|
|
9761
|
-
|
|
9762
|
-
|
|
9763
|
-
|
|
10775
|
+
if (d.className === "plate" && d.bbox !== void 0) {
|
|
10776
|
+
this.overlayState.notePlateDetail(deviceId, trackId, {
|
|
10777
|
+
x: d.bbox.x,
|
|
10778
|
+
y: d.bbox.y,
|
|
10779
|
+
w: d.bbox.w,
|
|
10780
|
+
h: d.bbox.h
|
|
10781
|
+
}, d.score, d.label, frame.timestamp);
|
|
10782
|
+
if (this.plateRecognizer) {
|
|
10783
|
+
const mediaSettings = await this.resolveDeviceMediaSettings(deviceId);
|
|
10784
|
+
await this.plateRecognizer.observePlateRead({
|
|
10785
|
+
deviceId,
|
|
10786
|
+
trackId,
|
|
10787
|
+
text: d.label,
|
|
10788
|
+
score: d.score,
|
|
10789
|
+
bbox: {
|
|
10790
|
+
x: d.bbox.x,
|
|
10791
|
+
y: d.bbox.y,
|
|
10792
|
+
w: d.bbox.w,
|
|
10793
|
+
h: d.bbox.h
|
|
10794
|
+
},
|
|
10795
|
+
timestamp: frame.timestamp,
|
|
10796
|
+
frameWidth: frame.frameWidth,
|
|
10797
|
+
frameHeight: frame.frameHeight,
|
|
10798
|
+
cropPadding: mediaSettings.cropPadding,
|
|
10799
|
+
...frame.frameHandle !== void 0 ? { frameHandle: frame.frameHandle } : {}
|
|
10800
|
+
});
|
|
10801
|
+
}
|
|
10802
|
+
}
|
|
10803
|
+
const label = d.className === "plate" ? this.plateRecognizer?.resolveLabel(d.label, d.score) ?? null : d.label;
|
|
10804
|
+
if (label !== null && label !== void 0) await this.applyTrackEnrichmentLabel(deviceId, trackId, label);
|
|
9764
10805
|
}
|
|
9765
10806
|
} catch (err) {
|
|
9766
10807
|
this.ctx.logger.warn("detail result route failed", {
|
|
@@ -9907,55 +10948,26 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
9907
10948
|
await Promise.all(bests.map(async (t) => {
|
|
9908
10949
|
if (!isClipObjectEmbedding(t)) return;
|
|
9909
10950
|
let mediaKey;
|
|
9910
|
-
|
|
9911
|
-
|
|
9912
|
-
|
|
9913
|
-
|
|
9914
|
-
|
|
9915
|
-
|
|
9916
|
-
|
|
9917
|
-
|
|
9918
|
-
|
|
9919
|
-
|
|
9920
|
-
|
|
9921
|
-
|
|
9922
|
-
|
|
9923
|
-
|
|
9924
|
-
|
|
9925
|
-
|
|
9926
|
-
trackId: t.trackId,
|
|
9927
|
-
error: require_dist.errMsg(err)
|
|
9928
|
-
}
|
|
9929
|
-
});
|
|
9930
|
-
}
|
|
9931
|
-
try {
|
|
9932
|
-
const keyFrame = await this.captureCrop(frameHandle, {
|
|
9933
|
-
x: 0,
|
|
9934
|
-
y: 0,
|
|
9935
|
-
w: frameWidth,
|
|
9936
|
-
h: frameHeight
|
|
9937
|
-
}, frameWidth, frameHeight, 0, KEYFRAME_NATIVE_MAX_WIDTH);
|
|
9938
|
-
if (keyFrame) {
|
|
9939
|
-
keyFrameMediaKey = await this.mediaStore.putReplacing({
|
|
9940
|
-
deviceId,
|
|
9941
|
-
ownerKind: "track",
|
|
9942
|
-
ownerId: t.trackId,
|
|
9943
|
-
kind: "keyFrame",
|
|
9944
|
-
timestamp,
|
|
9945
|
-
data: keyFrame
|
|
9946
|
-
});
|
|
9947
|
-
this.keyFrameKeyByTrackId.set(t.trackId, keyFrameMediaKey);
|
|
10951
|
+
if (frameHandle !== void 0 && this.captureCrop && this.mediaStore) try {
|
|
10952
|
+
const crop = await this.captureCrop(frameHandle, t.bbox, frameWidth, frameHeight, cropPadding);
|
|
10953
|
+
if (crop) mediaKey = await this.mediaStore.putReplacing({
|
|
10954
|
+
deviceId,
|
|
10955
|
+
ownerKind: "track",
|
|
10956
|
+
ownerId: t.trackId,
|
|
10957
|
+
kind: "crop",
|
|
10958
|
+
timestamp,
|
|
10959
|
+
data: crop
|
|
10960
|
+
});
|
|
10961
|
+
} catch (err) {
|
|
10962
|
+
this.ctx.logger.debug("object-embedding crop capture failed", {
|
|
10963
|
+
tags: { deviceId },
|
|
10964
|
+
meta: {
|
|
10965
|
+
trackId: t.trackId,
|
|
10966
|
+
error: require_dist.errMsg(err)
|
|
9948
10967
|
}
|
|
9949
|
-
}
|
|
9950
|
-
this.ctx.logger.debug("key-frame capture failed", {
|
|
9951
|
-
tags: { deviceId },
|
|
9952
|
-
meta: {
|
|
9953
|
-
trackId: t.trackId,
|
|
9954
|
-
error: require_dist.errMsg(err)
|
|
9955
|
-
}
|
|
9956
|
-
});
|
|
9957
|
-
}
|
|
10968
|
+
});
|
|
9958
10969
|
}
|
|
10970
|
+
const keyFrameMediaKey = this.keyFrameKeyByTrackId.get(t.trackId);
|
|
9959
10971
|
await store.upsertIfBetter({
|
|
9960
10972
|
trackId: t.trackId,
|
|
9961
10973
|
deviceId,
|
|
@@ -9969,6 +10981,48 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
9969
10981
|
});
|
|
9970
10982
|
}));
|
|
9971
10983
|
}
|
|
10984
|
+
/**
|
|
10985
|
+
* Capture ONE native-resolution KEY FRAME per given track at this best-
|
|
10986
|
+
* detection frame and store it (`putReplacing` → one keyFrame per track).
|
|
10987
|
+
*
|
|
10988
|
+
* The full frame is cropped NATIVE-FIRST via `captureCrop`: the request is the
|
|
10989
|
+
* FULL frame (no padding) at `KEYFRAME_NATIVE_MAX_WIDTH`, which routes through
|
|
10990
|
+
* `pipelineRunner.getNativeCrop` (the decode worker's retained native surface)
|
|
10991
|
+
* and only falls back to the ≤640 detection frame when the native lease is
|
|
10992
|
+
* gone. The stored key is recorded in `keyFrameKeyByTrackId` so the face /
|
|
10993
|
+
* plate / object-embedding rows LINK the SAME native key frame (Design B).
|
|
10994
|
+
* Issued in the live-frame window so the native lease is still held. Best-
|
|
10995
|
+
* effort (D8) — a per-track failure is logged and never thrown.
|
|
10996
|
+
*/
|
|
10997
|
+
async persistKeyFrames(deviceId, timestamp, trackIds, frameHandle, frameWidth, frameHeight) {
|
|
10998
|
+
const capture = this.captureCrop;
|
|
10999
|
+
const mediaStore = this.mediaStore;
|
|
11000
|
+
if (!capture || !mediaStore) return;
|
|
11001
|
+
const req = buildKeyFrameCaptureRequest(frameWidth, frameHeight);
|
|
11002
|
+
await Promise.all(trackIds.map(async (trackId) => {
|
|
11003
|
+
try {
|
|
11004
|
+
const keyFrame = await capture(frameHandle, req.bbox, frameWidth, frameHeight, req.padding, req.maxWidth);
|
|
11005
|
+
if (!keyFrame) return;
|
|
11006
|
+
const key = await mediaStore.putReplacing({
|
|
11007
|
+
deviceId,
|
|
11008
|
+
ownerKind: "track",
|
|
11009
|
+
ownerId: trackId,
|
|
11010
|
+
kind: "keyFrame",
|
|
11011
|
+
timestamp,
|
|
11012
|
+
data: keyFrame
|
|
11013
|
+
});
|
|
11014
|
+
this.keyFrameKeyByTrackId.set(trackId, key);
|
|
11015
|
+
} catch (err) {
|
|
11016
|
+
this.ctx.logger.debug("key-frame capture failed", {
|
|
11017
|
+
tags: { deviceId },
|
|
11018
|
+
meta: {
|
|
11019
|
+
trackId,
|
|
11020
|
+
error: require_dist.errMsg(err)
|
|
11021
|
+
}
|
|
11022
|
+
});
|
|
11023
|
+
}
|
|
11024
|
+
}));
|
|
11025
|
+
}
|
|
9972
11026
|
/** Emit a `PipelineAnalyticsTrackLifecycle` event (start / update / end). */
|
|
9973
11027
|
emitTrackLifecycle(payload, timestamp) {
|
|
9974
11028
|
this.ctx.eventBus.emit({
|
|
@@ -10018,27 +11072,48 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
10018
11072
|
...track?.zonesVisited !== void 0 ? { zonesVisited: track.zonesVisited } : {},
|
|
10019
11073
|
...track?.totalDistance !== void 0 ? { totalDistance: track.totalDistance } : {},
|
|
10020
11074
|
...track?.positions !== void 0 ? { positionsCount: track.positions.length } : {},
|
|
11075
|
+
...track?.audioLabels !== void 0 ? { audioLabels: track.audioLabels } : {},
|
|
10021
11076
|
...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {},
|
|
10022
11077
|
...t.embeddingModelId !== void 0 ? { embeddingModelId: t.embeddingModelId } : {}
|
|
10023
11078
|
});
|
|
10024
11079
|
this.emitTrackLifecycle(payload, timestamp);
|
|
10025
11080
|
}
|
|
10026
|
-
buildSnapshotTargets(deviceId, tracked, timestamp, media) {
|
|
11081
|
+
buildSnapshotTargets(deviceId, tracked, timestamp, media, frameWidth, frameHeight) {
|
|
10027
11082
|
const targets = [];
|
|
10028
11083
|
for (const t of tracked) {
|
|
10029
11084
|
const lastSnap = this.trackStore.lastSnapshotAt(t.trackId);
|
|
10030
|
-
const dueSnapshot = media.saveThumbnails &&
|
|
11085
|
+
const dueSnapshot = media.saveThumbnails && evaluatePeriodicSnapshot({
|
|
11086
|
+
lastSnapshotAt: lastSnap,
|
|
11087
|
+
lastSnapshotBbox: this.trackStore.lastSnapshotBbox(t.trackId),
|
|
11088
|
+
currentBbox: t.bbox,
|
|
11089
|
+
now: timestamp,
|
|
11090
|
+
frameWidth,
|
|
11091
|
+
frameHeight,
|
|
11092
|
+
intervalMs: media.snapshotIntervalMs,
|
|
11093
|
+
movementThreshold: media.snapshotMovementThreshold,
|
|
11094
|
+
maxIdleMs: media.snapshotMaxIdleMs
|
|
11095
|
+
}).capture;
|
|
10031
11096
|
const isNewBest = this.bestFrameTracker.observe(t.trackId, t.confidence, timestamp);
|
|
10032
11097
|
this.maybeEmitTrackLifecycleUpdate(deviceId, t, timestamp, isNewBest);
|
|
10033
|
-
|
|
11098
|
+
const plan = planPeriodicMedia({
|
|
11099
|
+
saveThumbnails: media.saveThumbnails,
|
|
11100
|
+
dueSnapshot,
|
|
11101
|
+
isNewBest,
|
|
11102
|
+
lastFrameAt: this.lastFrameAtByTrack.get(t.trackId) ?? 0,
|
|
11103
|
+
now: timestamp,
|
|
11104
|
+
intervalMs: media.snapshotIntervalMs
|
|
11105
|
+
});
|
|
11106
|
+
if (plan.rollingLastFrame) this.lastFrameAtByTrack.set(t.trackId, timestamp);
|
|
11107
|
+
if (plan.appendSnapshot) this.trackStore.markSnapshotPending(t.trackId, timestamp, t.bbox);
|
|
11108
|
+
if (!plan.appendSnapshot && !plan.rollingLastFrame && !plan.bestThumbnail) continue;
|
|
10034
11109
|
targets.push({
|
|
10035
11110
|
trackId: t.trackId,
|
|
10036
11111
|
timestamp,
|
|
10037
11112
|
bbox: { ...t.bbox },
|
|
10038
11113
|
...t.label ? { label: t.label } : {},
|
|
10039
|
-
appendSnapshot:
|
|
10040
|
-
rollingLastFrame:
|
|
10041
|
-
bestThumbnail:
|
|
11114
|
+
appendSnapshot: plan.appendSnapshot,
|
|
11115
|
+
rollingLastFrame: plan.rollingLastFrame,
|
|
11116
|
+
bestThumbnail: plan.bestThumbnail
|
|
10042
11117
|
});
|
|
10043
11118
|
}
|
|
10044
11119
|
return targets;
|
|
@@ -10094,6 +11169,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
10094
11169
|
atMs: timestamp
|
|
10095
11170
|
});
|
|
10096
11171
|
await this.eventStore.insertAudio(ev);
|
|
11172
|
+
this.trackStore?.addAudioLabelEpisode(deviceId, route.className, topClassification.score, timestamp);
|
|
10097
11173
|
this.ctx.eventBus.emit({
|
|
10098
11174
|
id: `pa-${ev.id}`,
|
|
10099
11175
|
timestamp: new Date(ev.timestamp),
|
|
@@ -10311,6 +11387,11 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
10311
11387
|
const peak = await this.eventStore?.peakForTrack(t.trackId);
|
|
10312
11388
|
if (peak) {
|
|
10313
11389
|
endBestEventId = peak.bestEventId;
|
|
11390
|
+
const dims = this.lastFrameDimsByDevice.get(t.deviceId);
|
|
11391
|
+
const staticMetrics = dims ? computeStaticTrackMetrics(t.positions.map((p) => ({
|
|
11392
|
+
x: p.x,
|
|
11393
|
+
y: p.y
|
|
11394
|
+
})), Math.hypot(dims.w, dims.h)) : void 0;
|
|
10314
11395
|
const { importance, reason } = computeImportance({
|
|
10315
11396
|
peakConfidence: trackerPeak?.confidence ?? peak.peakConfidence,
|
|
10316
11397
|
className: t.className,
|
|
@@ -10318,7 +11399,11 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
10318
11399
|
peakBboxAreaFrac: peak.peakBboxAreaFrac,
|
|
10319
11400
|
totalDistance: t.totalDistance,
|
|
10320
11401
|
zonesVisited: t.zonesVisited,
|
|
10321
|
-
...t.label !== void 0 ? { label: t.label } : {}
|
|
11402
|
+
...t.label !== void 0 ? { label: t.label } : {},
|
|
11403
|
+
...staticMetrics ? {
|
|
11404
|
+
netDisplacementFrac: staticMetrics.netDisplacementFrac,
|
|
11405
|
+
pathSpanFrac: staticMetrics.pathSpanFrac
|
|
11406
|
+
} : {}
|
|
10322
11407
|
});
|
|
10323
11408
|
endImportance = importance;
|
|
10324
11409
|
endImportanceReason = reason;
|
|
@@ -10333,6 +11418,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
10333
11418
|
}
|
|
10334
11419
|
this.bestFrameTracker.delete(t.trackId);
|
|
10335
11420
|
this.objectEmbeddingBestSelector.delete(t.trackId);
|
|
11421
|
+
this.lastFrameAtByTrack.delete(t.trackId);
|
|
10336
11422
|
this.detailDispatcher?.onTrackEnded(t.deviceId, t.trackId);
|
|
10337
11423
|
this.overlayState.onTrackEnded(t.deviceId, t.trackId);
|
|
10338
11424
|
if ((this.trackStore?.getActive(t.deviceId).length ?? 0) === 0) {
|
|
@@ -10377,6 +11463,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
10377
11463
|
positionsCount: t.positions.length,
|
|
10378
11464
|
...endImportance !== void 0 ? { importance: endImportance } : {},
|
|
10379
11465
|
...endImportanceReason !== void 0 ? { importanceReason: endImportanceReason } : {},
|
|
11466
|
+
...t.audioLabels !== void 0 ? { audioLabels: t.audioLabels } : {},
|
|
10380
11467
|
...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {},
|
|
10381
11468
|
...endBestEventId !== void 0 ? { bestEventId: endBestEventId } : {}
|
|
10382
11469
|
});
|
|
@@ -10539,6 +11626,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
10539
11626
|
for (const k of this.processors.keys()) if (k.startsWith(prefix)) this.processors.delete(k);
|
|
10540
11627
|
for (const k of this.lastActiveTrackIds.keys()) if (k.startsWith(prefix)) this.lastActiveTrackIds.delete(k);
|
|
10541
11628
|
for (const k of this.dropoutSkipsByKey.keys()) if (k.startsWith(prefix)) this.dropoutSkipsByKey.delete(k);
|
|
11629
|
+
this.lastFrameDimsByDevice.delete(deviceId);
|
|
10542
11630
|
}
|
|
10543
11631
|
/** Apply a mutation to every live source-processor of a device (zones/rules
|
|
10544
11632
|
* are device-level and must reach all sources). */
|
|
@@ -10546,6 +11634,57 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
10546
11634
|
const prefix = `${deviceId}:`;
|
|
10547
11635
|
for (const [k, p] of this.processors) if (k.startsWith(prefix)) fn(p);
|
|
10548
11636
|
}
|
|
11637
|
+
/**
|
|
11638
|
+
* Turn a parked track into a stationary-registry entry and tear the track
|
|
11639
|
+
* down WITHOUT firing an 'end' key-event / importance scoring / media flush
|
|
11640
|
+
* (a parked object is not a highlight; the durable record is the entry). The
|
|
11641
|
+
* tracker + store forget the track so its detections stop re-spawning tracks,
|
|
11642
|
+
* and the registry suppresses them from the next frame on.
|
|
11643
|
+
*/
|
|
11644
|
+
promoteToStationary(input) {
|
|
11645
|
+
const { deviceId, key, processor, track, firstSeen, label, frameWidth, frameHeight, timestamp } = input;
|
|
11646
|
+
const keyFrameMediaKey = this.keyFrameKeyByTrackId.get(track.trackId);
|
|
11647
|
+
const entry = {
|
|
11648
|
+
id: (0, node_crypto.randomUUID)(),
|
|
11649
|
+
deviceId,
|
|
11650
|
+
className: track.className,
|
|
11651
|
+
bbox: { ...track.bbox },
|
|
11652
|
+
frameWidth,
|
|
11653
|
+
frameHeight,
|
|
11654
|
+
firstSeenAt: firstSeen,
|
|
11655
|
+
becameStationaryAt: timestamp,
|
|
11656
|
+
lastConfirmedAt: timestamp,
|
|
11657
|
+
sourceTrackId: track.trackId,
|
|
11658
|
+
...label !== void 0 ? { label } : {},
|
|
11659
|
+
...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {}
|
|
11660
|
+
};
|
|
11661
|
+
this.stationaryRegistry?.promote(entry);
|
|
11662
|
+
processor.dropTrack(track.trackId);
|
|
11663
|
+
this.trackStore?.dropActive(track.trackId);
|
|
11664
|
+
const faceEnd = this.faceRecognizer?.onTrackEnd(deviceId, track.trackId);
|
|
11665
|
+
const dropKeyFrame = () => {
|
|
11666
|
+
this.keyFrameKeyByTrackId.delete(track.trackId);
|
|
11667
|
+
};
|
|
11668
|
+
if (faceEnd) faceEnd.finally(dropKeyFrame);
|
|
11669
|
+
else dropKeyFrame();
|
|
11670
|
+
this.plateRecognizer?.onTrackEnd(deviceId, track.trackId);
|
|
11671
|
+
this.bestFrameTracker.delete(track.trackId);
|
|
11672
|
+
this.objectEmbeddingBestSelector.delete(track.trackId);
|
|
11673
|
+
this.lastFrameAtByTrack.delete(track.trackId);
|
|
11674
|
+
this.trackLifecycleUpdateMem.delete(track.trackId);
|
|
11675
|
+
this.detailDispatcher?.onTrackEnded(deviceId, track.trackId);
|
|
11676
|
+
this.overlayState.onTrackEnded(deviceId, track.trackId);
|
|
11677
|
+
this.lastActiveTrackIds.get(key)?.delete(track.trackId);
|
|
11678
|
+
this.ctx.logger.info("track promoted to stationary", {
|
|
11679
|
+
tags: { deviceId },
|
|
11680
|
+
meta: {
|
|
11681
|
+
trackId: track.trackId,
|
|
11682
|
+
className: track.className,
|
|
11683
|
+
entryId: entry.id,
|
|
11684
|
+
...label ? { label } : {}
|
|
11685
|
+
}
|
|
11686
|
+
});
|
|
11687
|
+
}
|
|
10549
11688
|
async getOrCreateProcessor(deviceId, source) {
|
|
10550
11689
|
const key = this.procKey(deviceId, source);
|
|
10551
11690
|
let p = this.processors.get(key);
|
|
@@ -10571,6 +11710,18 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
10571
11710
|
cooldownSec,
|
|
10572
11711
|
minTrackAgeMs: trk.minTrackAgeMs
|
|
10573
11712
|
}, source);
|
|
11713
|
+
if (source === "pipeline" && this.stationaryRegistry) {
|
|
11714
|
+
const registry = this.stationaryRegistry;
|
|
11715
|
+
p.setStationaryGate({ filter: (input) => registry.filter({
|
|
11716
|
+
deviceId,
|
|
11717
|
+
detections: input.detections.map((d) => ({
|
|
11718
|
+
bbox: d.bbox,
|
|
11719
|
+
className: d.class
|
|
11720
|
+
})),
|
|
11721
|
+
frameWidth: input.frameWidth,
|
|
11722
|
+
frameHeight: input.frameHeight
|
|
11723
|
+
}) });
|
|
11724
|
+
}
|
|
10574
11725
|
this.processors.set(key, p);
|
|
10575
11726
|
}
|
|
10576
11727
|
return p;
|
|
@@ -10582,6 +11733,16 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
10582
11733
|
* forward updates to the per-device FrameProcessor so a rule change
|
|
10583
11734
|
* applies to the very next frame even when frames stop briefly
|
|
10584
11735
|
* (e.g. during binding flips).
|
|
11736
|
+
*
|
|
11737
|
+
* RECONCILE: the push channel behind `subscribe` (`device.state-changed`
|
|
11738
|
+
* via `live.onEvent`) does not reliably reach a forked addon child — a
|
|
11739
|
+
* zone created AFTER the proxy's cold read stayed invisible until the
|
|
11740
|
+
* addon respawned (live-diagnosed on device 617, 2026-07-16: zone slice
|
|
11741
|
+
* populated hub-side, `zones: []` in every snapshot). Events are lossy
|
|
11742
|
+
* telemetry (D8); the durable channel is RPC + reconcile — so each
|
|
11743
|
+
* proxy also refreshes its two slices on a slow timer. `refresh()`
|
|
11744
|
+
* round-trips `deviceState.getCapSlice` and fans out through the SAME
|
|
11745
|
+
* subscribe callbacks above, so a zone edit lands within one interval.
|
|
10585
11746
|
*/
|
|
10586
11747
|
async ensureProxy(deviceId) {
|
|
10587
11748
|
const cached = this.proxies.get(deviceId);
|
|
@@ -10590,13 +11751,22 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
10590
11751
|
const proxy = await this.ctx.api.deviceManager ? await this.ctx.fetchDevice(deviceId) : null;
|
|
10591
11752
|
if (!proxy) return null;
|
|
10592
11753
|
this.proxies.set(deviceId, proxy);
|
|
10593
|
-
const
|
|
10594
|
-
|
|
10595
|
-
|
|
10596
|
-
}
|
|
10597
|
-
|
|
10598
|
-
|
|
10599
|
-
|
|
11754
|
+
const reconcile = setInterval(() => {
|
|
11755
|
+
proxy.state.zones.refresh().catch(() => void 0);
|
|
11756
|
+
proxy.state.zoneRules.refresh().catch(() => void 0);
|
|
11757
|
+
}, ZONE_SLICE_RECONCILE_MS);
|
|
11758
|
+
reconcile.unref?.();
|
|
11759
|
+
const unsubs = [
|
|
11760
|
+
proxy.state.zones.subscribe((slice) => {
|
|
11761
|
+
const zones = slice?.zones ?? [];
|
|
11762
|
+
this.forEachDeviceProcessor(deviceId, (p) => p.setZones(zones));
|
|
11763
|
+
}),
|
|
11764
|
+
proxy.state.zoneRules.subscribe((slice) => {
|
|
11765
|
+
const rules = slice?.detection ?? [];
|
|
11766
|
+
this.forEachDeviceProcessor(deviceId, (p) => p.setDetectionRules(rules));
|
|
11767
|
+
}),
|
|
11768
|
+
() => clearInterval(reconcile)
|
|
11769
|
+
];
|
|
10600
11770
|
this.proxyUnsubs.set(deviceId, unsubs);
|
|
10601
11771
|
return proxy;
|
|
10602
11772
|
} catch (err) {
|
|
@@ -10628,6 +11798,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
10628
11798
|
}
|
|
10629
11799
|
async clearTracks(input) {
|
|
10630
11800
|
this.trackStore?.clearDevice(input.deviceId);
|
|
11801
|
+
this.stationaryRegistry?.clearDevice(input.deviceId);
|
|
10631
11802
|
this.overlayState.clearDevice(input.deviceId);
|
|
10632
11803
|
this.overlaySynthesisWarnAt.delete(input.deviceId);
|
|
10633
11804
|
const prefix = `${input.deviceId}:`;
|
|
@@ -10929,6 +12100,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
10929
12100
|
* Enrolled gallery + identity media are exempt.
|
|
10930
12101
|
*/
|
|
10931
12102
|
async wipeAllAnalytics(input) {
|
|
12103
|
+
await this.stationaryRegistry?.clearDevice(input.deviceId);
|
|
10932
12104
|
return this.pruneTracksBefore({
|
|
10933
12105
|
deviceId: input.deviceId,
|
|
10934
12106
|
cutoffMs: Date.now()
|
|
@@ -10991,13 +12163,19 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
10991
12163
|
}
|
|
10992
12164
|
return this.readEventThumbnail(id);
|
|
10993
12165
|
}
|
|
10994
|
-
async readEventThumbnail(
|
|
10995
|
-
const
|
|
10996
|
-
const
|
|
10997
|
-
if (
|
|
12166
|
+
async readEventThumbnail(id) {
|
|
12167
|
+
const eventFiles = await (this.mediaStore?.listByOwner("event", id) ?? Promise.resolve([]));
|
|
12168
|
+
const chosenEvent = eventFiles.find((f) => f.kind === "crop") ?? eventFiles.find((f) => f.kind === "fullFrameBoxed") ?? eventFiles[0];
|
|
12169
|
+
if (chosenEvent) return {
|
|
12170
|
+
bytes: Buffer.from(chosenEvent.base64, "base64"),
|
|
12171
|
+
key: chosenEvent.key
|
|
12172
|
+
};
|
|
12173
|
+
const trackFiles = await (this.mediaStore?.listByOwner("track", id) ?? Promise.resolve([]));
|
|
12174
|
+
const chosenTrack = trackFiles.find((f) => f.kind === "thumbnail") ?? trackFiles.find((f) => f.kind === "lastFrame") ?? trackFiles.find((f) => f.kind === "firstFrame") ?? [...trackFiles].reverse().find((f) => f.kind === "snapshot") ?? trackFiles[trackFiles.length - 1];
|
|
12175
|
+
if (!chosenTrack) return null;
|
|
10998
12176
|
return {
|
|
10999
|
-
bytes: Buffer.from(
|
|
11000
|
-
key:
|
|
12177
|
+
bytes: Buffer.from(chosenTrack.base64, "base64"),
|
|
12178
|
+
key: chosenTrack.key
|
|
11001
12179
|
};
|
|
11002
12180
|
}
|
|
11003
12181
|
/**
|
|
@@ -11090,6 +12268,32 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
11090
12268
|
unit: "s",
|
|
11091
12269
|
displayScale: 1e3
|
|
11092
12270
|
},
|
|
12271
|
+
{
|
|
12272
|
+
type: "slider",
|
|
12273
|
+
key: "snapshotMovementThreshold",
|
|
12274
|
+
label: "Snapshot movement gate",
|
|
12275
|
+
description: "After the interval elapses, only capture a snapshot if the object moved this fraction of the frame since the last one. Higher = fewer near-identical frames. 0 disables the gate.",
|
|
12276
|
+
min: 0,
|
|
12277
|
+
max: .15,
|
|
12278
|
+
step: .005,
|
|
12279
|
+
default: .03,
|
|
12280
|
+
showValue: true,
|
|
12281
|
+
unit: "%",
|
|
12282
|
+
displayScale: .01
|
|
12283
|
+
},
|
|
12284
|
+
{
|
|
12285
|
+
type: "slider",
|
|
12286
|
+
key: "snapshotMaxIdleMs",
|
|
12287
|
+
label: "Snapshot max idle",
|
|
12288
|
+
description: "Force a snapshot for a stationary but still-present track after this long without one, so its filmstrip is never empty.",
|
|
12289
|
+
min: 5e3,
|
|
12290
|
+
max: 12e4,
|
|
12291
|
+
step: 5e3,
|
|
12292
|
+
default: 3e4,
|
|
12293
|
+
showValue: true,
|
|
12294
|
+
unit: "s",
|
|
12295
|
+
displayScale: 1e3
|
|
12296
|
+
},
|
|
11093
12297
|
{
|
|
11094
12298
|
type: "select",
|
|
11095
12299
|
key: "mediaAttachPolicy",
|