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