@camstack/addon-post-analysis 1.2.159 → 1.2.160

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.
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-CLxPUxVN.js");
5
+ const require_dist = require("../dist-WrUmjaGQ.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_dist.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -3082,8 +3082,8 @@ async function raceTimeout(call, timeoutMs) {
3082
3082
  * detail the encoder can see.
3083
3083
  */
3084
3084
  async function downscaleJpeg(bytes, maxPx) {
3085
- const { default: sharp$16 } = await import("sharp");
3086
- const out = await sharp$16(Buffer.from(bytes)).resize({
3085
+ const { default: sharp$15 } = await import("sharp");
3086
+ const out = await sharp$15(Buffer.from(bytes)).resize({
3087
3087
  width: maxPx,
3088
3088
  height: maxPx,
3089
3089
  fit: "inside",
@@ -9982,8 +9982,8 @@ var NcDispatcher = class {
9982
9982
  try {
9983
9983
  const points = (await this.deps.getZonePolygons?.(deviceId, zoneIds) ?? []).flat();
9984
9984
  if (points.length === 0) return null;
9985
- const { default: sharp$15 } = await import("sharp");
9986
- const img = sharp$15(Buffer.from(jpeg));
9985
+ const { default: sharp$14 } = await import("sharp");
9986
+ const img = sharp$14(Buffer.from(jpeg));
9987
9987
  const meta = await img.metadata();
9988
9988
  const W = meta.width ?? 0;
9989
9989
  const H = meta.height ?? 0;
@@ -15697,10 +15697,9 @@ function remainingSummaryCandidates(kept, taken, rank) {
15697
15697
  /**
15698
15698
  * The media a tile is drawn from, best first.
15699
15699
  *
15700
- * A digest tile is a SUBJECT, so the crops come first: the group's first-level
15701
- * crop (`groupCrop`), the group's native-frame pointer, then the TRACK's own
15702
- * subject crop (`thumbnail`, then its `thumbnailSmall` downscale), and only
15703
- * then the clean whole scene (`keyFrame` → `keyFrameSmall` → `fullFrame`).
15700
+ * A digest tile is a SUBJECT, so the crops come first: the TRACK's own subject
15701
+ * crop (`thumbnail`, then its `thumbnailSmall` downscale), and only then the
15702
+ * clean whole scene (`keyFrame` → `keyFrameSmall` → `fullFrame`).
15704
15703
  *
15705
15704
  * ⚠ The whole-scene rungs are a FALLBACK and stay on the ladder. Measured on
15706
15705
  * 60 real tracks (615/617, 24 h): 52 carry a crop, 43 carry a scene rung, and
@@ -15708,19 +15707,19 @@ function remainingSummaryCandidates(kept, taken, rank) {
15708
15707
  * two tiles for nothing. A track with neither is dropped, never drawn as an
15709
15708
  * empty cell (see `NcSummaryProducer.loadTiles`).
15710
15709
  *
15711
- * ## Why `thumbnail` is back (2026-08-27)
15710
+ * ## Why `thumbnail` is on the ladder (2026-08-27)
15712
15711
  *
15713
15712
  * It was removed on 2026-08-24 (160200681) because a successful plate read
15714
15713
  * overwrites the vehicle first-level crop under the same `thumbnail` kind.
15715
- * **That collision belongs to the GROUP entity**: the sealer is what writes
15716
- * `ownerKind: 'group', kind: 'thumbnail'` (`group/group-sealer.ts`), and the
15717
- * group process is OFF (`GROUPS_PROCESS_ENABLED`, `group/group-enabled.ts`).
15718
- * On a TRACK, plates live under `ownerKind: 'plate'` / `kind: 'plateCrop'` and
15719
- * never touch `thumbnail`. The same commit turned the group process off, so
15720
- * `groupCrop` and `nativeFrame` became unreachable and every digest tile fell
15721
- * through to `keyFrame` — a whole frame — for three days.
15722
- * `scripts/check-summary-ladder-reachable.ts` is the guard that refuses that
15723
- * shape now.
15714
+ * **That collision belonged to the GROUP entity** — its sealer was what wrote
15715
+ * `ownerKind: 'group', kind: 'thumbnail'`. On a TRACK, plates live under
15716
+ * `ownerKind: 'plate'` / `kind: 'plateCrop'` and never touch `thumbnail`. The
15717
+ * same commit turned the group process off, so the two group-only head rungs
15718
+ * became unreachable and every digest tile fell through to `keyFrame` — a
15719
+ * whole frame — for three days. The group entity was deleted outright on
15720
+ * 2026-09-01 and its rungs went with it;
15721
+ * `scripts/check-summary-ladder-reachable.ts` is the guard that keeps the
15722
+ * best reachable rung a subject crop.
15724
15723
  *
15725
15724
  * Boxed frames stay absent: a drawn rectangle is a permanent lie in a digest
15726
15725
  * tile. Plate and face crops stay absent: a digest tile of a targa is the
@@ -15728,8 +15727,6 @@ function remainingSummaryCandidates(kept, taken, rank) {
15728
15727
  * `frameDedupeKey`, not by hiding the whole-scene fallback.
15729
15728
  */
15730
15729
  var SUMMARY_MEDIA_LADDER = [
15731
- "groupCrop",
15732
- "nativeFrame",
15733
15730
  "thumbnail",
15734
15731
  "thumbnailSmall",
15735
15732
  "keyFrame",
@@ -15764,7 +15761,8 @@ var SUMMARY_SIZE_GATED_RUNGS = new Set(["thumbnail"]);
15764
15761
  * Is this row a picture, or a template wearing a picture's kind?
15765
15762
  *
15766
15763
  * Only a size the index REPORTED can demote a row. A `null` size is unknown,
15767
- * and demoting on unknown would silently delete every group-owned pointer.
15764
+ * and demoting on unknown would silently delete every row an index could not
15765
+ * measure.
15768
15766
  */
15769
15767
  function rowClearsSizeFloor(row, rung) {
15770
15768
  if (!SUMMARY_SIZE_GATED_RUNGS.has(rung)) return true;
@@ -17395,8 +17393,8 @@ async function deriveSyntheticMedia(jpeg, bbox, timestamp) {
17395
17393
  ];
17396
17394
  if (bbox === void 0) return files;
17397
17395
  try {
17398
- const { default: sharp$14 } = await import("sharp");
17399
- const meta = await sharp$14(Buffer.from(jpeg)).metadata();
17396
+ const { default: sharp$13 } = await import("sharp");
17397
+ const meta = await sharp$13(Buffer.from(jpeg)).metadata();
17400
17398
  const W = meta.width ?? 0;
17401
17399
  const H = meta.height ?? 0;
17402
17400
  if (W === 0 || H === 0) return files;
@@ -17404,7 +17402,7 @@ async function deriveSyntheticMedia(jpeg, bbox, timestamp) {
17404
17402
  const top = Math.max(0, Math.min(H - 1, Math.floor(bbox.y * H)));
17405
17403
  const width = Math.max(1, Math.min(W - left, Math.ceil(bbox.w * W)));
17406
17404
  const height = Math.max(1, Math.min(H - top, Math.ceil(bbox.h * H)));
17407
- const cropBuf = await sharp$14(Buffer.from(jpeg)).extract({
17405
+ const cropBuf = await sharp$13(Buffer.from(jpeg)).extract({
17408
17406
  left,
17409
17407
  top,
17410
17408
  width,
@@ -17426,7 +17424,7 @@ async function deriveSyntheticMedia(jpeg, bbox, timestamp) {
17426
17424
  });
17427
17425
  const stroke = Math.max(2, Math.round(Math.min(W, H) / 240));
17428
17426
  const overlay = Buffer.from(`<svg width="${W}" height="${H}"><rect x="${left}" y="${top}" width="${width}" height="${height}" fill="none" stroke="#22d3ee" stroke-width="${stroke}"/></svg>`);
17429
- const boxedBuf = await sharp$14(Buffer.from(jpeg)).composite([{
17427
+ const boxedBuf = await sharp$13(Buffer.from(jpeg)).composite([{
17430
17428
  input: overlay,
17431
17429
  top: 0,
17432
17430
  left: 0
@@ -25958,10 +25956,29 @@ var OWNER_KINDS = [
25958
25956
  "identity",
25959
25957
  "plate",
25960
25958
  "vehicle",
25961
- "scene",
25962
- "group"
25959
+ "scene"
25963
25960
  ];
25964
25961
  /**
25962
+ * Owner kinds that NOTHING may write any more, but whose rows can still be on
25963
+ * disk from before the owning code was deleted.
25964
+ *
25965
+ * `'group'` is the co-moving group entity, removed on 2026-09-01 together with
25966
+ * `pipeline-analytics:track-groups` / `:track-group-members`
25967
+ * ([D183](../../../../../docs/decisions/adr-0183-code-that-is-deleted-takes-its-tables-with-it.md)).
25968
+ * The tables are dropped at boot from `RETIRED_COLLECTIONS`, so a group-owned
25969
+ * media row can never be claimed again: `resolveMediaOwner` maps it to
25970
+ * `{ kind: 'retired-owner' }` and the orphan audit reclaims it under the same
25971
+ * grace window and ceilings as any other orphan.
25972
+ *
25973
+ * Deliberately NOT a member of {@link OWNER_KINDS}: the type is what stops a
25974
+ * new writer, and this list is only what the READ side must still recognise.
25975
+ */
25976
+ var RETIRED_OWNER_KINDS = ["group"];
25977
+ /** Is this an owner kind whose owning code is gone? See {@link RETIRED_OWNER_KINDS}. */
25978
+ function isRetiredMediaOwnerKind(value) {
25979
+ return RETIRED_OWNER_KINDS.includes(value);
25980
+ }
25981
+ /**
25965
25982
  * Narrow an arbitrary string to an `OwnerKind`.
25966
25983
  *
25967
25984
  * Exists so a caller that builds owner scopes from data (the training-export
@@ -27091,10 +27108,6 @@ var MediaStore = class {
27091
27108
  async deleteForTracks(trackIds) {
27092
27109
  return this.deleteForOwner("track", trackIds);
27093
27110
  }
27094
- /** Delete the group's OWN crop. The native frame is a pointer and is not here. */
27095
- async deleteByGroups(groupIds) {
27096
- return this.deleteForOwnerCounted("group", groupIds);
27097
- }
27098
27111
  /**
27099
27112
  * `TrackScopedStore` contract for the retention cascade: delete all media
27100
27113
  * owned by the given tracks — the tracks' OWN media (`ownerKind:'track'`) plus
@@ -33963,7 +33976,6 @@ var EMPTY_CASCADE_TALLY = {
33963
33976
  faces: 0,
33964
33977
  plates: 0,
33965
33978
  embeddings: 0,
33966
- groups: 0,
33967
33979
  bytesReclaimed: 0
33968
33980
  };
33969
33981
  /** Fold a leaf store's receipts into a running tally. */
@@ -34007,13 +34019,6 @@ function addLeafCounts(tally, counts) {
34007
34019
  bytesReclaimed
34008
34020
  };
34009
34021
  break;
34010
- case "groups":
34011
- next = {
34012
- ...next,
34013
- groups: next.groups + c.rows,
34014
- bytesReclaimed
34015
- };
34016
- break;
34017
34022
  }
34018
34023
  }
34019
34024
  return next;
@@ -34027,7 +34032,6 @@ function mergeCascadeTallies(a, b) {
34027
34032
  faces: a.faces + b.faces,
34028
34033
  plates: a.plates + b.plates,
34029
34034
  embeddings: a.embeddings + b.embeddings,
34030
- groups: a.groups + b.groups,
34031
34035
  bytesReclaimed: a.bytesReclaimed + b.bytesReclaimed
34032
34036
  };
34033
34037
  }
@@ -34090,7 +34094,6 @@ function absentCascadeLegs(stores) {
34090
34094
  if (!stores.faceStore) absent.push("faces");
34091
34095
  if (!stores.plateStore) absent.push("plates");
34092
34096
  if (!stores.objectEmbeddingStore) absent.push("embeddings");
34093
- if (!stores.groupStore) absent.push("groups");
34094
34097
  return absent;
34095
34098
  }
34096
34099
  function buildAnalyticsCascadeRegistry(stores) {
@@ -34098,7 +34101,6 @@ function buildAnalyticsCascadeRegistry(stores) {
34098
34101
  if (stores.faceStore) registry.push(stores.faceStore);
34099
34102
  if (stores.plateStore) registry.push(stores.plateStore);
34100
34103
  if (stores.objectEmbeddingStore) registry.push(stores.objectEmbeddingStore);
34101
- if (stores.groupStore) registry.push(stores.groupStore);
34102
34104
  return registry;
34103
34105
  }
34104
34106
  /**
@@ -47843,1420 +47845,6 @@ function shouldAutoReembed(samples, targetModelId, knownPixelless, passRunning)
47843
47845
  if (passRunning || targetModelId === null || targetModelId === "") return false;
47844
47846
  return samples.some((s) => s.modelId !== targetModelId && s.mediaKey !== void 0 && !knownPixelless.has(s.sampleId));
47845
47847
  }
47846
- var ListGroupsInputSchema = require_dist.object({
47847
- deviceId: require_dist.number().int().optional(),
47848
- sinceMs: require_dist.number().int().optional(),
47849
- untilMs: require_dist.number().int().optional(),
47850
- limit: require_dist.number().int().min(1).max(100).optional(),
47851
- offset: require_dist.number().int().min(0).optional()
47852
- });
47853
- var groupActions = require_dist.defineCustomActions({
47854
- "analytics.listGroups": require_dist.customAction(ListGroupsInputSchema, require_dist.object({
47855
- groups: require_dist.array(require_dist.AnalyticsGroupRecordSchema).readonly(),
47856
- hasMore: require_dist.boolean()
47857
- })),
47858
- "analytics.getGroup": require_dist.customAction(require_dist.object({ groupId: require_dist.string().min(1) }), require_dist.object({ group: require_dist.AnalyticsGroupDetailSchema.nullable() }))
47859
- });
47860
- var MATCH_WINDOW_MS = 1e3;
47861
- var MOVE_EPS = .35;
47862
- /** Live-assigner window: same predicate, recent positions only. */
47863
- var LIVE_WINDOW_MS = 3e3;
47864
- /** Registry stationary is out; audio has no bbox and is never a member or a singleton. */
47865
- function isEligibleMember(track) {
47866
- if (track.closedReason === "stationary-promotion") return false;
47867
- if (track.className === "audio") return false;
47868
- return true;
47869
- }
47870
- function diag(bbox) {
47871
- return Math.hypot(bbox.w, bbox.h);
47872
- }
47873
- function nearestPosition(positions, t) {
47874
- let best;
47875
- let bestDt = 1001;
47876
- for (const p of positions) {
47877
- const dt = Math.abs(p.timestamp - t);
47878
- if (dt <= 1e3 && dt < bestDt) {
47879
- best = p;
47880
- bestDt = dt;
47881
- }
47882
- }
47883
- return best;
47884
- }
47885
- function meanDiag(positions) {
47886
- if (positions.length === 0) return 0;
47887
- let sum = 0;
47888
- for (const p of positions) sum += diag(p.bbox);
47889
- return sum / positions.length;
47890
- }
47891
- function vecLen(v) {
47892
- return Math.hypot(v.x, v.y);
47893
- }
47894
- function cosine(a, b) {
47895
- const la = vecLen(a);
47896
- const lb = vecLen(b);
47897
- if (la === 0 || lb === 0) return 1;
47898
- return (a.x * b.x + a.y * b.y) / (la * lb);
47899
- }
47900
- function positionsInWindow(track, start, end) {
47901
- return track.positions.filter((p) => p.timestamp >= start && p.timestamp <= end);
47902
- }
47903
- function windowDisplacement(positions) {
47904
- const first = positions[0];
47905
- const last = positions[positions.length - 1];
47906
- if (!first || !last) return {
47907
- x: 0,
47908
- y: 0
47909
- };
47910
- const a = bboxCentroid(first.bbox);
47911
- const b = bboxCentroid(last.bbox);
47912
- return {
47913
- x: b.x - a.x,
47914
- y: b.y - a.y
47915
- };
47916
- }
47917
- function windowRelativeMotion(positions) {
47918
- const mean = meanDiag(positions);
47919
- if (mean <= 0) return 0;
47920
- return vecLen(windowDisplacement(positions)) / mean;
47921
- }
47922
- /**
47923
- * Same cohesion + crossing veto as {@link pairTogether}, over `[now − 3 s, now]`.
47924
- * Displacement/cosine use the window, not the whole track.
47925
- */
47926
- function pairTogetherNow(a, b, now) {
47927
- const start = now - LIVE_WINDOW_MS;
47928
- const overlapStart = Math.max(a.firstSeen, b.firstSeen, start);
47929
- const overlapEnd = Math.min(a.lastSeen, b.lastSeen, now);
47930
- if (overlapEnd < overlapStart) return false;
47931
- let joint = 0;
47932
- let close = 0;
47933
- for (let t = overlapStart; t <= overlapEnd; t += 500) {
47934
- const pa = nearestPosition(a.positions, t);
47935
- const pb = nearestPosition(b.positions, t);
47936
- if (!pa || !pb) continue;
47937
- joint++;
47938
- const ca = bboxCentroid(pa.bbox);
47939
- const cb = bboxCentroid(pb.bbox);
47940
- const gap = Math.hypot(ca.x - cb.x, ca.y - cb.y);
47941
- const scale = (diag(pa.bbox) + diag(pb.bbox)) / 2;
47942
- if ((scale > 0 ? gap / scale : Number.POSITIVE_INFINITY) <= 1.5) close++;
47943
- }
47944
- if (joint === 0) return false;
47945
- if (close / joint < .5) return false;
47946
- const wa = positionsInWindow(a, start, now);
47947
- const wb = positionsInWindow(b, start, now);
47948
- const aMoves = windowRelativeMotion(wa) >= MOVE_EPS;
47949
- const bMoves = windowRelativeMotion(wb) >= MOVE_EPS;
47950
- if (aMoves && bMoves && cosine(windowDisplacement(wa), windowDisplacement(wb)) < .3) return false;
47951
- return true;
47952
- }
47953
- var GroupLiveAssigner = class {
47954
- nextId;
47955
- snapshots = /* @__PURE__ */ new Map();
47956
- groups = /* @__PURE__ */ new Map();
47957
- membership = /* @__PURE__ */ new Map();
47958
- /** `${trackId}>${groupId}` → first instant the join predicate held. */
47959
- joinSince = /* @__PURE__ */ new Map();
47960
- /** trackId → first instant they failed pair against every remaining member. */
47961
- leaveSince = /* @__PURE__ */ new Map();
47962
- constructor(nextId = () => (0, node_crypto.randomUUID)()) {
47963
- this.nextId = nextId;
47964
- }
47965
- observe(track, now) {
47966
- this.snapshots.set(track.trackId, track);
47967
- if (!isEligibleMember(track)) {
47968
- this.removeFromGroup(track.trackId);
47969
- return;
47970
- }
47971
- if (!this.membership.has(track.trackId)) this.birthSingleton(track, now);
47972
- this.considerLeave(track, now);
47973
- const groupId = this.membership.get(track.trackId);
47974
- const group = groupId !== void 0 ? this.groups.get(groupId) : void 0;
47975
- if (group) for (const id of [...group.members]) {
47976
- if (id === track.trackId) continue;
47977
- const snap = this.snapshots.get(id);
47978
- if (snap) this.considerLeave(snap, now);
47979
- }
47980
- this.considerJoin(track, now);
47981
- }
47982
- groupOf(trackId) {
47983
- return this.membership.get(trackId);
47984
- }
47985
- snapshot(trackId) {
47986
- return this.snapshots.get(trackId);
47987
- }
47988
- liveGroups() {
47989
- return [...this.groups.values()].map((g) => this.toLive(g));
47990
- }
47991
- idleGroups(now, idleMs) {
47992
- return this.liveGroups().filter((g) => now >= g.lastActiveAt + idleMs);
47993
- }
47994
- drop(ids) {
47995
- for (const id of ids) {
47996
- const g = this.groups.get(id);
47997
- if (!g) continue;
47998
- for (const member of g.members) this.membership.delete(member);
47999
- this.groups.delete(id);
48000
- }
48001
- }
48002
- birthSingleton(track, now) {
48003
- const id = this.nextId();
48004
- this.groups.set(id, {
48005
- id,
48006
- deviceId: track.deviceId,
48007
- openedAt: now,
48008
- members: new Set([track.trackId])
48009
- });
48010
- this.membership.set(track.trackId, id);
48011
- }
48012
- considerLeave(track, now) {
48013
- const groupId = this.membership.get(track.trackId);
48014
- if (groupId === void 0) return;
48015
- const group = this.groups.get(groupId);
48016
- if (!group || group.members.size < 2) {
48017
- this.leaveSince.delete(track.trackId);
48018
- return;
48019
- }
48020
- if ([...group.members].filter((id) => id !== track.trackId).some((id) => {
48021
- const other = this.snapshots.get(id);
48022
- return other !== void 0 && pairTogetherNow(track, other, now);
48023
- })) {
48024
- this.leaveSince.delete(track.trackId);
48025
- return;
48026
- }
48027
- const since = this.leaveSince.get(track.trackId) ?? now;
48028
- this.leaveSince.set(track.trackId, since);
48029
- if (now - since < 3e3) return;
48030
- this.peel(track, now);
48031
- }
48032
- peel(track, now) {
48033
- this.removeFromGroup(track.trackId);
48034
- this.leaveSince.delete(track.trackId);
48035
- this.birthSingleton(track, now);
48036
- }
48037
- considerJoin(track, now) {
48038
- const mineId = this.membership.get(track.trackId);
48039
- if (mineId === void 0) return;
48040
- const mine = this.groups.get(mineId);
48041
- if (!mine || mine.members.size !== 1) {
48042
- this.clearJoinClocks(track.trackId);
48043
- return;
48044
- }
48045
- let best;
48046
- for (const group of this.groups.values()) {
48047
- if (group.id === mineId || group.deviceId !== track.deviceId) continue;
48048
- let gap = Number.POSITIVE_INFINITY;
48049
- let together = false;
48050
- for (const memberId of group.members) {
48051
- const other = this.snapshots.get(memberId);
48052
- if (!other) continue;
48053
- if (pairTogetherNow(track, other, now)) {
48054
- together = true;
48055
- gap = Math.min(gap, centroidGap(track, other, now));
48056
- }
48057
- }
48058
- if (!together) continue;
48059
- if (best === void 0 || gap < best.gap) best = {
48060
- groupId: group.id,
48061
- gap
48062
- };
48063
- }
48064
- if (best === void 0) {
48065
- this.clearJoinClocks(track.trackId);
48066
- return;
48067
- }
48068
- const key = `${track.trackId}>${best.groupId}`;
48069
- for (const existing of [...this.joinSince.keys()]) if (existing.startsWith(`${track.trackId}>`) && existing !== key) this.joinSince.delete(existing);
48070
- const since = this.joinSince.get(key) ?? now;
48071
- this.joinSince.set(key, since);
48072
- if (now - since < 1500) return;
48073
- this.absorb(track.trackId, mineId, best.groupId);
48074
- }
48075
- absorb(trackId, fromId, intoId) {
48076
- const into = this.groups.get(intoId);
48077
- if (!into) return;
48078
- into.members.add(trackId);
48079
- this.membership.set(trackId, intoId);
48080
- this.groups.delete(fromId);
48081
- this.clearJoinClocks(trackId);
48082
- this.leaveSince.delete(trackId);
48083
- }
48084
- removeFromGroup(trackId) {
48085
- const groupId = this.membership.get(trackId);
48086
- this.membership.delete(trackId);
48087
- this.clearJoinClocks(trackId);
48088
- this.leaveSince.delete(trackId);
48089
- if (groupId === void 0) return;
48090
- const group = this.groups.get(groupId);
48091
- if (!group) return;
48092
- group.members.delete(trackId);
48093
- if (group.members.size === 0) this.groups.delete(groupId);
48094
- }
48095
- clearJoinClocks(trackId) {
48096
- for (const key of [...this.joinSince.keys()]) if (key.startsWith(`${trackId}>`)) this.joinSince.delete(key);
48097
- }
48098
- toLive(group) {
48099
- let lastActiveAt = group.openedAt;
48100
- for (const id of group.members) {
48101
- const snap = this.snapshots.get(id);
48102
- if (snap && snap.lastSeen > lastActiveAt) lastActiveAt = snap.lastSeen;
48103
- }
48104
- return {
48105
- id: group.id,
48106
- deviceId: group.deviceId,
48107
- memberTrackIds: [...group.members],
48108
- openedAt: group.openedAt,
48109
- lastActiveAt
48110
- };
48111
- }
48112
- };
48113
- function centroidGap(a, b, now) {
48114
- const pa = a.positions.reduce((best, p) => Math.abs(p.timestamp - now) < Math.abs(best.timestamp - now) ? p : best, a.positions[0]);
48115
- const pb = b.positions.reduce((best, p) => Math.abs(p.timestamp - now) < Math.abs(best.timestamp - now) ? p : best, b.positions[0]);
48116
- if (!pa || !pb) return Number.POSITIVE_INFINITY;
48117
- const ca = bboxCentroid(pa.bbox);
48118
- const cb = bboxCentroid(pb.bbox);
48119
- return Math.hypot(ca.x - cb.x, ca.y - cb.y);
48120
- }
48121
- //#endregion
48122
- //#region src/pipeline-analytics/group/group-session.ts
48123
- /** Same idle as the notification burst (`NC_GROUP_DEFAULT_IDLE_SEC`). */
48124
- var GROUP_SESSION_IDLE_MS = 3e4;
48125
- //#endregion
48126
- //#region src/pipeline-analytics/group/group-live-buffer.ts
48127
- function toGroupableTrack(track) {
48128
- return {
48129
- trackId: track.trackId,
48130
- deviceId: track.deviceId,
48131
- className: track.className,
48132
- firstSeen: track.firstSeen,
48133
- lastSeen: track.lastSeen,
48134
- positions: track.positions.map((p) => ({
48135
- ...p,
48136
- bbox: { ...p.bbox }
48137
- })),
48138
- ...track.closedReason !== void 0 ? { closedReason: track.closedReason } : {}
48139
- };
48140
- }
48141
- var GroupLiveBuffer = class {
48142
- assigner;
48143
- primed = /* @__PURE__ */ new Set();
48144
- constructor(nextId) {
48145
- this.assigner = new GroupLiveAssigner(nextId);
48146
- }
48147
- observe(track, now) {
48148
- this.assigner.observe(track, now);
48149
- }
48150
- groupOf(trackId) {
48151
- return this.assigner.groupOf(trackId);
48152
- }
48153
- snapshot(trackId) {
48154
- return this.assigner.snapshot(trackId);
48155
- }
48156
- liveGroups() {
48157
- return this.assigner.liveGroups();
48158
- }
48159
- idleGroups(now, idleMs = GROUP_SESSION_IDLE_MS) {
48160
- return this.assigner.idleGroups(now, idleMs);
48161
- }
48162
- drop(ids) {
48163
- this.assigner.drop(ids);
48164
- }
48165
- isPrimed(deviceId) {
48166
- return this.primed.has(deviceId);
48167
- }
48168
- markPrimed(deviceId) {
48169
- this.primed.add(deviceId);
48170
- }
48171
- };
48172
- function trackToLite(track) {
48173
- return {
48174
- trackId: track.trackId,
48175
- className: track.className,
48176
- classes: track.classes,
48177
- firstSeen: track.firstSeen,
48178
- lastSeen: track.lastSeen
48179
- };
48180
- }
48181
- /**
48182
- * Gallery and digest read the same ladder: groupCrop, then the native-frame
48183
- * pointer, then a member's clean full frame. Plate / face / member
48184
- * thumbnails never reach the picker — a plate read overwrites the vehicle
48185
- * first-level crop under the same `thumbnail` kind. A multi with no crop
48186
- * still has a picture if the sealer stored `nativeKey`.
48187
- */
48188
- var DIGEST_MEMBER_FALLBACK_KINDS = new Set([
48189
- "keyFrame",
48190
- "keyFrameSmall",
48191
- "fullFrame"
48192
- ]);
48193
- function digestMediaRows(input) {
48194
- const owned = [];
48195
- if (input.mediaKey !== null && input.mediaKey.length > 0) owned.push({
48196
- key: input.mediaKey,
48197
- kind: "groupCrop",
48198
- timestamp: input.timestamp,
48199
- sizeBytes: null
48200
- });
48201
- if (input.nativeKey !== null && input.nativeKey.length > 0) owned.push({
48202
- key: input.nativeKey,
48203
- kind: "nativeFrame",
48204
- timestamp: input.timestamp,
48205
- sizeBytes: null
48206
- });
48207
- if (owned.length > 0) return owned;
48208
- return input.memberRows.filter((row) => DIGEST_MEMBER_FALLBACK_KINDS.has(row.kind));
48209
- }
48210
- //#endregion
48211
- //#region src/pipeline-analytics/group/group-crop.ts
48212
- /** Always containSubject: true. Never the 0.5 delivered threshold. */
48213
- function groupWideCentralSquareLayout(unionBbox, frame) {
48214
- return wideCentralSquareLayout(unionBbox, frame, { containSubject: true });
48215
- }
48216
- async function cropGroupFromRgb(rgb, frameWidth, frameHeight, unionBbox) {
48217
- const layout = groupWideCentralSquareLayout(unionBbox, {
48218
- W: frameWidth,
48219
- H: frameHeight
48220
- });
48221
- const { crop: slab } = await extractCrop(rgb, frameWidth, frameHeight, layout.fetch);
48222
- return composeWideCentralSquareThumbnail(slab, layout);
48223
- }
48224
- //#endregion
48225
- //#region src/pipeline-analytics/group/group-instant.ts
48226
- /**
48227
- * Chosen instant + union bbox for a group, at close, over stored positions[]
48228
- * (`docs/design/2026-08-21-group-entity-and-group-media.md` §4.1).
48229
- *
48230
- * Tier order — the first that separates two candidates decides:
48231
- * 1. completeness (members with a position within ±1 s)
48232
- * 2. square containment of the union (D212)
48233
- * 3. semantic (a member's confirmed recognition)
48234
- * 4. edge-clear (union touches no frame border)
48235
- * 5. effective score = mean(confidence) × containment × sizeFactor
48236
- *
48237
- * Retrospective. No new capture path.
48238
- */
48239
- var GROUP_MEDIA_MATCH_MS = 2e3;
48240
- function nearest(positions, t, windowMs) {
48241
- let best;
48242
- let bestDt = windowMs + 1;
48243
- for (const p of positions) {
48244
- const dt = Math.abs(p.timestamp - t);
48245
- if (dt <= windowMs && dt < bestDt) {
48246
- best = p;
48247
- bestDt = dt;
48248
- }
48249
- }
48250
- return best;
48251
- }
48252
- function unionOf(boxes) {
48253
- if (boxes.length === 0) return null;
48254
- let x0 = Infinity;
48255
- let y0 = Infinity;
48256
- let x1 = -Infinity;
48257
- let y1 = -Infinity;
48258
- for (const b of boxes) {
48259
- x0 = Math.min(x0, b.x);
48260
- y0 = Math.min(y0, b.y);
48261
- x1 = Math.max(x1, b.x + b.w);
48262
- y1 = Math.max(y1, b.y + b.h);
48263
- }
48264
- return {
48265
- x: x0,
48266
- y: y0,
48267
- w: x1 - x0,
48268
- h: y1 - y0
48269
- };
48270
- }
48271
- function touchesBorder(box, frame, tol = 1) {
48272
- return box.x <= tol || box.y <= tol || box.x + box.w >= frame.width - tol || box.y + box.h >= frame.height - tol;
48273
- }
48274
- function scoreInstant(members, t, frame) {
48275
- const boxes = [];
48276
- let semantic = false;
48277
- let confSum = 0;
48278
- let confN = 0;
48279
- for (const m of members) {
48280
- const p = nearest(m.positions, t, MATCH_WINDOW_MS);
48281
- if (!p) continue;
48282
- boxes.push(p.bbox);
48283
- if (m.semantic === true) semantic = true;
48284
- if (m.confidence !== void 0) {
48285
- confSum += m.confidence;
48286
- confN += 1;
48287
- }
48288
- }
48289
- const union = unionOf(boxes);
48290
- if (!union) return null;
48291
- const square = centralSquareContainmentFraction(union, {
48292
- W: frame.width,
48293
- H: frame.height
48294
- });
48295
- const containment = bboxContainment(union, frame.width, frame.height);
48296
- const size = bboxSizeScore(union, frame.width, frame.height);
48297
- const meanConf = confN > 0 ? confSum / confN : 1;
48298
- return {
48299
- timestamp: t,
48300
- union,
48301
- visibleCount: boxes.length,
48302
- square,
48303
- semantic,
48304
- edgeClear: !touchesBorder(union, frame),
48305
- effective: meanConf * containment * size
48306
- };
48307
- }
48308
- function better(a, b) {
48309
- if (a.visibleCount !== b.visibleCount) return a.visibleCount > b.visibleCount ? a : b;
48310
- if (a.square !== b.square) return a.square > b.square ? a : b;
48311
- if (a.semantic !== b.semantic) return a.semantic ? a : b;
48312
- if (a.edgeClear !== b.edgeClear) return a.edgeClear ? a : b;
48313
- if (a.effective !== b.effective) return a.effective > b.effective ? a : b;
48314
- return a.timestamp <= b.timestamp ? a : b;
48315
- }
48316
- function chooseGroupInstant(members, frame) {
48317
- if (members.length === 0) return null;
48318
- const candidates = /* @__PURE__ */ new Set();
48319
- for (const m of members) for (const p of m.positions) candidates.add(p.timestamp);
48320
- let best = null;
48321
- for (const t of candidates) {
48322
- const scored = scoreInstant(members, t, frame);
48323
- if (!scored) continue;
48324
- best = best === null ? scored : better(scored, best);
48325
- }
48326
- if (!best) return null;
48327
- const w = Math.max(frame.width, 1);
48328
- const h = Math.max(frame.height, 1);
48329
- return {
48330
- timestamp: best.timestamp,
48331
- union: best.union,
48332
- ux: best.union.x / w,
48333
- uy: best.union.y / h,
48334
- uw: best.union.w / w,
48335
- uh: best.union.h / h,
48336
- visibleCount: best.visibleCount
48337
- };
48338
- }
48339
- /** Longest-lived member that owns a clean full frame; ties break on trackId. */
48340
- function chooseAnchorTrackId(members, ownsCleanFrame) {
48341
- const eligible = members.filter((m) => ownsCleanFrame(m.trackId));
48342
- if (eligible.length === 0) return null;
48343
- return eligible.toSorted((a, b) => {
48344
- const life = b.lastSeen - b.firstSeen - (a.lastSeen - a.firstSeen);
48345
- return life !== 0 ? life : a.trackId.localeCompare(b.trackId);
48346
- })[0].trackId;
48347
- }
48348
- /**
48349
- * The RGB source for a group crop is a full native frame. Thumbnails are
48350
- * already single-subject crops and cannot substitute for union-bbox geometry.
48351
- *
48352
- * Prefer a keyFrame within {@link GROUP_MEDIA_MATCH_MS} of the chosen instant;
48353
- * if none, take the nearest keyFrame anyway — dropping the crop because the
48354
- * best-shot was 3 s off the instant is how a recognised person left the
48355
- * digest with no crop.
48356
- */
48357
- function pickCleanPixelRow(rows, instantMs) {
48358
- const ranked = (maxDt) => {
48359
- const candidates = [];
48360
- for (const row of rows) {
48361
- const rank = row.kind === "keyFrame" ? 0 : row.kind === "keyFrameSmall" ? 1 : -1;
48362
- if (rank < 0) continue;
48363
- const dt = Math.abs(row.timestamp - instantMs);
48364
- if (maxDt !== null && dt > maxDt) continue;
48365
- candidates.push({
48366
- key: row.key,
48367
- dt,
48368
- rank
48369
- });
48370
- }
48371
- return candidates.toSorted((a, b) => a.rank - b.rank || a.dt - b.dt)[0];
48372
- };
48373
- const near = ranked(GROUP_MEDIA_MATCH_MS);
48374
- if (near !== void 0) return {
48375
- key: near.key,
48376
- usedFallback: false
48377
- };
48378
- const any = ranked(null);
48379
- if (any === void 0) return null;
48380
- return {
48381
- key: any.key,
48382
- usedFallback: true
48383
- };
48384
- }
48385
- //#endregion
48386
- //#region src/pipeline-analytics/group/group-live-replay.ts
48387
- function sliceTrackAt(track, now) {
48388
- const positions = track.positions.filter((p) => p.timestamp <= now);
48389
- const last = positions[positions.length - 1];
48390
- return {
48391
- ...track,
48392
- lastSeen: last?.timestamp ?? track.firstSeen,
48393
- positions
48394
- };
48395
- }
48396
- function replayInto(tracks, observer) {
48397
- const events = [];
48398
- const byId = /* @__PURE__ */ new Map();
48399
- for (const track of tracks) {
48400
- byId.set(track.trackId, track);
48401
- if (track.positions.length === 0) {
48402
- events.push({
48403
- t: track.lastSeen,
48404
- trackId: track.trackId
48405
- });
48406
- continue;
48407
- }
48408
- for (const p of track.positions) events.push({
48409
- t: p.timestamp,
48410
- trackId: track.trackId
48411
- });
48412
- }
48413
- events.sort((a, b) => a.t - b.t || a.trackId.localeCompare(b.trackId));
48414
- let lastKey = "";
48415
- for (const e of events) {
48416
- const key = `${e.trackId}@${e.t}`;
48417
- if (key === lastKey) continue;
48418
- lastKey = key;
48419
- const track = byId.get(e.trackId);
48420
- if (!track) continue;
48421
- observer.observe(sliceTrackAt(track, e.t), e.t);
48422
- }
48423
- }
48424
- function replayLive(tracks, assigner = new GroupLiveAssigner()) {
48425
- replayInto(tracks, assigner);
48426
- return assigner;
48427
- }
48428
- //#endregion
48429
- //#region src/pipeline-analytics/group/group-sealer.ts
48430
- var DEFAULT_FRAME = {
48431
- width: 640,
48432
- height: 360
48433
- };
48434
- var QUERY_LIMIT = 2e3;
48435
- /** Crash recover: replay this camera only, on its next activity. */
48436
- var GROUP_CRASH_LOOKBACK_MS = 2 * 6e4;
48437
- function toInstant(t) {
48438
- return {
48439
- trackId: t.trackId,
48440
- deviceId: t.deviceId,
48441
- className: t.className,
48442
- firstSeen: t.firstSeen,
48443
- lastSeen: t.lastSeen,
48444
- positions: t.positions,
48445
- ...t.closedReason !== void 0 ? { closedReason: t.closedReason } : {},
48446
- semantic: t.hasFace === true || t.hasPlate === true,
48447
- confidence: t.importance
48448
- };
48449
- }
48450
- function memberBoxesAt(members, at, frame) {
48451
- const w = Math.max(frame.width, 1);
48452
- const h = Math.max(frame.height, 1);
48453
- const boxes = [];
48454
- for (const member of members) {
48455
- let best = member.positions[0];
48456
- let bestDt = Number.POSITIVE_INFINITY;
48457
- for (const p of member.positions) {
48458
- const dt = Math.abs(p.timestamp - at);
48459
- if (dt <= 1e3 && dt < bestDt) {
48460
- best = p;
48461
- bestDt = dt;
48462
- }
48463
- }
48464
- if (best === void 0 || bestDt === Number.POSITIVE_INFINITY) continue;
48465
- boxes.push({
48466
- bbox: {
48467
- x: best.bbox.x / w,
48468
- y: best.bbox.y / h,
48469
- w: best.bbox.w / w,
48470
- h: best.bbox.h / h
48471
- },
48472
- label: member.className,
48473
- observedAt: best.timestamp
48474
- });
48475
- }
48476
- return boxes;
48477
- }
48478
- function toSealer(track) {
48479
- return {
48480
- trackId: track.trackId,
48481
- deviceId: track.deviceId,
48482
- className: track.className,
48483
- firstSeen: track.firstSeen,
48484
- lastSeen: track.lastSeen,
48485
- positions: track.positions,
48486
- ...track.closedReason !== void 0 ? { closedReason: track.closedReason } : {}
48487
- };
48488
- }
48489
- var GroupSealer = class {
48490
- deps;
48491
- constructor(deps) {
48492
- this.deps = deps;
48493
- }
48494
- get processEnabled() {
48495
- return this.deps.enabled ?? false;
48496
- }
48497
- async flushIdle(buffer, now = this.deps.now()) {
48498
- if (!this.processEnabled) return 0;
48499
- let written = 0;
48500
- for (const group of buffer.idleGroups(now, GROUP_SESSION_IDLE_MS)) if (await this.persistLiveGroup(group, buffer)) {
48501
- buffer.drop([group.id]);
48502
- written += 1;
48503
- }
48504
- return written;
48505
- }
48506
- /**
48507
- * First activity on camera D after a crash: replay 2 minutes of that camera
48508
- * into the live buffer, persist whoever is already idle, keep the rest in RAM.
48509
- */
48510
- async recoverCamera(deviceId, buffer) {
48511
- if (!this.processEnabled) return 0;
48512
- if (buffer.isPrimed(deviceId)) return 0;
48513
- const now = this.deps.now();
48514
- try {
48515
- replayInto(await this.deps.trackStore.queryHistorical({
48516
- deviceId,
48517
- since: now - GROUP_CRASH_LOOKBACK_MS,
48518
- until: now,
48519
- limit: QUERY_LIMIT,
48520
- projection: "full"
48521
- }), buffer);
48522
- } catch (err) {
48523
- this.deps.logger.warn("group sealer: lookback failed", {
48524
- tags: { deviceId },
48525
- meta: { error: String(err) }
48526
- });
48527
- } finally {
48528
- buffer.markPrimed(deviceId);
48529
- }
48530
- return this.flushIdle(buffer, now);
48531
- }
48532
- /**
48533
- * Seal ONE camera over a caller-owned window via live replay.
48534
- *
48535
- * This is how a digest recovers groups without a boot scan: the producer
48536
- * already knows the window. Walking every camera for 48h at initialize()
48537
- * is what wedged hub-main.
48538
- */
48539
- async sealWindow(deviceId, since, until) {
48540
- if (!this.processEnabled) return 0;
48541
- try {
48542
- const closed = await this.deps.trackStore.queryHistorical({
48543
- deviceId,
48544
- since,
48545
- until,
48546
- limit: QUERY_LIMIT,
48547
- projection: "full"
48548
- });
48549
- if (closed.length === 0) return 0;
48550
- const already = new Set((await this.deps.groupStore.membersOfTracks(closed.map((t) => t.trackId))).map((m) => m.trackId));
48551
- const pending = closed.filter((t) => !already.has(t.trackId));
48552
- if (pending.length === 0) return 0;
48553
- const assigner = replayLive(pending);
48554
- const now = this.deps.now();
48555
- const byId = new Map(pending.map((t) => [t.trackId, t]));
48556
- let written = 0;
48557
- for (const group of assigner.idleGroups(now, GROUP_SESSION_IDLE_MS)) {
48558
- const members = group.memberTrackIds.map((id) => byId.get(id)).filter((t) => t !== void 0);
48559
- if (members.length === 0) continue;
48560
- try {
48561
- await this.persistPartition(members, group.openedAt);
48562
- written += 1;
48563
- } catch (err) {
48564
- this.deps.logger.warn("group sealer: persist failed", {
48565
- tags: { deviceId },
48566
- meta: {
48567
- members: members.map((m) => m.trackId),
48568
- error: String(err)
48569
- }
48570
- });
48571
- }
48572
- }
48573
- return written;
48574
- } catch (err) {
48575
- this.deps.logger.warn("group sealer: device failed", {
48576
- tags: { deviceId },
48577
- meta: { error: String(err) }
48578
- });
48579
- return 0;
48580
- }
48581
- }
48582
- async persistLiveGroup(group, buffer) {
48583
- const deviceId = group.deviceId;
48584
- const snaps = group.memberTrackIds.map((id) => buffer.snapshot(id)).filter((t) => t !== void 0);
48585
- if (snaps.length === 0) return true;
48586
- const already = new Set((await this.deps.groupStore.membersOfTracks(snaps.map((t) => t.trackId))).map((m) => m.trackId));
48587
- const pending = snaps.filter((t) => !already.has(t.trackId)).map(toSealer);
48588
- if (pending.length === 0) return true;
48589
- try {
48590
- await this.persistPartition(pending, group.openedAt);
48591
- return true;
48592
- } catch (err) {
48593
- this.deps.logger.warn("group sealer: persist failed", {
48594
- tags: { deviceId },
48595
- meta: {
48596
- members: pending.map((m) => m.trackId),
48597
- error: String(err)
48598
- }
48599
- });
48600
- return false;
48601
- }
48602
- }
48603
- async persistPartition(members, openedAt) {
48604
- const deviceId = members[0].deviceId;
48605
- const frame = this.deps.frameDims(deviceId) ?? DEFAULT_FRAME;
48606
- const instantMembers = members.map(toInstant);
48607
- const instant = chooseGroupInstant(instantMembers, frame);
48608
- const closedAt = Math.max(...members.map((m) => m.lastSeen));
48609
- const timestamp = instant?.timestamp ?? closedAt;
48610
- const mediaInfos = await Promise.all(members.map(async (m) => ({
48611
- trackId: m.trackId,
48612
- rows: await this.deps.mediaStore.listInfoByOwner("track", m.trackId)
48613
- })));
48614
- const ownsClean = (trackId) => {
48615
- return mediaInfos.find((m) => m.trackId === trackId)?.rows.some((r) => r.kind === "keyFrame" || r.kind === "keyFrameSmall") ?? false;
48616
- };
48617
- const anchorTrackId = members.length >= 2 ? chooseAnchorTrackId(instantMembers, ownsClean) : null;
48618
- let nativeKey = null;
48619
- if (anchorTrackId !== null) {
48620
- const rows = mediaInfos.find((m) => m.trackId === anchorTrackId)?.rows ?? [];
48621
- nativeKey = (rows.find((r) => r.kind === "keyFrame") ?? rows.find((r) => r.kind === "keyFrameSmall"))?.key ?? null;
48622
- }
48623
- const draft = await this.deps.groupStore.insert({
48624
- deviceId,
48625
- timestamp,
48626
- openedAt,
48627
- closedAt,
48628
- members: members.map((m) => ({
48629
- trackId: m.trackId,
48630
- firstSeen: m.firstSeen
48631
- })),
48632
- anchorTrackId,
48633
- ux: instant?.ux ?? 0,
48634
- uy: instant?.uy ?? 0,
48635
- uw: instant?.uw ?? 0,
48636
- uh: instant?.uh ?? 0,
48637
- frameWidth: frame.width,
48638
- frameHeight: frame.height,
48639
- mediaKey: null,
48640
- nativeKey
48641
- });
48642
- if (members.length < 2 || instant === null) return;
48643
- const source = await this.pickCleanPixels(mediaInfos, timestamp, deviceId);
48644
- if (source === null) {
48645
- this.deps.logger.info("group sealer: no clean pixels for crop", {
48646
- tags: { deviceId },
48647
- meta: {
48648
- groupId: draft.id,
48649
- members: members.map((m) => m.trackId)
48650
- }
48651
- });
48652
- return;
48653
- }
48654
- try {
48655
- const raw = await (0, sharp.default)(source.jpeg).removeAlpha().toColorspace("srgb").raw().toBuffer({ resolveWithObject: true });
48656
- if (raw.info.channels === 3) {
48657
- const union = {
48658
- x: instant.ux * raw.info.width,
48659
- y: instant.uy * raw.info.height,
48660
- w: instant.uw * raw.info.width,
48661
- h: instant.uh * raw.info.height
48662
- };
48663
- const crop = await cropGroupFromRgb(raw.data, raw.info.width, raw.info.height, union);
48664
- const mediaKey = await this.deps.mediaStore.put({
48665
- deviceId,
48666
- ownerKind: "group",
48667
- ownerId: draft.id,
48668
- kind: "thumbnail",
48669
- timestamp,
48670
- data: crop
48671
- });
48672
- await this.deps.groupStore.setMediaKey(draft.id, mediaKey);
48673
- }
48674
- } catch (err) {
48675
- this.deps.logger.warn("group sealer: crop failed", {
48676
- tags: { deviceId },
48677
- meta: {
48678
- groupId: draft.id,
48679
- error: String(err)
48680
- }
48681
- });
48682
- }
48683
- try {
48684
- const composed = await composeGroupFrame({
48685
- jpeg: source.jpeg,
48686
- boxes: memberBoxesAt(instantMembers, timestamp, frame),
48687
- at: timestamp,
48688
- logger: this.deps.logger,
48689
- deviceId,
48690
- groupKey: draft.id
48691
- });
48692
- if (composed === null) return;
48693
- await this.deps.mediaStore.put({
48694
- deviceId,
48695
- ownerKind: "group",
48696
- ownerId: draft.id,
48697
- kind: "fullFrameBoxed",
48698
- timestamp,
48699
- data: composed.bytes
48700
- });
48701
- } catch (err) {
48702
- this.deps.logger.warn("group sealer: boxed frame failed", {
48703
- tags: { deviceId },
48704
- meta: {
48705
- groupId: draft.id,
48706
- error: String(err)
48707
- }
48708
- });
48709
- }
48710
- }
48711
- async pickCleanPixels(mediaInfos, instantMs, deviceId) {
48712
- const chosen = pickCleanPixelRow(mediaInfos.flatMap((info) => info.rows), instantMs);
48713
- if (chosen === null) return null;
48714
- if (chosen.usedFallback) this.deps.logger.info("group sealer: crop pixels not within 2s of instant, using nearest keyFrame", {
48715
- tags: { deviceId },
48716
- meta: {
48717
- instantMs,
48718
- mediaKey: chosen.key
48719
- }
48720
- });
48721
- const file = await this.deps.mediaStore.getByKey(chosen.key);
48722
- if (file === null) return null;
48723
- return { jpeg: Buffer.from(file.base64, "base64") };
48724
- }
48725
- };
48726
- //#endregion
48727
- //#region src/pipeline-analytics/group/group-cursor.ts
48728
- function encodeGroupCursor(cursor) {
48729
- return Buffer.from(JSON.stringify({
48730
- c: cursor.closedAt,
48731
- i: cursor.groupId
48732
- }), "utf8").toString("base64url");
48733
- }
48734
- function decodeGroupCursor(raw) {
48735
- let parsed;
48736
- try {
48737
- parsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
48738
- } catch {
48739
- throw new Error("listGroups: malformed cursor");
48740
- }
48741
- if (parsed === null || typeof parsed !== "object" || !("c" in parsed) || !("i" in parsed) || typeof parsed.c !== "number" || !Number.isFinite(parsed.c) || typeof parsed.i !== "string" || parsed.i.length === 0) throw new Error("listGroups: malformed cursor");
48742
- return {
48743
- closedAt: parsed.c,
48744
- groupId: parsed.i
48745
- };
48746
- }
48747
- function compareGroupDesc(a, b) {
48748
- if (a.closedAt !== b.closedAt) return b.closedAt - a.closedAt;
48749
- if (a.id === b.id) return 0;
48750
- return a.id < b.id ? 1 : -1;
48751
- }
48752
- /** True when `row` sits strictly AFTER the cursor in DESC order. */
48753
- function isAfterGroupCursor(row, cursor) {
48754
- if (row.closedAt < cursor.closedAt) return true;
48755
- return row.closedAt === cursor.closedAt && row.id < cursor.groupId;
48756
- }
48757
- //#endregion
48758
- //#region src/pipeline-analytics/group/group-store.ts
48759
- /**
48760
- * The group ENTITY store — a co-moving partition of one session, with one
48761
- * picture of its own (`docs/design/2026-08-21-group-entity-and-group-media.md`).
48762
- *
48763
- * Two tables, two questions:
48764
- *
48765
- * - `pipeline-analytics:track-groups` is the group row. It has N members and
48766
- * no `trackId` column; it is owned by its members (owner-indirection).
48767
- * - `pipeline-analytics:track-group-members` is one row per member. The column
48768
- * is named exactly `trackId` so the classification derives `track-owned`.
48769
- *
48770
- * `anchorTrackId` and `nativeKey` are the only fields mutable after birth:
48771
- * they are nulled when the anchor member is deleted alone. Everything else is
48772
- * written once. Re-anchoring is deliberately not done.
48773
- *
48774
- * No age clock. A group dies when its last member does; a group with one live
48775
- * member does not, and it never keeps a dead link.
48776
- */
48777
- /**
48778
- * @durable class=ledger owner=pipeline-analytics
48779
- * write="one row per co-moving group, sealed at idle — never a live row.
48780
- * Membership is the RAM members at persist, not a session partition. mediaKey is
48781
- * the group's own crop; nativeKey is a POINTER to the anchor member's
48782
- * keyFrame, nulled if that member is deleted alone. anchorTrackId and
48783
- * nativeKey are the only fields rewritten after birth."
48784
- * retention="NO age clock (D61). Rows leave with their last member through
48785
- * TrackGroupStore.deleteByTracks: memberships first; zero survivors
48786
- * deletes the group's media then the row; survivors plus a lost anchor
48787
- * null the pointer. The orphan audit verifies the same via a second
48788
- * resolver (groupId → any member still present)."
48789
- */
48790
- var TRACK_GROUPS_COLLECTION = "pipeline-analytics:track-groups";
48791
- /**
48792
- * @durable class=ledger owner=pipeline-analytics
48793
- * write="one row per member at group birth — id is '<groupId>:<trackId>'.
48794
- * timestamp is the member's firstSeen so the orphan audit has a clock
48795
- * column. Never rewritten."
48796
- * retention="TRACK-OWNED and clockless (D61): the column is named exactly
48797
- * trackId, so classification derives ownership and the cascade deletes
48798
- * the row with its track. A group with surviving members stays."
48799
- */
48800
- var TRACK_GROUP_MEMBERS_COLLECTION = "pipeline-analytics:track-group-members";
48801
- var TRACK_GROUPS_COLUMNS = [
48802
- {
48803
- name: "id",
48804
- type: "TEXT",
48805
- primaryKey: true,
48806
- notNull: true
48807
- },
48808
- {
48809
- name: "deviceId",
48810
- type: "INTEGER",
48811
- notNull: true
48812
- },
48813
- {
48814
- name: "timestamp",
48815
- type: "INTEGER",
48816
- notNull: true
48817
- },
48818
- {
48819
- name: "openedAt",
48820
- type: "INTEGER",
48821
- notNull: true
48822
- },
48823
- {
48824
- name: "closedAt",
48825
- type: "INTEGER",
48826
- notNull: true
48827
- },
48828
- {
48829
- name: "memberCount",
48830
- type: "INTEGER",
48831
- notNull: true
48832
- },
48833
- {
48834
- name: "anchorTrackId",
48835
- type: "TEXT"
48836
- },
48837
- {
48838
- name: "ux",
48839
- type: "REAL",
48840
- notNull: true
48841
- },
48842
- {
48843
- name: "uy",
48844
- type: "REAL",
48845
- notNull: true
48846
- },
48847
- {
48848
- name: "uw",
48849
- type: "REAL",
48850
- notNull: true
48851
- },
48852
- {
48853
- name: "uh",
48854
- type: "REAL",
48855
- notNull: true
48856
- },
48857
- {
48858
- name: "frameWidth",
48859
- type: "INTEGER",
48860
- notNull: true
48861
- },
48862
- {
48863
- name: "frameHeight",
48864
- type: "INTEGER",
48865
- notNull: true
48866
- },
48867
- {
48868
- name: "mediaKey",
48869
- type: "TEXT"
48870
- },
48871
- {
48872
- name: "nativeKey",
48873
- type: "TEXT"
48874
- },
48875
- {
48876
- name: "sealed",
48877
- type: "BOOLEAN",
48878
- notNull: true
48879
- }
48880
- ];
48881
- var TRACK_GROUP_MEMBERS_COLUMNS = [
48882
- {
48883
- name: "id",
48884
- type: "TEXT",
48885
- primaryKey: true,
48886
- notNull: true
48887
- },
48888
- {
48889
- name: "groupId",
48890
- type: "TEXT",
48891
- notNull: true
48892
- },
48893
- {
48894
- name: "trackId",
48895
- type: "TEXT",
48896
- notNull: true
48897
- },
48898
- {
48899
- name: "deviceId",
48900
- type: "INTEGER",
48901
- notNull: true
48902
- },
48903
- {
48904
- name: "timestamp",
48905
- type: "INTEGER",
48906
- notNull: true
48907
- }
48908
- ];
48909
- var TRACK_GROUPS_INDEXES = [{
48910
- name: "idx_track_groups_device_closed",
48911
- columns: ["deviceId", "closedAt"]
48912
- }, {
48913
- name: "idx_track_groups_device_opened",
48914
- columns: ["deviceId", "openedAt"]
48915
- }];
48916
- var TRACK_GROUP_MEMBERS_INDEXES = [{
48917
- name: "idx_track_group_members_track",
48918
- columns: ["trackId"]
48919
- }, {
48920
- name: "idx_track_group_members_group",
48921
- columns: ["groupId"]
48922
- }];
48923
- function newGroupId() {
48924
- return `grp-${(0, node_crypto.randomUUID)()}`;
48925
- }
48926
- function memberRowId(groupId, trackId) {
48927
- return `${groupId}:${trackId}`;
48928
- }
48929
- var TrackGroupStore = class {
48930
- store;
48931
- logger;
48932
- media;
48933
- constructor(deps) {
48934
- this.store = deps.store;
48935
- this.logger = deps.logger;
48936
- this.media = deps.media ?? null;
48937
- }
48938
- static async declare(store) {
48939
- await Promise.all([store.declareCollection.mutate({
48940
- collection: TRACK_GROUPS_COLLECTION,
48941
- columns: [...TRACK_GROUPS_COLUMNS],
48942
- indexes: [...TRACK_GROUPS_INDEXES]
48943
- }), store.declareCollection.mutate({
48944
- collection: TRACK_GROUP_MEMBERS_COLLECTION,
48945
- columns: [...TRACK_GROUP_MEMBERS_COLUMNS],
48946
- indexes: [...TRACK_GROUP_MEMBERS_INDEXES]
48947
- })]);
48948
- }
48949
- async insert(input) {
48950
- const id = newGroupId();
48951
- const group = {
48952
- id,
48953
- deviceId: input.deviceId,
48954
- timestamp: input.timestamp,
48955
- openedAt: input.openedAt,
48956
- closedAt: input.closedAt,
48957
- memberCount: input.members.length,
48958
- anchorTrackId: input.anchorTrackId,
48959
- ux: input.ux,
48960
- uy: input.uy,
48961
- uw: input.uw,
48962
- uh: input.uh,
48963
- frameWidth: input.frameWidth,
48964
- frameHeight: input.frameHeight,
48965
- mediaKey: input.mediaKey,
48966
- nativeKey: input.nativeKey,
48967
- sealed: true
48968
- };
48969
- await this.store.set.mutate({
48970
- collection: TRACK_GROUPS_COLLECTION,
48971
- key: id,
48972
- value: this.toRow(group)
48973
- });
48974
- for (const member of input.members) await this.store.set.mutate({
48975
- collection: TRACK_GROUP_MEMBERS_COLLECTION,
48976
- key: memberRowId(id, member.trackId),
48977
- value: {
48978
- groupId: id,
48979
- trackId: member.trackId,
48980
- deviceId: input.deviceId,
48981
- timestamp: member.firstSeen
48982
- }
48983
- });
48984
- return group;
48985
- }
48986
- /** The crop is written after birth; this is the only post-birth write besides the pointer null. */
48987
- async setMediaKey(groupId, mediaKey) {
48988
- const current = await this.get(groupId);
48989
- if (current === null) return;
48990
- await this.store.set.mutate({
48991
- collection: TRACK_GROUPS_COLLECTION,
48992
- key: groupId,
48993
- value: this.toRow({
48994
- ...current,
48995
- mediaKey
48996
- })
48997
- });
48998
- }
48999
- async get(groupId) {
49000
- const raw = await this.store.get.query({
49001
- collection: TRACK_GROUPS_COLLECTION,
49002
- key: groupId
49003
- });
49004
- if (!raw) return null;
49005
- return this.normalizeGroup({
49006
- id: groupId,
49007
- data: raw
49008
- });
49009
- }
49010
- async listMembers(groupId) {
49011
- return (await this.store.query.query({
49012
- collection: TRACK_GROUP_MEMBERS_COLLECTION,
49013
- filter: { where: { groupId } }
49014
- })).map((r) => this.normalizeMember({
49015
- id: r.id,
49016
- data: r.data
49017
- }));
49018
- }
49019
- /**
49020
- * Membership for a page of groups in ONE query. `listGroupRecords` used to
49021
- * call {@link listMembers} per row — 60 groups is 60 settings-store RPCs,
49022
- * ~20 s, and the viewer feed times out into a retry loop.
49023
- */
49024
- async listMembersForGroups(groupIds) {
49025
- if (groupIds.length === 0) return [];
49026
- return (await this.store.query.query({
49027
- collection: TRACK_GROUP_MEMBERS_COLLECTION,
49028
- filter: {
49029
- whereIn: { groupId: [...groupIds] },
49030
- limit: 1e4
49031
- }
49032
- })).map((r) => this.normalizeMember({
49033
- id: r.id,
49034
- data: r.data
49035
- }));
49036
- }
49037
- async membersOfTracks(trackIds) {
49038
- const out = [];
49039
- for (const trackId of trackIds) {
49040
- const rows = await this.store.query.query({
49041
- collection: TRACK_GROUP_MEMBERS_COLLECTION,
49042
- filter: { where: { trackId } }
49043
- });
49044
- for (const r of rows) out.push(this.normalizeMember({
49045
- id: r.id,
49046
- data: r.data
49047
- }));
49048
- }
49049
- return out;
49050
- }
49051
- /**
49052
- * Newest-closed page for the Groups feed.
49053
- *
49054
- * Time filter is overlap (`closedAt >= since` and `openedAt <= until`),
49055
- * same as {@link listOverlapping}. Pagination is applied AFTER the window
49056
- * so a day cursor is honest rather than a raw-row offset that skips live
49057
- * groups on a busy camera. `deviceIds: []` is an empty page (same as
49058
- * `listRecentTracks`); omit the field for every camera. `deviceId` stays
49059
- * as the single-camera alias the custom-action bridge still sends.
49060
- */
49061
- async listRecent(input) {
49062
- const limit = Math.min(Math.max(input.limit ?? 40, 1), 100);
49063
- const offset = Math.max(input.offset ?? 0, 0);
49064
- const deviceIds = input.deviceIds !== void 0 ? input.deviceIds : input.deviceId !== void 0 ? [input.deviceId] : void 0;
49065
- if (deviceIds !== void 0 && deviceIds.length === 0) return {
49066
- groups: [],
49067
- hasMore: false,
49068
- nextCursor: null
49069
- };
49070
- const cursor = input.cursor !== void 0 && input.cursor.length > 0 ? decodeGroupCursor(input.cursor) : null;
49071
- const filtered = (await this.store.query.query({
49072
- collection: TRACK_GROUPS_COLLECTION,
49073
- filter: {
49074
- ...deviceIds !== void 0 && deviceIds.length === 1 ? { where: { deviceId: deviceIds[0] } } : deviceIds !== void 0 ? { whereIn: { deviceId: [...deviceIds] } } : {},
49075
- orderBy: {
49076
- field: "closedAt",
49077
- direction: "desc"
49078
- },
49079
- limit: 2e3
49080
- }
49081
- })).map((r) => this.normalizeGroup({
49082
- id: r.id,
49083
- data: r.data
49084
- })).filter((g) => {
49085
- if (input.sinceMs !== void 0 && g.closedAt < input.sinceMs) return false;
49086
- if (input.untilMs !== void 0 && g.openedAt > input.untilMs) return false;
49087
- if (cursor !== null && !isAfterGroupCursor(g, cursor)) return false;
49088
- return true;
49089
- }).toSorted(compareGroupDesc);
49090
- const slice = (cursor !== null ? filtered : filtered.slice(offset)).slice(0, limit + 1);
49091
- const groups = slice.slice(0, limit);
49092
- const hasMore = slice.length > limit;
49093
- const last = groups[groups.length - 1];
49094
- return {
49095
- groups,
49096
- hasMore,
49097
- nextCursor: hasMore && last !== void 0 ? encodeGroupCursor({
49098
- closedAt: last.closedAt,
49099
- groupId: last.id
49100
- }) : null
49101
- };
49102
- }
49103
- /** Groups whose life overlaps `[since, until]` on one camera. */
49104
- async listOverlapping(input) {
49105
- return (await this.store.query.query({
49106
- collection: TRACK_GROUPS_COLLECTION,
49107
- filter: {
49108
- where: { deviceId: input.deviceId },
49109
- orderBy: {
49110
- field: "closedAt",
49111
- direction: "desc"
49112
- },
49113
- limit: input.limit ?? 2e3
49114
- }
49115
- })).map((r) => this.normalizeGroup({
49116
- id: r.id,
49117
- data: r.data
49118
- })).filter((g) => g.closedAt >= input.sinceMs && g.openedAt <= input.untilMs);
49119
- }
49120
- async existingGroupIds(ids) {
49121
- const live = /* @__PURE__ */ new Set();
49122
- for (const id of ids) if (await this.store.get.query({
49123
- collection: "pipeline-analytics:track-groups",
49124
- key: id
49125
- })) live.add(id);
49126
- return live;
49127
- }
49128
- /** Group ids that still have at least one membership row. */
49129
- async groupsWithMembers(ids) {
49130
- const live = /* @__PURE__ */ new Set();
49131
- for (const groupId of ids) if ((await this.store.query.query({
49132
- collection: "pipeline-analytics:track-group-members",
49133
- filter: {
49134
- where: { groupId },
49135
- limit: 1
49136
- }
49137
- })).length > 0) live.add(groupId);
49138
- return live;
49139
- }
49140
- /**
49141
- * Cascade leaf: delete memberships for the batch; a group with zero survivors
49142
- * dies (media then row); a group that lost only its anchor nulls the pointer.
49143
- */
49144
- async deleteByTracks(trackIds) {
49145
- const batch = new Set(trackIds);
49146
- let memberRows = 0;
49147
- let groupRows = 0;
49148
- let mediaRows = 0;
49149
- let mediaBytes = 0;
49150
- const affected = /* @__PURE__ */ new Set();
49151
- try {
49152
- const memberships = await this.membersOfTracks(trackIds);
49153
- for (const member of memberships) try {
49154
- await this.store.delete.mutate({
49155
- collection: TRACK_GROUP_MEMBERS_COLLECTION,
49156
- key: member.id
49157
- });
49158
- memberRows += 1;
49159
- affected.add(member.groupId);
49160
- } catch (err) {
49161
- this.logger.warn("TrackGroupStore.deleteByTracks member delete failed", { meta: {
49162
- memberId: member.id,
49163
- error: String(err)
49164
- } });
49165
- }
49166
- for (const groupId of affected) try {
49167
- if ((await this.listMembers(groupId)).length === 0) {
49168
- if (this.media) {
49169
- const gone = await this.media.deleteByGroups([groupId]);
49170
- mediaRows += gone.rows;
49171
- mediaBytes += gone.bytes;
49172
- }
49173
- await this.store.delete.mutate({
49174
- collection: TRACK_GROUPS_COLLECTION,
49175
- key: groupId
49176
- });
49177
- groupRows += 1;
49178
- continue;
49179
- }
49180
- const group = await this.get(groupId);
49181
- if (group?.anchorTrackId && batch.has(group.anchorTrackId)) await this.store.set.mutate({
49182
- collection: TRACK_GROUPS_COLLECTION,
49183
- key: groupId,
49184
- value: this.toRow({
49185
- ...group,
49186
- anchorTrackId: null,
49187
- nativeKey: null
49188
- })
49189
- });
49190
- } catch (err) {
49191
- this.logger.warn("TrackGroupStore.deleteByTracks group update failed", { meta: {
49192
- groupId,
49193
- error: String(err)
49194
- } });
49195
- }
49196
- } catch (err) {
49197
- this.logger.warn("TrackGroupStore.deleteByTracks failed", { meta: { error: String(err) } });
49198
- }
49199
- return [{
49200
- family: "groups",
49201
- rows: memberRows + groupRows,
49202
- bytes: 0
49203
- }, {
49204
- family: "media",
49205
- rows: mediaRows,
49206
- bytes: mediaBytes
49207
- }];
49208
- }
49209
- toRow(group) {
49210
- return {
49211
- deviceId: group.deviceId,
49212
- timestamp: group.timestamp,
49213
- openedAt: group.openedAt,
49214
- closedAt: group.closedAt,
49215
- memberCount: group.memberCount,
49216
- anchorTrackId: group.anchorTrackId,
49217
- ux: group.ux,
49218
- uy: group.uy,
49219
- uw: group.uw,
49220
- uh: group.uh,
49221
- frameWidth: group.frameWidth,
49222
- frameHeight: group.frameHeight,
49223
- mediaKey: group.mediaKey,
49224
- nativeKey: group.nativeKey,
49225
- sealed: group.sealed
49226
- };
49227
- }
49228
- normalizeGroup(r) {
49229
- const data = r.data;
49230
- return {
49231
- id: r.id,
49232
- deviceId: Number(data["deviceId"]),
49233
- timestamp: Number(data["timestamp"]),
49234
- openedAt: Number(data["openedAt"]),
49235
- closedAt: Number(data["closedAt"]),
49236
- memberCount: Number(data["memberCount"]),
49237
- anchorTrackId: typeof data["anchorTrackId"] === "string" ? data["anchorTrackId"] : null,
49238
- ux: Number(data["ux"]),
49239
- uy: Number(data["uy"]),
49240
- uw: Number(data["uw"]),
49241
- uh: Number(data["uh"]),
49242
- frameWidth: Number(data["frameWidth"]),
49243
- frameHeight: Number(data["frameHeight"]),
49244
- mediaKey: typeof data["mediaKey"] === "string" ? data["mediaKey"] : null,
49245
- nativeKey: typeof data["nativeKey"] === "string" ? data["nativeKey"] : null,
49246
- sealed: Boolean(data["sealed"])
49247
- };
49248
- }
49249
- normalizeMember(r) {
49250
- const data = r.data;
49251
- return {
49252
- id: r.id,
49253
- groupId: String(data["groupId"]),
49254
- trackId: String(data["trackId"]),
49255
- deviceId: Number(data["deviceId"]),
49256
- timestamp: Number(data["timestamp"])
49257
- };
49258
- }
49259
- };
49260
47848
  //#endregion
49261
47849
  //#region src/pipeline-analytics/media-capture-log.ts
49262
47850
  var DEFAULT_FLUSH_INTERVAL_MS$1 = 6e4;
@@ -57663,24 +56251,12 @@ var ANALYTICS_COLLECTIONS = [
57663
56251
  collection: PLATES_COLLECTION,
57664
56252
  classification: derived(PLATE_COLUMNS)
57665
56253
  },
57666
- {
57667
- collection: TRACK_GROUP_MEMBERS_COLLECTION,
57668
- classification: derived(TRACK_GROUP_MEMBERS_COLUMNS)
57669
- },
57670
56254
  {
57671
56255
  collection: MEDIA_COLLECTION,
57672
56256
  classification: {
57673
56257
  kind: "owner-indirection",
57674
56258
  derivation: "explicit",
57675
- why: "(ownerKind, ownerId) means a different entity per kind — a trackId for 'track', a PREFIXED trackId for 'face'/'plate', an eventId for 'event', a groupId for 'group', and an enrolled gallery row for 'identity'/'vehicle' that retention must never touch. The mapping is `resolveMediaOwner` in orphan-audit.ts."
57676
- }
57677
- },
57678
- {
57679
- collection: TRACK_GROUPS_COLLECTION,
57680
- classification: {
57681
- kind: "owner-indirection",
57682
- derivation: "explicit",
57683
- why: "a group has N members and no trackId column; it is owned by its membership rows. The mapping is `resolveGroupOwner` in orphan-audit.ts (groupId → any member still present). Do not classify never-orphaned — that would make the last-member guarantee unverifiable."
56259
+ why: "(ownerKind, ownerId) means a different entity per kind — a trackId for 'track', a PREFIXED trackId for 'face'/'plate', an eventId for 'event', and an enrolled gallery row for 'identity'/'vehicle' that retention must never touch. A RETIRED ownerKind (the deleted group entity) resolves to an owner that can never exist again and is always an orphan. The mapping is `resolveMediaOwner` in orphan-audit.ts."
57684
56260
  }
57685
56261
  },
57686
56262
  {
@@ -57792,7 +56368,7 @@ var ANALYTICS_COLLECTIONS = [
57792
56368
  collection: SCENE_LATCH_COLLECTION,
57793
56369
  classification: {
57794
56370
  kind: "never-orphaned",
57795
- why: "it IS owned — by the scene set — but the owner-cleanup is self-contained: `pruneExcept(the scene set)` runs on every scene reload, so a latch whose scene is gone is collected there and never by retention. Deliberately NOT `owner-indirection`: that kind puts a collection into `auditableCollections()`, and the orphan audit resolves only the indirections it has a resolver for (`pipeline-analytics:media` and `pipeline-analytics:track-groups`)."
56371
+ why: "it IS owned — by the scene set — but the owner-cleanup is self-contained: `pruneExcept(the scene set)` runs on every scene reload, so a latch whose scene is gone is collected there and never by retention. Deliberately NOT `owner-indirection`: that kind puts a collection into `auditableCollections()`, and the orphan audit resolves only the indirections it has a resolver for (`pipeline-analytics:media`)."
57796
56372
  }
57797
56373
  },
57798
56374
  {
@@ -57852,6 +56428,10 @@ function resolveMediaOwner(row) {
57852
56428
  kind: "unresolvable",
57853
56429
  why: "media row carries no (ownerKind, ownerId)"
57854
56430
  };
56431
+ if (isRetiredMediaOwnerKind(kindRaw)) return {
56432
+ kind: "retired-owner",
56433
+ ownerKind: kindRaw
56434
+ };
57855
56435
  const ownerKind = kindRaw;
57856
56436
  if (isRetentionExemptOwnerKind(ownerKind)) return {
57857
56437
  kind: "exempt",
@@ -57880,10 +56460,6 @@ function resolveMediaOwner(row) {
57880
56460
  kind: "unresolvable",
57881
56461
  why: `plate-owned media whose ownerId does not carry the '${PLATE_MEDIA_OWNER_PREFIX}' prefix`
57882
56462
  };
57883
- case "group": return {
57884
- kind: "group",
57885
- groupId: ownerId
57886
- };
57887
56463
  default: return {
57888
56464
  kind: "unresolvable",
57889
56465
  why: `unknown ownerKind '${kindRaw}'`
@@ -57895,13 +56471,6 @@ function resolveMediaOwner(row) {
57895
56471
  * is derived the same way the classification is — an enrolled row is out of
57896
56472
  * reach regardless of what happened to the track that produced it.
57897
56473
  */
57898
- /** A group row lives iff any membership row still names it. */
57899
- function resolveGroupOwner(row) {
57900
- return {
57901
- kind: "group-members",
57902
- groupId: row.key
57903
- };
57904
- }
57905
56474
  function resolveDeclaredTrackOwner(row, enrolmentExempt) {
57906
56475
  if (enrolmentExempt && row.data["assigned"] === true) return {
57907
56476
  kind: "exempt",
@@ -57932,17 +56501,6 @@ function buildOrphanScopes(collections = auditableCollections()) {
57932
56501
  });
57933
56502
  continue;
57934
56503
  }
57935
- if (entry.collection === "pipeline-analytics:track-groups") {
57936
- scopes.push({
57937
- id: entry.collection,
57938
- collection: entry.collection,
57939
- resolve: resolveGroupOwner,
57940
- timestampField: "timestamp",
57941
- sizeField: null,
57942
- deviceIdField: "deviceId"
57943
- });
57944
- continue;
57945
- }
57946
56504
  continue;
57947
56505
  }
57948
56506
  if (entry.classification.kind !== "track-owned") continue;
@@ -57974,17 +56532,11 @@ var DEFAULT_ORPHAN_LIMITS = {
57974
56532
  function questionsFor(refs) {
57975
56533
  const trackIds = /* @__PURE__ */ new Set();
57976
56534
  const eventIds = /* @__PURE__ */ new Set();
57977
- const groupIds = /* @__PURE__ */ new Set();
57978
- const groupMemberIds = /* @__PURE__ */ new Set();
57979
56535
  for (const ref of refs) if (ref.kind === "track") trackIds.add(ref.trackId);
57980
56536
  else if (ref.kind === "event") eventIds.add(ref.eventId);
57981
- else if (ref.kind === "group") groupIds.add(ref.groupId);
57982
- else if (ref.kind === "group-members") groupMemberIds.add(ref.groupId);
57983
56537
  return {
57984
56538
  trackIds: [...trackIds],
57985
- eventIds: [...eventIds],
57986
- groupIds: [...groupIds],
57987
- groupMemberIds: [...groupMemberIds]
56539
+ eventIds: [...eventIds]
57988
56540
  };
57989
56541
  }
57990
56542
  /**
@@ -58016,16 +56568,12 @@ async function auditScope(scope, deps, limits, cursor) {
58016
56568
  break;
58017
56569
  }
58018
56570
  const refs = rows.map((row) => scope.resolve(row));
58019
- const { trackIds, eventIds, groupIds, groupMemberIds } = questionsFor(refs);
56571
+ const { trackIds, eventIds } = questionsFor(refs);
58020
56572
  let liveTracks;
58021
56573
  let liveEvents;
58022
- let liveGroups;
58023
- let liveGroupsWithMembers;
58024
56574
  try {
58025
56575
  liveTracks = trackIds.length > 0 ? await deps.liveTrackIds(trackIds) : /* @__PURE__ */ new Set();
58026
56576
  liveEvents = eventIds.length > 0 ? await deps.liveEventIds(eventIds) : /* @__PURE__ */ new Set();
58027
- liveGroups = groupIds.length > 0 && deps.liveGroupIds ? await deps.liveGroupIds(groupIds) : /* @__PURE__ */ new Set();
58028
- liveGroupsWithMembers = groupMemberIds.length > 0 && deps.liveGroupsWithMembers ? await deps.liveGroupsWithMembers(groupMemberIds) : /* @__PURE__ */ new Set();
58029
56577
  } catch (err) {
58030
56578
  deps.logger.warn("orphan audit: liveness probe failed — scope abandoned for this run", { meta: {
58031
56579
  collection: scope.collection,
@@ -58049,7 +56597,7 @@ async function auditScope(scope, deps, limits, cursor) {
58049
56597
  tally.unresolvable += 1;
58050
56598
  continue;
58051
56599
  }
58052
- if (ref.kind === "track" ? liveTracks.has(ref.trackId) : ref.kind === "event" ? liveEvents.has(ref.eventId) : ref.kind === "group" ? liveGroups.has(ref.groupId) : liveGroupsWithMembers.has(ref.groupId)) continue;
56600
+ if (ref.kind === "track" ? liveTracks.has(ref.trackId) : ref.kind === "event" ? liveEvents.has(ref.eventId) : false) continue;
58053
56601
  const ts = num(row.data, scope.timestampField);
58054
56602
  if (ts !== null && ts > graceFloor) {
58055
56603
  tally.withinGrace += 1;
@@ -64887,8 +63435,7 @@ var customActions = {
64887
63435
  ...locationDiscardActions,
64888
63436
  ...viewerSettingsActions,
64889
63437
  ...photoEnrollActions,
64890
- ...faceRescoreActions,
64891
- ...groupActions
63438
+ ...faceRescoreActions
64892
63439
  };
64893
63440
  /**
64894
63441
  * Assist threshold when the caller does not pin one. Packages have their own
@@ -65180,11 +63727,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
65180
63727
  faceStore = null;
65181
63728
  faceRecognizer = null;
65182
63729
  plateStore = null;
65183
- groupStore = null;
65184
- groupSealer = null;
65185
- groupLive = null;
65186
- /** Serialise first-activity lookback per camera, then observe in order. */
65187
- groupObserveTail = /* @__PURE__ */ new Map();
65188
63730
  plateRecognizer = null;
65189
63731
  objectEmbeddingStore = null;
65190
63732
  /** Live state of the detached embedding rebuild — see `rebuildObjectEmbeddings`. */
@@ -66142,8 +64684,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
66142
64684
  ...locationDiscardActions,
66143
64685
  ...viewerSettingsActions,
66144
64686
  ...photoEnrollActions,
66145
- ...faceRescoreActions,
66146
- ...groupActions
64687
+ ...faceRescoreActions
66147
64688
  },
66148
64689
  actionHandlers: {
66149
64690
  ...ncHandlers,
@@ -66151,7 +64692,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
66151
64692
  ...this.buildDebugActionHandlers(),
66152
64693
  ...this.buildPhotoEnrollActionHandlers(),
66153
64694
  ...this.buildFaceRescoreActionHandlers(),
66154
- ...this.buildGroupActionHandlers(),
66155
64695
  ...this.viewerSettingsSnapshots ? makeViewerSettingsActionHandlers(this.viewerSettingsSnapshots) : {}
66156
64696
  }
66157
64697
  } : {}
@@ -66385,7 +64925,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
66385
64925
  SceneStore.declare(api.settingsStore),
66386
64926
  NotificationCenter.declare(api.settingsStore),
66387
64927
  ViewerSettingsSnapshotStore.declare(api.settingsStore),
66388
- TrackGroupStore.declare(api.settingsStore),
66389
64928
  declareMigrationMarkers(api.settingsStore)
66390
64929
  ]);
66391
64930
  this.viewerSettingsSnapshots = new ViewerSettingsSnapshotStore(api.settingsStore);
@@ -66745,31 +65284,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
66745
65284
  logger: logger.child("PlateStore")
66746
65285
  });
66747
65286
  this.plateStore = plateStore;
66748
- const vehicleStore = new VehicleStore({
66749
- store: api.settingsStore,
66750
- logger: logger.child("VehicleStore")
66751
- });
66752
- const groupStore = new TrackGroupStore({
66753
- store: api.settingsStore,
66754
- logger: logger.child("TrackGroupStore"),
66755
- media: mediaStore
66756
- });
66757
- this.groupStore = groupStore;
66758
- this.groupLive = new GroupLiveBuffer();
66759
- this.groupSealer = new GroupSealer({
66760
- trackStore,
66761
- groupStore,
66762
- mediaStore,
66763
- logger: logger.child("GroupSealer"),
66764
- frameDims: (deviceId) => {
66765
- const dims = this.lastFrameDimsByDevice.get(deviceId);
66766
- return dims ? {
66767
- width: dims.w,
66768
- height: dims.h
66769
- } : void 0;
66770
- },
66771
- now: () => Date.now()
66772
- });
66773
65287
  return {
66774
65288
  trackStore,
66775
65289
  mediaStore,
@@ -66777,8 +65291,10 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
66777
65291
  identityStore,
66778
65292
  faceStore,
66779
65293
  plateStore,
66780
- vehicleStore,
66781
- groupStore
65294
+ vehicleStore: new VehicleStore({
65295
+ store: api.settingsStore,
65296
+ logger: logger.child("VehicleStore")
65297
+ })
66782
65298
  };
66783
65299
  }
66784
65300
  /** Face + plate recognizers, the durable keyFrame-derived plate crop and the
@@ -67299,28 +65815,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
67299
65815
  }));
67300
65816
  },
67301
65817
  listMediaInfo: async (id) => {
67302
- const group = await stores.groupStore.get(id);
67303
- if (group) {
67304
- if (group.mediaKey || group.nativeKey) return digestMediaRows({
67305
- mediaKey: group.mediaKey,
67306
- nativeKey: group.nativeKey,
67307
- timestamp: group.timestamp,
67308
- memberRows: []
67309
- });
67310
- const members = await stores.groupStore.listMembers(group.id);
67311
- const memberRows = (await Promise.all(members.map((m) => stores.mediaStore.listInfoByOwner("track", m.trackId)))).flatMap((rows) => rows.map((r) => ({
67312
- key: r.key,
67313
- kind: r.kind,
67314
- timestamp: r.timestamp,
67315
- sizeBytes: r.sizeBytes
67316
- })));
67317
- return digestMediaRows({
67318
- mediaKey: group.mediaKey,
67319
- nativeKey: group.nativeKey,
67320
- timestamp: group.timestamp,
67321
- memberRows
67322
- });
67323
- }
67324
65818
  return (await stores.mediaStore.listInfoByOwner("track", id)).map((r) => ({
67325
65819
  key: r.key,
67326
65820
  kind: r.kind,
@@ -68691,7 +67185,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
68691
67185
  state: t.state
68692
67186
  });
68693
67187
  positionsCountById.set(t.trackId, upserted.positions.length);
68694
- this.enqueueLiveGroup(upserted, result.timestamp);
68695
67188
  }
68696
67189
  if (result.rawTrackedDetections.length > 0) {
68697
67190
  const { minHits } = await this.resolveDeviceDetectionSensitivitySettings(deviceId);
@@ -70788,11 +69281,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
70788
69281
  async sweepExpiredTracks() {
70789
69282
  if (this.shuttingDown || !this.trackStore) return;
70790
69283
  await this.trackCloser.sweep();
70791
- if (this.isPostProcessingNode && this.groupLive) this.groupSealer?.flushIdle(this.groupLive);
70792
69284
  }
70793
- /** Hub-only: assign this sighting in RAM. First activity on a camera
70794
- * replays 2 minutes of that camera (crash recover), then observe is sync. */
70795
- enqueueLiveGroup(track, now) {}
70796
69285
  /** Unified retention (Phase 1): earliest retained footage per device — the
70797
69286
  * follow-recordings cutoff source. Cached 10 min: availability answers in
70798
69287
  * ~70 ms but the sweep asks once per device per pass. `null` = no footage
@@ -71494,16 +69983,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
71494
69983
  };
71495
69984
  if (plausible) this.stationaryRegistry?.promote(entry);
71496
69985
  processor.dropTrack(track.trackId);
71497
- const peek = this.trackStore?.peekActive(track.trackId);
71498
- if (peek) this.enqueueLiveGroup(toGroupableTrack({
71499
- trackId: track.trackId,
71500
- deviceId,
71501
- className: track.className,
71502
- firstSeen: peek.firstSeen,
71503
- lastSeen: timestamp,
71504
- positions: peek.positions,
71505
- closedReason: "stationary-promotion"
71506
- }), timestamp);
71507
69986
  this.trackStore?.closeActive(track.trackId, "stationary-promotion").catch((err) => {
71508
69987
  this.ctx.logger.warn("stationary promotion: closing the source track failed — its media, events and vector are now unowned", {
71509
69988
  tags: { deviceId },
@@ -71731,15 +70210,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
71731
70210
  async listRecentTracks(input) {
71732
70211
  return this.queryFacade.listRecentTracks(input);
71733
70212
  }
71734
- async listGroups(input) {
71735
- return {
71736
- groups: [],
71737
- nextCursor: null
71738
- };
71739
- }
71740
- async getGroup(input) {
71741
- return null;
71742
- }
71743
70213
  /**
71744
70214
  * Every event kind the device can produce: built-ins (motion + audio),
71745
70215
  * detection classes actually observed on the device, and sensor kinds
@@ -72258,8 +70728,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
72258
70728
  },
72259
70729
  liveTrackIds: (ids) => trackStore.existingTrackIds(ids),
72260
70730
  liveEventIds: (ids) => eventStore.existingEventIds(ids),
72261
- liveGroupIds: (ids) => this.groupStore?.existingGroupIds(ids) ?? Promise.resolve(/* @__PURE__ */ new Set()),
72262
- liveGroupsWithMembers: (ids) => this.groupStore?.groupsWithMembers(ids) ?? Promise.resolve(/* @__PURE__ */ new Set()),
72263
70731
  reclaim: async (collection, key) => {
72264
70732
  if (collection === "pipeline-analytics:media") {
72265
70733
  await mediaStore.deleteByKey(key);
@@ -73022,41 +71490,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
73022
71490
  buildFaceRescoreActionHandlers() {
73023
71491
  return { "face.rescoreTrack": (input) => this.rescoreFaceForTrack(input) };
73024
71492
  }
73025
- groupQueryPorts() {
73026
- const groupStore = this.groupStore;
73027
- if (groupStore === null) return null;
73028
- return {
73029
- groupStore,
73030
- loadTracks: async (trackIds) => {
73031
- const map = /* @__PURE__ */ new Map();
73032
- const tracks = this.trackStore;
73033
- if (!tracks || trackIds.length === 0) return map;
73034
- const missing = [];
73035
- for (const id of trackIds) {
73036
- const active = tracks.getActiveByTrack(id);
73037
- if (active !== null) map.set(id, trackToLite(active));
73038
- else missing.push(id);
73039
- }
73040
- if (missing.length === 0) return map;
73041
- for (const persisted of await tracks.getPersistedByTrackIds(missing)) map.set(persisted.trackId, trackToLite(persisted));
73042
- return map;
73043
- },
73044
- eventMediaBaseUrl: () => this.eventMediaBaseUrl
73045
- };
73046
- }
73047
- buildGroupActionHandlers() {
73048
- return {
73049
- "analytics.listGroups": async (input) => {
73050
- return {
73051
- groups: [],
73052
- hasMore: false
73053
- };
73054
- },
73055
- "analytics.getGroup": async (input) => {
73056
- return { group: null };
73057
- }
73058
- };
73059
- }
73060
71493
  buildPhotoEnrollActionHandlers() {
73061
71494
  return {
73062
71495
  "photo.analyze": async (input) => {
@@ -73442,8 +71875,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
73442
71875
  mediaStore,
73443
71876
  faceStore: this.faceStore,
73444
71877
  plateStore: this.plateStore,
73445
- objectEmbeddingStore: this.objectEmbeddingStore,
73446
- groupStore: this.groupStore
71878
+ objectEmbeddingStore: this.objectEmbeddingStore
73447
71879
  };
73448
71880
  const absent = absentCascadeLegs(stores);
73449
71881
  if (absent.length > 0) this.ctx.logger.warn("track cascade is missing a leg — its rows will be left behind", { meta: {
@@ -73652,8 +72084,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
73652
72084
  media: 0,
73653
72085
  faces: 0,
73654
72086
  plates: 0,
73655
- embeddings: 0,
73656
- groups: 0
72087
+ embeddings: 0
73657
72088
  };
73658
72089
  this.lastPruneBytes = 0;
73659
72090
  const cascade = this.buildTrackCascadeRegistry();
@@ -73713,8 +72144,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
73713
72144
  media: tally.media,
73714
72145
  faces: tally.faces,
73715
72146
  plates: tally.plates,
73716
- embeddings: tally.embeddings,
73717
- groups: tally.groups
72147
+ embeddings: tally.embeddings
73718
72148
  };
73719
72149
  }
73720
72150
  /**