@camstack/addon-post-analysis 1.2.115 → 1.2.116
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/pipeline-analytics/index.js +598 -273
- package/dist/pipeline-analytics/index.mjs +598 -273
- package/package.json +1 -1
|
@@ -15132,21 +15132,29 @@ function selectSummaryTiles(candidates, opts) {
|
|
|
15132
15132
|
};
|
|
15133
15133
|
}
|
|
15134
15134
|
/**
|
|
15135
|
+
* Ranked leftover after the quota pass — refill a mosaic that dropped
|
|
15136
|
+
* duplicate frames or missing crops without walking the same track twice.
|
|
15137
|
+
*/
|
|
15138
|
+
function remainingSummaryCandidates(kept, taken, rank) {
|
|
15139
|
+
const takenIds = new Set(taken.map((c) => c.trackId));
|
|
15140
|
+
return kept.filter((c) => !takenIds.has(c.trackId)).toSorted(byRankDesc(rank));
|
|
15141
|
+
}
|
|
15142
|
+
/**
|
|
15135
15143
|
* The media a tile is drawn from, best first.
|
|
15136
15144
|
*
|
|
15137
|
-
* `groupCrop`
|
|
15138
|
-
*
|
|
15139
|
-
*
|
|
15140
|
-
* `
|
|
15145
|
+
* For a group: `groupCrop` (union crop, the group's own blob) then
|
|
15146
|
+
* `nativeFrame` (pointer at the anchor's clean keyFrame). A digest of a
|
|
15147
|
+
* cohort may show the native still when the crop was not written; a
|
|
15148
|
+
* member's `keyFrame` listed as itself stays off this ladder so tracker
|
|
15149
|
+
* ID churn cannot refill a mosaic with eleven driveways. Boxed frames stay
|
|
15150
|
+
* absent: a drawn rectangle is a permanent lie in a digest tile.
|
|
15141
15151
|
*/
|
|
15142
15152
|
var SUMMARY_MEDIA_LADDER = [
|
|
15143
15153
|
"groupCrop",
|
|
15144
|
-
"
|
|
15145
|
-
"
|
|
15146
|
-
"snapshot",
|
|
15147
|
-
"fullFrame",
|
|
15154
|
+
"nativeFrame",
|
|
15155
|
+
"thumbnail",
|
|
15148
15156
|
"thumbnailSmall",
|
|
15149
|
-
"
|
|
15157
|
+
"snapshot"
|
|
15150
15158
|
];
|
|
15151
15159
|
/**
|
|
15152
15160
|
* Pick ONE media row for a track — the cheap question first (index rows only),
|
|
@@ -15164,6 +15172,13 @@ function pickSummaryMedia(rows, aroundMs) {
|
|
|
15164
15172
|
}
|
|
15165
15173
|
return null;
|
|
15166
15174
|
}
|
|
15175
|
+
/**
|
|
15176
|
+
* Same camera, same wall-clock second → same JPEG. Tracker ID churn
|
|
15177
|
+
* otherwise fills a mosaic with eleven copies of one driveway still.
|
|
15178
|
+
*/
|
|
15179
|
+
function frameDedupeKey(deviceId, mediaTimestamp) {
|
|
15180
|
+
return `${deviceId}:${Math.floor(mediaTimestamp / 1e3)}`;
|
|
15181
|
+
}
|
|
15167
15182
|
//#endregion
|
|
15168
15183
|
//#region src/notification-center/summary/summary-window.ts
|
|
15169
15184
|
/**
|
|
@@ -15463,7 +15478,12 @@ var NcSummaryProducer = class {
|
|
|
15463
15478
|
droppedStationary: filtered.droppedStationary
|
|
15464
15479
|
};
|
|
15465
15480
|
}
|
|
15466
|
-
const loaded = await this.loadTiles(
|
|
15481
|
+
const loaded = await this.loadTiles({
|
|
15482
|
+
rule,
|
|
15483
|
+
preferred: selection.tiles,
|
|
15484
|
+
refill: remainingSummaryCandidates(filtered.kept, selection.tiles, rule.rank),
|
|
15485
|
+
maxTiles: rule.maxTiles
|
|
15486
|
+
});
|
|
15467
15487
|
const detections = countDetections({ tracks: selection.tiles.map((t) => ({
|
|
15468
15488
|
firstSeen: t.firstSeen,
|
|
15469
15489
|
lastSeen: t.lastSeen,
|
|
@@ -15521,7 +15541,7 @@ var NcSummaryProducer = class {
|
|
|
15521
15541
|
tiles: loaded.length,
|
|
15522
15542
|
cameras: new Set(loaded.map((t) => t.candidate.deviceId)).size,
|
|
15523
15543
|
camerasDropped: selection.camerasDropped,
|
|
15524
|
-
droppedNoMedia:
|
|
15544
|
+
droppedNoMedia: Math.max(0, rule.maxTiles - loaded.length),
|
|
15525
15545
|
mosaicBytes: mosaic.bytes,
|
|
15526
15546
|
mosaicPublished: mosaic.ref !== null,
|
|
15527
15547
|
enqueued,
|
|
@@ -15585,10 +15605,18 @@ var NcSummaryProducer = class {
|
|
|
15585
15605
|
}
|
|
15586
15606
|
return all;
|
|
15587
15607
|
}
|
|
15588
|
-
/** One blob per tile — the cheap question (index rows) always first.
|
|
15589
|
-
|
|
15608
|
+
/** One blob per tile — the cheap question (index rows) always first.
|
|
15609
|
+
* Duplicate camera-seconds are skipped so tracker ID churn cannot paint
|
|
15610
|
+
* the same driveway eleven times; leftover ranked candidates refill. */
|
|
15611
|
+
async loadTiles(input) {
|
|
15590
15612
|
const out = [];
|
|
15591
|
-
|
|
15613
|
+
const seenFrames = /* @__PURE__ */ new Set();
|
|
15614
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
15615
|
+
const queue = [...input.preferred, ...input.refill];
|
|
15616
|
+
for (const candidate of queue) {
|
|
15617
|
+
if (out.length >= input.maxTiles) break;
|
|
15618
|
+
if (seenIds.has(candidate.trackId)) continue;
|
|
15619
|
+
seenIds.add(candidate.trackId);
|
|
15592
15620
|
const tags = { deviceId: candidate.deviceId };
|
|
15593
15621
|
try {
|
|
15594
15622
|
const rows = await this.deps.listMediaInfo(candidate.trackId);
|
|
@@ -15597,25 +15625,38 @@ var NcSummaryProducer = class {
|
|
|
15597
15625
|
this.deps.logger.debug("summary tile dropped: the track carries no clean media", {
|
|
15598
15626
|
tags,
|
|
15599
15627
|
meta: {
|
|
15600
|
-
ruleId: rule.id,
|
|
15628
|
+
ruleId: input.rule.id,
|
|
15601
15629
|
trackId: candidate.trackId,
|
|
15602
15630
|
mediaRows: rows.length
|
|
15603
15631
|
}
|
|
15604
15632
|
});
|
|
15605
15633
|
continue;
|
|
15606
15634
|
}
|
|
15635
|
+
const frameKey = frameDedupeKey(candidate.deviceId, picked.timestamp);
|
|
15636
|
+
if (seenFrames.has(frameKey)) {
|
|
15637
|
+
this.deps.logger.debug("summary tile dropped: same frame as an earlier tile", {
|
|
15638
|
+
tags,
|
|
15639
|
+
meta: {
|
|
15640
|
+
ruleId: input.rule.id,
|
|
15641
|
+
trackId: candidate.trackId,
|
|
15642
|
+
frameKey
|
|
15643
|
+
}
|
|
15644
|
+
});
|
|
15645
|
+
continue;
|
|
15646
|
+
}
|
|
15607
15647
|
const jpeg = await this.deps.readMedia(picked.key);
|
|
15608
15648
|
if (jpeg === null || jpeg.byteLength === 0) {
|
|
15609
15649
|
this.deps.logger.debug("summary tile dropped: the media blob could not be read", {
|
|
15610
15650
|
tags,
|
|
15611
15651
|
meta: {
|
|
15612
|
-
ruleId: rule.id,
|
|
15652
|
+
ruleId: input.rule.id,
|
|
15613
15653
|
trackId: candidate.trackId,
|
|
15614
15654
|
mediaKey: picked.key
|
|
15615
15655
|
}
|
|
15616
15656
|
});
|
|
15617
15657
|
continue;
|
|
15618
15658
|
}
|
|
15659
|
+
seenFrames.add(frameKey);
|
|
15619
15660
|
out.push({
|
|
15620
15661
|
candidate,
|
|
15621
15662
|
jpeg
|
|
@@ -15624,7 +15665,7 @@ var NcSummaryProducer = class {
|
|
|
15624
15665
|
this.deps.logger.warn("summary tile dropped: reading its media threw", {
|
|
15625
15666
|
tags,
|
|
15626
15667
|
meta: {
|
|
15627
|
-
ruleId: rule.id,
|
|
15668
|
+
ruleId: input.rule.id,
|
|
15628
15669
|
trackId: candidate.trackId,
|
|
15629
15670
|
error: String(err)
|
|
15630
15671
|
}
|
|
@@ -40231,7 +40272,7 @@ function aggregateGroupClasses(tracks) {
|
|
|
40231
40272
|
}
|
|
40232
40273
|
function toGroupRecord(input) {
|
|
40233
40274
|
const { className, classes } = aggregateGroupClasses(input.tracks);
|
|
40234
|
-
const mediaId = input.group.mediaKey ?? (input.group.memberCount === 1 ? input.memberTrackIds[0] : null);
|
|
40275
|
+
const mediaId = input.group.mediaKey ?? input.group.nativeKey ?? (input.group.memberCount === 1 ? input.memberTrackIds[0] : null);
|
|
40235
40276
|
return {
|
|
40236
40277
|
id: input.group.id,
|
|
40237
40278
|
deviceId: input.group.deviceId,
|
|
@@ -40310,71 +40351,36 @@ async function buildGroupRecord(ports, group) {
|
|
|
40310
40351
|
eventMediaBaseUrl: ports.eventMediaBaseUrl()
|
|
40311
40352
|
});
|
|
40312
40353
|
}
|
|
40313
|
-
|
|
40314
|
-
|
|
40315
|
-
|
|
40316
|
-
|
|
40317
|
-
|
|
40318
|
-
|
|
40319
|
-
|
|
40320
|
-
|
|
40321
|
-
|
|
40322
|
-
|
|
40354
|
+
/**
|
|
40355
|
+
* Gallery and digest read the same ladder: groupCrop, then the native-frame
|
|
40356
|
+
* pointer, then member subject crops. A multi with no crop still has a
|
|
40357
|
+
* picture if the sealer stored `nativeKey`.
|
|
40358
|
+
*/
|
|
40359
|
+
function digestMediaRows(input) {
|
|
40360
|
+
const owned = [];
|
|
40361
|
+
if (input.mediaKey !== null && input.mediaKey.length > 0) owned.push({
|
|
40362
|
+
key: input.mediaKey,
|
|
40363
|
+
kind: "groupCrop",
|
|
40364
|
+
timestamp: input.timestamp
|
|
40323
40365
|
});
|
|
40324
|
-
|
|
40325
|
-
|
|
40366
|
+
if (input.nativeKey !== null && input.nativeKey.length > 0) owned.push({
|
|
40367
|
+
key: input.nativeKey,
|
|
40368
|
+
kind: "nativeFrame",
|
|
40369
|
+
timestamp: input.timestamp
|
|
40370
|
+
});
|
|
40371
|
+
if (owned.length > 0) return owned;
|
|
40372
|
+
return input.memberRows;
|
|
40326
40373
|
}
|
|
40327
40374
|
var MATCH_WINDOW_MS = 1e3;
|
|
40328
40375
|
var MOVE_EPS = .35;
|
|
40376
|
+
/** Live-assigner window: same predicate, recent positions only. */
|
|
40377
|
+
var LIVE_WINDOW_MS = 3e3;
|
|
40329
40378
|
/** Registry stationary is out; audio has no bbox and is never a member or a singleton. */
|
|
40330
40379
|
function isEligibleMember(track) {
|
|
40331
40380
|
if (track.closedReason === "stationary-promotion") return false;
|
|
40332
40381
|
if (track.className === "audio") return false;
|
|
40333
40382
|
return true;
|
|
40334
40383
|
}
|
|
40335
|
-
function temporallyOverlap(a, b, slackMs) {
|
|
40336
|
-
return a.firstSeen <= b.lastSeen + slackMs && b.firstSeen <= a.lastSeen + slackMs;
|
|
40337
|
-
}
|
|
40338
|
-
/** Connected components of temporal overlap (+ slack) per camera. */
|
|
40339
|
-
function sessionsOf(tracks) {
|
|
40340
|
-
const byDevice = /* @__PURE__ */ new Map();
|
|
40341
|
-
for (const t of tracks) {
|
|
40342
|
-
const list = byDevice.get(t.deviceId);
|
|
40343
|
-
if (list) list.push(t);
|
|
40344
|
-
else byDevice.set(t.deviceId, [t]);
|
|
40345
|
-
}
|
|
40346
|
-
const sessions = [];
|
|
40347
|
-
for (const deviceTracks of byDevice.values()) {
|
|
40348
|
-
const n = deviceTracks.length;
|
|
40349
|
-
const parent = deviceTracks.map((_, i) => i);
|
|
40350
|
-
const find = (i) => {
|
|
40351
|
-
let cur = i;
|
|
40352
|
-
while (parent[cur] !== cur) cur = parent[cur];
|
|
40353
|
-
let walk = i;
|
|
40354
|
-
while (parent[walk] !== cur) {
|
|
40355
|
-
const next = parent[walk];
|
|
40356
|
-
parent[walk] = cur;
|
|
40357
|
-
walk = next;
|
|
40358
|
-
}
|
|
40359
|
-
return cur;
|
|
40360
|
-
};
|
|
40361
|
-
const union = (i, j) => {
|
|
40362
|
-
const ri = find(i);
|
|
40363
|
-
const rj = find(j);
|
|
40364
|
-
if (ri !== rj) parent[ri] = rj;
|
|
40365
|
-
};
|
|
40366
|
-
for (let i = 0; i < n; i++) for (let j = i + 1; j < n; j++) if (temporallyOverlap(deviceTracks[i], deviceTracks[j], 3e3)) union(i, j);
|
|
40367
|
-
const buckets = /* @__PURE__ */ new Map();
|
|
40368
|
-
for (let i = 0; i < n; i++) {
|
|
40369
|
-
const root = find(i);
|
|
40370
|
-
const bucket = buckets.get(root);
|
|
40371
|
-
if (bucket) bucket.push(deviceTracks[i]);
|
|
40372
|
-
else buckets.set(root, [deviceTracks[i]]);
|
|
40373
|
-
}
|
|
40374
|
-
sessions.push(...buckets.values());
|
|
40375
|
-
}
|
|
40376
|
-
return sessions;
|
|
40377
|
-
}
|
|
40378
40384
|
function diag(bbox) {
|
|
40379
40385
|
return Math.hypot(bbox.w, bbox.h);
|
|
40380
40386
|
}
|
|
@@ -40396,9 +40402,21 @@ function meanDiag(positions) {
|
|
|
40396
40402
|
for (const p of positions) sum += diag(p.bbox);
|
|
40397
40403
|
return sum / positions.length;
|
|
40398
40404
|
}
|
|
40399
|
-
function
|
|
40400
|
-
|
|
40401
|
-
|
|
40405
|
+
function vecLen(v) {
|
|
40406
|
+
return Math.hypot(v.x, v.y);
|
|
40407
|
+
}
|
|
40408
|
+
function cosine(a, b) {
|
|
40409
|
+
const la = vecLen(a);
|
|
40410
|
+
const lb = vecLen(b);
|
|
40411
|
+
if (la === 0 || lb === 0) return 1;
|
|
40412
|
+
return (a.x * b.x + a.y * b.y) / (la * lb);
|
|
40413
|
+
}
|
|
40414
|
+
function positionsInWindow(track, start, end) {
|
|
40415
|
+
return track.positions.filter((p) => p.timestamp >= start && p.timestamp <= end);
|
|
40416
|
+
}
|
|
40417
|
+
function windowDisplacement(positions) {
|
|
40418
|
+
const first = positions[0];
|
|
40419
|
+
const last = positions[positions.length - 1];
|
|
40402
40420
|
if (!first || !last) return {
|
|
40403
40421
|
x: 0,
|
|
40404
40422
|
y: 0
|
|
@@ -40410,24 +40428,19 @@ function netDisplacement(track) {
|
|
|
40410
40428
|
y: b.y - a.y
|
|
40411
40429
|
};
|
|
40412
40430
|
}
|
|
40413
|
-
function
|
|
40414
|
-
|
|
40415
|
-
}
|
|
40416
|
-
function cosine(a, b) {
|
|
40417
|
-
const la = vecLen(a);
|
|
40418
|
-
const lb = vecLen(b);
|
|
40419
|
-
if (la === 0 || lb === 0) return 1;
|
|
40420
|
-
return (a.x * b.x + a.y * b.y) / (la * lb);
|
|
40421
|
-
}
|
|
40422
|
-
function relativeMotion(track) {
|
|
40423
|
-
const mean = meanDiag(track.positions);
|
|
40431
|
+
function windowRelativeMotion(positions) {
|
|
40432
|
+
const mean = meanDiag(positions);
|
|
40424
40433
|
if (mean <= 0) return 0;
|
|
40425
|
-
return vecLen(
|
|
40434
|
+
return vecLen(windowDisplacement(positions)) / mean;
|
|
40426
40435
|
}
|
|
40427
|
-
/**
|
|
40428
|
-
|
|
40429
|
-
|
|
40430
|
-
|
|
40436
|
+
/**
|
|
40437
|
+
* Same cohesion + crossing veto as {@link pairTogether}, over `[now − 3 s, now]`.
|
|
40438
|
+
* Displacement/cosine use the window, not the whole track.
|
|
40439
|
+
*/
|
|
40440
|
+
function pairTogetherNow(a, b, now) {
|
|
40441
|
+
const start = now - LIVE_WINDOW_MS;
|
|
40442
|
+
const overlapStart = Math.max(a.firstSeen, b.firstSeen, start);
|
|
40443
|
+
const overlapEnd = Math.min(a.lastSeen, b.lastSeen, now);
|
|
40431
40444
|
if (overlapEnd < overlapStart) return false;
|
|
40432
40445
|
let joint = 0;
|
|
40433
40446
|
let close = 0;
|
|
@@ -40444,55 +40457,245 @@ function pairTogether(a, b) {
|
|
|
40444
40457
|
}
|
|
40445
40458
|
if (joint === 0) return false;
|
|
40446
40459
|
if (close / joint < .5) return false;
|
|
40447
|
-
const
|
|
40448
|
-
const
|
|
40449
|
-
|
|
40460
|
+
const wa = positionsInWindow(a, start, now);
|
|
40461
|
+
const wb = positionsInWindow(b, start, now);
|
|
40462
|
+
const aMoves = windowRelativeMotion(wa) >= MOVE_EPS;
|
|
40463
|
+
const bMoves = windowRelativeMotion(wb) >= MOVE_EPS;
|
|
40464
|
+
if (aMoves && bMoves && cosine(windowDisplacement(wa), windowDisplacement(wb)) < .3) return false;
|
|
40450
40465
|
return true;
|
|
40451
40466
|
}
|
|
40452
|
-
|
|
40453
|
-
|
|
40454
|
-
|
|
40455
|
-
|
|
40456
|
-
|
|
40457
|
-
|
|
40458
|
-
|
|
40459
|
-
|
|
40460
|
-
|
|
40461
|
-
|
|
40462
|
-
|
|
40463
|
-
|
|
40464
|
-
|
|
40465
|
-
|
|
40466
|
-
|
|
40467
|
-
|
|
40468
|
-
|
|
40469
|
-
|
|
40470
|
-
|
|
40471
|
-
|
|
40472
|
-
|
|
40473
|
-
|
|
40474
|
-
|
|
40475
|
-
|
|
40476
|
-
const
|
|
40477
|
-
|
|
40478
|
-
|
|
40479
|
-
|
|
40480
|
-
|
|
40481
|
-
|
|
40482
|
-
|
|
40483
|
-
|
|
40484
|
-
|
|
40485
|
-
|
|
40486
|
-
|
|
40487
|
-
|
|
40488
|
-
|
|
40489
|
-
|
|
40490
|
-
|
|
40491
|
-
|
|
40492
|
-
|
|
40467
|
+
var GroupLiveAssigner = class {
|
|
40468
|
+
nextId;
|
|
40469
|
+
snapshots = /* @__PURE__ */ new Map();
|
|
40470
|
+
groups = /* @__PURE__ */ new Map();
|
|
40471
|
+
membership = /* @__PURE__ */ new Map();
|
|
40472
|
+
/** `${trackId}>${groupId}` → first instant the join predicate held. */
|
|
40473
|
+
joinSince = /* @__PURE__ */ new Map();
|
|
40474
|
+
/** trackId → first instant they failed pair against every remaining member. */
|
|
40475
|
+
leaveSince = /* @__PURE__ */ new Map();
|
|
40476
|
+
constructor(nextId = () => randomUUID()) {
|
|
40477
|
+
this.nextId = nextId;
|
|
40478
|
+
}
|
|
40479
|
+
observe(track, now) {
|
|
40480
|
+
this.snapshots.set(track.trackId, track);
|
|
40481
|
+
if (!isEligibleMember(track)) {
|
|
40482
|
+
this.removeFromGroup(track.trackId);
|
|
40483
|
+
return;
|
|
40484
|
+
}
|
|
40485
|
+
if (!this.membership.has(track.trackId)) this.birthSingleton(track, now);
|
|
40486
|
+
this.considerLeave(track, now);
|
|
40487
|
+
const groupId = this.membership.get(track.trackId);
|
|
40488
|
+
const group = groupId !== void 0 ? this.groups.get(groupId) : void 0;
|
|
40489
|
+
if (group) for (const id of [...group.members]) {
|
|
40490
|
+
if (id === track.trackId) continue;
|
|
40491
|
+
const snap = this.snapshots.get(id);
|
|
40492
|
+
if (snap) this.considerLeave(snap, now);
|
|
40493
|
+
}
|
|
40494
|
+
this.considerJoin(track, now);
|
|
40495
|
+
}
|
|
40496
|
+
groupOf(trackId) {
|
|
40497
|
+
return this.membership.get(trackId);
|
|
40498
|
+
}
|
|
40499
|
+
snapshot(trackId) {
|
|
40500
|
+
return this.snapshots.get(trackId);
|
|
40501
|
+
}
|
|
40502
|
+
liveGroups() {
|
|
40503
|
+
return [...this.groups.values()].map((g) => this.toLive(g));
|
|
40504
|
+
}
|
|
40505
|
+
idleGroups(now, idleMs) {
|
|
40506
|
+
return this.liveGroups().filter((g) => now >= g.lastActiveAt + idleMs);
|
|
40507
|
+
}
|
|
40508
|
+
drop(ids) {
|
|
40509
|
+
for (const id of ids) {
|
|
40510
|
+
const g = this.groups.get(id);
|
|
40511
|
+
if (!g) continue;
|
|
40512
|
+
for (const member of g.members) this.membership.delete(member);
|
|
40513
|
+
this.groups.delete(id);
|
|
40514
|
+
}
|
|
40515
|
+
}
|
|
40516
|
+
birthSingleton(track, now) {
|
|
40517
|
+
const id = this.nextId();
|
|
40518
|
+
this.groups.set(id, {
|
|
40519
|
+
id,
|
|
40520
|
+
deviceId: track.deviceId,
|
|
40521
|
+
openedAt: now,
|
|
40522
|
+
members: new Set([track.trackId])
|
|
40493
40523
|
});
|
|
40524
|
+
this.membership.set(track.trackId, id);
|
|
40494
40525
|
}
|
|
40495
|
-
|
|
40526
|
+
considerLeave(track, now) {
|
|
40527
|
+
const groupId = this.membership.get(track.trackId);
|
|
40528
|
+
if (groupId === void 0) return;
|
|
40529
|
+
const group = this.groups.get(groupId);
|
|
40530
|
+
if (!group || group.members.size < 2) {
|
|
40531
|
+
this.leaveSince.delete(track.trackId);
|
|
40532
|
+
return;
|
|
40533
|
+
}
|
|
40534
|
+
if ([...group.members].filter((id) => id !== track.trackId).some((id) => {
|
|
40535
|
+
const other = this.snapshots.get(id);
|
|
40536
|
+
return other !== void 0 && pairTogetherNow(track, other, now);
|
|
40537
|
+
})) {
|
|
40538
|
+
this.leaveSince.delete(track.trackId);
|
|
40539
|
+
return;
|
|
40540
|
+
}
|
|
40541
|
+
const since = this.leaveSince.get(track.trackId) ?? now;
|
|
40542
|
+
this.leaveSince.set(track.trackId, since);
|
|
40543
|
+
if (now - since < 3e3) return;
|
|
40544
|
+
this.peel(track, now);
|
|
40545
|
+
}
|
|
40546
|
+
peel(track, now) {
|
|
40547
|
+
this.removeFromGroup(track.trackId);
|
|
40548
|
+
this.leaveSince.delete(track.trackId);
|
|
40549
|
+
this.birthSingleton(track, now);
|
|
40550
|
+
}
|
|
40551
|
+
considerJoin(track, now) {
|
|
40552
|
+
const mineId = this.membership.get(track.trackId);
|
|
40553
|
+
if (mineId === void 0) return;
|
|
40554
|
+
const mine = this.groups.get(mineId);
|
|
40555
|
+
if (!mine || mine.members.size !== 1) {
|
|
40556
|
+
this.clearJoinClocks(track.trackId);
|
|
40557
|
+
return;
|
|
40558
|
+
}
|
|
40559
|
+
let best;
|
|
40560
|
+
for (const group of this.groups.values()) {
|
|
40561
|
+
if (group.id === mineId || group.deviceId !== track.deviceId) continue;
|
|
40562
|
+
let gap = Number.POSITIVE_INFINITY;
|
|
40563
|
+
let together = false;
|
|
40564
|
+
for (const memberId of group.members) {
|
|
40565
|
+
const other = this.snapshots.get(memberId);
|
|
40566
|
+
if (!other) continue;
|
|
40567
|
+
if (pairTogetherNow(track, other, now)) {
|
|
40568
|
+
together = true;
|
|
40569
|
+
gap = Math.min(gap, centroidGap(track, other, now));
|
|
40570
|
+
}
|
|
40571
|
+
}
|
|
40572
|
+
if (!together) continue;
|
|
40573
|
+
if (best === void 0 || gap < best.gap) best = {
|
|
40574
|
+
groupId: group.id,
|
|
40575
|
+
gap
|
|
40576
|
+
};
|
|
40577
|
+
}
|
|
40578
|
+
if (best === void 0) {
|
|
40579
|
+
this.clearJoinClocks(track.trackId);
|
|
40580
|
+
return;
|
|
40581
|
+
}
|
|
40582
|
+
const key = `${track.trackId}>${best.groupId}`;
|
|
40583
|
+
for (const existing of [...this.joinSince.keys()]) if (existing.startsWith(`${track.trackId}>`) && existing !== key) this.joinSince.delete(existing);
|
|
40584
|
+
const since = this.joinSince.get(key) ?? now;
|
|
40585
|
+
this.joinSince.set(key, since);
|
|
40586
|
+
if (now - since < 1500) return;
|
|
40587
|
+
this.absorb(track.trackId, mineId, best.groupId);
|
|
40588
|
+
}
|
|
40589
|
+
absorb(trackId, fromId, intoId) {
|
|
40590
|
+
const into = this.groups.get(intoId);
|
|
40591
|
+
if (!into) return;
|
|
40592
|
+
into.members.add(trackId);
|
|
40593
|
+
this.membership.set(trackId, intoId);
|
|
40594
|
+
this.groups.delete(fromId);
|
|
40595
|
+
this.clearJoinClocks(trackId);
|
|
40596
|
+
this.leaveSince.delete(trackId);
|
|
40597
|
+
}
|
|
40598
|
+
removeFromGroup(trackId) {
|
|
40599
|
+
const groupId = this.membership.get(trackId);
|
|
40600
|
+
this.membership.delete(trackId);
|
|
40601
|
+
this.clearJoinClocks(trackId);
|
|
40602
|
+
this.leaveSince.delete(trackId);
|
|
40603
|
+
if (groupId === void 0) return;
|
|
40604
|
+
const group = this.groups.get(groupId);
|
|
40605
|
+
if (!group) return;
|
|
40606
|
+
group.members.delete(trackId);
|
|
40607
|
+
if (group.members.size === 0) this.groups.delete(groupId);
|
|
40608
|
+
}
|
|
40609
|
+
clearJoinClocks(trackId) {
|
|
40610
|
+
for (const key of [...this.joinSince.keys()]) if (key.startsWith(`${trackId}>`)) this.joinSince.delete(key);
|
|
40611
|
+
}
|
|
40612
|
+
toLive(group) {
|
|
40613
|
+
let lastActiveAt = group.openedAt;
|
|
40614
|
+
for (const id of group.members) {
|
|
40615
|
+
const snap = this.snapshots.get(id);
|
|
40616
|
+
if (snap && snap.lastSeen > lastActiveAt) lastActiveAt = snap.lastSeen;
|
|
40617
|
+
}
|
|
40618
|
+
return {
|
|
40619
|
+
id: group.id,
|
|
40620
|
+
deviceId: group.deviceId,
|
|
40621
|
+
memberTrackIds: [...group.members],
|
|
40622
|
+
openedAt: group.openedAt,
|
|
40623
|
+
lastActiveAt
|
|
40624
|
+
};
|
|
40625
|
+
}
|
|
40626
|
+
};
|
|
40627
|
+
function centroidGap(a, b, now) {
|
|
40628
|
+
const pa = a.positions.reduce((best, p) => Math.abs(p.timestamp - now) < Math.abs(best.timestamp - now) ? p : best, a.positions[0]);
|
|
40629
|
+
const pb = b.positions.reduce((best, p) => Math.abs(p.timestamp - now) < Math.abs(best.timestamp - now) ? p : best, b.positions[0]);
|
|
40630
|
+
if (!pa || !pb) return Number.POSITIVE_INFINITY;
|
|
40631
|
+
const ca = bboxCentroid(pa.bbox);
|
|
40632
|
+
const cb = bboxCentroid(pb.bbox);
|
|
40633
|
+
return Math.hypot(ca.x - cb.x, ca.y - cb.y);
|
|
40634
|
+
}
|
|
40635
|
+
//#endregion
|
|
40636
|
+
//#region src/pipeline-analytics/group/group-session.ts
|
|
40637
|
+
/** Same idle as the notification burst (`NC_GROUP_DEFAULT_IDLE_SEC`). */
|
|
40638
|
+
var GROUP_SESSION_IDLE_MS = 3e4;
|
|
40639
|
+
//#endregion
|
|
40640
|
+
//#region src/pipeline-analytics/group/group-live-buffer.ts
|
|
40641
|
+
function toGroupableTrack(track) {
|
|
40642
|
+
return {
|
|
40643
|
+
trackId: track.trackId,
|
|
40644
|
+
deviceId: track.deviceId,
|
|
40645
|
+
className: track.className,
|
|
40646
|
+
firstSeen: track.firstSeen,
|
|
40647
|
+
lastSeen: track.lastSeen,
|
|
40648
|
+
positions: track.positions.map((p) => ({
|
|
40649
|
+
...p,
|
|
40650
|
+
bbox: { ...p.bbox }
|
|
40651
|
+
})),
|
|
40652
|
+
...track.closedReason !== void 0 ? { closedReason: track.closedReason } : {}
|
|
40653
|
+
};
|
|
40654
|
+
}
|
|
40655
|
+
var GroupLiveBuffer = class {
|
|
40656
|
+
assigner;
|
|
40657
|
+
primed = /* @__PURE__ */ new Set();
|
|
40658
|
+
constructor(nextId) {
|
|
40659
|
+
this.assigner = new GroupLiveAssigner(nextId);
|
|
40660
|
+
}
|
|
40661
|
+
observe(track, now) {
|
|
40662
|
+
this.assigner.observe(track, now);
|
|
40663
|
+
}
|
|
40664
|
+
groupOf(trackId) {
|
|
40665
|
+
return this.assigner.groupOf(trackId);
|
|
40666
|
+
}
|
|
40667
|
+
snapshot(trackId) {
|
|
40668
|
+
return this.assigner.snapshot(trackId);
|
|
40669
|
+
}
|
|
40670
|
+
liveGroups() {
|
|
40671
|
+
return this.assigner.liveGroups();
|
|
40672
|
+
}
|
|
40673
|
+
idleGroups(now, idleMs = GROUP_SESSION_IDLE_MS) {
|
|
40674
|
+
return this.assigner.idleGroups(now, idleMs);
|
|
40675
|
+
}
|
|
40676
|
+
drop(ids) {
|
|
40677
|
+
this.assigner.drop(ids);
|
|
40678
|
+
}
|
|
40679
|
+
isPrimed(deviceId) {
|
|
40680
|
+
return this.primed.has(deviceId);
|
|
40681
|
+
}
|
|
40682
|
+
markPrimed(deviceId) {
|
|
40683
|
+
this.primed.add(deviceId);
|
|
40684
|
+
}
|
|
40685
|
+
};
|
|
40686
|
+
//#endregion
|
|
40687
|
+
//#region src/pipeline-analytics/group/group-crop.ts
|
|
40688
|
+
/** Always containSubject: true. Never the 0.5 delivered threshold. */
|
|
40689
|
+
function groupWideCentralSquareLayout(unionBbox, frame) {
|
|
40690
|
+
return wideCentralSquareLayout(unionBbox, frame, { containSubject: true });
|
|
40691
|
+
}
|
|
40692
|
+
async function cropGroupFromRgb(rgb, frameWidth, frameHeight, unionBbox) {
|
|
40693
|
+
const layout = groupWideCentralSquareLayout(unionBbox, {
|
|
40694
|
+
W: frameWidth,
|
|
40695
|
+
H: frameHeight
|
|
40696
|
+
});
|
|
40697
|
+
const { crop: slab } = await extractCrop(rgb, frameWidth, frameHeight, layout.fetch);
|
|
40698
|
+
return composeWideCentralSquareThumbnail(slab, layout);
|
|
40496
40699
|
}
|
|
40497
40700
|
//#endregion
|
|
40498
40701
|
//#region src/pipeline-analytics/group/group-instant.ts
|
|
@@ -40509,6 +40712,7 @@ function clusterGroups(tracks) {
|
|
|
40509
40712
|
*
|
|
40510
40713
|
* Retrospective. No new capture path.
|
|
40511
40714
|
*/
|
|
40715
|
+
var GROUP_MEDIA_MATCH_MS = 2e3;
|
|
40512
40716
|
function nearest(positions, t, windowMs) {
|
|
40513
40717
|
let best;
|
|
40514
40718
|
let bestDt = windowMs + 1;
|
|
@@ -40617,55 +40821,85 @@ function chooseAnchorTrackId(members, ownsCleanFrame) {
|
|
|
40617
40821
|
return life !== 0 ? life : a.trackId.localeCompare(b.trackId);
|
|
40618
40822
|
})[0].trackId;
|
|
40619
40823
|
}
|
|
40620
|
-
//#endregion
|
|
40621
|
-
//#region src/pipeline-analytics/group/group-session.ts
|
|
40622
40824
|
/**
|
|
40623
|
-
*
|
|
40624
|
-
*
|
|
40625
|
-
* Session = temporal chain + 3 s slack, per camera. Close = every overlapping
|
|
40626
|
-
* track is persisted AND the newest lastSeen is older than the idle cutoff
|
|
40627
|
-
* (the Phase-1 burst's 30 s, reused — not a second clock).
|
|
40825
|
+
* The RGB source for a group crop is a full native frame. Thumbnails are
|
|
40826
|
+
* already single-subject crops and cannot substitute for union-bbox geometry.
|
|
40628
40827
|
*
|
|
40629
|
-
*
|
|
40630
|
-
*
|
|
40631
|
-
*
|
|
40828
|
+
* Prefer a keyFrame within {@link GROUP_MEDIA_MATCH_MS} of the chosen instant;
|
|
40829
|
+
* if none, take the nearest keyFrame anyway — dropping the crop because the
|
|
40830
|
+
* best-shot was 3 s off the instant is how a recognised person left the
|
|
40831
|
+
* digest with no crop.
|
|
40632
40832
|
*/
|
|
40633
|
-
|
|
40634
|
-
|
|
40635
|
-
|
|
40636
|
-
|
|
40833
|
+
function pickCleanPixelRow(rows, instantMs) {
|
|
40834
|
+
const ranked = (maxDt) => {
|
|
40835
|
+
const candidates = [];
|
|
40836
|
+
for (const row of rows) {
|
|
40837
|
+
const rank = row.kind === "keyFrame" ? 0 : row.kind === "keyFrameSmall" ? 1 : -1;
|
|
40838
|
+
if (rank < 0) continue;
|
|
40839
|
+
const dt = Math.abs(row.timestamp - instantMs);
|
|
40840
|
+
if (maxDt !== null && dt > maxDt) continue;
|
|
40841
|
+
candidates.push({
|
|
40842
|
+
key: row.key,
|
|
40843
|
+
dt,
|
|
40844
|
+
rank
|
|
40845
|
+
});
|
|
40846
|
+
}
|
|
40847
|
+
return candidates.toSorted((a, b) => a.rank - b.rank || a.dt - b.dt)[0];
|
|
40848
|
+
};
|
|
40849
|
+
const near = ranked(GROUP_MEDIA_MATCH_MS);
|
|
40850
|
+
if (near !== void 0) return {
|
|
40851
|
+
key: near.key,
|
|
40852
|
+
usedFallback: false
|
|
40853
|
+
};
|
|
40854
|
+
const any = ranked(null);
|
|
40855
|
+
if (any === void 0) return null;
|
|
40856
|
+
return {
|
|
40857
|
+
key: any.key,
|
|
40858
|
+
usedFallback: true
|
|
40859
|
+
};
|
|
40637
40860
|
}
|
|
40638
|
-
|
|
40861
|
+
//#endregion
|
|
40862
|
+
//#region src/pipeline-analytics/group/group-live-replay.ts
|
|
40863
|
+
function sliceTrackAt(track, now) {
|
|
40864
|
+
const positions = track.positions.filter((p) => p.timestamp <= now);
|
|
40865
|
+
const last = positions[positions.length - 1];
|
|
40639
40866
|
return {
|
|
40640
|
-
|
|
40641
|
-
lastSeen:
|
|
40867
|
+
...track,
|
|
40868
|
+
lastSeen: last?.timestamp ?? track.firstSeen,
|
|
40869
|
+
positions
|
|
40642
40870
|
};
|
|
40643
40871
|
}
|
|
40644
|
-
|
|
40645
|
-
|
|
40646
|
-
|
|
40647
|
-
|
|
40648
|
-
|
|
40649
|
-
|
|
40650
|
-
|
|
40651
|
-
|
|
40652
|
-
|
|
40653
|
-
|
|
40654
|
-
|
|
40655
|
-
const ready = [];
|
|
40656
|
-
for (const [deviceId, tracks] of byDevice) {
|
|
40657
|
-
const deviceActive = active.filter((a) => a.deviceId === deviceId);
|
|
40658
|
-
for (const session of sessionsOf(tracks)) {
|
|
40659
|
-
const span = sessionSpan(session);
|
|
40660
|
-
if (now < span.lastSeen + idleMs) continue;
|
|
40661
|
-
if (deviceActive.some((a) => overlaps(span, a))) continue;
|
|
40662
|
-
ready.push(session);
|
|
40872
|
+
function replayInto(tracks, observer) {
|
|
40873
|
+
const events = [];
|
|
40874
|
+
const byId = /* @__PURE__ */ new Map();
|
|
40875
|
+
for (const track of tracks) {
|
|
40876
|
+
byId.set(track.trackId, track);
|
|
40877
|
+
if (track.positions.length === 0) {
|
|
40878
|
+
events.push({
|
|
40879
|
+
t: track.lastSeen,
|
|
40880
|
+
trackId: track.trackId
|
|
40881
|
+
});
|
|
40882
|
+
continue;
|
|
40663
40883
|
}
|
|
40884
|
+
for (const p of track.positions) events.push({
|
|
40885
|
+
t: p.timestamp,
|
|
40886
|
+
trackId: track.trackId
|
|
40887
|
+
});
|
|
40888
|
+
}
|
|
40889
|
+
events.sort((a, b) => a.t - b.t || a.trackId.localeCompare(b.trackId));
|
|
40890
|
+
let lastKey = "";
|
|
40891
|
+
for (const e of events) {
|
|
40892
|
+
const key = `${e.trackId}@${e.t}`;
|
|
40893
|
+
if (key === lastKey) continue;
|
|
40894
|
+
lastKey = key;
|
|
40895
|
+
const track = byId.get(e.trackId);
|
|
40896
|
+
if (!track) continue;
|
|
40897
|
+
observer.observe(sliceTrackAt(track, e.t), e.t);
|
|
40664
40898
|
}
|
|
40665
|
-
return ready;
|
|
40666
40899
|
}
|
|
40667
|
-
function
|
|
40668
|
-
|
|
40900
|
+
function replayLive(tracks, assigner = new GroupLiveAssigner()) {
|
|
40901
|
+
replayInto(tracks, assigner);
|
|
40902
|
+
return assigner;
|
|
40669
40903
|
}
|
|
40670
40904
|
//#endregion
|
|
40671
40905
|
//#region src/pipeline-analytics/group/group-sealer.ts
|
|
@@ -40673,9 +40907,9 @@ var DEFAULT_FRAME = {
|
|
|
40673
40907
|
width: 640,
|
|
40674
40908
|
height: 360
|
|
40675
40909
|
};
|
|
40676
|
-
var SEAL_LOOKBACK_MS = 10 * 6e4;
|
|
40677
|
-
var BACKFILL_LOOKBACK_MS = 2880 * 6e4;
|
|
40678
40910
|
var QUERY_LIMIT = 2e3;
|
|
40911
|
+
/** Crash recover: replay this camera only, on its next activity. */
|
|
40912
|
+
var GROUP_CRASH_LOOKBACK_MS = 2 * 6e4;
|
|
40679
40913
|
function toInstant(t) {
|
|
40680
40914
|
return {
|
|
40681
40915
|
trackId: t.trackId,
|
|
@@ -40689,93 +40923,130 @@ function toInstant(t) {
|
|
|
40689
40923
|
confidence: t.importance
|
|
40690
40924
|
};
|
|
40691
40925
|
}
|
|
40926
|
+
function toSealer(track) {
|
|
40927
|
+
return {
|
|
40928
|
+
trackId: track.trackId,
|
|
40929
|
+
deviceId: track.deviceId,
|
|
40930
|
+
className: track.className,
|
|
40931
|
+
firstSeen: track.firstSeen,
|
|
40932
|
+
lastSeen: track.lastSeen,
|
|
40933
|
+
positions: track.positions,
|
|
40934
|
+
...track.closedReason !== void 0 ? { closedReason: track.closedReason } : {}
|
|
40935
|
+
};
|
|
40936
|
+
}
|
|
40692
40937
|
var GroupSealer = class {
|
|
40693
40938
|
deps;
|
|
40694
40939
|
constructor(deps) {
|
|
40695
40940
|
this.deps = deps;
|
|
40696
40941
|
}
|
|
40697
|
-
async
|
|
40698
|
-
|
|
40699
|
-
|
|
40942
|
+
async flushIdle(buffer, now = this.deps.now()) {
|
|
40943
|
+
let written = 0;
|
|
40944
|
+
for (const group of buffer.idleGroups(now, GROUP_SESSION_IDLE_MS)) if (await this.persistLiveGroup(group, buffer)) {
|
|
40945
|
+
buffer.drop([group.id]);
|
|
40946
|
+
written += 1;
|
|
40947
|
+
}
|
|
40948
|
+
return written;
|
|
40700
40949
|
}
|
|
40701
|
-
|
|
40950
|
+
/**
|
|
40951
|
+
* First activity on camera D after a crash: replay 2 minutes of that camera
|
|
40952
|
+
* into the live buffer, persist whoever is already idle, keep the rest in RAM.
|
|
40953
|
+
*/
|
|
40954
|
+
async recoverCamera(deviceId, buffer) {
|
|
40955
|
+
if (buffer.isPrimed(deviceId)) return 0;
|
|
40702
40956
|
const now = this.deps.now();
|
|
40703
|
-
return this.sealSince(now - BACKFILL_LOOKBACK_MS, now);
|
|
40704
|
-
}
|
|
40705
|
-
async sealSince(since, now) {
|
|
40706
|
-
let written = 0;
|
|
40707
|
-
let devices;
|
|
40708
40957
|
try {
|
|
40709
|
-
|
|
40958
|
+
replayInto(await this.deps.trackStore.queryHistorical({
|
|
40959
|
+
deviceId,
|
|
40960
|
+
since: now - GROUP_CRASH_LOOKBACK_MS,
|
|
40961
|
+
until: now,
|
|
40962
|
+
limit: QUERY_LIMIT,
|
|
40963
|
+
projection: "full"
|
|
40964
|
+
}), buffer);
|
|
40710
40965
|
} catch (err) {
|
|
40711
|
-
this.deps.logger.warn("group sealer:
|
|
40712
|
-
|
|
40966
|
+
this.deps.logger.warn("group sealer: lookback failed", {
|
|
40967
|
+
tags: { deviceId },
|
|
40968
|
+
meta: { error: String(err) }
|
|
40969
|
+
});
|
|
40970
|
+
} finally {
|
|
40971
|
+
buffer.markPrimed(deviceId);
|
|
40713
40972
|
}
|
|
40714
|
-
|
|
40715
|
-
|
|
40973
|
+
return this.flushIdle(buffer, now);
|
|
40974
|
+
}
|
|
40975
|
+
/**
|
|
40976
|
+
* Seal ONE camera over a caller-owned window via live replay.
|
|
40977
|
+
*
|
|
40978
|
+
* This is how a digest recovers groups without a boot scan: the producer
|
|
40979
|
+
* already knows the window. Walking every camera for 48h at initialize()
|
|
40980
|
+
* is what wedged hub-main.
|
|
40981
|
+
*/
|
|
40982
|
+
async sealWindow(deviceId, since, until) {
|
|
40983
|
+
try {
|
|
40984
|
+
const closed = await this.deps.trackStore.queryHistorical({
|
|
40985
|
+
deviceId,
|
|
40986
|
+
since,
|
|
40987
|
+
until,
|
|
40988
|
+
limit: QUERY_LIMIT,
|
|
40989
|
+
projection: "full"
|
|
40990
|
+
});
|
|
40991
|
+
if (closed.length === 0) return 0;
|
|
40992
|
+
const already = new Set((await this.deps.groupStore.membersOfTracks(closed.map((t) => t.trackId))).map((m) => m.trackId));
|
|
40993
|
+
const pending = closed.filter((t) => !already.has(t.trackId));
|
|
40994
|
+
if (pending.length === 0) return 0;
|
|
40995
|
+
const assigner = replayLive(pending);
|
|
40996
|
+
const now = this.deps.now();
|
|
40997
|
+
const byId = new Map(pending.map((t) => [t.trackId, t]));
|
|
40998
|
+
let written = 0;
|
|
40999
|
+
for (const group of assigner.idleGroups(now, GROUP_SESSION_IDLE_MS)) {
|
|
41000
|
+
const members = group.memberTrackIds.map((id) => byId.get(id)).filter((t) => t !== void 0);
|
|
41001
|
+
if (members.length === 0) continue;
|
|
41002
|
+
try {
|
|
41003
|
+
await this.persistPartition(members, group.openedAt);
|
|
41004
|
+
written += 1;
|
|
41005
|
+
} catch (err) {
|
|
41006
|
+
this.deps.logger.warn("group sealer: persist failed", {
|
|
41007
|
+
tags: { deviceId },
|
|
41008
|
+
meta: {
|
|
41009
|
+
members: members.map((m) => m.trackId),
|
|
41010
|
+
error: String(err)
|
|
41011
|
+
}
|
|
41012
|
+
});
|
|
41013
|
+
}
|
|
41014
|
+
}
|
|
41015
|
+
return written;
|
|
40716
41016
|
} catch (err) {
|
|
40717
41017
|
this.deps.logger.warn("group sealer: device failed", {
|
|
40718
41018
|
tags: { deviceId },
|
|
40719
41019
|
meta: { error: String(err) }
|
|
40720
41020
|
});
|
|
41021
|
+
return 0;
|
|
40721
41022
|
}
|
|
40722
|
-
return written;
|
|
40723
41023
|
}
|
|
40724
|
-
async
|
|
40725
|
-
const
|
|
40726
|
-
|
|
40727
|
-
|
|
40728
|
-
|
|
40729
|
-
|
|
40730
|
-
|
|
40731
|
-
|
|
40732
|
-
|
|
40733
|
-
|
|
40734
|
-
|
|
40735
|
-
|
|
40736
|
-
const active = this.deps.trackStore.getActive(deviceId).map((t) => ({
|
|
40737
|
-
deviceId,
|
|
40738
|
-
firstSeen: t.firstSeen,
|
|
40739
|
-
lastSeen: t.lastSeen
|
|
40740
|
-
}));
|
|
40741
|
-
const partitions = partitionsForSealedSessions(pending.map(toInstant), active, now, GROUP_SESSION_IDLE_MS);
|
|
40742
|
-
if (partitions.length === 0) {
|
|
40743
|
-
if (pending.filter((t) => t.closedReason !== "stationary-promotion" && t.className !== "audio").length === 0 && pending.length > 0) this.deps.logger.info("group session produced no groups", {
|
|
41024
|
+
async persistLiveGroup(group, buffer) {
|
|
41025
|
+
const deviceId = group.deviceId;
|
|
41026
|
+
const snaps = group.memberTrackIds.map((id) => buffer.snapshot(id)).filter((t) => t !== void 0);
|
|
41027
|
+
if (snaps.length === 0) return true;
|
|
41028
|
+
const already = new Set((await this.deps.groupStore.membersOfTracks(snaps.map((t) => t.trackId))).map((m) => m.trackId));
|
|
41029
|
+
const pending = snaps.filter((t) => !already.has(t.trackId)).map(toSealer);
|
|
41030
|
+
if (pending.length === 0) return true;
|
|
41031
|
+
try {
|
|
41032
|
+
await this.persistPartition(pending, group.openedAt);
|
|
41033
|
+
return true;
|
|
41034
|
+
} catch (err) {
|
|
41035
|
+
this.deps.logger.warn("group sealer: persist failed", {
|
|
40744
41036
|
tags: { deviceId },
|
|
40745
41037
|
meta: {
|
|
40746
|
-
|
|
40747
|
-
|
|
41038
|
+
members: pending.map((m) => m.trackId),
|
|
41039
|
+
error: String(err)
|
|
40748
41040
|
}
|
|
40749
41041
|
});
|
|
40750
|
-
return
|
|
40751
|
-
}
|
|
40752
|
-
const byId = new Map(pending.map((t) => [t.trackId, t]));
|
|
40753
|
-
let written = 0;
|
|
40754
|
-
for (const part of partitions) {
|
|
40755
|
-
const members = part.memberTrackIds.map((id) => byId.get(id)).filter((t) => t !== void 0);
|
|
40756
|
-
if (members.length === 0) continue;
|
|
40757
|
-
if (members.every((m) => already.has(m.trackId))) continue;
|
|
40758
|
-
try {
|
|
40759
|
-
await this.persistPartition(members);
|
|
40760
|
-
written += 1;
|
|
40761
|
-
} catch (err) {
|
|
40762
|
-
this.deps.logger.warn("group sealer: persist failed", {
|
|
40763
|
-
tags: { deviceId },
|
|
40764
|
-
meta: {
|
|
40765
|
-
members: members.map((m) => m.trackId),
|
|
40766
|
-
error: String(err)
|
|
40767
|
-
}
|
|
40768
|
-
});
|
|
40769
|
-
}
|
|
41042
|
+
return false;
|
|
40770
41043
|
}
|
|
40771
|
-
return written;
|
|
40772
41044
|
}
|
|
40773
|
-
async persistPartition(members) {
|
|
41045
|
+
async persistPartition(members, openedAt) {
|
|
40774
41046
|
const deviceId = members[0].deviceId;
|
|
40775
41047
|
const frame = this.deps.frameDims(deviceId) ?? DEFAULT_FRAME;
|
|
40776
41048
|
const instantMembers = members.map(toInstant);
|
|
40777
41049
|
const instant = chooseGroupInstant(instantMembers, frame);
|
|
40778
|
-
const openedAt = Math.min(...members.map((m) => m.firstSeen));
|
|
40779
41050
|
const closedAt = Math.max(...members.map((m) => m.lastSeen));
|
|
40780
41051
|
const timestamp = instant?.timestamp ?? closedAt;
|
|
40781
41052
|
const mediaInfos = await Promise.all(members.map(async (m) => ({
|
|
@@ -40811,8 +41082,17 @@ var GroupSealer = class {
|
|
|
40811
41082
|
nativeKey
|
|
40812
41083
|
});
|
|
40813
41084
|
if (members.length < 2 || instant === null) return;
|
|
40814
|
-
const source = await this.pickCleanPixels(mediaInfos, timestamp);
|
|
40815
|
-
if (source === null)
|
|
41085
|
+
const source = await this.pickCleanPixels(mediaInfos, timestamp, deviceId);
|
|
41086
|
+
if (source === null) {
|
|
41087
|
+
this.deps.logger.info("group sealer: no clean pixels for crop", {
|
|
41088
|
+
tags: { deviceId },
|
|
41089
|
+
meta: {
|
|
41090
|
+
groupId: draft.id,
|
|
41091
|
+
members: members.map((m) => m.trackId)
|
|
41092
|
+
}
|
|
41093
|
+
});
|
|
41094
|
+
return;
|
|
41095
|
+
}
|
|
40816
41096
|
try {
|
|
40817
41097
|
const raw = await sharp(source.jpeg).removeAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
40818
41098
|
if (raw.info.channels !== 3) return;
|
|
@@ -40842,21 +41122,16 @@ var GroupSealer = class {
|
|
|
40842
41122
|
});
|
|
40843
41123
|
}
|
|
40844
41124
|
}
|
|
40845
|
-
async pickCleanPixels(mediaInfos, instantMs) {
|
|
40846
|
-
const
|
|
40847
|
-
|
|
40848
|
-
|
|
40849
|
-
|
|
40850
|
-
|
|
40851
|
-
|
|
40852
|
-
|
|
40853
|
-
|
|
40854
|
-
|
|
40855
|
-
rank
|
|
40856
|
-
});
|
|
40857
|
-
}
|
|
40858
|
-
const chosen = candidates.toSorted((a, b) => a.rank - b.rank || a.dt - b.dt)[0];
|
|
40859
|
-
if (chosen === void 0) return null;
|
|
41125
|
+
async pickCleanPixels(mediaInfos, instantMs, deviceId) {
|
|
41126
|
+
const chosen = pickCleanPixelRow(mediaInfos.flatMap((info) => info.rows), instantMs);
|
|
41127
|
+
if (chosen === null) return null;
|
|
41128
|
+
if (chosen.usedFallback) this.deps.logger.info("group sealer: crop pixels not within 2s of instant, using nearest keyFrame", {
|
|
41129
|
+
tags: { deviceId },
|
|
41130
|
+
meta: {
|
|
41131
|
+
instantMs,
|
|
41132
|
+
mediaKey: chosen.key
|
|
41133
|
+
}
|
|
41134
|
+
});
|
|
40860
41135
|
const file = await this.deps.mediaStore.getByKey(chosen.key);
|
|
40861
41136
|
if (file === null) return null;
|
|
40862
41137
|
return { jpeg: Buffer.from(file.base64, "base64") };
|
|
@@ -40884,8 +41159,8 @@ var GroupSealer = class {
|
|
|
40884
41159
|
*/
|
|
40885
41160
|
/**
|
|
40886
41161
|
* @durable class=ledger owner=pipeline-analytics
|
|
40887
|
-
* write="one row per co-moving group at
|
|
40888
|
-
* Membership is
|
|
41162
|
+
* write="one row per co-moving group, sealed at idle — never a live row.
|
|
41163
|
+
* Membership is the RAM members at persist, not a session partition. mediaKey is
|
|
40889
41164
|
* the group's own crop; nativeKey is a POINTER to the anchor member's
|
|
40890
41165
|
* keyFrame, nulled if that member is deleted alone. anchorTrackId and
|
|
40891
41166
|
* nativeKey are the only fields rewritten after birth."
|
|
@@ -49287,10 +49562,21 @@ var TrackStore = class {
|
|
|
49287
49562
|
* in-memory dedup (the query cap has no DISTINCT); best-effort.
|
|
49288
49563
|
*/
|
|
49289
49564
|
async listDeviceIds() {
|
|
49565
|
+
return this.collectDeviceIds(0);
|
|
49566
|
+
}
|
|
49567
|
+
/**
|
|
49568
|
+
* Distinct deviceIds with a persisted track whose `lastSeen` is at least
|
|
49569
|
+
* `since`. The group sealer's 10-minute window. Walking from epoch 0 is how
|
|
49570
|
+
* `sealRecent` wedged hub-main every TTL sweep.
|
|
49571
|
+
*/
|
|
49572
|
+
async listDeviceIdsSince(since) {
|
|
49573
|
+
return this.collectDeviceIds(Math.max(0, Math.floor(since)));
|
|
49574
|
+
}
|
|
49575
|
+
async collectDeviceIds(fromLastSeen) {
|
|
49290
49576
|
const seenDevices = /* @__PURE__ */ new Set();
|
|
49291
49577
|
const seenIds = /* @__PURE__ */ new Set();
|
|
49292
49578
|
const PAGE = 500;
|
|
49293
|
-
let cursor =
|
|
49579
|
+
let cursor = fromLastSeen;
|
|
49294
49580
|
try {
|
|
49295
49581
|
for (;;) {
|
|
49296
49582
|
const rows = await this.store.query.query({
|
|
@@ -56677,6 +56963,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
56677
56963
|
plateStore = null;
|
|
56678
56964
|
groupStore = null;
|
|
56679
56965
|
groupSealer = null;
|
|
56966
|
+
groupLive = null;
|
|
56967
|
+
/** Serialise first-activity lookback per camera, then observe in order. */
|
|
56968
|
+
groupObserveTail = /* @__PURE__ */ new Map();
|
|
56680
56969
|
plateRecognizer = null;
|
|
56681
56970
|
objectEmbeddingStore = null;
|
|
56682
56971
|
/** Live state of the detached embedding rebuild — see `rebuildObjectEmbeddings`. */
|
|
@@ -58001,6 +58290,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
58001
58290
|
media: mediaStore
|
|
58002
58291
|
});
|
|
58003
58292
|
this.groupStore = groupStore;
|
|
58293
|
+
this.groupLive = new GroupLiveBuffer();
|
|
58004
58294
|
this.groupSealer = new GroupSealer({
|
|
58005
58295
|
trackStore,
|
|
58006
58296
|
groupStore,
|
|
@@ -58015,9 +58305,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
58015
58305
|
},
|
|
58016
58306
|
now: () => Date.now()
|
|
58017
58307
|
});
|
|
58018
|
-
this.groupSealer.backfill().catch((err) => {
|
|
58019
|
-
logger.warn("group sealer backfill failed", { meta: { error: errMsg(err) } });
|
|
58020
|
-
});
|
|
58021
58308
|
return {
|
|
58022
58309
|
trackStore,
|
|
58023
58310
|
mediaStore,
|
|
@@ -58460,6 +58747,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
58460
58747
|
buildSummaryPorts(stores) {
|
|
58461
58748
|
return {
|
|
58462
58749
|
listTracks: async ({ deviceId, sinceMs, untilMs, limit }) => {
|
|
58750
|
+
await this.groupSealer?.sealWindow(deviceId, sinceMs, untilMs);
|
|
58463
58751
|
const groups = await stores.groupStore.listOverlapping({
|
|
58464
58752
|
deviceId,
|
|
58465
58753
|
sinceMs,
|
|
@@ -58503,21 +58791,26 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
58503
58791
|
},
|
|
58504
58792
|
listMediaInfo: async (id) => {
|
|
58505
58793
|
const group = await stores.groupStore.get(id);
|
|
58506
|
-
if (group
|
|
58507
|
-
|
|
58508
|
-
|
|
58509
|
-
|
|
58510
|
-
|
|
58511
|
-
|
|
58512
|
-
|
|
58513
|
-
|
|
58514
|
-
|
|
58794
|
+
if (group) {
|
|
58795
|
+
if (group.mediaKey || group.nativeKey) return digestMediaRows({
|
|
58796
|
+
mediaKey: group.mediaKey,
|
|
58797
|
+
nativeKey: group.nativeKey,
|
|
58798
|
+
timestamp: group.timestamp,
|
|
58799
|
+
memberRows: []
|
|
58800
|
+
});
|
|
58801
|
+
const members = await stores.groupStore.listMembers(group.id);
|
|
58802
|
+
const memberRows = (await Promise.all(members.map((m) => stores.mediaStore.listInfoByOwner("track", m.trackId)))).flatMap((rows) => rows.map((r) => ({
|
|
58515
58803
|
key: r.key,
|
|
58516
58804
|
kind: r.kind,
|
|
58517
58805
|
timestamp: r.timestamp
|
|
58518
|
-
}));
|
|
58806
|
+
})));
|
|
58807
|
+
return digestMediaRows({
|
|
58808
|
+
mediaKey: group.mediaKey,
|
|
58809
|
+
nativeKey: group.nativeKey,
|
|
58810
|
+
timestamp: group.timestamp,
|
|
58811
|
+
memberRows
|
|
58812
|
+
});
|
|
58519
58813
|
}
|
|
58520
|
-
if (group) return [];
|
|
58521
58814
|
return (await stores.mediaStore.listInfoByOwner("track", id)).map((r) => ({
|
|
58522
58815
|
key: r.key,
|
|
58523
58816
|
kind: r.kind,
|
|
@@ -59763,6 +60056,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
59763
60056
|
state: t.state
|
|
59764
60057
|
});
|
|
59765
60058
|
positionsCountById.set(t.trackId, upserted.positions.length);
|
|
60059
|
+
this.enqueueLiveGroup(upserted, result.timestamp);
|
|
59766
60060
|
}
|
|
59767
60061
|
if (result.rawTrackedDetections.length > 0) {
|
|
59768
60062
|
const { minHits } = await this.resolveDeviceDetectionSensitivitySettings(deviceId);
|
|
@@ -61686,7 +61980,28 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
61686
61980
|
async sweepExpiredTracks() {
|
|
61687
61981
|
if (this.shuttingDown || !this.trackStore) return;
|
|
61688
61982
|
await this.trackCloser.sweep();
|
|
61689
|
-
this.groupSealer?.
|
|
61983
|
+
if (this.isPostProcessingNode && this.groupLive) this.groupSealer?.flushIdle(this.groupLive);
|
|
61984
|
+
}
|
|
61985
|
+
/** Hub-only: assign this sighting in RAM. First activity on a camera
|
|
61986
|
+
* replays 2 minutes of that camera (crash recover), then observe is sync. */
|
|
61987
|
+
enqueueLiveGroup(track, now) {
|
|
61988
|
+
if (!this.isPostProcessingNode || !this.groupLive) return;
|
|
61989
|
+
const deviceId = track.deviceId;
|
|
61990
|
+
const snapshot = toGroupableTrack(track);
|
|
61991
|
+
if (this.groupLive.isPrimed(deviceId)) {
|
|
61992
|
+
this.groupLive.observe(snapshot, now);
|
|
61993
|
+
return;
|
|
61994
|
+
}
|
|
61995
|
+
const next = (this.groupObserveTail.get(deviceId) ?? Promise.resolve()).then(async () => {
|
|
61996
|
+
if (this.groupLive && this.groupSealer && !this.groupLive.isPrimed(deviceId)) await this.groupSealer.recoverCamera(deviceId, this.groupLive);
|
|
61997
|
+
this.groupLive?.observe(snapshot, now);
|
|
61998
|
+
}).catch((err) => {
|
|
61999
|
+
this.ctx.logger.warn("group live observe failed", {
|
|
62000
|
+
tags: { deviceId },
|
|
62001
|
+
meta: { error: String(err) }
|
|
62002
|
+
});
|
|
62003
|
+
});
|
|
62004
|
+
this.groupObserveTail.set(deviceId, next);
|
|
61690
62005
|
}
|
|
61691
62006
|
/** Unified retention (Phase 1): earliest retained footage per device — the
|
|
61692
62007
|
* follow-recordings cutoff source. Cached 10 min: availability answers in
|
|
@@ -62331,6 +62646,16 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
62331
62646
|
};
|
|
62332
62647
|
if (plausible) this.stationaryRegistry?.promote(entry);
|
|
62333
62648
|
processor.dropTrack(track.trackId);
|
|
62649
|
+
const peek = this.trackStore?.peekActive(track.trackId);
|
|
62650
|
+
if (peek) this.enqueueLiveGroup(toGroupableTrack({
|
|
62651
|
+
trackId: track.trackId,
|
|
62652
|
+
deviceId,
|
|
62653
|
+
className: track.className,
|
|
62654
|
+
firstSeen: peek.firstSeen,
|
|
62655
|
+
lastSeen: timestamp,
|
|
62656
|
+
positions: peek.positions,
|
|
62657
|
+
closedReason: "stationary-promotion"
|
|
62658
|
+
}), timestamp);
|
|
62334
62659
|
this.trackStore?.closeActive(track.trackId, "stationary-promotion").catch((err) => {
|
|
62335
62660
|
this.ctx.logger.warn("stationary promotion: closing the source track failed — its media, events and vector are now unowned", {
|
|
62336
62661
|
tags: { deviceId },
|