@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.
@@ -1,4 +1,4 @@
1
- import { $ as audioMetricsCapability, A as NcSnoozeInputSchema, At as errMsg, B as SCENE_DEFAULT_ANCHOR_THRESHOLD, Bt as boolean, C as NC_TAXONOMY, D as NcRuleSchema, Dt as vectorDimFromBase64, E as NcRulePatchSchema, Et as systemEventFilterApplies, F as OpsLogEntrySchema, Ft as isDeviceScopedCap, G as TimelapseRuleInputSchema, Gt as partialRecord, H as SCENE_DIVERGED, Ht as literal, It as nodePin, J as TrackSourceSchema, Jt as unknown, K as TimelapseRulePatchSchema, Kt as record, L as RECORDING_EXPORT_MAX_READ_BYTES, Lt as sleep$1, M as NcSnoozeSuppressedSchema, Mt as DeviceType, N as NcSystemEventKindSchema, Nt as createEvent, O as NcRuleTargetSchema, Ot as videoclipsCapability, P as NcTaxonomySchema, Pt as hydrateSchema, Q as assertTimelapseCadences, R as RetrainStatusSchema, Rt as _enum, St as readTimelapseGeneratedAt, T as NcRuleInputSchema, Tt as subKindsOf, U as SceneMonitorSchema, Ut as number, V as SCENE_DEFAULT_UNCOVERED_POLICY, Vt as discriminatedUnion, W as TIMELAPSE_DENSE_FLOOR_SEC, Wt as object, X as addonWidgetsSourceCapability, Y as VISIT_MERGE_GAP_MS, Yt as EventCategory, Z as alarmPanelCapability, _ as MACRO_LABELS, a as COCO_TO_MACRO, at as deriveRecordingMode, b as NC_CONDITION_CATALOG, bt as plateGalleryCapability, ct as evaluateSensorEdge, d as DeclaredDevices, et as audioModeOf, f as EVENT_KIND_BY_CAP, ft as isDetectionMacroClass, g as LabelAttributionSchema, gt as notificationRulesCapability, h as FailureCounters, ht as kebabToCamel, i as CLUSTER_MODEL_SCOPED_STEPS, it as defineCustomActions, j as NcSnoozeSchema, jt as BaseAddon, k as NcScheduleSchema, kt as zoneAnalyticsCapability, l as DETECTION_MACRO_CLASSES, lt as faceGalleryCapability, m as FULL_IMAGE_BBOX, mt as isSourceCap, n as AnalyticsGroupRecordSchema, nt as cosineSimilarity$1, o as DEFAULT_EVENT_COLOR, p as EVENT_PAD_MS, pt as isScheduleActive, q as TimelapseRuleSchema, qt as string, r as BaseDevice, rt as customAction, st as encodeVectorBase64, t as AnalyticsGroupDetailSchema, tt as buildEventKindDescriptor, ut as failureContributionCapability, v as MediaFileKindEnum, vt as pickClusterStepModels, w as NcConditionDescriptorSchema, wt as sceneMonitorCapability, x as NC_DEFAULT_SNOOZE_MINUTES, xt as readDeviceStateFrom, y as NC_ALARM_SYSTEM_EVENT_KINDS, yt as pipelineAnalyticsCapability, zt as array } from "../dist-D_O7oxyS.mjs";
1
+ import { $ as buildEventKindDescriptor, A as NcSnoozeSuppressedSchema, At as DeviceType, B as SCENE_DIVERGED, Bt as literal, C as NcRuleInputSchema, Ct as subKindsOf, D as NcScheduleSchema, Dt as zoneAnalyticsCapability, E as NcRuleTargetSchema, Et as videoclipsCapability, F as RECORDING_EXPORT_MAX_READ_BYTES, Ft as sleep$1, G as TimelapseRuleSchema, Gt as string, H as TIMELAPSE_DENSE_FLOOR_SEC, Ht as object, I as RetrainStatusSchema, It as _enum, J as addonWidgetsSourceCapability, K as TrackSourceSchema, Kt as unknown, Lt as array, M as NcTaxonomySchema, Mt as hydrateSchema, N as OpsLogEntrySchema, Nt as isDeviceScopedCap, O as NcSnoozeInputSchema, Ot as errMsg, Pt as nodePin, Q as audioModeOf, R as SCENE_DEFAULT_ANCHOR_THRESHOLD, Rt as boolean, S as NcConditionDescriptorSchema, St as sceneMonitorCapability, T as NcRuleSchema, Tt as vectorDimFromBase64, U as TimelapseRuleInputSchema, Ut as partialRecord, V as SceneMonitorSchema, Vt as number, W as TimelapseRulePatchSchema, Wt as record, X as assertTimelapseCadences, Y as alarmPanelCapability, Z as audioMetricsCapability, _ as NC_ALARM_SYSTEM_EVENT_KINDS, _t as pipelineAnalyticsCapability, at as encodeVectorBase64, bt as readTimelapseGeneratedAt, ct as failureContributionCapability, d as EVENT_PAD_MS, dt as isScheduleActive, et as cosineSimilarity$1, f as FULL_IMAGE_BBOX, ft as isSourceCap, g as MediaFileKindEnum, gt as pickClusterStepModels, h as MACRO_LABELS, i as DEFAULT_EVENT_COLOR, j as NcSystemEventKindSchema, jt as createEvent, k as NcSnoozeSchema, kt as BaseAddon, l as DeclaredDevices, m as LabelAttributionSchema, mt as notificationRulesCapability, n as CLUSTER_MODEL_SCOPED_STEPS, nt as defineCustomActions, ot as evaluateSensorEdge, p as FailureCounters, pt as kebabToCamel, q as VISIT_MERGE_GAP_MS, qt as EventCategory, r as COCO_TO_MACRO, rt as deriveRecordingMode, s as DETECTION_MACRO_CLASSES, st as faceGalleryCapability, t as BaseDevice, tt as customAction, u as EVENT_KIND_BY_CAP, ut as isDetectionMacroClass, v as NC_CONDITION_CATALOG, vt as plateGalleryCapability, w as NcRulePatchSchema, wt as systemEventFilterApplies, x as NC_TAXONOMY, y as NC_DEFAULT_SNOOZE_MINUTES, yt as readDeviceStateFrom, z as SCENE_DEFAULT_UNCOVERED_POLICY, zt as discriminatedUnion } from "../dist-BnxvdGGq.mjs";
2
2
  import { t as __exportAll } from "../embedding-encoder/index.mjs";
3
3
  import * as fs from "node:fs";
4
4
  import { promises } from "node:fs";
@@ -15690,10 +15690,9 @@ function remainingSummaryCandidates(kept, taken, rank) {
15690
15690
  /**
15691
15691
  * The media a tile is drawn from, best first.
15692
15692
  *
15693
- * A digest tile is a SUBJECT, so the crops come first: the group's first-level
15694
- * crop (`groupCrop`), the group's native-frame pointer, then the TRACK's own
15695
- * subject crop (`thumbnail`, then its `thumbnailSmall` downscale), and only
15696
- * then the clean whole scene (`keyFrame` → `keyFrameSmall` → `fullFrame`).
15693
+ * A digest tile is a SUBJECT, so the crops come first: the TRACK's own subject
15694
+ * crop (`thumbnail`, then its `thumbnailSmall` downscale), and only then the
15695
+ * clean whole scene (`keyFrame` → `keyFrameSmall` → `fullFrame`).
15697
15696
  *
15698
15697
  * ⚠ The whole-scene rungs are a FALLBACK and stay on the ladder. Measured on
15699
15698
  * 60 real tracks (615/617, 24 h): 52 carry a crop, 43 carry a scene rung, and
@@ -15701,19 +15700,19 @@ function remainingSummaryCandidates(kept, taken, rank) {
15701
15700
  * two tiles for nothing. A track with neither is dropped, never drawn as an
15702
15701
  * empty cell (see `NcSummaryProducer.loadTiles`).
15703
15702
  *
15704
- * ## Why `thumbnail` is back (2026-08-27)
15703
+ * ## Why `thumbnail` is on the ladder (2026-08-27)
15705
15704
  *
15706
15705
  * It was removed on 2026-08-24 (160200681) because a successful plate read
15707
15706
  * overwrites the vehicle first-level crop under the same `thumbnail` kind.
15708
- * **That collision belongs to the GROUP entity**: the sealer is what writes
15709
- * `ownerKind: 'group', kind: 'thumbnail'` (`group/group-sealer.ts`), and the
15710
- * group process is OFF (`GROUPS_PROCESS_ENABLED`, `group/group-enabled.ts`).
15711
- * On a TRACK, plates live under `ownerKind: 'plate'` / `kind: 'plateCrop'` and
15712
- * never touch `thumbnail`. The same commit turned the group process off, so
15713
- * `groupCrop` and `nativeFrame` became unreachable and every digest tile fell
15714
- * through to `keyFrame` — a whole frame — for three days.
15715
- * `scripts/check-summary-ladder-reachable.ts` is the guard that refuses that
15716
- * shape now.
15707
+ * **That collision belonged to the GROUP entity** — its sealer was what wrote
15708
+ * `ownerKind: 'group', kind: 'thumbnail'`. On a TRACK, plates live under
15709
+ * `ownerKind: 'plate'` / `kind: 'plateCrop'` and never touch `thumbnail`. The
15710
+ * same commit turned the group process off, so the two group-only head rungs
15711
+ * became unreachable and every digest tile fell through to `keyFrame` — a
15712
+ * whole frame — for three days. The group entity was deleted outright on
15713
+ * 2026-09-01 and its rungs went with it;
15714
+ * `scripts/check-summary-ladder-reachable.ts` is the guard that keeps the
15715
+ * best reachable rung a subject crop.
15717
15716
  *
15718
15717
  * Boxed frames stay absent: a drawn rectangle is a permanent lie in a digest
15719
15718
  * tile. Plate and face crops stay absent: a digest tile of a targa is the
@@ -15721,8 +15720,6 @@ function remainingSummaryCandidates(kept, taken, rank) {
15721
15720
  * `frameDedupeKey`, not by hiding the whole-scene fallback.
15722
15721
  */
15723
15722
  var SUMMARY_MEDIA_LADDER = [
15724
- "groupCrop",
15725
- "nativeFrame",
15726
15723
  "thumbnail",
15727
15724
  "thumbnailSmall",
15728
15725
  "keyFrame",
@@ -15757,7 +15754,8 @@ var SUMMARY_SIZE_GATED_RUNGS = new Set(["thumbnail"]);
15757
15754
  * Is this row a picture, or a template wearing a picture's kind?
15758
15755
  *
15759
15756
  * Only a size the index REPORTED can demote a row. A `null` size is unknown,
15760
- * and demoting on unknown would silently delete every group-owned pointer.
15757
+ * and demoting on unknown would silently delete every row an index could not
15758
+ * measure.
15761
15759
  */
15762
15760
  function rowClearsSizeFloor(row, rung) {
15763
15761
  if (!SUMMARY_SIZE_GATED_RUNGS.has(rung)) return true;
@@ -25936,10 +25934,29 @@ var OWNER_KINDS = [
25936
25934
  "identity",
25937
25935
  "plate",
25938
25936
  "vehicle",
25939
- "scene",
25940
- "group"
25937
+ "scene"
25941
25938
  ];
25942
25939
  /**
25940
+ * Owner kinds that NOTHING may write any more, but whose rows can still be on
25941
+ * disk from before the owning code was deleted.
25942
+ *
25943
+ * `'group'` is the co-moving group entity, removed on 2026-09-01 together with
25944
+ * `pipeline-analytics:track-groups` / `:track-group-members`
25945
+ * ([D183](../../../../../docs/decisions/adr-0183-code-that-is-deleted-takes-its-tables-with-it.md)).
25946
+ * The tables are dropped at boot from `RETIRED_COLLECTIONS`, so a group-owned
25947
+ * media row can never be claimed again: `resolveMediaOwner` maps it to
25948
+ * `{ kind: 'retired-owner' }` and the orphan audit reclaims it under the same
25949
+ * grace window and ceilings as any other orphan.
25950
+ *
25951
+ * Deliberately NOT a member of {@link OWNER_KINDS}: the type is what stops a
25952
+ * new writer, and this list is only what the READ side must still recognise.
25953
+ */
25954
+ var RETIRED_OWNER_KINDS = ["group"];
25955
+ /** Is this an owner kind whose owning code is gone? See {@link RETIRED_OWNER_KINDS}. */
25956
+ function isRetiredMediaOwnerKind(value) {
25957
+ return RETIRED_OWNER_KINDS.includes(value);
25958
+ }
25959
+ /**
25943
25960
  * Narrow an arbitrary string to an `OwnerKind`.
25944
25961
  *
25945
25962
  * Exists so a caller that builds owner scopes from data (the training-export
@@ -27069,10 +27086,6 @@ var MediaStore = class {
27069
27086
  async deleteForTracks(trackIds) {
27070
27087
  return this.deleteForOwner("track", trackIds);
27071
27088
  }
27072
- /** Delete the group's OWN crop. The native frame is a pointer and is not here. */
27073
- async deleteByGroups(groupIds) {
27074
- return this.deleteForOwnerCounted("group", groupIds);
27075
- }
27076
27089
  /**
27077
27090
  * `TrackScopedStore` contract for the retention cascade: delete all media
27078
27091
  * owned by the given tracks — the tracks' OWN media (`ownerKind:'track'`) plus
@@ -33938,7 +33951,6 @@ var EMPTY_CASCADE_TALLY = {
33938
33951
  faces: 0,
33939
33952
  plates: 0,
33940
33953
  embeddings: 0,
33941
- groups: 0,
33942
33954
  bytesReclaimed: 0
33943
33955
  };
33944
33956
  /** Fold a leaf store's receipts into a running tally. */
@@ -33982,13 +33994,6 @@ function addLeafCounts(tally, counts) {
33982
33994
  bytesReclaimed
33983
33995
  };
33984
33996
  break;
33985
- case "groups":
33986
- next = {
33987
- ...next,
33988
- groups: next.groups + c.rows,
33989
- bytesReclaimed
33990
- };
33991
- break;
33992
33997
  }
33993
33998
  }
33994
33999
  return next;
@@ -34002,7 +34007,6 @@ function mergeCascadeTallies(a, b) {
34002
34007
  faces: a.faces + b.faces,
34003
34008
  plates: a.plates + b.plates,
34004
34009
  embeddings: a.embeddings + b.embeddings,
34005
- groups: a.groups + b.groups,
34006
34010
  bytesReclaimed: a.bytesReclaimed + b.bytesReclaimed
34007
34011
  };
34008
34012
  }
@@ -34065,7 +34069,6 @@ function absentCascadeLegs(stores) {
34065
34069
  if (!stores.faceStore) absent.push("faces");
34066
34070
  if (!stores.plateStore) absent.push("plates");
34067
34071
  if (!stores.objectEmbeddingStore) absent.push("embeddings");
34068
- if (!stores.groupStore) absent.push("groups");
34069
34072
  return absent;
34070
34073
  }
34071
34074
  function buildAnalyticsCascadeRegistry(stores) {
@@ -34073,7 +34076,6 @@ function buildAnalyticsCascadeRegistry(stores) {
34073
34076
  if (stores.faceStore) registry.push(stores.faceStore);
34074
34077
  if (stores.plateStore) registry.push(stores.plateStore);
34075
34078
  if (stores.objectEmbeddingStore) registry.push(stores.objectEmbeddingStore);
34076
- if (stores.groupStore) registry.push(stores.groupStore);
34077
34079
  return registry;
34078
34080
  }
34079
34081
  /**
@@ -47769,1419 +47771,6 @@ function shouldAutoReembed(samples, targetModelId, knownPixelless, passRunning)
47769
47771
  if (passRunning || targetModelId === null || targetModelId === "") return false;
47770
47772
  return samples.some((s) => s.modelId !== targetModelId && s.mediaKey !== void 0 && !knownPixelless.has(s.sampleId));
47771
47773
  }
47772
- var groupActions = defineCustomActions({
47773
- "analytics.listGroups": customAction(object({
47774
- deviceId: number().int().optional(),
47775
- sinceMs: number().int().optional(),
47776
- untilMs: number().int().optional(),
47777
- limit: number().int().min(1).max(100).optional(),
47778
- offset: number().int().min(0).optional()
47779
- }), object({
47780
- groups: array(AnalyticsGroupRecordSchema).readonly(),
47781
- hasMore: boolean()
47782
- })),
47783
- "analytics.getGroup": customAction(object({ groupId: string().min(1) }), object({ group: AnalyticsGroupDetailSchema.nullable() }))
47784
- });
47785
- var MATCH_WINDOW_MS = 1e3;
47786
- var MOVE_EPS = .35;
47787
- /** Live-assigner window: same predicate, recent positions only. */
47788
- var LIVE_WINDOW_MS = 3e3;
47789
- /** Registry stationary is out; audio has no bbox and is never a member or a singleton. */
47790
- function isEligibleMember(track) {
47791
- if (track.closedReason === "stationary-promotion") return false;
47792
- if (track.className === "audio") return false;
47793
- return true;
47794
- }
47795
- function diag(bbox) {
47796
- return Math.hypot(bbox.w, bbox.h);
47797
- }
47798
- function nearestPosition(positions, t) {
47799
- let best;
47800
- let bestDt = 1001;
47801
- for (const p of positions) {
47802
- const dt = Math.abs(p.timestamp - t);
47803
- if (dt <= 1e3 && dt < bestDt) {
47804
- best = p;
47805
- bestDt = dt;
47806
- }
47807
- }
47808
- return best;
47809
- }
47810
- function meanDiag(positions) {
47811
- if (positions.length === 0) return 0;
47812
- let sum = 0;
47813
- for (const p of positions) sum += diag(p.bbox);
47814
- return sum / positions.length;
47815
- }
47816
- function vecLen(v) {
47817
- return Math.hypot(v.x, v.y);
47818
- }
47819
- function cosine(a, b) {
47820
- const la = vecLen(a);
47821
- const lb = vecLen(b);
47822
- if (la === 0 || lb === 0) return 1;
47823
- return (a.x * b.x + a.y * b.y) / (la * lb);
47824
- }
47825
- function positionsInWindow(track, start, end) {
47826
- return track.positions.filter((p) => p.timestamp >= start && p.timestamp <= end);
47827
- }
47828
- function windowDisplacement(positions) {
47829
- const first = positions[0];
47830
- const last = positions[positions.length - 1];
47831
- if (!first || !last) return {
47832
- x: 0,
47833
- y: 0
47834
- };
47835
- const a = bboxCentroid(first.bbox);
47836
- const b = bboxCentroid(last.bbox);
47837
- return {
47838
- x: b.x - a.x,
47839
- y: b.y - a.y
47840
- };
47841
- }
47842
- function windowRelativeMotion(positions) {
47843
- const mean = meanDiag(positions);
47844
- if (mean <= 0) return 0;
47845
- return vecLen(windowDisplacement(positions)) / mean;
47846
- }
47847
- /**
47848
- * Same cohesion + crossing veto as {@link pairTogether}, over `[now − 3 s, now]`.
47849
- * Displacement/cosine use the window, not the whole track.
47850
- */
47851
- function pairTogetherNow(a, b, now) {
47852
- const start = now - LIVE_WINDOW_MS;
47853
- const overlapStart = Math.max(a.firstSeen, b.firstSeen, start);
47854
- const overlapEnd = Math.min(a.lastSeen, b.lastSeen, now);
47855
- if (overlapEnd < overlapStart) return false;
47856
- let joint = 0;
47857
- let close = 0;
47858
- for (let t = overlapStart; t <= overlapEnd; t += 500) {
47859
- const pa = nearestPosition(a.positions, t);
47860
- const pb = nearestPosition(b.positions, t);
47861
- if (!pa || !pb) continue;
47862
- joint++;
47863
- const ca = bboxCentroid(pa.bbox);
47864
- const cb = bboxCentroid(pb.bbox);
47865
- const gap = Math.hypot(ca.x - cb.x, ca.y - cb.y);
47866
- const scale = (diag(pa.bbox) + diag(pb.bbox)) / 2;
47867
- if ((scale > 0 ? gap / scale : Number.POSITIVE_INFINITY) <= 1.5) close++;
47868
- }
47869
- if (joint === 0) return false;
47870
- if (close / joint < .5) return false;
47871
- const wa = positionsInWindow(a, start, now);
47872
- const wb = positionsInWindow(b, start, now);
47873
- const aMoves = windowRelativeMotion(wa) >= MOVE_EPS;
47874
- const bMoves = windowRelativeMotion(wb) >= MOVE_EPS;
47875
- if (aMoves && bMoves && cosine(windowDisplacement(wa), windowDisplacement(wb)) < .3) return false;
47876
- return true;
47877
- }
47878
- var GroupLiveAssigner = class {
47879
- nextId;
47880
- snapshots = /* @__PURE__ */ new Map();
47881
- groups = /* @__PURE__ */ new Map();
47882
- membership = /* @__PURE__ */ new Map();
47883
- /** `${trackId}>${groupId}` → first instant the join predicate held. */
47884
- joinSince = /* @__PURE__ */ new Map();
47885
- /** trackId → first instant they failed pair against every remaining member. */
47886
- leaveSince = /* @__PURE__ */ new Map();
47887
- constructor(nextId = () => randomUUID()) {
47888
- this.nextId = nextId;
47889
- }
47890
- observe(track, now) {
47891
- this.snapshots.set(track.trackId, track);
47892
- if (!isEligibleMember(track)) {
47893
- this.removeFromGroup(track.trackId);
47894
- return;
47895
- }
47896
- if (!this.membership.has(track.trackId)) this.birthSingleton(track, now);
47897
- this.considerLeave(track, now);
47898
- const groupId = this.membership.get(track.trackId);
47899
- const group = groupId !== void 0 ? this.groups.get(groupId) : void 0;
47900
- if (group) for (const id of [...group.members]) {
47901
- if (id === track.trackId) continue;
47902
- const snap = this.snapshots.get(id);
47903
- if (snap) this.considerLeave(snap, now);
47904
- }
47905
- this.considerJoin(track, now);
47906
- }
47907
- groupOf(trackId) {
47908
- return this.membership.get(trackId);
47909
- }
47910
- snapshot(trackId) {
47911
- return this.snapshots.get(trackId);
47912
- }
47913
- liveGroups() {
47914
- return [...this.groups.values()].map((g) => this.toLive(g));
47915
- }
47916
- idleGroups(now, idleMs) {
47917
- return this.liveGroups().filter((g) => now >= g.lastActiveAt + idleMs);
47918
- }
47919
- drop(ids) {
47920
- for (const id of ids) {
47921
- const g = this.groups.get(id);
47922
- if (!g) continue;
47923
- for (const member of g.members) this.membership.delete(member);
47924
- this.groups.delete(id);
47925
- }
47926
- }
47927
- birthSingleton(track, now) {
47928
- const id = this.nextId();
47929
- this.groups.set(id, {
47930
- id,
47931
- deviceId: track.deviceId,
47932
- openedAt: now,
47933
- members: new Set([track.trackId])
47934
- });
47935
- this.membership.set(track.trackId, id);
47936
- }
47937
- considerLeave(track, now) {
47938
- const groupId = this.membership.get(track.trackId);
47939
- if (groupId === void 0) return;
47940
- const group = this.groups.get(groupId);
47941
- if (!group || group.members.size < 2) {
47942
- this.leaveSince.delete(track.trackId);
47943
- return;
47944
- }
47945
- if ([...group.members].filter((id) => id !== track.trackId).some((id) => {
47946
- const other = this.snapshots.get(id);
47947
- return other !== void 0 && pairTogetherNow(track, other, now);
47948
- })) {
47949
- this.leaveSince.delete(track.trackId);
47950
- return;
47951
- }
47952
- const since = this.leaveSince.get(track.trackId) ?? now;
47953
- this.leaveSince.set(track.trackId, since);
47954
- if (now - since < 3e3) return;
47955
- this.peel(track, now);
47956
- }
47957
- peel(track, now) {
47958
- this.removeFromGroup(track.trackId);
47959
- this.leaveSince.delete(track.trackId);
47960
- this.birthSingleton(track, now);
47961
- }
47962
- considerJoin(track, now) {
47963
- const mineId = this.membership.get(track.trackId);
47964
- if (mineId === void 0) return;
47965
- const mine = this.groups.get(mineId);
47966
- if (!mine || mine.members.size !== 1) {
47967
- this.clearJoinClocks(track.trackId);
47968
- return;
47969
- }
47970
- let best;
47971
- for (const group of this.groups.values()) {
47972
- if (group.id === mineId || group.deviceId !== track.deviceId) continue;
47973
- let gap = Number.POSITIVE_INFINITY;
47974
- let together = false;
47975
- for (const memberId of group.members) {
47976
- const other = this.snapshots.get(memberId);
47977
- if (!other) continue;
47978
- if (pairTogetherNow(track, other, now)) {
47979
- together = true;
47980
- gap = Math.min(gap, centroidGap(track, other, now));
47981
- }
47982
- }
47983
- if (!together) continue;
47984
- if (best === void 0 || gap < best.gap) best = {
47985
- groupId: group.id,
47986
- gap
47987
- };
47988
- }
47989
- if (best === void 0) {
47990
- this.clearJoinClocks(track.trackId);
47991
- return;
47992
- }
47993
- const key = `${track.trackId}>${best.groupId}`;
47994
- for (const existing of [...this.joinSince.keys()]) if (existing.startsWith(`${track.trackId}>`) && existing !== key) this.joinSince.delete(existing);
47995
- const since = this.joinSince.get(key) ?? now;
47996
- this.joinSince.set(key, since);
47997
- if (now - since < 1500) return;
47998
- this.absorb(track.trackId, mineId, best.groupId);
47999
- }
48000
- absorb(trackId, fromId, intoId) {
48001
- const into = this.groups.get(intoId);
48002
- if (!into) return;
48003
- into.members.add(trackId);
48004
- this.membership.set(trackId, intoId);
48005
- this.groups.delete(fromId);
48006
- this.clearJoinClocks(trackId);
48007
- this.leaveSince.delete(trackId);
48008
- }
48009
- removeFromGroup(trackId) {
48010
- const groupId = this.membership.get(trackId);
48011
- this.membership.delete(trackId);
48012
- this.clearJoinClocks(trackId);
48013
- this.leaveSince.delete(trackId);
48014
- if (groupId === void 0) return;
48015
- const group = this.groups.get(groupId);
48016
- if (!group) return;
48017
- group.members.delete(trackId);
48018
- if (group.members.size === 0) this.groups.delete(groupId);
48019
- }
48020
- clearJoinClocks(trackId) {
48021
- for (const key of [...this.joinSince.keys()]) if (key.startsWith(`${trackId}>`)) this.joinSince.delete(key);
48022
- }
48023
- toLive(group) {
48024
- let lastActiveAt = group.openedAt;
48025
- for (const id of group.members) {
48026
- const snap = this.snapshots.get(id);
48027
- if (snap && snap.lastSeen > lastActiveAt) lastActiveAt = snap.lastSeen;
48028
- }
48029
- return {
48030
- id: group.id,
48031
- deviceId: group.deviceId,
48032
- memberTrackIds: [...group.members],
48033
- openedAt: group.openedAt,
48034
- lastActiveAt
48035
- };
48036
- }
48037
- };
48038
- function centroidGap(a, b, now) {
48039
- const pa = a.positions.reduce((best, p) => Math.abs(p.timestamp - now) < Math.abs(best.timestamp - now) ? p : best, a.positions[0]);
48040
- const pb = b.positions.reduce((best, p) => Math.abs(p.timestamp - now) < Math.abs(best.timestamp - now) ? p : best, b.positions[0]);
48041
- if (!pa || !pb) return Number.POSITIVE_INFINITY;
48042
- const ca = bboxCentroid(pa.bbox);
48043
- const cb = bboxCentroid(pb.bbox);
48044
- return Math.hypot(ca.x - cb.x, ca.y - cb.y);
48045
- }
48046
- //#endregion
48047
- //#region src/pipeline-analytics/group/group-session.ts
48048
- /** Same idle as the notification burst (`NC_GROUP_DEFAULT_IDLE_SEC`). */
48049
- var GROUP_SESSION_IDLE_MS = 3e4;
48050
- //#endregion
48051
- //#region src/pipeline-analytics/group/group-live-buffer.ts
48052
- function toGroupableTrack(track) {
48053
- return {
48054
- trackId: track.trackId,
48055
- deviceId: track.deviceId,
48056
- className: track.className,
48057
- firstSeen: track.firstSeen,
48058
- lastSeen: track.lastSeen,
48059
- positions: track.positions.map((p) => ({
48060
- ...p,
48061
- bbox: { ...p.bbox }
48062
- })),
48063
- ...track.closedReason !== void 0 ? { closedReason: track.closedReason } : {}
48064
- };
48065
- }
48066
- var GroupLiveBuffer = class {
48067
- assigner;
48068
- primed = /* @__PURE__ */ new Set();
48069
- constructor(nextId) {
48070
- this.assigner = new GroupLiveAssigner(nextId);
48071
- }
48072
- observe(track, now) {
48073
- this.assigner.observe(track, now);
48074
- }
48075
- groupOf(trackId) {
48076
- return this.assigner.groupOf(trackId);
48077
- }
48078
- snapshot(trackId) {
48079
- return this.assigner.snapshot(trackId);
48080
- }
48081
- liveGroups() {
48082
- return this.assigner.liveGroups();
48083
- }
48084
- idleGroups(now, idleMs = GROUP_SESSION_IDLE_MS) {
48085
- return this.assigner.idleGroups(now, idleMs);
48086
- }
48087
- drop(ids) {
48088
- this.assigner.drop(ids);
48089
- }
48090
- isPrimed(deviceId) {
48091
- return this.primed.has(deviceId);
48092
- }
48093
- markPrimed(deviceId) {
48094
- this.primed.add(deviceId);
48095
- }
48096
- };
48097
- function trackToLite(track) {
48098
- return {
48099
- trackId: track.trackId,
48100
- className: track.className,
48101
- classes: track.classes,
48102
- firstSeen: track.firstSeen,
48103
- lastSeen: track.lastSeen
48104
- };
48105
- }
48106
- /**
48107
- * Gallery and digest read the same ladder: groupCrop, then the native-frame
48108
- * pointer, then a member's clean full frame. Plate / face / member
48109
- * thumbnails never reach the picker — a plate read overwrites the vehicle
48110
- * first-level crop under the same `thumbnail` kind. A multi with no crop
48111
- * still has a picture if the sealer stored `nativeKey`.
48112
- */
48113
- var DIGEST_MEMBER_FALLBACK_KINDS = new Set([
48114
- "keyFrame",
48115
- "keyFrameSmall",
48116
- "fullFrame"
48117
- ]);
48118
- function digestMediaRows(input) {
48119
- const owned = [];
48120
- if (input.mediaKey !== null && input.mediaKey.length > 0) owned.push({
48121
- key: input.mediaKey,
48122
- kind: "groupCrop",
48123
- timestamp: input.timestamp,
48124
- sizeBytes: null
48125
- });
48126
- if (input.nativeKey !== null && input.nativeKey.length > 0) owned.push({
48127
- key: input.nativeKey,
48128
- kind: "nativeFrame",
48129
- timestamp: input.timestamp,
48130
- sizeBytes: null
48131
- });
48132
- if (owned.length > 0) return owned;
48133
- return input.memberRows.filter((row) => DIGEST_MEMBER_FALLBACK_KINDS.has(row.kind));
48134
- }
48135
- //#endregion
48136
- //#region src/pipeline-analytics/group/group-crop.ts
48137
- /** Always containSubject: true. Never the 0.5 delivered threshold. */
48138
- function groupWideCentralSquareLayout(unionBbox, frame) {
48139
- return wideCentralSquareLayout(unionBbox, frame, { containSubject: true });
48140
- }
48141
- async function cropGroupFromRgb(rgb, frameWidth, frameHeight, unionBbox) {
48142
- const layout = groupWideCentralSquareLayout(unionBbox, {
48143
- W: frameWidth,
48144
- H: frameHeight
48145
- });
48146
- const { crop: slab } = await extractCrop(rgb, frameWidth, frameHeight, layout.fetch);
48147
- return composeWideCentralSquareThumbnail(slab, layout);
48148
- }
48149
- //#endregion
48150
- //#region src/pipeline-analytics/group/group-instant.ts
48151
- /**
48152
- * Chosen instant + union bbox for a group, at close, over stored positions[]
48153
- * (`docs/design/2026-08-21-group-entity-and-group-media.md` §4.1).
48154
- *
48155
- * Tier order — the first that separates two candidates decides:
48156
- * 1. completeness (members with a position within ±1 s)
48157
- * 2. square containment of the union (D212)
48158
- * 3. semantic (a member's confirmed recognition)
48159
- * 4. edge-clear (union touches no frame border)
48160
- * 5. effective score = mean(confidence) × containment × sizeFactor
48161
- *
48162
- * Retrospective. No new capture path.
48163
- */
48164
- var GROUP_MEDIA_MATCH_MS = 2e3;
48165
- function nearest(positions, t, windowMs) {
48166
- let best;
48167
- let bestDt = windowMs + 1;
48168
- for (const p of positions) {
48169
- const dt = Math.abs(p.timestamp - t);
48170
- if (dt <= windowMs && dt < bestDt) {
48171
- best = p;
48172
- bestDt = dt;
48173
- }
48174
- }
48175
- return best;
48176
- }
48177
- function unionOf(boxes) {
48178
- if (boxes.length === 0) return null;
48179
- let x0 = Infinity;
48180
- let y0 = Infinity;
48181
- let x1 = -Infinity;
48182
- let y1 = -Infinity;
48183
- for (const b of boxes) {
48184
- x0 = Math.min(x0, b.x);
48185
- y0 = Math.min(y0, b.y);
48186
- x1 = Math.max(x1, b.x + b.w);
48187
- y1 = Math.max(y1, b.y + b.h);
48188
- }
48189
- return {
48190
- x: x0,
48191
- y: y0,
48192
- w: x1 - x0,
48193
- h: y1 - y0
48194
- };
48195
- }
48196
- function touchesBorder(box, frame, tol = 1) {
48197
- return box.x <= tol || box.y <= tol || box.x + box.w >= frame.width - tol || box.y + box.h >= frame.height - tol;
48198
- }
48199
- function scoreInstant(members, t, frame) {
48200
- const boxes = [];
48201
- let semantic = false;
48202
- let confSum = 0;
48203
- let confN = 0;
48204
- for (const m of members) {
48205
- const p = nearest(m.positions, t, MATCH_WINDOW_MS);
48206
- if (!p) continue;
48207
- boxes.push(p.bbox);
48208
- if (m.semantic === true) semantic = true;
48209
- if (m.confidence !== void 0) {
48210
- confSum += m.confidence;
48211
- confN += 1;
48212
- }
48213
- }
48214
- const union = unionOf(boxes);
48215
- if (!union) return null;
48216
- const square = centralSquareContainmentFraction(union, {
48217
- W: frame.width,
48218
- H: frame.height
48219
- });
48220
- const containment = bboxContainment(union, frame.width, frame.height);
48221
- const size = bboxSizeScore(union, frame.width, frame.height);
48222
- const meanConf = confN > 0 ? confSum / confN : 1;
48223
- return {
48224
- timestamp: t,
48225
- union,
48226
- visibleCount: boxes.length,
48227
- square,
48228
- semantic,
48229
- edgeClear: !touchesBorder(union, frame),
48230
- effective: meanConf * containment * size
48231
- };
48232
- }
48233
- function better(a, b) {
48234
- if (a.visibleCount !== b.visibleCount) return a.visibleCount > b.visibleCount ? a : b;
48235
- if (a.square !== b.square) return a.square > b.square ? a : b;
48236
- if (a.semantic !== b.semantic) return a.semantic ? a : b;
48237
- if (a.edgeClear !== b.edgeClear) return a.edgeClear ? a : b;
48238
- if (a.effective !== b.effective) return a.effective > b.effective ? a : b;
48239
- return a.timestamp <= b.timestamp ? a : b;
48240
- }
48241
- function chooseGroupInstant(members, frame) {
48242
- if (members.length === 0) return null;
48243
- const candidates = /* @__PURE__ */ new Set();
48244
- for (const m of members) for (const p of m.positions) candidates.add(p.timestamp);
48245
- let best = null;
48246
- for (const t of candidates) {
48247
- const scored = scoreInstant(members, t, frame);
48248
- if (!scored) continue;
48249
- best = best === null ? scored : better(scored, best);
48250
- }
48251
- if (!best) return null;
48252
- const w = Math.max(frame.width, 1);
48253
- const h = Math.max(frame.height, 1);
48254
- return {
48255
- timestamp: best.timestamp,
48256
- union: best.union,
48257
- ux: best.union.x / w,
48258
- uy: best.union.y / h,
48259
- uw: best.union.w / w,
48260
- uh: best.union.h / h,
48261
- visibleCount: best.visibleCount
48262
- };
48263
- }
48264
- /** Longest-lived member that owns a clean full frame; ties break on trackId. */
48265
- function chooseAnchorTrackId(members, ownsCleanFrame) {
48266
- const eligible = members.filter((m) => ownsCleanFrame(m.trackId));
48267
- if (eligible.length === 0) return null;
48268
- return eligible.toSorted((a, b) => {
48269
- const life = b.lastSeen - b.firstSeen - (a.lastSeen - a.firstSeen);
48270
- return life !== 0 ? life : a.trackId.localeCompare(b.trackId);
48271
- })[0].trackId;
48272
- }
48273
- /**
48274
- * The RGB source for a group crop is a full native frame. Thumbnails are
48275
- * already single-subject crops and cannot substitute for union-bbox geometry.
48276
- *
48277
- * Prefer a keyFrame within {@link GROUP_MEDIA_MATCH_MS} of the chosen instant;
48278
- * if none, take the nearest keyFrame anyway — dropping the crop because the
48279
- * best-shot was 3 s off the instant is how a recognised person left the
48280
- * digest with no crop.
48281
- */
48282
- function pickCleanPixelRow(rows, instantMs) {
48283
- const ranked = (maxDt) => {
48284
- const candidates = [];
48285
- for (const row of rows) {
48286
- const rank = row.kind === "keyFrame" ? 0 : row.kind === "keyFrameSmall" ? 1 : -1;
48287
- if (rank < 0) continue;
48288
- const dt = Math.abs(row.timestamp - instantMs);
48289
- if (maxDt !== null && dt > maxDt) continue;
48290
- candidates.push({
48291
- key: row.key,
48292
- dt,
48293
- rank
48294
- });
48295
- }
48296
- return candidates.toSorted((a, b) => a.rank - b.rank || a.dt - b.dt)[0];
48297
- };
48298
- const near = ranked(GROUP_MEDIA_MATCH_MS);
48299
- if (near !== void 0) return {
48300
- key: near.key,
48301
- usedFallback: false
48302
- };
48303
- const any = ranked(null);
48304
- if (any === void 0) return null;
48305
- return {
48306
- key: any.key,
48307
- usedFallback: true
48308
- };
48309
- }
48310
- //#endregion
48311
- //#region src/pipeline-analytics/group/group-live-replay.ts
48312
- function sliceTrackAt(track, now) {
48313
- const positions = track.positions.filter((p) => p.timestamp <= now);
48314
- const last = positions[positions.length - 1];
48315
- return {
48316
- ...track,
48317
- lastSeen: last?.timestamp ?? track.firstSeen,
48318
- positions
48319
- };
48320
- }
48321
- function replayInto(tracks, observer) {
48322
- const events = [];
48323
- const byId = /* @__PURE__ */ new Map();
48324
- for (const track of tracks) {
48325
- byId.set(track.trackId, track);
48326
- if (track.positions.length === 0) {
48327
- events.push({
48328
- t: track.lastSeen,
48329
- trackId: track.trackId
48330
- });
48331
- continue;
48332
- }
48333
- for (const p of track.positions) events.push({
48334
- t: p.timestamp,
48335
- trackId: track.trackId
48336
- });
48337
- }
48338
- events.sort((a, b) => a.t - b.t || a.trackId.localeCompare(b.trackId));
48339
- let lastKey = "";
48340
- for (const e of events) {
48341
- const key = `${e.trackId}@${e.t}`;
48342
- if (key === lastKey) continue;
48343
- lastKey = key;
48344
- const track = byId.get(e.trackId);
48345
- if (!track) continue;
48346
- observer.observe(sliceTrackAt(track, e.t), e.t);
48347
- }
48348
- }
48349
- function replayLive(tracks, assigner = new GroupLiveAssigner()) {
48350
- replayInto(tracks, assigner);
48351
- return assigner;
48352
- }
48353
- //#endregion
48354
- //#region src/pipeline-analytics/group/group-sealer.ts
48355
- var DEFAULT_FRAME = {
48356
- width: 640,
48357
- height: 360
48358
- };
48359
- var QUERY_LIMIT = 2e3;
48360
- /** Crash recover: replay this camera only, on its next activity. */
48361
- var GROUP_CRASH_LOOKBACK_MS = 2 * 6e4;
48362
- function toInstant(t) {
48363
- return {
48364
- trackId: t.trackId,
48365
- deviceId: t.deviceId,
48366
- className: t.className,
48367
- firstSeen: t.firstSeen,
48368
- lastSeen: t.lastSeen,
48369
- positions: t.positions,
48370
- ...t.closedReason !== void 0 ? { closedReason: t.closedReason } : {},
48371
- semantic: t.hasFace === true || t.hasPlate === true,
48372
- confidence: t.importance
48373
- };
48374
- }
48375
- function memberBoxesAt(members, at, frame) {
48376
- const w = Math.max(frame.width, 1);
48377
- const h = Math.max(frame.height, 1);
48378
- const boxes = [];
48379
- for (const member of members) {
48380
- let best = member.positions[0];
48381
- let bestDt = Number.POSITIVE_INFINITY;
48382
- for (const p of member.positions) {
48383
- const dt = Math.abs(p.timestamp - at);
48384
- if (dt <= 1e3 && dt < bestDt) {
48385
- best = p;
48386
- bestDt = dt;
48387
- }
48388
- }
48389
- if (best === void 0 || bestDt === Number.POSITIVE_INFINITY) continue;
48390
- boxes.push({
48391
- bbox: {
48392
- x: best.bbox.x / w,
48393
- y: best.bbox.y / h,
48394
- w: best.bbox.w / w,
48395
- h: best.bbox.h / h
48396
- },
48397
- label: member.className,
48398
- observedAt: best.timestamp
48399
- });
48400
- }
48401
- return boxes;
48402
- }
48403
- function toSealer(track) {
48404
- return {
48405
- trackId: track.trackId,
48406
- deviceId: track.deviceId,
48407
- className: track.className,
48408
- firstSeen: track.firstSeen,
48409
- lastSeen: track.lastSeen,
48410
- positions: track.positions,
48411
- ...track.closedReason !== void 0 ? { closedReason: track.closedReason } : {}
48412
- };
48413
- }
48414
- var GroupSealer = class {
48415
- deps;
48416
- constructor(deps) {
48417
- this.deps = deps;
48418
- }
48419
- get processEnabled() {
48420
- return this.deps.enabled ?? false;
48421
- }
48422
- async flushIdle(buffer, now = this.deps.now()) {
48423
- if (!this.processEnabled) return 0;
48424
- let written = 0;
48425
- for (const group of buffer.idleGroups(now, GROUP_SESSION_IDLE_MS)) if (await this.persistLiveGroup(group, buffer)) {
48426
- buffer.drop([group.id]);
48427
- written += 1;
48428
- }
48429
- return written;
48430
- }
48431
- /**
48432
- * First activity on camera D after a crash: replay 2 minutes of that camera
48433
- * into the live buffer, persist whoever is already idle, keep the rest in RAM.
48434
- */
48435
- async recoverCamera(deviceId, buffer) {
48436
- if (!this.processEnabled) return 0;
48437
- if (buffer.isPrimed(deviceId)) return 0;
48438
- const now = this.deps.now();
48439
- try {
48440
- replayInto(await this.deps.trackStore.queryHistorical({
48441
- deviceId,
48442
- since: now - GROUP_CRASH_LOOKBACK_MS,
48443
- until: now,
48444
- limit: QUERY_LIMIT,
48445
- projection: "full"
48446
- }), buffer);
48447
- } catch (err) {
48448
- this.deps.logger.warn("group sealer: lookback failed", {
48449
- tags: { deviceId },
48450
- meta: { error: String(err) }
48451
- });
48452
- } finally {
48453
- buffer.markPrimed(deviceId);
48454
- }
48455
- return this.flushIdle(buffer, now);
48456
- }
48457
- /**
48458
- * Seal ONE camera over a caller-owned window via live replay.
48459
- *
48460
- * This is how a digest recovers groups without a boot scan: the producer
48461
- * already knows the window. Walking every camera for 48h at initialize()
48462
- * is what wedged hub-main.
48463
- */
48464
- async sealWindow(deviceId, since, until) {
48465
- if (!this.processEnabled) return 0;
48466
- try {
48467
- const closed = await this.deps.trackStore.queryHistorical({
48468
- deviceId,
48469
- since,
48470
- until,
48471
- limit: QUERY_LIMIT,
48472
- projection: "full"
48473
- });
48474
- if (closed.length === 0) return 0;
48475
- const already = new Set((await this.deps.groupStore.membersOfTracks(closed.map((t) => t.trackId))).map((m) => m.trackId));
48476
- const pending = closed.filter((t) => !already.has(t.trackId));
48477
- if (pending.length === 0) return 0;
48478
- const assigner = replayLive(pending);
48479
- const now = this.deps.now();
48480
- const byId = new Map(pending.map((t) => [t.trackId, t]));
48481
- let written = 0;
48482
- for (const group of assigner.idleGroups(now, GROUP_SESSION_IDLE_MS)) {
48483
- const members = group.memberTrackIds.map((id) => byId.get(id)).filter((t) => t !== void 0);
48484
- if (members.length === 0) continue;
48485
- try {
48486
- await this.persistPartition(members, group.openedAt);
48487
- written += 1;
48488
- } catch (err) {
48489
- this.deps.logger.warn("group sealer: persist failed", {
48490
- tags: { deviceId },
48491
- meta: {
48492
- members: members.map((m) => m.trackId),
48493
- error: String(err)
48494
- }
48495
- });
48496
- }
48497
- }
48498
- return written;
48499
- } catch (err) {
48500
- this.deps.logger.warn("group sealer: device failed", {
48501
- tags: { deviceId },
48502
- meta: { error: String(err) }
48503
- });
48504
- return 0;
48505
- }
48506
- }
48507
- async persistLiveGroup(group, buffer) {
48508
- const deviceId = group.deviceId;
48509
- const snaps = group.memberTrackIds.map((id) => buffer.snapshot(id)).filter((t) => t !== void 0);
48510
- if (snaps.length === 0) return true;
48511
- const already = new Set((await this.deps.groupStore.membersOfTracks(snaps.map((t) => t.trackId))).map((m) => m.trackId));
48512
- const pending = snaps.filter((t) => !already.has(t.trackId)).map(toSealer);
48513
- if (pending.length === 0) return true;
48514
- try {
48515
- await this.persistPartition(pending, group.openedAt);
48516
- return true;
48517
- } catch (err) {
48518
- this.deps.logger.warn("group sealer: persist failed", {
48519
- tags: { deviceId },
48520
- meta: {
48521
- members: pending.map((m) => m.trackId),
48522
- error: String(err)
48523
- }
48524
- });
48525
- return false;
48526
- }
48527
- }
48528
- async persistPartition(members, openedAt) {
48529
- const deviceId = members[0].deviceId;
48530
- const frame = this.deps.frameDims(deviceId) ?? DEFAULT_FRAME;
48531
- const instantMembers = members.map(toInstant);
48532
- const instant = chooseGroupInstant(instantMembers, frame);
48533
- const closedAt = Math.max(...members.map((m) => m.lastSeen));
48534
- const timestamp = instant?.timestamp ?? closedAt;
48535
- const mediaInfos = await Promise.all(members.map(async (m) => ({
48536
- trackId: m.trackId,
48537
- rows: await this.deps.mediaStore.listInfoByOwner("track", m.trackId)
48538
- })));
48539
- const ownsClean = (trackId) => {
48540
- return mediaInfos.find((m) => m.trackId === trackId)?.rows.some((r) => r.kind === "keyFrame" || r.kind === "keyFrameSmall") ?? false;
48541
- };
48542
- const anchorTrackId = members.length >= 2 ? chooseAnchorTrackId(instantMembers, ownsClean) : null;
48543
- let nativeKey = null;
48544
- if (anchorTrackId !== null) {
48545
- const rows = mediaInfos.find((m) => m.trackId === anchorTrackId)?.rows ?? [];
48546
- nativeKey = (rows.find((r) => r.kind === "keyFrame") ?? rows.find((r) => r.kind === "keyFrameSmall"))?.key ?? null;
48547
- }
48548
- const draft = await this.deps.groupStore.insert({
48549
- deviceId,
48550
- timestamp,
48551
- openedAt,
48552
- closedAt,
48553
- members: members.map((m) => ({
48554
- trackId: m.trackId,
48555
- firstSeen: m.firstSeen
48556
- })),
48557
- anchorTrackId,
48558
- ux: instant?.ux ?? 0,
48559
- uy: instant?.uy ?? 0,
48560
- uw: instant?.uw ?? 0,
48561
- uh: instant?.uh ?? 0,
48562
- frameWidth: frame.width,
48563
- frameHeight: frame.height,
48564
- mediaKey: null,
48565
- nativeKey
48566
- });
48567
- if (members.length < 2 || instant === null) return;
48568
- const source = await this.pickCleanPixels(mediaInfos, timestamp, deviceId);
48569
- if (source === null) {
48570
- this.deps.logger.info("group sealer: no clean pixels for crop", {
48571
- tags: { deviceId },
48572
- meta: {
48573
- groupId: draft.id,
48574
- members: members.map((m) => m.trackId)
48575
- }
48576
- });
48577
- return;
48578
- }
48579
- try {
48580
- const raw = await sharp(source.jpeg).removeAlpha().toColorspace("srgb").raw().toBuffer({ resolveWithObject: true });
48581
- if (raw.info.channels === 3) {
48582
- const union = {
48583
- x: instant.ux * raw.info.width,
48584
- y: instant.uy * raw.info.height,
48585
- w: instant.uw * raw.info.width,
48586
- h: instant.uh * raw.info.height
48587
- };
48588
- const crop = await cropGroupFromRgb(raw.data, raw.info.width, raw.info.height, union);
48589
- const mediaKey = await this.deps.mediaStore.put({
48590
- deviceId,
48591
- ownerKind: "group",
48592
- ownerId: draft.id,
48593
- kind: "thumbnail",
48594
- timestamp,
48595
- data: crop
48596
- });
48597
- await this.deps.groupStore.setMediaKey(draft.id, mediaKey);
48598
- }
48599
- } catch (err) {
48600
- this.deps.logger.warn("group sealer: crop failed", {
48601
- tags: { deviceId },
48602
- meta: {
48603
- groupId: draft.id,
48604
- error: String(err)
48605
- }
48606
- });
48607
- }
48608
- try {
48609
- const composed = await composeGroupFrame({
48610
- jpeg: source.jpeg,
48611
- boxes: memberBoxesAt(instantMembers, timestamp, frame),
48612
- at: timestamp,
48613
- logger: this.deps.logger,
48614
- deviceId,
48615
- groupKey: draft.id
48616
- });
48617
- if (composed === null) return;
48618
- await this.deps.mediaStore.put({
48619
- deviceId,
48620
- ownerKind: "group",
48621
- ownerId: draft.id,
48622
- kind: "fullFrameBoxed",
48623
- timestamp,
48624
- data: composed.bytes
48625
- });
48626
- } catch (err) {
48627
- this.deps.logger.warn("group sealer: boxed frame failed", {
48628
- tags: { deviceId },
48629
- meta: {
48630
- groupId: draft.id,
48631
- error: String(err)
48632
- }
48633
- });
48634
- }
48635
- }
48636
- async pickCleanPixels(mediaInfos, instantMs, deviceId) {
48637
- const chosen = pickCleanPixelRow(mediaInfos.flatMap((info) => info.rows), instantMs);
48638
- if (chosen === null) return null;
48639
- if (chosen.usedFallback) this.deps.logger.info("group sealer: crop pixels not within 2s of instant, using nearest keyFrame", {
48640
- tags: { deviceId },
48641
- meta: {
48642
- instantMs,
48643
- mediaKey: chosen.key
48644
- }
48645
- });
48646
- const file = await this.deps.mediaStore.getByKey(chosen.key);
48647
- if (file === null) return null;
48648
- return { jpeg: Buffer.from(file.base64, "base64") };
48649
- }
48650
- };
48651
- //#endregion
48652
- //#region src/pipeline-analytics/group/group-cursor.ts
48653
- function encodeGroupCursor(cursor) {
48654
- return Buffer.from(JSON.stringify({
48655
- c: cursor.closedAt,
48656
- i: cursor.groupId
48657
- }), "utf8").toString("base64url");
48658
- }
48659
- function decodeGroupCursor(raw) {
48660
- let parsed;
48661
- try {
48662
- parsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
48663
- } catch {
48664
- throw new Error("listGroups: malformed cursor");
48665
- }
48666
- 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");
48667
- return {
48668
- closedAt: parsed.c,
48669
- groupId: parsed.i
48670
- };
48671
- }
48672
- function compareGroupDesc(a, b) {
48673
- if (a.closedAt !== b.closedAt) return b.closedAt - a.closedAt;
48674
- if (a.id === b.id) return 0;
48675
- return a.id < b.id ? 1 : -1;
48676
- }
48677
- /** True when `row` sits strictly AFTER the cursor in DESC order. */
48678
- function isAfterGroupCursor(row, cursor) {
48679
- if (row.closedAt < cursor.closedAt) return true;
48680
- return row.closedAt === cursor.closedAt && row.id < cursor.groupId;
48681
- }
48682
- //#endregion
48683
- //#region src/pipeline-analytics/group/group-store.ts
48684
- /**
48685
- * The group ENTITY store — a co-moving partition of one session, with one
48686
- * picture of its own (`docs/design/2026-08-21-group-entity-and-group-media.md`).
48687
- *
48688
- * Two tables, two questions:
48689
- *
48690
- * - `pipeline-analytics:track-groups` is the group row. It has N members and
48691
- * no `trackId` column; it is owned by its members (owner-indirection).
48692
- * - `pipeline-analytics:track-group-members` is one row per member. The column
48693
- * is named exactly `trackId` so the classification derives `track-owned`.
48694
- *
48695
- * `anchorTrackId` and `nativeKey` are the only fields mutable after birth:
48696
- * they are nulled when the anchor member is deleted alone. Everything else is
48697
- * written once. Re-anchoring is deliberately not done.
48698
- *
48699
- * No age clock. A group dies when its last member does; a group with one live
48700
- * member does not, and it never keeps a dead link.
48701
- */
48702
- /**
48703
- * @durable class=ledger owner=pipeline-analytics
48704
- * write="one row per co-moving group, sealed at idle — never a live row.
48705
- * Membership is the RAM members at persist, not a session partition. mediaKey is
48706
- * the group's own crop; nativeKey is a POINTER to the anchor member's
48707
- * keyFrame, nulled if that member is deleted alone. anchorTrackId and
48708
- * nativeKey are the only fields rewritten after birth."
48709
- * retention="NO age clock (D61). Rows leave with their last member through
48710
- * TrackGroupStore.deleteByTracks: memberships first; zero survivors
48711
- * deletes the group's media then the row; survivors plus a lost anchor
48712
- * null the pointer. The orphan audit verifies the same via a second
48713
- * resolver (groupId → any member still present)."
48714
- */
48715
- var TRACK_GROUPS_COLLECTION = "pipeline-analytics:track-groups";
48716
- /**
48717
- * @durable class=ledger owner=pipeline-analytics
48718
- * write="one row per member at group birth — id is '<groupId>:<trackId>'.
48719
- * timestamp is the member's firstSeen so the orphan audit has a clock
48720
- * column. Never rewritten."
48721
- * retention="TRACK-OWNED and clockless (D61): the column is named exactly
48722
- * trackId, so classification derives ownership and the cascade deletes
48723
- * the row with its track. A group with surviving members stays."
48724
- */
48725
- var TRACK_GROUP_MEMBERS_COLLECTION = "pipeline-analytics:track-group-members";
48726
- var TRACK_GROUPS_COLUMNS = [
48727
- {
48728
- name: "id",
48729
- type: "TEXT",
48730
- primaryKey: true,
48731
- notNull: true
48732
- },
48733
- {
48734
- name: "deviceId",
48735
- type: "INTEGER",
48736
- notNull: true
48737
- },
48738
- {
48739
- name: "timestamp",
48740
- type: "INTEGER",
48741
- notNull: true
48742
- },
48743
- {
48744
- name: "openedAt",
48745
- type: "INTEGER",
48746
- notNull: true
48747
- },
48748
- {
48749
- name: "closedAt",
48750
- type: "INTEGER",
48751
- notNull: true
48752
- },
48753
- {
48754
- name: "memberCount",
48755
- type: "INTEGER",
48756
- notNull: true
48757
- },
48758
- {
48759
- name: "anchorTrackId",
48760
- type: "TEXT"
48761
- },
48762
- {
48763
- name: "ux",
48764
- type: "REAL",
48765
- notNull: true
48766
- },
48767
- {
48768
- name: "uy",
48769
- type: "REAL",
48770
- notNull: true
48771
- },
48772
- {
48773
- name: "uw",
48774
- type: "REAL",
48775
- notNull: true
48776
- },
48777
- {
48778
- name: "uh",
48779
- type: "REAL",
48780
- notNull: true
48781
- },
48782
- {
48783
- name: "frameWidth",
48784
- type: "INTEGER",
48785
- notNull: true
48786
- },
48787
- {
48788
- name: "frameHeight",
48789
- type: "INTEGER",
48790
- notNull: true
48791
- },
48792
- {
48793
- name: "mediaKey",
48794
- type: "TEXT"
48795
- },
48796
- {
48797
- name: "nativeKey",
48798
- type: "TEXT"
48799
- },
48800
- {
48801
- name: "sealed",
48802
- type: "BOOLEAN",
48803
- notNull: true
48804
- }
48805
- ];
48806
- var TRACK_GROUP_MEMBERS_COLUMNS = [
48807
- {
48808
- name: "id",
48809
- type: "TEXT",
48810
- primaryKey: true,
48811
- notNull: true
48812
- },
48813
- {
48814
- name: "groupId",
48815
- type: "TEXT",
48816
- notNull: true
48817
- },
48818
- {
48819
- name: "trackId",
48820
- type: "TEXT",
48821
- notNull: true
48822
- },
48823
- {
48824
- name: "deviceId",
48825
- type: "INTEGER",
48826
- notNull: true
48827
- },
48828
- {
48829
- name: "timestamp",
48830
- type: "INTEGER",
48831
- notNull: true
48832
- }
48833
- ];
48834
- var TRACK_GROUPS_INDEXES = [{
48835
- name: "idx_track_groups_device_closed",
48836
- columns: ["deviceId", "closedAt"]
48837
- }, {
48838
- name: "idx_track_groups_device_opened",
48839
- columns: ["deviceId", "openedAt"]
48840
- }];
48841
- var TRACK_GROUP_MEMBERS_INDEXES = [{
48842
- name: "idx_track_group_members_track",
48843
- columns: ["trackId"]
48844
- }, {
48845
- name: "idx_track_group_members_group",
48846
- columns: ["groupId"]
48847
- }];
48848
- function newGroupId() {
48849
- return `grp-${randomUUID()}`;
48850
- }
48851
- function memberRowId(groupId, trackId) {
48852
- return `${groupId}:${trackId}`;
48853
- }
48854
- var TrackGroupStore = class {
48855
- store;
48856
- logger;
48857
- media;
48858
- constructor(deps) {
48859
- this.store = deps.store;
48860
- this.logger = deps.logger;
48861
- this.media = deps.media ?? null;
48862
- }
48863
- static async declare(store) {
48864
- await Promise.all([store.declareCollection.mutate({
48865
- collection: TRACK_GROUPS_COLLECTION,
48866
- columns: [...TRACK_GROUPS_COLUMNS],
48867
- indexes: [...TRACK_GROUPS_INDEXES]
48868
- }), store.declareCollection.mutate({
48869
- collection: TRACK_GROUP_MEMBERS_COLLECTION,
48870
- columns: [...TRACK_GROUP_MEMBERS_COLUMNS],
48871
- indexes: [...TRACK_GROUP_MEMBERS_INDEXES]
48872
- })]);
48873
- }
48874
- async insert(input) {
48875
- const id = newGroupId();
48876
- const group = {
48877
- id,
48878
- deviceId: input.deviceId,
48879
- timestamp: input.timestamp,
48880
- openedAt: input.openedAt,
48881
- closedAt: input.closedAt,
48882
- memberCount: input.members.length,
48883
- anchorTrackId: input.anchorTrackId,
48884
- ux: input.ux,
48885
- uy: input.uy,
48886
- uw: input.uw,
48887
- uh: input.uh,
48888
- frameWidth: input.frameWidth,
48889
- frameHeight: input.frameHeight,
48890
- mediaKey: input.mediaKey,
48891
- nativeKey: input.nativeKey,
48892
- sealed: true
48893
- };
48894
- await this.store.set.mutate({
48895
- collection: TRACK_GROUPS_COLLECTION,
48896
- key: id,
48897
- value: this.toRow(group)
48898
- });
48899
- for (const member of input.members) await this.store.set.mutate({
48900
- collection: TRACK_GROUP_MEMBERS_COLLECTION,
48901
- key: memberRowId(id, member.trackId),
48902
- value: {
48903
- groupId: id,
48904
- trackId: member.trackId,
48905
- deviceId: input.deviceId,
48906
- timestamp: member.firstSeen
48907
- }
48908
- });
48909
- return group;
48910
- }
48911
- /** The crop is written after birth; this is the only post-birth write besides the pointer null. */
48912
- async setMediaKey(groupId, mediaKey) {
48913
- const current = await this.get(groupId);
48914
- if (current === null) return;
48915
- await this.store.set.mutate({
48916
- collection: TRACK_GROUPS_COLLECTION,
48917
- key: groupId,
48918
- value: this.toRow({
48919
- ...current,
48920
- mediaKey
48921
- })
48922
- });
48923
- }
48924
- async get(groupId) {
48925
- const raw = await this.store.get.query({
48926
- collection: TRACK_GROUPS_COLLECTION,
48927
- key: groupId
48928
- });
48929
- if (!raw) return null;
48930
- return this.normalizeGroup({
48931
- id: groupId,
48932
- data: raw
48933
- });
48934
- }
48935
- async listMembers(groupId) {
48936
- return (await this.store.query.query({
48937
- collection: TRACK_GROUP_MEMBERS_COLLECTION,
48938
- filter: { where: { groupId } }
48939
- })).map((r) => this.normalizeMember({
48940
- id: r.id,
48941
- data: r.data
48942
- }));
48943
- }
48944
- /**
48945
- * Membership for a page of groups in ONE query. `listGroupRecords` used to
48946
- * call {@link listMembers} per row — 60 groups is 60 settings-store RPCs,
48947
- * ~20 s, and the viewer feed times out into a retry loop.
48948
- */
48949
- async listMembersForGroups(groupIds) {
48950
- if (groupIds.length === 0) return [];
48951
- return (await this.store.query.query({
48952
- collection: TRACK_GROUP_MEMBERS_COLLECTION,
48953
- filter: {
48954
- whereIn: { groupId: [...groupIds] },
48955
- limit: 1e4
48956
- }
48957
- })).map((r) => this.normalizeMember({
48958
- id: r.id,
48959
- data: r.data
48960
- }));
48961
- }
48962
- async membersOfTracks(trackIds) {
48963
- const out = [];
48964
- for (const trackId of trackIds) {
48965
- const rows = await this.store.query.query({
48966
- collection: TRACK_GROUP_MEMBERS_COLLECTION,
48967
- filter: { where: { trackId } }
48968
- });
48969
- for (const r of rows) out.push(this.normalizeMember({
48970
- id: r.id,
48971
- data: r.data
48972
- }));
48973
- }
48974
- return out;
48975
- }
48976
- /**
48977
- * Newest-closed page for the Groups feed.
48978
- *
48979
- * Time filter is overlap (`closedAt >= since` and `openedAt <= until`),
48980
- * same as {@link listOverlapping}. Pagination is applied AFTER the window
48981
- * so a day cursor is honest rather than a raw-row offset that skips live
48982
- * groups on a busy camera. `deviceIds: []` is an empty page (same as
48983
- * `listRecentTracks`); omit the field for every camera. `deviceId` stays
48984
- * as the single-camera alias the custom-action bridge still sends.
48985
- */
48986
- async listRecent(input) {
48987
- const limit = Math.min(Math.max(input.limit ?? 40, 1), 100);
48988
- const offset = Math.max(input.offset ?? 0, 0);
48989
- const deviceIds = input.deviceIds !== void 0 ? input.deviceIds : input.deviceId !== void 0 ? [input.deviceId] : void 0;
48990
- if (deviceIds !== void 0 && deviceIds.length === 0) return {
48991
- groups: [],
48992
- hasMore: false,
48993
- nextCursor: null
48994
- };
48995
- const cursor = input.cursor !== void 0 && input.cursor.length > 0 ? decodeGroupCursor(input.cursor) : null;
48996
- const filtered = (await this.store.query.query({
48997
- collection: TRACK_GROUPS_COLLECTION,
48998
- filter: {
48999
- ...deviceIds !== void 0 && deviceIds.length === 1 ? { where: { deviceId: deviceIds[0] } } : deviceIds !== void 0 ? { whereIn: { deviceId: [...deviceIds] } } : {},
49000
- orderBy: {
49001
- field: "closedAt",
49002
- direction: "desc"
49003
- },
49004
- limit: 2e3
49005
- }
49006
- })).map((r) => this.normalizeGroup({
49007
- id: r.id,
49008
- data: r.data
49009
- })).filter((g) => {
49010
- if (input.sinceMs !== void 0 && g.closedAt < input.sinceMs) return false;
49011
- if (input.untilMs !== void 0 && g.openedAt > input.untilMs) return false;
49012
- if (cursor !== null && !isAfterGroupCursor(g, cursor)) return false;
49013
- return true;
49014
- }).toSorted(compareGroupDesc);
49015
- const slice = (cursor !== null ? filtered : filtered.slice(offset)).slice(0, limit + 1);
49016
- const groups = slice.slice(0, limit);
49017
- const hasMore = slice.length > limit;
49018
- const last = groups[groups.length - 1];
49019
- return {
49020
- groups,
49021
- hasMore,
49022
- nextCursor: hasMore && last !== void 0 ? encodeGroupCursor({
49023
- closedAt: last.closedAt,
49024
- groupId: last.id
49025
- }) : null
49026
- };
49027
- }
49028
- /** Groups whose life overlaps `[since, until]` on one camera. */
49029
- async listOverlapping(input) {
49030
- return (await this.store.query.query({
49031
- collection: TRACK_GROUPS_COLLECTION,
49032
- filter: {
49033
- where: { deviceId: input.deviceId },
49034
- orderBy: {
49035
- field: "closedAt",
49036
- direction: "desc"
49037
- },
49038
- limit: input.limit ?? 2e3
49039
- }
49040
- })).map((r) => this.normalizeGroup({
49041
- id: r.id,
49042
- data: r.data
49043
- })).filter((g) => g.closedAt >= input.sinceMs && g.openedAt <= input.untilMs);
49044
- }
49045
- async existingGroupIds(ids) {
49046
- const live = /* @__PURE__ */ new Set();
49047
- for (const id of ids) if (await this.store.get.query({
49048
- collection: "pipeline-analytics:track-groups",
49049
- key: id
49050
- })) live.add(id);
49051
- return live;
49052
- }
49053
- /** Group ids that still have at least one membership row. */
49054
- async groupsWithMembers(ids) {
49055
- const live = /* @__PURE__ */ new Set();
49056
- for (const groupId of ids) if ((await this.store.query.query({
49057
- collection: "pipeline-analytics:track-group-members",
49058
- filter: {
49059
- where: { groupId },
49060
- limit: 1
49061
- }
49062
- })).length > 0) live.add(groupId);
49063
- return live;
49064
- }
49065
- /**
49066
- * Cascade leaf: delete memberships for the batch; a group with zero survivors
49067
- * dies (media then row); a group that lost only its anchor nulls the pointer.
49068
- */
49069
- async deleteByTracks(trackIds) {
49070
- const batch = new Set(trackIds);
49071
- let memberRows = 0;
49072
- let groupRows = 0;
49073
- let mediaRows = 0;
49074
- let mediaBytes = 0;
49075
- const affected = /* @__PURE__ */ new Set();
49076
- try {
49077
- const memberships = await this.membersOfTracks(trackIds);
49078
- for (const member of memberships) try {
49079
- await this.store.delete.mutate({
49080
- collection: TRACK_GROUP_MEMBERS_COLLECTION,
49081
- key: member.id
49082
- });
49083
- memberRows += 1;
49084
- affected.add(member.groupId);
49085
- } catch (err) {
49086
- this.logger.warn("TrackGroupStore.deleteByTracks member delete failed", { meta: {
49087
- memberId: member.id,
49088
- error: String(err)
49089
- } });
49090
- }
49091
- for (const groupId of affected) try {
49092
- if ((await this.listMembers(groupId)).length === 0) {
49093
- if (this.media) {
49094
- const gone = await this.media.deleteByGroups([groupId]);
49095
- mediaRows += gone.rows;
49096
- mediaBytes += gone.bytes;
49097
- }
49098
- await this.store.delete.mutate({
49099
- collection: TRACK_GROUPS_COLLECTION,
49100
- key: groupId
49101
- });
49102
- groupRows += 1;
49103
- continue;
49104
- }
49105
- const group = await this.get(groupId);
49106
- if (group?.anchorTrackId && batch.has(group.anchorTrackId)) await this.store.set.mutate({
49107
- collection: TRACK_GROUPS_COLLECTION,
49108
- key: groupId,
49109
- value: this.toRow({
49110
- ...group,
49111
- anchorTrackId: null,
49112
- nativeKey: null
49113
- })
49114
- });
49115
- } catch (err) {
49116
- this.logger.warn("TrackGroupStore.deleteByTracks group update failed", { meta: {
49117
- groupId,
49118
- error: String(err)
49119
- } });
49120
- }
49121
- } catch (err) {
49122
- this.logger.warn("TrackGroupStore.deleteByTracks failed", { meta: { error: String(err) } });
49123
- }
49124
- return [{
49125
- family: "groups",
49126
- rows: memberRows + groupRows,
49127
- bytes: 0
49128
- }, {
49129
- family: "media",
49130
- rows: mediaRows,
49131
- bytes: mediaBytes
49132
- }];
49133
- }
49134
- toRow(group) {
49135
- return {
49136
- deviceId: group.deviceId,
49137
- timestamp: group.timestamp,
49138
- openedAt: group.openedAt,
49139
- closedAt: group.closedAt,
49140
- memberCount: group.memberCount,
49141
- anchorTrackId: group.anchorTrackId,
49142
- ux: group.ux,
49143
- uy: group.uy,
49144
- uw: group.uw,
49145
- uh: group.uh,
49146
- frameWidth: group.frameWidth,
49147
- frameHeight: group.frameHeight,
49148
- mediaKey: group.mediaKey,
49149
- nativeKey: group.nativeKey,
49150
- sealed: group.sealed
49151
- };
49152
- }
49153
- normalizeGroup(r) {
49154
- const data = r.data;
49155
- return {
49156
- id: r.id,
49157
- deviceId: Number(data["deviceId"]),
49158
- timestamp: Number(data["timestamp"]),
49159
- openedAt: Number(data["openedAt"]),
49160
- closedAt: Number(data["closedAt"]),
49161
- memberCount: Number(data["memberCount"]),
49162
- anchorTrackId: typeof data["anchorTrackId"] === "string" ? data["anchorTrackId"] : null,
49163
- ux: Number(data["ux"]),
49164
- uy: Number(data["uy"]),
49165
- uw: Number(data["uw"]),
49166
- uh: Number(data["uh"]),
49167
- frameWidth: Number(data["frameWidth"]),
49168
- frameHeight: Number(data["frameHeight"]),
49169
- mediaKey: typeof data["mediaKey"] === "string" ? data["mediaKey"] : null,
49170
- nativeKey: typeof data["nativeKey"] === "string" ? data["nativeKey"] : null,
49171
- sealed: Boolean(data["sealed"])
49172
- };
49173
- }
49174
- normalizeMember(r) {
49175
- const data = r.data;
49176
- return {
49177
- id: r.id,
49178
- groupId: String(data["groupId"]),
49179
- trackId: String(data["trackId"]),
49180
- deviceId: Number(data["deviceId"]),
49181
- timestamp: Number(data["timestamp"])
49182
- };
49183
- }
49184
- };
49185
47774
  //#endregion
49186
47775
  //#region src/pipeline-analytics/media-capture-log.ts
49187
47776
  var DEFAULT_FLUSH_INTERVAL_MS$1 = 6e4;
@@ -57588,24 +56177,12 @@ var ANALYTICS_COLLECTIONS = [
57588
56177
  collection: PLATES_COLLECTION,
57589
56178
  classification: derived(PLATE_COLUMNS)
57590
56179
  },
57591
- {
57592
- collection: TRACK_GROUP_MEMBERS_COLLECTION,
57593
- classification: derived(TRACK_GROUP_MEMBERS_COLUMNS)
57594
- },
57595
56180
  {
57596
56181
  collection: MEDIA_COLLECTION,
57597
56182
  classification: {
57598
56183
  kind: "owner-indirection",
57599
56184
  derivation: "explicit",
57600
- 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."
57601
- }
57602
- },
57603
- {
57604
- collection: TRACK_GROUPS_COLLECTION,
57605
- classification: {
57606
- kind: "owner-indirection",
57607
- derivation: "explicit",
57608
- 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."
56185
+ 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."
57609
56186
  }
57610
56187
  },
57611
56188
  {
@@ -57717,7 +56294,7 @@ var ANALYTICS_COLLECTIONS = [
57717
56294
  collection: SCENE_LATCH_COLLECTION,
57718
56295
  classification: {
57719
56296
  kind: "never-orphaned",
57720
- 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`)."
56297
+ 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`)."
57721
56298
  }
57722
56299
  },
57723
56300
  {
@@ -57777,6 +56354,10 @@ function resolveMediaOwner(row) {
57777
56354
  kind: "unresolvable",
57778
56355
  why: "media row carries no (ownerKind, ownerId)"
57779
56356
  };
56357
+ if (isRetiredMediaOwnerKind(kindRaw)) return {
56358
+ kind: "retired-owner",
56359
+ ownerKind: kindRaw
56360
+ };
57780
56361
  const ownerKind = kindRaw;
57781
56362
  if (isRetentionExemptOwnerKind(ownerKind)) return {
57782
56363
  kind: "exempt",
@@ -57805,10 +56386,6 @@ function resolveMediaOwner(row) {
57805
56386
  kind: "unresolvable",
57806
56387
  why: `plate-owned media whose ownerId does not carry the '${PLATE_MEDIA_OWNER_PREFIX}' prefix`
57807
56388
  };
57808
- case "group": return {
57809
- kind: "group",
57810
- groupId: ownerId
57811
- };
57812
56389
  default: return {
57813
56390
  kind: "unresolvable",
57814
56391
  why: `unknown ownerKind '${kindRaw}'`
@@ -57820,13 +56397,6 @@ function resolveMediaOwner(row) {
57820
56397
  * is derived the same way the classification is — an enrolled row is out of
57821
56398
  * reach regardless of what happened to the track that produced it.
57822
56399
  */
57823
- /** A group row lives iff any membership row still names it. */
57824
- function resolveGroupOwner(row) {
57825
- return {
57826
- kind: "group-members",
57827
- groupId: row.key
57828
- };
57829
- }
57830
56400
  function resolveDeclaredTrackOwner(row, enrolmentExempt) {
57831
56401
  if (enrolmentExempt && row.data["assigned"] === true) return {
57832
56402
  kind: "exempt",
@@ -57857,17 +56427,6 @@ function buildOrphanScopes(collections = auditableCollections()) {
57857
56427
  });
57858
56428
  continue;
57859
56429
  }
57860
- if (entry.collection === "pipeline-analytics:track-groups") {
57861
- scopes.push({
57862
- id: entry.collection,
57863
- collection: entry.collection,
57864
- resolve: resolveGroupOwner,
57865
- timestampField: "timestamp",
57866
- sizeField: null,
57867
- deviceIdField: "deviceId"
57868
- });
57869
- continue;
57870
- }
57871
56430
  continue;
57872
56431
  }
57873
56432
  if (entry.classification.kind !== "track-owned") continue;
@@ -57899,17 +56458,11 @@ var DEFAULT_ORPHAN_LIMITS = {
57899
56458
  function questionsFor(refs) {
57900
56459
  const trackIds = /* @__PURE__ */ new Set();
57901
56460
  const eventIds = /* @__PURE__ */ new Set();
57902
- const groupIds = /* @__PURE__ */ new Set();
57903
- const groupMemberIds = /* @__PURE__ */ new Set();
57904
56461
  for (const ref of refs) if (ref.kind === "track") trackIds.add(ref.trackId);
57905
56462
  else if (ref.kind === "event") eventIds.add(ref.eventId);
57906
- else if (ref.kind === "group") groupIds.add(ref.groupId);
57907
- else if (ref.kind === "group-members") groupMemberIds.add(ref.groupId);
57908
56463
  return {
57909
56464
  trackIds: [...trackIds],
57910
- eventIds: [...eventIds],
57911
- groupIds: [...groupIds],
57912
- groupMemberIds: [...groupMemberIds]
56465
+ eventIds: [...eventIds]
57913
56466
  };
57914
56467
  }
57915
56468
  /**
@@ -57941,16 +56494,12 @@ async function auditScope(scope, deps, limits, cursor) {
57941
56494
  break;
57942
56495
  }
57943
56496
  const refs = rows.map((row) => scope.resolve(row));
57944
- const { trackIds, eventIds, groupIds, groupMemberIds } = questionsFor(refs);
56497
+ const { trackIds, eventIds } = questionsFor(refs);
57945
56498
  let liveTracks;
57946
56499
  let liveEvents;
57947
- let liveGroups;
57948
- let liveGroupsWithMembers;
57949
56500
  try {
57950
56501
  liveTracks = trackIds.length > 0 ? await deps.liveTrackIds(trackIds) : /* @__PURE__ */ new Set();
57951
56502
  liveEvents = eventIds.length > 0 ? await deps.liveEventIds(eventIds) : /* @__PURE__ */ new Set();
57952
- liveGroups = groupIds.length > 0 && deps.liveGroupIds ? await deps.liveGroupIds(groupIds) : /* @__PURE__ */ new Set();
57953
- liveGroupsWithMembers = groupMemberIds.length > 0 && deps.liveGroupsWithMembers ? await deps.liveGroupsWithMembers(groupMemberIds) : /* @__PURE__ */ new Set();
57954
56503
  } catch (err) {
57955
56504
  deps.logger.warn("orphan audit: liveness probe failed — scope abandoned for this run", { meta: {
57956
56505
  collection: scope.collection,
@@ -57974,7 +56523,7 @@ async function auditScope(scope, deps, limits, cursor) {
57974
56523
  tally.unresolvable += 1;
57975
56524
  continue;
57976
56525
  }
57977
- 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;
56526
+ if (ref.kind === "track" ? liveTracks.has(ref.trackId) : ref.kind === "event" ? liveEvents.has(ref.eventId) : false) continue;
57978
56527
  const ts = num(row.data, scope.timestampField);
57979
56528
  if (ts !== null && ts > graceFloor) {
57980
56529
  tally.withinGrace += 1;
@@ -64812,8 +63361,7 @@ var customActions = {
64812
63361
  ...locationDiscardActions,
64813
63362
  ...viewerSettingsActions,
64814
63363
  ...photoEnrollActions,
64815
- ...faceRescoreActions,
64816
- ...groupActions
63364
+ ...faceRescoreActions
64817
63365
  };
64818
63366
  /**
64819
63367
  * Assist threshold when the caller does not pin one. Packages have their own
@@ -65105,11 +63653,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
65105
63653
  faceStore = null;
65106
63654
  faceRecognizer = null;
65107
63655
  plateStore = null;
65108
- groupStore = null;
65109
- groupSealer = null;
65110
- groupLive = null;
65111
- /** Serialise first-activity lookback per camera, then observe in order. */
65112
- groupObserveTail = /* @__PURE__ */ new Map();
65113
63656
  plateRecognizer = null;
65114
63657
  objectEmbeddingStore = null;
65115
63658
  /** Live state of the detached embedding rebuild — see `rebuildObjectEmbeddings`. */
@@ -66067,8 +64610,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
66067
64610
  ...locationDiscardActions,
66068
64611
  ...viewerSettingsActions,
66069
64612
  ...photoEnrollActions,
66070
- ...faceRescoreActions,
66071
- ...groupActions
64613
+ ...faceRescoreActions
66072
64614
  },
66073
64615
  actionHandlers: {
66074
64616
  ...ncHandlers,
@@ -66076,7 +64618,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
66076
64618
  ...this.buildDebugActionHandlers(),
66077
64619
  ...this.buildPhotoEnrollActionHandlers(),
66078
64620
  ...this.buildFaceRescoreActionHandlers(),
66079
- ...this.buildGroupActionHandlers(),
66080
64621
  ...this.viewerSettingsSnapshots ? makeViewerSettingsActionHandlers(this.viewerSettingsSnapshots) : {}
66081
64622
  }
66082
64623
  } : {}
@@ -66310,7 +64851,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
66310
64851
  SceneStore.declare(api.settingsStore),
66311
64852
  NotificationCenter.declare(api.settingsStore),
66312
64853
  ViewerSettingsSnapshotStore.declare(api.settingsStore),
66313
- TrackGroupStore.declare(api.settingsStore),
66314
64854
  declareMigrationMarkers(api.settingsStore)
66315
64855
  ]);
66316
64856
  this.viewerSettingsSnapshots = new ViewerSettingsSnapshotStore(api.settingsStore);
@@ -66670,31 +65210,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
66670
65210
  logger: logger.child("PlateStore")
66671
65211
  });
66672
65212
  this.plateStore = plateStore;
66673
- const vehicleStore = new VehicleStore({
66674
- store: api.settingsStore,
66675
- logger: logger.child("VehicleStore")
66676
- });
66677
- const groupStore = new TrackGroupStore({
66678
- store: api.settingsStore,
66679
- logger: logger.child("TrackGroupStore"),
66680
- media: mediaStore
66681
- });
66682
- this.groupStore = groupStore;
66683
- this.groupLive = new GroupLiveBuffer();
66684
- this.groupSealer = new GroupSealer({
66685
- trackStore,
66686
- groupStore,
66687
- mediaStore,
66688
- logger: logger.child("GroupSealer"),
66689
- frameDims: (deviceId) => {
66690
- const dims = this.lastFrameDimsByDevice.get(deviceId);
66691
- return dims ? {
66692
- width: dims.w,
66693
- height: dims.h
66694
- } : void 0;
66695
- },
66696
- now: () => Date.now()
66697
- });
66698
65213
  return {
66699
65214
  trackStore,
66700
65215
  mediaStore,
@@ -66702,8 +65217,10 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
66702
65217
  identityStore,
66703
65218
  faceStore,
66704
65219
  plateStore,
66705
- vehicleStore,
66706
- groupStore
65220
+ vehicleStore: new VehicleStore({
65221
+ store: api.settingsStore,
65222
+ logger: logger.child("VehicleStore")
65223
+ })
66707
65224
  };
66708
65225
  }
66709
65226
  /** Face + plate recognizers, the durable keyFrame-derived plate crop and the
@@ -67224,28 +65741,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
67224
65741
  }));
67225
65742
  },
67226
65743
  listMediaInfo: async (id) => {
67227
- const group = await stores.groupStore.get(id);
67228
- if (group) {
67229
- if (group.mediaKey || group.nativeKey) return digestMediaRows({
67230
- mediaKey: group.mediaKey,
67231
- nativeKey: group.nativeKey,
67232
- timestamp: group.timestamp,
67233
- memberRows: []
67234
- });
67235
- const members = await stores.groupStore.listMembers(group.id);
67236
- const memberRows = (await Promise.all(members.map((m) => stores.mediaStore.listInfoByOwner("track", m.trackId)))).flatMap((rows) => rows.map((r) => ({
67237
- key: r.key,
67238
- kind: r.kind,
67239
- timestamp: r.timestamp,
67240
- sizeBytes: r.sizeBytes
67241
- })));
67242
- return digestMediaRows({
67243
- mediaKey: group.mediaKey,
67244
- nativeKey: group.nativeKey,
67245
- timestamp: group.timestamp,
67246
- memberRows
67247
- });
67248
- }
67249
65744
  return (await stores.mediaStore.listInfoByOwner("track", id)).map((r) => ({
67250
65745
  key: r.key,
67251
65746
  kind: r.kind,
@@ -68616,7 +67111,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
68616
67111
  state: t.state
68617
67112
  });
68618
67113
  positionsCountById.set(t.trackId, upserted.positions.length);
68619
- this.enqueueLiveGroup(upserted, result.timestamp);
68620
67114
  }
68621
67115
  if (result.rawTrackedDetections.length > 0) {
68622
67116
  const { minHits } = await this.resolveDeviceDetectionSensitivitySettings(deviceId);
@@ -70713,11 +69207,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
70713
69207
  async sweepExpiredTracks() {
70714
69208
  if (this.shuttingDown || !this.trackStore) return;
70715
69209
  await this.trackCloser.sweep();
70716
- if (this.isPostProcessingNode && this.groupLive) this.groupSealer?.flushIdle(this.groupLive);
70717
69210
  }
70718
- /** Hub-only: assign this sighting in RAM. First activity on a camera
70719
- * replays 2 minutes of that camera (crash recover), then observe is sync. */
70720
- enqueueLiveGroup(track, now) {}
70721
69211
  /** Unified retention (Phase 1): earliest retained footage per device — the
70722
69212
  * follow-recordings cutoff source. Cached 10 min: availability answers in
70723
69213
  * ~70 ms but the sweep asks once per device per pass. `null` = no footage
@@ -71419,16 +69909,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
71419
69909
  };
71420
69910
  if (plausible) this.stationaryRegistry?.promote(entry);
71421
69911
  processor.dropTrack(track.trackId);
71422
- const peek = this.trackStore?.peekActive(track.trackId);
71423
- if (peek) this.enqueueLiveGroup(toGroupableTrack({
71424
- trackId: track.trackId,
71425
- deviceId,
71426
- className: track.className,
71427
- firstSeen: peek.firstSeen,
71428
- lastSeen: timestamp,
71429
- positions: peek.positions,
71430
- closedReason: "stationary-promotion"
71431
- }), timestamp);
71432
69912
  this.trackStore?.closeActive(track.trackId, "stationary-promotion").catch((err) => {
71433
69913
  this.ctx.logger.warn("stationary promotion: closing the source track failed — its media, events and vector are now unowned", {
71434
69914
  tags: { deviceId },
@@ -71656,15 +70136,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
71656
70136
  async listRecentTracks(input) {
71657
70137
  return this.queryFacade.listRecentTracks(input);
71658
70138
  }
71659
- async listGroups(input) {
71660
- return {
71661
- groups: [],
71662
- nextCursor: null
71663
- };
71664
- }
71665
- async getGroup(input) {
71666
- return null;
71667
- }
71668
70139
  /**
71669
70140
  * Every event kind the device can produce: built-ins (motion + audio),
71670
70141
  * detection classes actually observed on the device, and sensor kinds
@@ -72183,8 +70654,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
72183
70654
  },
72184
70655
  liveTrackIds: (ids) => trackStore.existingTrackIds(ids),
72185
70656
  liveEventIds: (ids) => eventStore.existingEventIds(ids),
72186
- liveGroupIds: (ids) => this.groupStore?.existingGroupIds(ids) ?? Promise.resolve(/* @__PURE__ */ new Set()),
72187
- liveGroupsWithMembers: (ids) => this.groupStore?.groupsWithMembers(ids) ?? Promise.resolve(/* @__PURE__ */ new Set()),
72188
70657
  reclaim: async (collection, key) => {
72189
70658
  if (collection === "pipeline-analytics:media") {
72190
70659
  await mediaStore.deleteByKey(key);
@@ -72947,41 +71416,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
72947
71416
  buildFaceRescoreActionHandlers() {
72948
71417
  return { "face.rescoreTrack": (input) => this.rescoreFaceForTrack(input) };
72949
71418
  }
72950
- groupQueryPorts() {
72951
- const groupStore = this.groupStore;
72952
- if (groupStore === null) return null;
72953
- return {
72954
- groupStore,
72955
- loadTracks: async (trackIds) => {
72956
- const map = /* @__PURE__ */ new Map();
72957
- const tracks = this.trackStore;
72958
- if (!tracks || trackIds.length === 0) return map;
72959
- const missing = [];
72960
- for (const id of trackIds) {
72961
- const active = tracks.getActiveByTrack(id);
72962
- if (active !== null) map.set(id, trackToLite(active));
72963
- else missing.push(id);
72964
- }
72965
- if (missing.length === 0) return map;
72966
- for (const persisted of await tracks.getPersistedByTrackIds(missing)) map.set(persisted.trackId, trackToLite(persisted));
72967
- return map;
72968
- },
72969
- eventMediaBaseUrl: () => this.eventMediaBaseUrl
72970
- };
72971
- }
72972
- buildGroupActionHandlers() {
72973
- return {
72974
- "analytics.listGroups": async (input) => {
72975
- return {
72976
- groups: [],
72977
- hasMore: false
72978
- };
72979
- },
72980
- "analytics.getGroup": async (input) => {
72981
- return { group: null };
72982
- }
72983
- };
72984
- }
72985
71419
  buildPhotoEnrollActionHandlers() {
72986
71420
  return {
72987
71421
  "photo.analyze": async (input) => {
@@ -73367,8 +71801,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
73367
71801
  mediaStore,
73368
71802
  faceStore: this.faceStore,
73369
71803
  plateStore: this.plateStore,
73370
- objectEmbeddingStore: this.objectEmbeddingStore,
73371
- groupStore: this.groupStore
71804
+ objectEmbeddingStore: this.objectEmbeddingStore
73372
71805
  };
73373
71806
  const absent = absentCascadeLegs(stores);
73374
71807
  if (absent.length > 0) this.ctx.logger.warn("track cascade is missing a leg — its rows will be left behind", { meta: {
@@ -73577,8 +72010,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
73577
72010
  media: 0,
73578
72011
  faces: 0,
73579
72012
  plates: 0,
73580
- embeddings: 0,
73581
- groups: 0
72013
+ embeddings: 0
73582
72014
  };
73583
72015
  this.lastPruneBytes = 0;
73584
72016
  const cascade = this.buildTrackCascadeRegistry();
@@ -73638,8 +72070,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
73638
72070
  media: tally.media,
73639
72071
  faces: tally.faces,
73640
72072
  plates: tally.plates,
73641
- embeddings: tally.embeddings,
73642
- groups: tally.groups
72073
+ embeddings: tally.embeddings
73643
72074
  };
73644
72075
  }
73645
72076
  /**