@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.
@@ -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` heads the ladder: it is the group's own clean wide-central-square
15145
- * over the union bbox, and a drawn rectangle is a permanent lie in a digest
15146
- * tile. Singletons have no groupCrop and fall through to the member ladder.
15147
- * `fullFrameBoxed` and `firstFrame` stay absent for the same reason.
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
- "keyFrameSmall",
15152
- "keyFrame",
15153
- "snapshot",
15154
- "fullFrame",
15161
+ "nativeFrame",
15162
+ "thumbnail",
15155
15163
  "thumbnailSmall",
15156
- "thumbnail"
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(rule, selection.tiles);
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: selection.tiles.length - loaded.length,
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
- async loadTiles(rule, tiles) {
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
- for (const candidate of tiles) {
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
- //#endregion
40377
- //#region src/pipeline-analytics/group/group-crop.ts
40378
- /** Always containSubject: true. Never the 0.5 delivered threshold. */
40379
- function groupWideCentralSquareLayout(unionBbox, frame) {
40380
- return wideCentralSquareLayout(unionBbox, frame, { containSubject: true });
40381
- }
40382
- async function cropGroupFromRgb(rgb, frameWidth, frameHeight, unionBbox) {
40383
- const layout = groupWideCentralSquareLayout(unionBbox, {
40384
- W: frameWidth,
40385
- H: frameHeight
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
- const { crop: slab } = await extractCrop(rgb, frameWidth, frameHeight, layout.fetch);
40388
- return composeWideCentralSquareThumbnail(slab, layout);
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 netDisplacement(track) {
40463
- const first = track.positions[0];
40464
- const last = track.positions[track.positions.length - 1];
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 vecLen(v) {
40477
- return Math.hypot(v.x, v.y);
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(netDisplacement(track)) / mean;
40497
+ return vecLen(windowDisplacement(positions)) / mean;
40489
40498
  }
40490
- /** A ~ B iff cohesion ≥ 0.5 AND NOT (both move AND cos < 0.3). */
40491
- function pairTogether(a, b) {
40492
- const overlapStart = Math.max(a.firstSeen, b.firstSeen);
40493
- const overlapEnd = Math.min(a.lastSeen, b.lastSeen);
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 aMoves = relativeMotion(a) >= MOVE_EPS;
40511
- const bMoves = relativeMotion(b) >= MOVE_EPS;
40512
- if (aMoves && bMoves && cosine(netDisplacement(a), netDisplacement(b)) < .3) return false;
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
- function clusterGroups(tracks) {
40516
- const eligible = tracks.filter(isEligibleMember);
40517
- const out = [];
40518
- for (const session of sessionsOf(eligible)) {
40519
- const pairable = [];
40520
- for (const t of session) if (t.positions.length < 2) out.push({
40521
- deviceId: t.deviceId,
40522
- memberTrackIds: [t.trackId]
40523
- });
40524
- else pairable.push(t);
40525
- if (pairable.length === 0) continue;
40526
- const parent = pairable.map((_, i) => i);
40527
- const find = (i) => {
40528
- let cur = i;
40529
- while (parent[cur] !== cur) cur = parent[cur];
40530
- let walk = i;
40531
- while (parent[walk] !== cur) {
40532
- const next = parent[walk];
40533
- parent[walk] = cur;
40534
- walk = next;
40535
- }
40536
- return cur;
40537
- };
40538
- const union = (i, j) => {
40539
- const ri = find(i);
40540
- const rj = find(j);
40541
- if (ri !== rj) parent[ri] = rj;
40542
- };
40543
- for (let i = 0; i < pairable.length; i++) for (let j = i + 1; j < pairable.length; j++) if (pairTogether(pairable[i], pairable[j])) union(i, j);
40544
- const buckets = /* @__PURE__ */ new Map();
40545
- for (let i = 0; i < pairable.length; i++) {
40546
- const root = find(i);
40547
- const bucket = buckets.get(root);
40548
- const id = pairable[i].trackId;
40549
- if (bucket) bucket.push(id);
40550
- else buckets.set(root, [id]);
40551
- }
40552
- const deviceId = pairable[0].deviceId;
40553
- for (const memberTrackIds of buckets.values()) out.push({
40554
- deviceId,
40555
- memberTrackIds
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
- return out;
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
- * Session close → sealed groups. A group is born here, never live.
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
- * Merge and split cannot occur: connected components of a closed session is a
40693
- * partition. Say it out loud so a later live-group attempt does not reach
40694
- * for them here.
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
- /** Same idle as the notification burst (`NC_GROUP_DEFAULT_IDLE_SEC`). */
40697
- var GROUP_SESSION_IDLE_MS = 3e4;
40698
- function overlaps(a, b) {
40699
- return a.firstSeen <= b.lastSeen + 3e3 && b.firstSeen <= a.lastSeen + 3e3;
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
- function sessionSpan(tracks) {
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
- firstSeen: Math.min(...tracks.map((t) => t.firstSeen)),
40704
- lastSeen: Math.max(...tracks.map((t) => t.lastSeen))
40930
+ ...track,
40931
+ lastSeen: last?.timestamp ?? track.firstSeen,
40932
+ positions
40705
40933
  };
40706
40934
  }
40707
- /**
40708
- * Sessions whose members are all in `closed` and whose newest lastSeen is
40709
- * older than `idleMs`. An overlapping ACTIVE track keeps the session open.
40710
- */
40711
- function sessionsReadyToSeal(closed, active, now, idleMs = GROUP_SESSION_IDLE_MS) {
40712
- const byDevice = /* @__PURE__ */ new Map();
40713
- for (const t of closed) {
40714
- const list = byDevice.get(t.deviceId);
40715
- if (list) list.push(t);
40716
- else byDevice.set(t.deviceId, [t]);
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 partitionsForSealedSessions(closed, active, now, idleMs = GROUP_SESSION_IDLE_MS) {
40731
- return clusterGroups(sessionsReadyToSeal(closed, active, now, idleMs).flat());
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 sealRecent() {
40761
- const now = this.deps.now();
40762
- return this.sealSince(now - SEAL_LOOKBACK_MS, now);
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
- async backfill() {
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
- devices = await this.deps.trackStore.listDeviceIds();
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: listDeviceIds failed", { meta: { error: String(err) } });
40775
- return 0;
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
- for (const deviceId of devices) try {
40778
- written += await this.sealDevice(deviceId, since, now);
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 sealDevice(deviceId, since, now) {
40788
- const closed = await this.deps.trackStore.queryHistorical({
40789
- deviceId,
40790
- since,
40791
- until: now,
40792
- limit: QUERY_LIMIT,
40793
- projection: "full"
40794
- });
40795
- if (closed.length === 0) return 0;
40796
- const already = new Set((await this.deps.groupStore.membersOfTracks(closed.map((t) => t.trackId))).map((m) => m.trackId));
40797
- const pending = closed.filter((t) => !already.has(t.trackId));
40798
- if (pending.length === 0) return 0;
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
- dropped: pending.length,
40810
- reason: "no-eligible-members"
41101
+ members: pending.map((m) => m.trackId),
41102
+ error: String(err)
40811
41103
  }
40812
41104
  });
40813
- return 0;
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) return;
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 candidates = [];
40910
- for (const info of mediaInfos) for (const row of info.rows) {
40911
- const rank = row.kind === "keyFrame" ? 0 : row.kind === "keyFrameSmall" ? 1 : -1;
40912
- if (rank < 0) continue;
40913
- const dt = Math.abs(row.timestamp - instantMs);
40914
- if (dt > 2e3) continue;
40915
- candidates.push({
40916
- key: row.key,
40917
- dt,
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 session close — sealed, never live.
40951
- * Membership is a partition of one camera's eligible tracks. mediaKey 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 = 0;
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?.mediaKey) return [{
58570
- key: group.mediaKey,
58571
- kind: "groupCrop",
58572
- timestamp: group.timestamp
58573
- }];
58574
- if (group && group.memberCount === 1) {
58575
- const memberId = (await stores.groupStore.listMembers(group.id))[0]?.trackId;
58576
- if (!memberId) return [];
58577
- return (await stores.mediaStore.listInfoByOwner("track", memberId)).map((r) => ({
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?.sealRecent();
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 },