@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
|
@@ -15139,21 +15139,29 @@ function selectSummaryTiles(candidates, opts) {
|
|
|
15139
15139
|
};
|
|
15140
15140
|
}
|
|
15141
15141
|
/**
|
|
15142
|
+
* Ranked leftover after the quota pass — refill a mosaic that dropped
|
|
15143
|
+
* duplicate frames or missing crops without walking the same track twice.
|
|
15144
|
+
*/
|
|
15145
|
+
function remainingSummaryCandidates(kept, taken, rank) {
|
|
15146
|
+
const takenIds = new Set(taken.map((c) => c.trackId));
|
|
15147
|
+
return kept.filter((c) => !takenIds.has(c.trackId)).toSorted(byRankDesc(rank));
|
|
15148
|
+
}
|
|
15149
|
+
/**
|
|
15142
15150
|
* The media a tile is drawn from, best first.
|
|
15143
15151
|
*
|
|
15144
|
-
* `groupCrop`
|
|
15145
|
-
*
|
|
15146
|
-
*
|
|
15147
|
-
* `
|
|
15152
|
+
* For a group: `groupCrop` (union crop, the group's own blob) then
|
|
15153
|
+
* `nativeFrame` (pointer at the anchor's clean keyFrame). A digest of a
|
|
15154
|
+
* cohort may show the native still when the crop was not written; a
|
|
15155
|
+
* member's `keyFrame` listed as itself stays off this ladder so tracker
|
|
15156
|
+
* ID churn cannot refill a mosaic with eleven driveways. Boxed frames stay
|
|
15157
|
+
* absent: a drawn rectangle is a permanent lie in a digest tile.
|
|
15148
15158
|
*/
|
|
15149
15159
|
var SUMMARY_MEDIA_LADDER = [
|
|
15150
15160
|
"groupCrop",
|
|
15151
|
-
"
|
|
15152
|
-
"
|
|
15153
|
-
"snapshot",
|
|
15154
|
-
"fullFrame",
|
|
15161
|
+
"nativeFrame",
|
|
15162
|
+
"thumbnail",
|
|
15155
15163
|
"thumbnailSmall",
|
|
15156
|
-
"
|
|
15164
|
+
"snapshot"
|
|
15157
15165
|
];
|
|
15158
15166
|
/**
|
|
15159
15167
|
* Pick ONE media row for a track — the cheap question first (index rows only),
|
|
@@ -15171,6 +15179,13 @@ function pickSummaryMedia(rows, aroundMs) {
|
|
|
15171
15179
|
}
|
|
15172
15180
|
return null;
|
|
15173
15181
|
}
|
|
15182
|
+
/**
|
|
15183
|
+
* Same camera, same wall-clock second → same JPEG. Tracker ID churn
|
|
15184
|
+
* otherwise fills a mosaic with eleven copies of one driveway still.
|
|
15185
|
+
*/
|
|
15186
|
+
function frameDedupeKey(deviceId, mediaTimestamp) {
|
|
15187
|
+
return `${deviceId}:${Math.floor(mediaTimestamp / 1e3)}`;
|
|
15188
|
+
}
|
|
15174
15189
|
//#endregion
|
|
15175
15190
|
//#region src/notification-center/summary/summary-window.ts
|
|
15176
15191
|
/**
|
|
@@ -15470,7 +15485,12 @@ var NcSummaryProducer = class {
|
|
|
15470
15485
|
droppedStationary: filtered.droppedStationary
|
|
15471
15486
|
};
|
|
15472
15487
|
}
|
|
15473
|
-
const loaded = await this.loadTiles(
|
|
15488
|
+
const loaded = await this.loadTiles({
|
|
15489
|
+
rule,
|
|
15490
|
+
preferred: selection.tiles,
|
|
15491
|
+
refill: remainingSummaryCandidates(filtered.kept, selection.tiles, rule.rank),
|
|
15492
|
+
maxTiles: rule.maxTiles
|
|
15493
|
+
});
|
|
15474
15494
|
const detections = countDetections({ tracks: selection.tiles.map((t) => ({
|
|
15475
15495
|
firstSeen: t.firstSeen,
|
|
15476
15496
|
lastSeen: t.lastSeen,
|
|
@@ -15528,7 +15548,7 @@ var NcSummaryProducer = class {
|
|
|
15528
15548
|
tiles: loaded.length,
|
|
15529
15549
|
cameras: new Set(loaded.map((t) => t.candidate.deviceId)).size,
|
|
15530
15550
|
camerasDropped: selection.camerasDropped,
|
|
15531
|
-
droppedNoMedia:
|
|
15551
|
+
droppedNoMedia: Math.max(0, rule.maxTiles - loaded.length),
|
|
15532
15552
|
mosaicBytes: mosaic.bytes,
|
|
15533
15553
|
mosaicPublished: mosaic.ref !== null,
|
|
15534
15554
|
enqueued,
|
|
@@ -15592,10 +15612,18 @@ var NcSummaryProducer = class {
|
|
|
15592
15612
|
}
|
|
15593
15613
|
return all;
|
|
15594
15614
|
}
|
|
15595
|
-
/** One blob per tile — the cheap question (index rows) always first.
|
|
15596
|
-
|
|
15615
|
+
/** One blob per tile — the cheap question (index rows) always first.
|
|
15616
|
+
* Duplicate camera-seconds are skipped so tracker ID churn cannot paint
|
|
15617
|
+
* the same driveway eleven times; leftover ranked candidates refill. */
|
|
15618
|
+
async loadTiles(input) {
|
|
15597
15619
|
const out = [];
|
|
15598
|
-
|
|
15620
|
+
const seenFrames = /* @__PURE__ */ new Set();
|
|
15621
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
15622
|
+
const queue = [...input.preferred, ...input.refill];
|
|
15623
|
+
for (const candidate of queue) {
|
|
15624
|
+
if (out.length >= input.maxTiles) break;
|
|
15625
|
+
if (seenIds.has(candidate.trackId)) continue;
|
|
15626
|
+
seenIds.add(candidate.trackId);
|
|
15599
15627
|
const tags = { deviceId: candidate.deviceId };
|
|
15600
15628
|
try {
|
|
15601
15629
|
const rows = await this.deps.listMediaInfo(candidate.trackId);
|
|
@@ -15604,25 +15632,38 @@ var NcSummaryProducer = class {
|
|
|
15604
15632
|
this.deps.logger.debug("summary tile dropped: the track carries no clean media", {
|
|
15605
15633
|
tags,
|
|
15606
15634
|
meta: {
|
|
15607
|
-
ruleId: rule.id,
|
|
15635
|
+
ruleId: input.rule.id,
|
|
15608
15636
|
trackId: candidate.trackId,
|
|
15609
15637
|
mediaRows: rows.length
|
|
15610
15638
|
}
|
|
15611
15639
|
});
|
|
15612
15640
|
continue;
|
|
15613
15641
|
}
|
|
15642
|
+
const frameKey = frameDedupeKey(candidate.deviceId, picked.timestamp);
|
|
15643
|
+
if (seenFrames.has(frameKey)) {
|
|
15644
|
+
this.deps.logger.debug("summary tile dropped: same frame as an earlier tile", {
|
|
15645
|
+
tags,
|
|
15646
|
+
meta: {
|
|
15647
|
+
ruleId: input.rule.id,
|
|
15648
|
+
trackId: candidate.trackId,
|
|
15649
|
+
frameKey
|
|
15650
|
+
}
|
|
15651
|
+
});
|
|
15652
|
+
continue;
|
|
15653
|
+
}
|
|
15614
15654
|
const jpeg = await this.deps.readMedia(picked.key);
|
|
15615
15655
|
if (jpeg === null || jpeg.byteLength === 0) {
|
|
15616
15656
|
this.deps.logger.debug("summary tile dropped: the media blob could not be read", {
|
|
15617
15657
|
tags,
|
|
15618
15658
|
meta: {
|
|
15619
|
-
ruleId: rule.id,
|
|
15659
|
+
ruleId: input.rule.id,
|
|
15620
15660
|
trackId: candidate.trackId,
|
|
15621
15661
|
mediaKey: picked.key
|
|
15622
15662
|
}
|
|
15623
15663
|
});
|
|
15624
15664
|
continue;
|
|
15625
15665
|
}
|
|
15666
|
+
seenFrames.add(frameKey);
|
|
15626
15667
|
out.push({
|
|
15627
15668
|
candidate,
|
|
15628
15669
|
jpeg
|
|
@@ -15631,7 +15672,7 @@ var NcSummaryProducer = class {
|
|
|
15631
15672
|
this.deps.logger.warn("summary tile dropped: reading its media threw", {
|
|
15632
15673
|
tags,
|
|
15633
15674
|
meta: {
|
|
15634
|
-
ruleId: rule.id,
|
|
15675
|
+
ruleId: input.rule.id,
|
|
15635
15676
|
trackId: candidate.trackId,
|
|
15636
15677
|
error: String(err)
|
|
15637
15678
|
}
|
|
@@ -40294,7 +40335,7 @@ function aggregateGroupClasses(tracks) {
|
|
|
40294
40335
|
}
|
|
40295
40336
|
function toGroupRecord(input) {
|
|
40296
40337
|
const { className, classes } = aggregateGroupClasses(input.tracks);
|
|
40297
|
-
const mediaId = input.group.mediaKey ?? (input.group.memberCount === 1 ? input.memberTrackIds[0] : null);
|
|
40338
|
+
const mediaId = input.group.mediaKey ?? input.group.nativeKey ?? (input.group.memberCount === 1 ? input.memberTrackIds[0] : null);
|
|
40298
40339
|
return {
|
|
40299
40340
|
id: input.group.id,
|
|
40300
40341
|
deviceId: input.group.deviceId,
|
|
@@ -40373,71 +40414,36 @@ async function buildGroupRecord(ports, group) {
|
|
|
40373
40414
|
eventMediaBaseUrl: ports.eventMediaBaseUrl()
|
|
40374
40415
|
});
|
|
40375
40416
|
}
|
|
40376
|
-
|
|
40377
|
-
|
|
40378
|
-
|
|
40379
|
-
|
|
40380
|
-
|
|
40381
|
-
|
|
40382
|
-
|
|
40383
|
-
|
|
40384
|
-
|
|
40385
|
-
|
|
40417
|
+
/**
|
|
40418
|
+
* Gallery and digest read the same ladder: groupCrop, then the native-frame
|
|
40419
|
+
* pointer, then member subject crops. A multi with no crop still has a
|
|
40420
|
+
* picture if the sealer stored `nativeKey`.
|
|
40421
|
+
*/
|
|
40422
|
+
function digestMediaRows(input) {
|
|
40423
|
+
const owned = [];
|
|
40424
|
+
if (input.mediaKey !== null && input.mediaKey.length > 0) owned.push({
|
|
40425
|
+
key: input.mediaKey,
|
|
40426
|
+
kind: "groupCrop",
|
|
40427
|
+
timestamp: input.timestamp
|
|
40386
40428
|
});
|
|
40387
|
-
|
|
40388
|
-
|
|
40429
|
+
if (input.nativeKey !== null && input.nativeKey.length > 0) owned.push({
|
|
40430
|
+
key: input.nativeKey,
|
|
40431
|
+
kind: "nativeFrame",
|
|
40432
|
+
timestamp: input.timestamp
|
|
40433
|
+
});
|
|
40434
|
+
if (owned.length > 0) return owned;
|
|
40435
|
+
return input.memberRows;
|
|
40389
40436
|
}
|
|
40390
40437
|
var MATCH_WINDOW_MS = 1e3;
|
|
40391
40438
|
var MOVE_EPS = .35;
|
|
40439
|
+
/** Live-assigner window: same predicate, recent positions only. */
|
|
40440
|
+
var LIVE_WINDOW_MS = 3e3;
|
|
40392
40441
|
/** Registry stationary is out; audio has no bbox and is never a member or a singleton. */
|
|
40393
40442
|
function isEligibleMember(track) {
|
|
40394
40443
|
if (track.closedReason === "stationary-promotion") return false;
|
|
40395
40444
|
if (track.className === "audio") return false;
|
|
40396
40445
|
return true;
|
|
40397
40446
|
}
|
|
40398
|
-
function temporallyOverlap(a, b, slackMs) {
|
|
40399
|
-
return a.firstSeen <= b.lastSeen + slackMs && b.firstSeen <= a.lastSeen + slackMs;
|
|
40400
|
-
}
|
|
40401
|
-
/** Connected components of temporal overlap (+ slack) per camera. */
|
|
40402
|
-
function sessionsOf(tracks) {
|
|
40403
|
-
const byDevice = /* @__PURE__ */ new Map();
|
|
40404
|
-
for (const t of tracks) {
|
|
40405
|
-
const list = byDevice.get(t.deviceId);
|
|
40406
|
-
if (list) list.push(t);
|
|
40407
|
-
else byDevice.set(t.deviceId, [t]);
|
|
40408
|
-
}
|
|
40409
|
-
const sessions = [];
|
|
40410
|
-
for (const deviceTracks of byDevice.values()) {
|
|
40411
|
-
const n = deviceTracks.length;
|
|
40412
|
-
const parent = deviceTracks.map((_, i) => i);
|
|
40413
|
-
const find = (i) => {
|
|
40414
|
-
let cur = i;
|
|
40415
|
-
while (parent[cur] !== cur) cur = parent[cur];
|
|
40416
|
-
let walk = i;
|
|
40417
|
-
while (parent[walk] !== cur) {
|
|
40418
|
-
const next = parent[walk];
|
|
40419
|
-
parent[walk] = cur;
|
|
40420
|
-
walk = next;
|
|
40421
|
-
}
|
|
40422
|
-
return cur;
|
|
40423
|
-
};
|
|
40424
|
-
const union = (i, j) => {
|
|
40425
|
-
const ri = find(i);
|
|
40426
|
-
const rj = find(j);
|
|
40427
|
-
if (ri !== rj) parent[ri] = rj;
|
|
40428
|
-
};
|
|
40429
|
-
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);
|
|
40430
|
-
const buckets = /* @__PURE__ */ new Map();
|
|
40431
|
-
for (let i = 0; i < n; i++) {
|
|
40432
|
-
const root = find(i);
|
|
40433
|
-
const bucket = buckets.get(root);
|
|
40434
|
-
if (bucket) bucket.push(deviceTracks[i]);
|
|
40435
|
-
else buckets.set(root, [deviceTracks[i]]);
|
|
40436
|
-
}
|
|
40437
|
-
sessions.push(...buckets.values());
|
|
40438
|
-
}
|
|
40439
|
-
return sessions;
|
|
40440
|
-
}
|
|
40441
40447
|
function diag(bbox) {
|
|
40442
40448
|
return Math.hypot(bbox.w, bbox.h);
|
|
40443
40449
|
}
|
|
@@ -40459,9 +40465,21 @@ function meanDiag(positions) {
|
|
|
40459
40465
|
for (const p of positions) sum += diag(p.bbox);
|
|
40460
40466
|
return sum / positions.length;
|
|
40461
40467
|
}
|
|
40462
|
-
function
|
|
40463
|
-
|
|
40464
|
-
|
|
40468
|
+
function vecLen(v) {
|
|
40469
|
+
return Math.hypot(v.x, v.y);
|
|
40470
|
+
}
|
|
40471
|
+
function cosine(a, b) {
|
|
40472
|
+
const la = vecLen(a);
|
|
40473
|
+
const lb = vecLen(b);
|
|
40474
|
+
if (la === 0 || lb === 0) return 1;
|
|
40475
|
+
return (a.x * b.x + a.y * b.y) / (la * lb);
|
|
40476
|
+
}
|
|
40477
|
+
function positionsInWindow(track, start, end) {
|
|
40478
|
+
return track.positions.filter((p) => p.timestamp >= start && p.timestamp <= end);
|
|
40479
|
+
}
|
|
40480
|
+
function windowDisplacement(positions) {
|
|
40481
|
+
const first = positions[0];
|
|
40482
|
+
const last = positions[positions.length - 1];
|
|
40465
40483
|
if (!first || !last) return {
|
|
40466
40484
|
x: 0,
|
|
40467
40485
|
y: 0
|
|
@@ -40473,24 +40491,19 @@ function netDisplacement(track) {
|
|
|
40473
40491
|
y: b.y - a.y
|
|
40474
40492
|
};
|
|
40475
40493
|
}
|
|
40476
|
-
function
|
|
40477
|
-
|
|
40478
|
-
}
|
|
40479
|
-
function cosine(a, b) {
|
|
40480
|
-
const la = vecLen(a);
|
|
40481
|
-
const lb = vecLen(b);
|
|
40482
|
-
if (la === 0 || lb === 0) return 1;
|
|
40483
|
-
return (a.x * b.x + a.y * b.y) / (la * lb);
|
|
40484
|
-
}
|
|
40485
|
-
function relativeMotion(track) {
|
|
40486
|
-
const mean = meanDiag(track.positions);
|
|
40494
|
+
function windowRelativeMotion(positions) {
|
|
40495
|
+
const mean = meanDiag(positions);
|
|
40487
40496
|
if (mean <= 0) return 0;
|
|
40488
|
-
return vecLen(
|
|
40497
|
+
return vecLen(windowDisplacement(positions)) / mean;
|
|
40489
40498
|
}
|
|
40490
|
-
/**
|
|
40491
|
-
|
|
40492
|
-
|
|
40493
|
-
|
|
40499
|
+
/**
|
|
40500
|
+
* Same cohesion + crossing veto as {@link pairTogether}, over `[now − 3 s, now]`.
|
|
40501
|
+
* Displacement/cosine use the window, not the whole track.
|
|
40502
|
+
*/
|
|
40503
|
+
function pairTogetherNow(a, b, now) {
|
|
40504
|
+
const start = now - LIVE_WINDOW_MS;
|
|
40505
|
+
const overlapStart = Math.max(a.firstSeen, b.firstSeen, start);
|
|
40506
|
+
const overlapEnd = Math.min(a.lastSeen, b.lastSeen, now);
|
|
40494
40507
|
if (overlapEnd < overlapStart) return false;
|
|
40495
40508
|
let joint = 0;
|
|
40496
40509
|
let close = 0;
|
|
@@ -40507,55 +40520,245 @@ function pairTogether(a, b) {
|
|
|
40507
40520
|
}
|
|
40508
40521
|
if (joint === 0) return false;
|
|
40509
40522
|
if (close / joint < .5) return false;
|
|
40510
|
-
const
|
|
40511
|
-
const
|
|
40512
|
-
|
|
40523
|
+
const wa = positionsInWindow(a, start, now);
|
|
40524
|
+
const wb = positionsInWindow(b, start, now);
|
|
40525
|
+
const aMoves = windowRelativeMotion(wa) >= MOVE_EPS;
|
|
40526
|
+
const bMoves = windowRelativeMotion(wb) >= MOVE_EPS;
|
|
40527
|
+
if (aMoves && bMoves && cosine(windowDisplacement(wa), windowDisplacement(wb)) < .3) return false;
|
|
40513
40528
|
return true;
|
|
40514
40529
|
}
|
|
40515
|
-
|
|
40516
|
-
|
|
40517
|
-
|
|
40518
|
-
|
|
40519
|
-
|
|
40520
|
-
|
|
40521
|
-
|
|
40522
|
-
|
|
40523
|
-
|
|
40524
|
-
|
|
40525
|
-
|
|
40526
|
-
|
|
40527
|
-
|
|
40528
|
-
|
|
40529
|
-
|
|
40530
|
-
|
|
40531
|
-
|
|
40532
|
-
|
|
40533
|
-
|
|
40534
|
-
|
|
40535
|
-
|
|
40536
|
-
|
|
40537
|
-
|
|
40538
|
-
|
|
40539
|
-
const
|
|
40540
|
-
|
|
40541
|
-
|
|
40542
|
-
|
|
40543
|
-
|
|
40544
|
-
|
|
40545
|
-
|
|
40546
|
-
|
|
40547
|
-
|
|
40548
|
-
|
|
40549
|
-
|
|
40550
|
-
|
|
40551
|
-
|
|
40552
|
-
|
|
40553
|
-
|
|
40554
|
-
|
|
40555
|
-
|
|
40530
|
+
var GroupLiveAssigner = class {
|
|
40531
|
+
nextId;
|
|
40532
|
+
snapshots = /* @__PURE__ */ new Map();
|
|
40533
|
+
groups = /* @__PURE__ */ new Map();
|
|
40534
|
+
membership = /* @__PURE__ */ new Map();
|
|
40535
|
+
/** `${trackId}>${groupId}` → first instant the join predicate held. */
|
|
40536
|
+
joinSince = /* @__PURE__ */ new Map();
|
|
40537
|
+
/** trackId → first instant they failed pair against every remaining member. */
|
|
40538
|
+
leaveSince = /* @__PURE__ */ new Map();
|
|
40539
|
+
constructor(nextId = () => (0, node_crypto.randomUUID)()) {
|
|
40540
|
+
this.nextId = nextId;
|
|
40541
|
+
}
|
|
40542
|
+
observe(track, now) {
|
|
40543
|
+
this.snapshots.set(track.trackId, track);
|
|
40544
|
+
if (!isEligibleMember(track)) {
|
|
40545
|
+
this.removeFromGroup(track.trackId);
|
|
40546
|
+
return;
|
|
40547
|
+
}
|
|
40548
|
+
if (!this.membership.has(track.trackId)) this.birthSingleton(track, now);
|
|
40549
|
+
this.considerLeave(track, now);
|
|
40550
|
+
const groupId = this.membership.get(track.trackId);
|
|
40551
|
+
const group = groupId !== void 0 ? this.groups.get(groupId) : void 0;
|
|
40552
|
+
if (group) for (const id of [...group.members]) {
|
|
40553
|
+
if (id === track.trackId) continue;
|
|
40554
|
+
const snap = this.snapshots.get(id);
|
|
40555
|
+
if (snap) this.considerLeave(snap, now);
|
|
40556
|
+
}
|
|
40557
|
+
this.considerJoin(track, now);
|
|
40558
|
+
}
|
|
40559
|
+
groupOf(trackId) {
|
|
40560
|
+
return this.membership.get(trackId);
|
|
40561
|
+
}
|
|
40562
|
+
snapshot(trackId) {
|
|
40563
|
+
return this.snapshots.get(trackId);
|
|
40564
|
+
}
|
|
40565
|
+
liveGroups() {
|
|
40566
|
+
return [...this.groups.values()].map((g) => this.toLive(g));
|
|
40567
|
+
}
|
|
40568
|
+
idleGroups(now, idleMs) {
|
|
40569
|
+
return this.liveGroups().filter((g) => now >= g.lastActiveAt + idleMs);
|
|
40570
|
+
}
|
|
40571
|
+
drop(ids) {
|
|
40572
|
+
for (const id of ids) {
|
|
40573
|
+
const g = this.groups.get(id);
|
|
40574
|
+
if (!g) continue;
|
|
40575
|
+
for (const member of g.members) this.membership.delete(member);
|
|
40576
|
+
this.groups.delete(id);
|
|
40577
|
+
}
|
|
40578
|
+
}
|
|
40579
|
+
birthSingleton(track, now) {
|
|
40580
|
+
const id = this.nextId();
|
|
40581
|
+
this.groups.set(id, {
|
|
40582
|
+
id,
|
|
40583
|
+
deviceId: track.deviceId,
|
|
40584
|
+
openedAt: now,
|
|
40585
|
+
members: new Set([track.trackId])
|
|
40556
40586
|
});
|
|
40587
|
+
this.membership.set(track.trackId, id);
|
|
40557
40588
|
}
|
|
40558
|
-
|
|
40589
|
+
considerLeave(track, now) {
|
|
40590
|
+
const groupId = this.membership.get(track.trackId);
|
|
40591
|
+
if (groupId === void 0) return;
|
|
40592
|
+
const group = this.groups.get(groupId);
|
|
40593
|
+
if (!group || group.members.size < 2) {
|
|
40594
|
+
this.leaveSince.delete(track.trackId);
|
|
40595
|
+
return;
|
|
40596
|
+
}
|
|
40597
|
+
if ([...group.members].filter((id) => id !== track.trackId).some((id) => {
|
|
40598
|
+
const other = this.snapshots.get(id);
|
|
40599
|
+
return other !== void 0 && pairTogetherNow(track, other, now);
|
|
40600
|
+
})) {
|
|
40601
|
+
this.leaveSince.delete(track.trackId);
|
|
40602
|
+
return;
|
|
40603
|
+
}
|
|
40604
|
+
const since = this.leaveSince.get(track.trackId) ?? now;
|
|
40605
|
+
this.leaveSince.set(track.trackId, since);
|
|
40606
|
+
if (now - since < 3e3) return;
|
|
40607
|
+
this.peel(track, now);
|
|
40608
|
+
}
|
|
40609
|
+
peel(track, now) {
|
|
40610
|
+
this.removeFromGroup(track.trackId);
|
|
40611
|
+
this.leaveSince.delete(track.trackId);
|
|
40612
|
+
this.birthSingleton(track, now);
|
|
40613
|
+
}
|
|
40614
|
+
considerJoin(track, now) {
|
|
40615
|
+
const mineId = this.membership.get(track.trackId);
|
|
40616
|
+
if (mineId === void 0) return;
|
|
40617
|
+
const mine = this.groups.get(mineId);
|
|
40618
|
+
if (!mine || mine.members.size !== 1) {
|
|
40619
|
+
this.clearJoinClocks(track.trackId);
|
|
40620
|
+
return;
|
|
40621
|
+
}
|
|
40622
|
+
let best;
|
|
40623
|
+
for (const group of this.groups.values()) {
|
|
40624
|
+
if (group.id === mineId || group.deviceId !== track.deviceId) continue;
|
|
40625
|
+
let gap = Number.POSITIVE_INFINITY;
|
|
40626
|
+
let together = false;
|
|
40627
|
+
for (const memberId of group.members) {
|
|
40628
|
+
const other = this.snapshots.get(memberId);
|
|
40629
|
+
if (!other) continue;
|
|
40630
|
+
if (pairTogetherNow(track, other, now)) {
|
|
40631
|
+
together = true;
|
|
40632
|
+
gap = Math.min(gap, centroidGap(track, other, now));
|
|
40633
|
+
}
|
|
40634
|
+
}
|
|
40635
|
+
if (!together) continue;
|
|
40636
|
+
if (best === void 0 || gap < best.gap) best = {
|
|
40637
|
+
groupId: group.id,
|
|
40638
|
+
gap
|
|
40639
|
+
};
|
|
40640
|
+
}
|
|
40641
|
+
if (best === void 0) {
|
|
40642
|
+
this.clearJoinClocks(track.trackId);
|
|
40643
|
+
return;
|
|
40644
|
+
}
|
|
40645
|
+
const key = `${track.trackId}>${best.groupId}`;
|
|
40646
|
+
for (const existing of [...this.joinSince.keys()]) if (existing.startsWith(`${track.trackId}>`) && existing !== key) this.joinSince.delete(existing);
|
|
40647
|
+
const since = this.joinSince.get(key) ?? now;
|
|
40648
|
+
this.joinSince.set(key, since);
|
|
40649
|
+
if (now - since < 1500) return;
|
|
40650
|
+
this.absorb(track.trackId, mineId, best.groupId);
|
|
40651
|
+
}
|
|
40652
|
+
absorb(trackId, fromId, intoId) {
|
|
40653
|
+
const into = this.groups.get(intoId);
|
|
40654
|
+
if (!into) return;
|
|
40655
|
+
into.members.add(trackId);
|
|
40656
|
+
this.membership.set(trackId, intoId);
|
|
40657
|
+
this.groups.delete(fromId);
|
|
40658
|
+
this.clearJoinClocks(trackId);
|
|
40659
|
+
this.leaveSince.delete(trackId);
|
|
40660
|
+
}
|
|
40661
|
+
removeFromGroup(trackId) {
|
|
40662
|
+
const groupId = this.membership.get(trackId);
|
|
40663
|
+
this.membership.delete(trackId);
|
|
40664
|
+
this.clearJoinClocks(trackId);
|
|
40665
|
+
this.leaveSince.delete(trackId);
|
|
40666
|
+
if (groupId === void 0) return;
|
|
40667
|
+
const group = this.groups.get(groupId);
|
|
40668
|
+
if (!group) return;
|
|
40669
|
+
group.members.delete(trackId);
|
|
40670
|
+
if (group.members.size === 0) this.groups.delete(groupId);
|
|
40671
|
+
}
|
|
40672
|
+
clearJoinClocks(trackId) {
|
|
40673
|
+
for (const key of [...this.joinSince.keys()]) if (key.startsWith(`${trackId}>`)) this.joinSince.delete(key);
|
|
40674
|
+
}
|
|
40675
|
+
toLive(group) {
|
|
40676
|
+
let lastActiveAt = group.openedAt;
|
|
40677
|
+
for (const id of group.members) {
|
|
40678
|
+
const snap = this.snapshots.get(id);
|
|
40679
|
+
if (snap && snap.lastSeen > lastActiveAt) lastActiveAt = snap.lastSeen;
|
|
40680
|
+
}
|
|
40681
|
+
return {
|
|
40682
|
+
id: group.id,
|
|
40683
|
+
deviceId: group.deviceId,
|
|
40684
|
+
memberTrackIds: [...group.members],
|
|
40685
|
+
openedAt: group.openedAt,
|
|
40686
|
+
lastActiveAt
|
|
40687
|
+
};
|
|
40688
|
+
}
|
|
40689
|
+
};
|
|
40690
|
+
function centroidGap(a, b, now) {
|
|
40691
|
+
const pa = a.positions.reduce((best, p) => Math.abs(p.timestamp - now) < Math.abs(best.timestamp - now) ? p : best, a.positions[0]);
|
|
40692
|
+
const pb = b.positions.reduce((best, p) => Math.abs(p.timestamp - now) < Math.abs(best.timestamp - now) ? p : best, b.positions[0]);
|
|
40693
|
+
if (!pa || !pb) return Number.POSITIVE_INFINITY;
|
|
40694
|
+
const ca = bboxCentroid(pa.bbox);
|
|
40695
|
+
const cb = bboxCentroid(pb.bbox);
|
|
40696
|
+
return Math.hypot(ca.x - cb.x, ca.y - cb.y);
|
|
40697
|
+
}
|
|
40698
|
+
//#endregion
|
|
40699
|
+
//#region src/pipeline-analytics/group/group-session.ts
|
|
40700
|
+
/** Same idle as the notification burst (`NC_GROUP_DEFAULT_IDLE_SEC`). */
|
|
40701
|
+
var GROUP_SESSION_IDLE_MS = 3e4;
|
|
40702
|
+
//#endregion
|
|
40703
|
+
//#region src/pipeline-analytics/group/group-live-buffer.ts
|
|
40704
|
+
function toGroupableTrack(track) {
|
|
40705
|
+
return {
|
|
40706
|
+
trackId: track.trackId,
|
|
40707
|
+
deviceId: track.deviceId,
|
|
40708
|
+
className: track.className,
|
|
40709
|
+
firstSeen: track.firstSeen,
|
|
40710
|
+
lastSeen: track.lastSeen,
|
|
40711
|
+
positions: track.positions.map((p) => ({
|
|
40712
|
+
...p,
|
|
40713
|
+
bbox: { ...p.bbox }
|
|
40714
|
+
})),
|
|
40715
|
+
...track.closedReason !== void 0 ? { closedReason: track.closedReason } : {}
|
|
40716
|
+
};
|
|
40717
|
+
}
|
|
40718
|
+
var GroupLiveBuffer = class {
|
|
40719
|
+
assigner;
|
|
40720
|
+
primed = /* @__PURE__ */ new Set();
|
|
40721
|
+
constructor(nextId) {
|
|
40722
|
+
this.assigner = new GroupLiveAssigner(nextId);
|
|
40723
|
+
}
|
|
40724
|
+
observe(track, now) {
|
|
40725
|
+
this.assigner.observe(track, now);
|
|
40726
|
+
}
|
|
40727
|
+
groupOf(trackId) {
|
|
40728
|
+
return this.assigner.groupOf(trackId);
|
|
40729
|
+
}
|
|
40730
|
+
snapshot(trackId) {
|
|
40731
|
+
return this.assigner.snapshot(trackId);
|
|
40732
|
+
}
|
|
40733
|
+
liveGroups() {
|
|
40734
|
+
return this.assigner.liveGroups();
|
|
40735
|
+
}
|
|
40736
|
+
idleGroups(now, idleMs = GROUP_SESSION_IDLE_MS) {
|
|
40737
|
+
return this.assigner.idleGroups(now, idleMs);
|
|
40738
|
+
}
|
|
40739
|
+
drop(ids) {
|
|
40740
|
+
this.assigner.drop(ids);
|
|
40741
|
+
}
|
|
40742
|
+
isPrimed(deviceId) {
|
|
40743
|
+
return this.primed.has(deviceId);
|
|
40744
|
+
}
|
|
40745
|
+
markPrimed(deviceId) {
|
|
40746
|
+
this.primed.add(deviceId);
|
|
40747
|
+
}
|
|
40748
|
+
};
|
|
40749
|
+
//#endregion
|
|
40750
|
+
//#region src/pipeline-analytics/group/group-crop.ts
|
|
40751
|
+
/** Always containSubject: true. Never the 0.5 delivered threshold. */
|
|
40752
|
+
function groupWideCentralSquareLayout(unionBbox, frame) {
|
|
40753
|
+
return wideCentralSquareLayout(unionBbox, frame, { containSubject: true });
|
|
40754
|
+
}
|
|
40755
|
+
async function cropGroupFromRgb(rgb, frameWidth, frameHeight, unionBbox) {
|
|
40756
|
+
const layout = groupWideCentralSquareLayout(unionBbox, {
|
|
40757
|
+
W: frameWidth,
|
|
40758
|
+
H: frameHeight
|
|
40759
|
+
});
|
|
40760
|
+
const { crop: slab } = await extractCrop(rgb, frameWidth, frameHeight, layout.fetch);
|
|
40761
|
+
return composeWideCentralSquareThumbnail(slab, layout);
|
|
40559
40762
|
}
|
|
40560
40763
|
//#endregion
|
|
40561
40764
|
//#region src/pipeline-analytics/group/group-instant.ts
|
|
@@ -40572,6 +40775,7 @@ function clusterGroups(tracks) {
|
|
|
40572
40775
|
*
|
|
40573
40776
|
* Retrospective. No new capture path.
|
|
40574
40777
|
*/
|
|
40778
|
+
var GROUP_MEDIA_MATCH_MS = 2e3;
|
|
40575
40779
|
function nearest(positions, t, windowMs) {
|
|
40576
40780
|
let best;
|
|
40577
40781
|
let bestDt = windowMs + 1;
|
|
@@ -40680,55 +40884,85 @@ function chooseAnchorTrackId(members, ownsCleanFrame) {
|
|
|
40680
40884
|
return life !== 0 ? life : a.trackId.localeCompare(b.trackId);
|
|
40681
40885
|
})[0].trackId;
|
|
40682
40886
|
}
|
|
40683
|
-
//#endregion
|
|
40684
|
-
//#region src/pipeline-analytics/group/group-session.ts
|
|
40685
40887
|
/**
|
|
40686
|
-
*
|
|
40687
|
-
*
|
|
40688
|
-
* Session = temporal chain + 3 s slack, per camera. Close = every overlapping
|
|
40689
|
-
* track is persisted AND the newest lastSeen is older than the idle cutoff
|
|
40690
|
-
* (the Phase-1 burst's 30 s, reused — not a second clock).
|
|
40888
|
+
* The RGB source for a group crop is a full native frame. Thumbnails are
|
|
40889
|
+
* already single-subject crops and cannot substitute for union-bbox geometry.
|
|
40691
40890
|
*
|
|
40692
|
-
*
|
|
40693
|
-
*
|
|
40694
|
-
*
|
|
40891
|
+
* Prefer a keyFrame within {@link GROUP_MEDIA_MATCH_MS} of the chosen instant;
|
|
40892
|
+
* if none, take the nearest keyFrame anyway — dropping the crop because the
|
|
40893
|
+
* best-shot was 3 s off the instant is how a recognised person left the
|
|
40894
|
+
* digest with no crop.
|
|
40695
40895
|
*/
|
|
40696
|
-
|
|
40697
|
-
|
|
40698
|
-
|
|
40699
|
-
|
|
40896
|
+
function pickCleanPixelRow(rows, instantMs) {
|
|
40897
|
+
const ranked = (maxDt) => {
|
|
40898
|
+
const candidates = [];
|
|
40899
|
+
for (const row of rows) {
|
|
40900
|
+
const rank = row.kind === "keyFrame" ? 0 : row.kind === "keyFrameSmall" ? 1 : -1;
|
|
40901
|
+
if (rank < 0) continue;
|
|
40902
|
+
const dt = Math.abs(row.timestamp - instantMs);
|
|
40903
|
+
if (maxDt !== null && dt > maxDt) continue;
|
|
40904
|
+
candidates.push({
|
|
40905
|
+
key: row.key,
|
|
40906
|
+
dt,
|
|
40907
|
+
rank
|
|
40908
|
+
});
|
|
40909
|
+
}
|
|
40910
|
+
return candidates.toSorted((a, b) => a.rank - b.rank || a.dt - b.dt)[0];
|
|
40911
|
+
};
|
|
40912
|
+
const near = ranked(GROUP_MEDIA_MATCH_MS);
|
|
40913
|
+
if (near !== void 0) return {
|
|
40914
|
+
key: near.key,
|
|
40915
|
+
usedFallback: false
|
|
40916
|
+
};
|
|
40917
|
+
const any = ranked(null);
|
|
40918
|
+
if (any === void 0) return null;
|
|
40919
|
+
return {
|
|
40920
|
+
key: any.key,
|
|
40921
|
+
usedFallback: true
|
|
40922
|
+
};
|
|
40700
40923
|
}
|
|
40701
|
-
|
|
40924
|
+
//#endregion
|
|
40925
|
+
//#region src/pipeline-analytics/group/group-live-replay.ts
|
|
40926
|
+
function sliceTrackAt(track, now) {
|
|
40927
|
+
const positions = track.positions.filter((p) => p.timestamp <= now);
|
|
40928
|
+
const last = positions[positions.length - 1];
|
|
40702
40929
|
return {
|
|
40703
|
-
|
|
40704
|
-
lastSeen:
|
|
40930
|
+
...track,
|
|
40931
|
+
lastSeen: last?.timestamp ?? track.firstSeen,
|
|
40932
|
+
positions
|
|
40705
40933
|
};
|
|
40706
40934
|
}
|
|
40707
|
-
|
|
40708
|
-
|
|
40709
|
-
|
|
40710
|
-
|
|
40711
|
-
|
|
40712
|
-
|
|
40713
|
-
|
|
40714
|
-
|
|
40715
|
-
|
|
40716
|
-
|
|
40717
|
-
|
|
40718
|
-
const ready = [];
|
|
40719
|
-
for (const [deviceId, tracks] of byDevice) {
|
|
40720
|
-
const deviceActive = active.filter((a) => a.deviceId === deviceId);
|
|
40721
|
-
for (const session of sessionsOf(tracks)) {
|
|
40722
|
-
const span = sessionSpan(session);
|
|
40723
|
-
if (now < span.lastSeen + idleMs) continue;
|
|
40724
|
-
if (deviceActive.some((a) => overlaps(span, a))) continue;
|
|
40725
|
-
ready.push(session);
|
|
40935
|
+
function replayInto(tracks, observer) {
|
|
40936
|
+
const events = [];
|
|
40937
|
+
const byId = /* @__PURE__ */ new Map();
|
|
40938
|
+
for (const track of tracks) {
|
|
40939
|
+
byId.set(track.trackId, track);
|
|
40940
|
+
if (track.positions.length === 0) {
|
|
40941
|
+
events.push({
|
|
40942
|
+
t: track.lastSeen,
|
|
40943
|
+
trackId: track.trackId
|
|
40944
|
+
});
|
|
40945
|
+
continue;
|
|
40726
40946
|
}
|
|
40947
|
+
for (const p of track.positions) events.push({
|
|
40948
|
+
t: p.timestamp,
|
|
40949
|
+
trackId: track.trackId
|
|
40950
|
+
});
|
|
40951
|
+
}
|
|
40952
|
+
events.sort((a, b) => a.t - b.t || a.trackId.localeCompare(b.trackId));
|
|
40953
|
+
let lastKey = "";
|
|
40954
|
+
for (const e of events) {
|
|
40955
|
+
const key = `${e.trackId}@${e.t}`;
|
|
40956
|
+
if (key === lastKey) continue;
|
|
40957
|
+
lastKey = key;
|
|
40958
|
+
const track = byId.get(e.trackId);
|
|
40959
|
+
if (!track) continue;
|
|
40960
|
+
observer.observe(sliceTrackAt(track, e.t), e.t);
|
|
40727
40961
|
}
|
|
40728
|
-
return ready;
|
|
40729
40962
|
}
|
|
40730
|
-
function
|
|
40731
|
-
|
|
40963
|
+
function replayLive(tracks, assigner = new GroupLiveAssigner()) {
|
|
40964
|
+
replayInto(tracks, assigner);
|
|
40965
|
+
return assigner;
|
|
40732
40966
|
}
|
|
40733
40967
|
//#endregion
|
|
40734
40968
|
//#region src/pipeline-analytics/group/group-sealer.ts
|
|
@@ -40736,9 +40970,9 @@ var DEFAULT_FRAME = {
|
|
|
40736
40970
|
width: 640,
|
|
40737
40971
|
height: 360
|
|
40738
40972
|
};
|
|
40739
|
-
var SEAL_LOOKBACK_MS = 10 * 6e4;
|
|
40740
|
-
var BACKFILL_LOOKBACK_MS = 2880 * 6e4;
|
|
40741
40973
|
var QUERY_LIMIT = 2e3;
|
|
40974
|
+
/** Crash recover: replay this camera only, on its next activity. */
|
|
40975
|
+
var GROUP_CRASH_LOOKBACK_MS = 2 * 6e4;
|
|
40742
40976
|
function toInstant(t) {
|
|
40743
40977
|
return {
|
|
40744
40978
|
trackId: t.trackId,
|
|
@@ -40752,93 +40986,130 @@ function toInstant(t) {
|
|
|
40752
40986
|
confidence: t.importance
|
|
40753
40987
|
};
|
|
40754
40988
|
}
|
|
40989
|
+
function toSealer(track) {
|
|
40990
|
+
return {
|
|
40991
|
+
trackId: track.trackId,
|
|
40992
|
+
deviceId: track.deviceId,
|
|
40993
|
+
className: track.className,
|
|
40994
|
+
firstSeen: track.firstSeen,
|
|
40995
|
+
lastSeen: track.lastSeen,
|
|
40996
|
+
positions: track.positions,
|
|
40997
|
+
...track.closedReason !== void 0 ? { closedReason: track.closedReason } : {}
|
|
40998
|
+
};
|
|
40999
|
+
}
|
|
40755
41000
|
var GroupSealer = class {
|
|
40756
41001
|
deps;
|
|
40757
41002
|
constructor(deps) {
|
|
40758
41003
|
this.deps = deps;
|
|
40759
41004
|
}
|
|
40760
|
-
async
|
|
40761
|
-
|
|
40762
|
-
|
|
41005
|
+
async flushIdle(buffer, now = this.deps.now()) {
|
|
41006
|
+
let written = 0;
|
|
41007
|
+
for (const group of buffer.idleGroups(now, GROUP_SESSION_IDLE_MS)) if (await this.persistLiveGroup(group, buffer)) {
|
|
41008
|
+
buffer.drop([group.id]);
|
|
41009
|
+
written += 1;
|
|
41010
|
+
}
|
|
41011
|
+
return written;
|
|
40763
41012
|
}
|
|
40764
|
-
|
|
41013
|
+
/**
|
|
41014
|
+
* First activity on camera D after a crash: replay 2 minutes of that camera
|
|
41015
|
+
* into the live buffer, persist whoever is already idle, keep the rest in RAM.
|
|
41016
|
+
*/
|
|
41017
|
+
async recoverCamera(deviceId, buffer) {
|
|
41018
|
+
if (buffer.isPrimed(deviceId)) return 0;
|
|
40765
41019
|
const now = this.deps.now();
|
|
40766
|
-
return this.sealSince(now - BACKFILL_LOOKBACK_MS, now);
|
|
40767
|
-
}
|
|
40768
|
-
async sealSince(since, now) {
|
|
40769
|
-
let written = 0;
|
|
40770
|
-
let devices;
|
|
40771
41020
|
try {
|
|
40772
|
-
|
|
41021
|
+
replayInto(await this.deps.trackStore.queryHistorical({
|
|
41022
|
+
deviceId,
|
|
41023
|
+
since: now - GROUP_CRASH_LOOKBACK_MS,
|
|
41024
|
+
until: now,
|
|
41025
|
+
limit: QUERY_LIMIT,
|
|
41026
|
+
projection: "full"
|
|
41027
|
+
}), buffer);
|
|
40773
41028
|
} catch (err) {
|
|
40774
|
-
this.deps.logger.warn("group sealer:
|
|
40775
|
-
|
|
41029
|
+
this.deps.logger.warn("group sealer: lookback failed", {
|
|
41030
|
+
tags: { deviceId },
|
|
41031
|
+
meta: { error: String(err) }
|
|
41032
|
+
});
|
|
41033
|
+
} finally {
|
|
41034
|
+
buffer.markPrimed(deviceId);
|
|
40776
41035
|
}
|
|
40777
|
-
|
|
40778
|
-
|
|
41036
|
+
return this.flushIdle(buffer, now);
|
|
41037
|
+
}
|
|
41038
|
+
/**
|
|
41039
|
+
* Seal ONE camera over a caller-owned window via live replay.
|
|
41040
|
+
*
|
|
41041
|
+
* This is how a digest recovers groups without a boot scan: the producer
|
|
41042
|
+
* already knows the window. Walking every camera for 48h at initialize()
|
|
41043
|
+
* is what wedged hub-main.
|
|
41044
|
+
*/
|
|
41045
|
+
async sealWindow(deviceId, since, until) {
|
|
41046
|
+
try {
|
|
41047
|
+
const closed = await this.deps.trackStore.queryHistorical({
|
|
41048
|
+
deviceId,
|
|
41049
|
+
since,
|
|
41050
|
+
until,
|
|
41051
|
+
limit: QUERY_LIMIT,
|
|
41052
|
+
projection: "full"
|
|
41053
|
+
});
|
|
41054
|
+
if (closed.length === 0) return 0;
|
|
41055
|
+
const already = new Set((await this.deps.groupStore.membersOfTracks(closed.map((t) => t.trackId))).map((m) => m.trackId));
|
|
41056
|
+
const pending = closed.filter((t) => !already.has(t.trackId));
|
|
41057
|
+
if (pending.length === 0) return 0;
|
|
41058
|
+
const assigner = replayLive(pending);
|
|
41059
|
+
const now = this.deps.now();
|
|
41060
|
+
const byId = new Map(pending.map((t) => [t.trackId, t]));
|
|
41061
|
+
let written = 0;
|
|
41062
|
+
for (const group of assigner.idleGroups(now, GROUP_SESSION_IDLE_MS)) {
|
|
41063
|
+
const members = group.memberTrackIds.map((id) => byId.get(id)).filter((t) => t !== void 0);
|
|
41064
|
+
if (members.length === 0) continue;
|
|
41065
|
+
try {
|
|
41066
|
+
await this.persistPartition(members, group.openedAt);
|
|
41067
|
+
written += 1;
|
|
41068
|
+
} catch (err) {
|
|
41069
|
+
this.deps.logger.warn("group sealer: persist failed", {
|
|
41070
|
+
tags: { deviceId },
|
|
41071
|
+
meta: {
|
|
41072
|
+
members: members.map((m) => m.trackId),
|
|
41073
|
+
error: String(err)
|
|
41074
|
+
}
|
|
41075
|
+
});
|
|
41076
|
+
}
|
|
41077
|
+
}
|
|
41078
|
+
return written;
|
|
40779
41079
|
} catch (err) {
|
|
40780
41080
|
this.deps.logger.warn("group sealer: device failed", {
|
|
40781
41081
|
tags: { deviceId },
|
|
40782
41082
|
meta: { error: String(err) }
|
|
40783
41083
|
});
|
|
41084
|
+
return 0;
|
|
40784
41085
|
}
|
|
40785
|
-
return written;
|
|
40786
41086
|
}
|
|
40787
|
-
async
|
|
40788
|
-
const
|
|
40789
|
-
|
|
40790
|
-
|
|
40791
|
-
|
|
40792
|
-
|
|
40793
|
-
|
|
40794
|
-
|
|
40795
|
-
|
|
40796
|
-
|
|
40797
|
-
|
|
40798
|
-
|
|
40799
|
-
const active = this.deps.trackStore.getActive(deviceId).map((t) => ({
|
|
40800
|
-
deviceId,
|
|
40801
|
-
firstSeen: t.firstSeen,
|
|
40802
|
-
lastSeen: t.lastSeen
|
|
40803
|
-
}));
|
|
40804
|
-
const partitions = partitionsForSealedSessions(pending.map(toInstant), active, now, GROUP_SESSION_IDLE_MS);
|
|
40805
|
-
if (partitions.length === 0) {
|
|
40806
|
-
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", {
|
|
41087
|
+
async persistLiveGroup(group, buffer) {
|
|
41088
|
+
const deviceId = group.deviceId;
|
|
41089
|
+
const snaps = group.memberTrackIds.map((id) => buffer.snapshot(id)).filter((t) => t !== void 0);
|
|
41090
|
+
if (snaps.length === 0) return true;
|
|
41091
|
+
const already = new Set((await this.deps.groupStore.membersOfTracks(snaps.map((t) => t.trackId))).map((m) => m.trackId));
|
|
41092
|
+
const pending = snaps.filter((t) => !already.has(t.trackId)).map(toSealer);
|
|
41093
|
+
if (pending.length === 0) return true;
|
|
41094
|
+
try {
|
|
41095
|
+
await this.persistPartition(pending, group.openedAt);
|
|
41096
|
+
return true;
|
|
41097
|
+
} catch (err) {
|
|
41098
|
+
this.deps.logger.warn("group sealer: persist failed", {
|
|
40807
41099
|
tags: { deviceId },
|
|
40808
41100
|
meta: {
|
|
40809
|
-
|
|
40810
|
-
|
|
41101
|
+
members: pending.map((m) => m.trackId),
|
|
41102
|
+
error: String(err)
|
|
40811
41103
|
}
|
|
40812
41104
|
});
|
|
40813
|
-
return
|
|
40814
|
-
}
|
|
40815
|
-
const byId = new Map(pending.map((t) => [t.trackId, t]));
|
|
40816
|
-
let written = 0;
|
|
40817
|
-
for (const part of partitions) {
|
|
40818
|
-
const members = part.memberTrackIds.map((id) => byId.get(id)).filter((t) => t !== void 0);
|
|
40819
|
-
if (members.length === 0) continue;
|
|
40820
|
-
if (members.every((m) => already.has(m.trackId))) continue;
|
|
40821
|
-
try {
|
|
40822
|
-
await this.persistPartition(members);
|
|
40823
|
-
written += 1;
|
|
40824
|
-
} catch (err) {
|
|
40825
|
-
this.deps.logger.warn("group sealer: persist failed", {
|
|
40826
|
-
tags: { deviceId },
|
|
40827
|
-
meta: {
|
|
40828
|
-
members: members.map((m) => m.trackId),
|
|
40829
|
-
error: String(err)
|
|
40830
|
-
}
|
|
40831
|
-
});
|
|
40832
|
-
}
|
|
41105
|
+
return false;
|
|
40833
41106
|
}
|
|
40834
|
-
return written;
|
|
40835
41107
|
}
|
|
40836
|
-
async persistPartition(members) {
|
|
41108
|
+
async persistPartition(members, openedAt) {
|
|
40837
41109
|
const deviceId = members[0].deviceId;
|
|
40838
41110
|
const frame = this.deps.frameDims(deviceId) ?? DEFAULT_FRAME;
|
|
40839
41111
|
const instantMembers = members.map(toInstant);
|
|
40840
41112
|
const instant = chooseGroupInstant(instantMembers, frame);
|
|
40841
|
-
const openedAt = Math.min(...members.map((m) => m.firstSeen));
|
|
40842
41113
|
const closedAt = Math.max(...members.map((m) => m.lastSeen));
|
|
40843
41114
|
const timestamp = instant?.timestamp ?? closedAt;
|
|
40844
41115
|
const mediaInfos = await Promise.all(members.map(async (m) => ({
|
|
@@ -40874,8 +41145,17 @@ var GroupSealer = class {
|
|
|
40874
41145
|
nativeKey
|
|
40875
41146
|
});
|
|
40876
41147
|
if (members.length < 2 || instant === null) return;
|
|
40877
|
-
const source = await this.pickCleanPixels(mediaInfos, timestamp);
|
|
40878
|
-
if (source === null)
|
|
41148
|
+
const source = await this.pickCleanPixels(mediaInfos, timestamp, deviceId);
|
|
41149
|
+
if (source === null) {
|
|
41150
|
+
this.deps.logger.info("group sealer: no clean pixels for crop", {
|
|
41151
|
+
tags: { deviceId },
|
|
41152
|
+
meta: {
|
|
41153
|
+
groupId: draft.id,
|
|
41154
|
+
members: members.map((m) => m.trackId)
|
|
41155
|
+
}
|
|
41156
|
+
});
|
|
41157
|
+
return;
|
|
41158
|
+
}
|
|
40879
41159
|
try {
|
|
40880
41160
|
const raw = await (0, sharp.default)(source.jpeg).removeAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
40881
41161
|
if (raw.info.channels !== 3) return;
|
|
@@ -40905,21 +41185,16 @@ var GroupSealer = class {
|
|
|
40905
41185
|
});
|
|
40906
41186
|
}
|
|
40907
41187
|
}
|
|
40908
|
-
async pickCleanPixels(mediaInfos, instantMs) {
|
|
40909
|
-
const
|
|
40910
|
-
|
|
40911
|
-
|
|
40912
|
-
|
|
40913
|
-
|
|
40914
|
-
|
|
40915
|
-
|
|
40916
|
-
|
|
40917
|
-
|
|
40918
|
-
rank
|
|
40919
|
-
});
|
|
40920
|
-
}
|
|
40921
|
-
const chosen = candidates.toSorted((a, b) => a.rank - b.rank || a.dt - b.dt)[0];
|
|
40922
|
-
if (chosen === void 0) return null;
|
|
41188
|
+
async pickCleanPixels(mediaInfos, instantMs, deviceId) {
|
|
41189
|
+
const chosen = pickCleanPixelRow(mediaInfos.flatMap((info) => info.rows), instantMs);
|
|
41190
|
+
if (chosen === null) return null;
|
|
41191
|
+
if (chosen.usedFallback) this.deps.logger.info("group sealer: crop pixels not within 2s of instant, using nearest keyFrame", {
|
|
41192
|
+
tags: { deviceId },
|
|
41193
|
+
meta: {
|
|
41194
|
+
instantMs,
|
|
41195
|
+
mediaKey: chosen.key
|
|
41196
|
+
}
|
|
41197
|
+
});
|
|
40923
41198
|
const file = await this.deps.mediaStore.getByKey(chosen.key);
|
|
40924
41199
|
if (file === null) return null;
|
|
40925
41200
|
return { jpeg: Buffer.from(file.base64, "base64") };
|
|
@@ -40947,8 +41222,8 @@ var GroupSealer = class {
|
|
|
40947
41222
|
*/
|
|
40948
41223
|
/**
|
|
40949
41224
|
* @durable class=ledger owner=pipeline-analytics
|
|
40950
|
-
* write="one row per co-moving group at
|
|
40951
|
-
* Membership is
|
|
41225
|
+
* write="one row per co-moving group, sealed at idle — never a live row.
|
|
41226
|
+
* Membership is the RAM members at persist, not a session partition. mediaKey is
|
|
40952
41227
|
* the group's own crop; nativeKey is a POINTER to the anchor member's
|
|
40953
41228
|
* keyFrame, nulled if that member is deleted alone. anchorTrackId and
|
|
40954
41229
|
* nativeKey are the only fields rewritten after birth."
|
|
@@ -49350,10 +49625,21 @@ var TrackStore = class {
|
|
|
49350
49625
|
* in-memory dedup (the query cap has no DISTINCT); best-effort.
|
|
49351
49626
|
*/
|
|
49352
49627
|
async listDeviceIds() {
|
|
49628
|
+
return this.collectDeviceIds(0);
|
|
49629
|
+
}
|
|
49630
|
+
/**
|
|
49631
|
+
* Distinct deviceIds with a persisted track whose `lastSeen` is at least
|
|
49632
|
+
* `since`. The group sealer's 10-minute window. Walking from epoch 0 is how
|
|
49633
|
+
* `sealRecent` wedged hub-main every TTL sweep.
|
|
49634
|
+
*/
|
|
49635
|
+
async listDeviceIdsSince(since) {
|
|
49636
|
+
return this.collectDeviceIds(Math.max(0, Math.floor(since)));
|
|
49637
|
+
}
|
|
49638
|
+
async collectDeviceIds(fromLastSeen) {
|
|
49353
49639
|
const seenDevices = /* @__PURE__ */ new Set();
|
|
49354
49640
|
const seenIds = /* @__PURE__ */ new Set();
|
|
49355
49641
|
const PAGE = 500;
|
|
49356
|
-
let cursor =
|
|
49642
|
+
let cursor = fromLastSeen;
|
|
49357
49643
|
try {
|
|
49358
49644
|
for (;;) {
|
|
49359
49645
|
const rows = await this.store.query.query({
|
|
@@ -56740,6 +57026,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
56740
57026
|
plateStore = null;
|
|
56741
57027
|
groupStore = null;
|
|
56742
57028
|
groupSealer = null;
|
|
57029
|
+
groupLive = null;
|
|
57030
|
+
/** Serialise first-activity lookback per camera, then observe in order. */
|
|
57031
|
+
groupObserveTail = /* @__PURE__ */ new Map();
|
|
56743
57032
|
plateRecognizer = null;
|
|
56744
57033
|
objectEmbeddingStore = null;
|
|
56745
57034
|
/** Live state of the detached embedding rebuild — see `rebuildObjectEmbeddings`. */
|
|
@@ -58064,6 +58353,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
58064
58353
|
media: mediaStore
|
|
58065
58354
|
});
|
|
58066
58355
|
this.groupStore = groupStore;
|
|
58356
|
+
this.groupLive = new GroupLiveBuffer();
|
|
58067
58357
|
this.groupSealer = new GroupSealer({
|
|
58068
58358
|
trackStore,
|
|
58069
58359
|
groupStore,
|
|
@@ -58078,9 +58368,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
58078
58368
|
},
|
|
58079
58369
|
now: () => Date.now()
|
|
58080
58370
|
});
|
|
58081
|
-
this.groupSealer.backfill().catch((err) => {
|
|
58082
|
-
logger.warn("group sealer backfill failed", { meta: { error: require_dist.errMsg(err) } });
|
|
58083
|
-
});
|
|
58084
58371
|
return {
|
|
58085
58372
|
trackStore,
|
|
58086
58373
|
mediaStore,
|
|
@@ -58523,6 +58810,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
58523
58810
|
buildSummaryPorts(stores) {
|
|
58524
58811
|
return {
|
|
58525
58812
|
listTracks: async ({ deviceId, sinceMs, untilMs, limit }) => {
|
|
58813
|
+
await this.groupSealer?.sealWindow(deviceId, sinceMs, untilMs);
|
|
58526
58814
|
const groups = await stores.groupStore.listOverlapping({
|
|
58527
58815
|
deviceId,
|
|
58528
58816
|
sinceMs,
|
|
@@ -58566,21 +58854,26 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
58566
58854
|
},
|
|
58567
58855
|
listMediaInfo: async (id) => {
|
|
58568
58856
|
const group = await stores.groupStore.get(id);
|
|
58569
|
-
if (group
|
|
58570
|
-
|
|
58571
|
-
|
|
58572
|
-
|
|
58573
|
-
|
|
58574
|
-
|
|
58575
|
-
|
|
58576
|
-
|
|
58577
|
-
|
|
58857
|
+
if (group) {
|
|
58858
|
+
if (group.mediaKey || group.nativeKey) return digestMediaRows({
|
|
58859
|
+
mediaKey: group.mediaKey,
|
|
58860
|
+
nativeKey: group.nativeKey,
|
|
58861
|
+
timestamp: group.timestamp,
|
|
58862
|
+
memberRows: []
|
|
58863
|
+
});
|
|
58864
|
+
const members = await stores.groupStore.listMembers(group.id);
|
|
58865
|
+
const memberRows = (await Promise.all(members.map((m) => stores.mediaStore.listInfoByOwner("track", m.trackId)))).flatMap((rows) => rows.map((r) => ({
|
|
58578
58866
|
key: r.key,
|
|
58579
58867
|
kind: r.kind,
|
|
58580
58868
|
timestamp: r.timestamp
|
|
58581
|
-
}));
|
|
58869
|
+
})));
|
|
58870
|
+
return digestMediaRows({
|
|
58871
|
+
mediaKey: group.mediaKey,
|
|
58872
|
+
nativeKey: group.nativeKey,
|
|
58873
|
+
timestamp: group.timestamp,
|
|
58874
|
+
memberRows
|
|
58875
|
+
});
|
|
58582
58876
|
}
|
|
58583
|
-
if (group) return [];
|
|
58584
58877
|
return (await stores.mediaStore.listInfoByOwner("track", id)).map((r) => ({
|
|
58585
58878
|
key: r.key,
|
|
58586
58879
|
kind: r.kind,
|
|
@@ -59826,6 +60119,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
59826
60119
|
state: t.state
|
|
59827
60120
|
});
|
|
59828
60121
|
positionsCountById.set(t.trackId, upserted.positions.length);
|
|
60122
|
+
this.enqueueLiveGroup(upserted, result.timestamp);
|
|
59829
60123
|
}
|
|
59830
60124
|
if (result.rawTrackedDetections.length > 0) {
|
|
59831
60125
|
const { minHits } = await this.resolveDeviceDetectionSensitivitySettings(deviceId);
|
|
@@ -61749,7 +62043,28 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
61749
62043
|
async sweepExpiredTracks() {
|
|
61750
62044
|
if (this.shuttingDown || !this.trackStore) return;
|
|
61751
62045
|
await this.trackCloser.sweep();
|
|
61752
|
-
this.groupSealer?.
|
|
62046
|
+
if (this.isPostProcessingNode && this.groupLive) this.groupSealer?.flushIdle(this.groupLive);
|
|
62047
|
+
}
|
|
62048
|
+
/** Hub-only: assign this sighting in RAM. First activity on a camera
|
|
62049
|
+
* replays 2 minutes of that camera (crash recover), then observe is sync. */
|
|
62050
|
+
enqueueLiveGroup(track, now) {
|
|
62051
|
+
if (!this.isPostProcessingNode || !this.groupLive) return;
|
|
62052
|
+
const deviceId = track.deviceId;
|
|
62053
|
+
const snapshot = toGroupableTrack(track);
|
|
62054
|
+
if (this.groupLive.isPrimed(deviceId)) {
|
|
62055
|
+
this.groupLive.observe(snapshot, now);
|
|
62056
|
+
return;
|
|
62057
|
+
}
|
|
62058
|
+
const next = (this.groupObserveTail.get(deviceId) ?? Promise.resolve()).then(async () => {
|
|
62059
|
+
if (this.groupLive && this.groupSealer && !this.groupLive.isPrimed(deviceId)) await this.groupSealer.recoverCamera(deviceId, this.groupLive);
|
|
62060
|
+
this.groupLive?.observe(snapshot, now);
|
|
62061
|
+
}).catch((err) => {
|
|
62062
|
+
this.ctx.logger.warn("group live observe failed", {
|
|
62063
|
+
tags: { deviceId },
|
|
62064
|
+
meta: { error: String(err) }
|
|
62065
|
+
});
|
|
62066
|
+
});
|
|
62067
|
+
this.groupObserveTail.set(deviceId, next);
|
|
61753
62068
|
}
|
|
61754
62069
|
/** Unified retention (Phase 1): earliest retained footage per device — the
|
|
61755
62070
|
* follow-recordings cutoff source. Cached 10 min: availability answers in
|
|
@@ -62394,6 +62709,16 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
62394
62709
|
};
|
|
62395
62710
|
if (plausible) this.stationaryRegistry?.promote(entry);
|
|
62396
62711
|
processor.dropTrack(track.trackId);
|
|
62712
|
+
const peek = this.trackStore?.peekActive(track.trackId);
|
|
62713
|
+
if (peek) this.enqueueLiveGroup(toGroupableTrack({
|
|
62714
|
+
trackId: track.trackId,
|
|
62715
|
+
deviceId,
|
|
62716
|
+
className: track.className,
|
|
62717
|
+
firstSeen: peek.firstSeen,
|
|
62718
|
+
lastSeen: timestamp,
|
|
62719
|
+
positions: peek.positions,
|
|
62720
|
+
closedReason: "stationary-promotion"
|
|
62721
|
+
}), timestamp);
|
|
62397
62722
|
this.trackStore?.closeActive(track.trackId, "stationary-promotion").catch((err) => {
|
|
62398
62723
|
this.ctx.logger.warn("stationary promotion: closing the source track failed — its media, events and vector are now unowned", {
|
|
62399
62724
|
tags: { deviceId },
|