@camstack/addon-post-analysis 1.2.115 → 1.2.117

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 deriveRecordingMode, At as array, B as TimelapseRulePatchSchema, Bt as EventCategory, C as NcScheduleSchema, Ct as DeviceType, D as NcSystemEventKindSchema, Dt as nodePin, E as NcSnoozeSuppressedSchema, Et as isDeviceScopedCap, F as SCENE_DEFAULT_UNCOVERED_POLICY, Ft as object, G as alarmPanelCapability, H as TrackSourceSchema, I as SCENE_DIVERGED, It as partialRecord, J as audioModeOf, K as assertTimelapseCadences, L as SceneMonitorSchema, Lt as record, M as RetrainStatusSchema, Mt as discriminatedUnion, Nt as literal, O as NcTaxonomySchema, Ot as sleep, P as SCENE_DEFAULT_ANCHOR_THRESHOLD, Pt as number, Q as defineCustomActions, R as TIMELAPSE_DENSE_FLOOR_SEC, Rt as string, S as NcRuleTargetSchema, St as BaseAddon, T as NcSnoozeSchema, Tt as hydrateSchema, U as VISIT_MERGE_GAP_MS, V as TimelapseRuleSchema, W as addonWidgetsSourceCapability, X as cosineSimilarity$1, Y as buildEventKindDescriptor, Z as customAction, _ as NC_TAXONOMY, _t as systemEventFilterApplies, a as DETECTION_MACRO_CLASSES, at as isScheduleActive, b as NcRulePatchSchema, bt as zoneAnalyticsCapability, c as EVENT_KIND_BY_CAP, d as LabelAttributionSchema, dt as plateGalleryCapability, f as MACRO_LABELS, ft as readDeviceStateFrom, gt as subKindsOf, h as NC_DEFAULT_SNOOZE_MINUTES, ht as sceneMonitorCapability, it as isDetectionMacroClass, j as RECORDING_EXPORT_MAX_READ_BYTES, jt as boolean, k as OpsLogEntrySchema, kt as _enum, l as EVENT_PAD_MS, lt as pickClusterStepModels, m as NC_CONDITION_CATALOG, n as CLUSTER_MODEL_SCOPED_STEPS, nt as faceGalleryCapability, ot as kebabToCamel, p as NC_ALARM_SYSTEM_EVENT_KINDS, pt as readTimelapseGeneratedAt, q as audioMetricsCapability, r as DEFAULT_EVENT_COLOR, s as DeclaredDevices, st as notificationRulesCapability, t as BaseDevice, tt as encodeVectorBase64, u as FULL_IMAGE_BBOX, ut as pipelineAnalyticsCapability, v as NcConditionDescriptorSchema, vt as vectorDimFromBase64, w as NcSnoozeInputSchema, wt as createEvent, x as NcRuleSchema, xt as errMsg, y as NcRuleInputSchema, yt as videoclipsCapability, z as TimelapseRuleInputSchema, zt as unknown } from "../dist-B13sFjR2.mjs";
1
+ import { $ as customAction, A as NcTaxonomySchema, At as sleep, B as TIMELAPSE_DENSE_FLOOR_SEC, Bt as string, C as NcRuleSchema, Ct as errMsg, D as NcSnoozeSchema, Dt as hydrateSchema, E as NcSnoozeInputSchema, Et as createEvent, Ft as literal, G as VISIT_MERGE_GAP_MS, H as TimelapseRulePatchSchema, Ht as EventCategory, I as SCENE_DEFAULT_ANCHOR_THRESHOLD, It as number, J as assertTimelapseCadences, K as addonWidgetsSourceCapability, L as SCENE_DEFAULT_UNCOVERED_POLICY, Lt as object, Mt as array, N as RECORDING_EXPORT_MAX_READ_BYTES, Nt as boolean, O as NcSnoozeSuppressedSchema, Ot as isDeviceScopedCap, P as RetrainStatusSchema, Pt as discriminatedUnion, Q as cosineSimilarity$1, R as SCENE_DIVERGED, Rt as partialRecord, S as NcRulePatchSchema, St as zoneAnalyticsCapability, T as NcScheduleSchema, Tt as DeviceType, U as TimelapseRuleSchema, V as TimelapseRuleInputSchema, Vt as unknown, W as TrackSourceSchema, X as audioModeOf, Y as audioMetricsCapability, Z as buildEventKindDescriptor, _ as NC_DEFAULT_SNOOZE_MINUTES, _t as sceneMonitorCapability, a as DEFAULT_EVENT_COLOR, b as NcConditionDescriptorSchema, bt as vectorDimFromBase64, ct as kebabToCamel, d as EVENT_PAD_MS, dt as pickClusterStepModels, et as defineCustomActions, f as FULL_IMAGE_BBOX, ft as pipelineAnalyticsCapability, g as NC_CONDITION_CATALOG, h as NC_ALARM_SYSTEM_EVENT_KINDS, ht as readTimelapseGeneratedAt, i as CLUSTER_MODEL_SCOPED_STEPS, it as faceGalleryCapability, j as OpsLogEntrySchema, jt as _enum, k as NcSystemEventKindSchema, kt as nodePin, l as DeclaredDevices, lt as notificationRulesCapability, m as MACRO_LABELS, mt as readDeviceStateFrom, n as AnalyticsGroupRecordSchema, ot as isDetectionMacroClass, p as LabelAttributionSchema, pt as plateGalleryCapability, q as alarmPanelCapability, r as BaseDevice, rt as encodeVectorBase64, s as DETECTION_MACRO_CLASSES, st as isScheduleActive, t as AnalyticsGroupDetailSchema, tt as deriveRecordingMode, u as EVENT_KIND_BY_CAP, vt as subKindsOf, w as NcRuleTargetSchema, wt as BaseAddon, x as NcRuleInputSchema, xt as videoclipsCapability, y as NC_TAXONOMY, yt as systemEventFilterApplies, z as SceneMonitorSchema, zt as record } from "../dist-PMOtjzh2.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";
@@ -15132,28 +15132,38 @@ function selectSummaryTiles(candidates, opts) {
15132
15132
  };
15133
15133
  }
15134
15134
  /**
15135
+ * Ranked leftover after the quota pass — refill a mosaic that dropped
15136
+ * duplicate frames or missing crops without walking the same track twice.
15137
+ */
15138
+ function remainingSummaryCandidates(kept, taken, rank) {
15139
+ const takenIds = new Set(taken.map((c) => c.trackId));
15140
+ return kept.filter((c) => !takenIds.has(c.trackId)).toSorted(byRankDesc(rank));
15141
+ }
15142
+ /**
15135
15143
  * The media a tile is drawn from, best first.
15136
15144
  *
15137
- * `groupCrop` heads the ladder: it is the group's own clean wide-central-square
15138
- * over the union bbox, and a drawn rectangle is a permanent lie in a digest
15139
- * tile. Singletons have no groupCrop and fall through to the member ladder.
15140
- * `fullFrameBoxed` and `firstFrame` stay absent for the same reason.
15145
+ * First-level group crop only (`groupCrop`), then the group's native-frame
15146
+ * pointer, then a member's clean full frame. Member `thumbnail` / plate /
15147
+ * face crops stay off this ladder: a successful plate read overwrites the
15148
+ * vehicle first-level crop under the same `thumbnail` kind, and a digest
15149
+ * tile of a targa is the wrong picture. Boxed frames stay absent: a drawn
15150
+ * rectangle is a permanent lie in a digest tile. Duplicate driveways from
15151
+ * tracker ID churn are collapsed by `frameDedupeKey`, not by hiding the
15152
+ * full-frame fallback.
15141
15153
  */
15142
15154
  var SUMMARY_MEDIA_LADDER = [
15143
15155
  "groupCrop",
15144
- "keyFrameSmall",
15156
+ "nativeFrame",
15145
15157
  "keyFrame",
15146
- "snapshot",
15147
- "fullFrame",
15148
- "thumbnailSmall",
15149
- "thumbnail"
15158
+ "keyFrameSmall",
15159
+ "fullFrame"
15150
15160
  ];
15151
15161
  /**
15152
15162
  * Pick ONE media row for a track — the cheap question first (index rows only),
15153
15163
  * so exactly one blob is ever read.
15154
15164
  *
15155
- * Within a rung the row CLOSEST to the track's peak is chosen; `snapshot` is a
15156
- * filmstrip and its rows accumulate, so "the first one" would systematically
15165
+ * Within a rung the row CLOSEST to the track's peak is chosen; a member may
15166
+ * carry more than one full-frame still, so "the first one" would systematically
15157
15167
  * pick the moment the subject entered rather than the moment it was best seen.
15158
15168
  */
15159
15169
  function pickSummaryMedia(rows, aroundMs) {
@@ -15164,6 +15174,13 @@ function pickSummaryMedia(rows, aroundMs) {
15164
15174
  }
15165
15175
  return null;
15166
15176
  }
15177
+ /**
15178
+ * Same camera, same wall-clock second → same JPEG. Tracker ID churn
15179
+ * otherwise fills a mosaic with eleven copies of one driveway still.
15180
+ */
15181
+ function frameDedupeKey(deviceId, mediaTimestamp) {
15182
+ return `${deviceId}:${Math.floor(mediaTimestamp / 1e3)}`;
15183
+ }
15167
15184
  //#endregion
15168
15185
  //#region src/notification-center/summary/summary-window.ts
15169
15186
  /**
@@ -15463,7 +15480,12 @@ var NcSummaryProducer = class {
15463
15480
  droppedStationary: filtered.droppedStationary
15464
15481
  };
15465
15482
  }
15466
- const loaded = await this.loadTiles(rule, selection.tiles);
15483
+ const loaded = await this.loadTiles({
15484
+ rule,
15485
+ preferred: selection.tiles,
15486
+ refill: remainingSummaryCandidates(filtered.kept, selection.tiles, rule.rank),
15487
+ maxTiles: rule.maxTiles
15488
+ });
15467
15489
  const detections = countDetections({ tracks: selection.tiles.map((t) => ({
15468
15490
  firstSeen: t.firstSeen,
15469
15491
  lastSeen: t.lastSeen,
@@ -15521,7 +15543,7 @@ var NcSummaryProducer = class {
15521
15543
  tiles: loaded.length,
15522
15544
  cameras: new Set(loaded.map((t) => t.candidate.deviceId)).size,
15523
15545
  camerasDropped: selection.camerasDropped,
15524
- droppedNoMedia: selection.tiles.length - loaded.length,
15546
+ droppedNoMedia: Math.max(0, rule.maxTiles - loaded.length),
15525
15547
  mosaicBytes: mosaic.bytes,
15526
15548
  mosaicPublished: mosaic.ref !== null,
15527
15549
  enqueued,
@@ -15585,10 +15607,18 @@ var NcSummaryProducer = class {
15585
15607
  }
15586
15608
  return all;
15587
15609
  }
15588
- /** One blob per tile — the cheap question (index rows) always first. */
15589
- async loadTiles(rule, tiles) {
15610
+ /** One blob per tile — the cheap question (index rows) always first.
15611
+ * Duplicate camera-seconds are skipped so tracker ID churn cannot paint
15612
+ * the same driveway eleven times; leftover ranked candidates refill. */
15613
+ async loadTiles(input) {
15590
15614
  const out = [];
15591
- for (const candidate of tiles) {
15615
+ const seenFrames = /* @__PURE__ */ new Set();
15616
+ const seenIds = /* @__PURE__ */ new Set();
15617
+ const queue = [...input.preferred, ...input.refill];
15618
+ for (const candidate of queue) {
15619
+ if (out.length >= input.maxTiles) break;
15620
+ if (seenIds.has(candidate.trackId)) continue;
15621
+ seenIds.add(candidate.trackId);
15592
15622
  const tags = { deviceId: candidate.deviceId };
15593
15623
  try {
15594
15624
  const rows = await this.deps.listMediaInfo(candidate.trackId);
@@ -15597,25 +15627,38 @@ var NcSummaryProducer = class {
15597
15627
  this.deps.logger.debug("summary tile dropped: the track carries no clean media", {
15598
15628
  tags,
15599
15629
  meta: {
15600
- ruleId: rule.id,
15630
+ ruleId: input.rule.id,
15601
15631
  trackId: candidate.trackId,
15602
15632
  mediaRows: rows.length
15603
15633
  }
15604
15634
  });
15605
15635
  continue;
15606
15636
  }
15637
+ const frameKey = frameDedupeKey(candidate.deviceId, picked.timestamp);
15638
+ if (seenFrames.has(frameKey)) {
15639
+ this.deps.logger.debug("summary tile dropped: same frame as an earlier tile", {
15640
+ tags,
15641
+ meta: {
15642
+ ruleId: input.rule.id,
15643
+ trackId: candidate.trackId,
15644
+ frameKey
15645
+ }
15646
+ });
15647
+ continue;
15648
+ }
15607
15649
  const jpeg = await this.deps.readMedia(picked.key);
15608
15650
  if (jpeg === null || jpeg.byteLength === 0) {
15609
15651
  this.deps.logger.debug("summary tile dropped: the media blob could not be read", {
15610
15652
  tags,
15611
15653
  meta: {
15612
- ruleId: rule.id,
15654
+ ruleId: input.rule.id,
15613
15655
  trackId: candidate.trackId,
15614
15656
  mediaKey: picked.key
15615
15657
  }
15616
15658
  });
15617
15659
  continue;
15618
15660
  }
15661
+ seenFrames.add(frameKey);
15619
15662
  out.push({
15620
15663
  candidate,
15621
15664
  jpeg
@@ -15624,7 +15667,7 @@ var NcSummaryProducer = class {
15624
15667
  this.deps.logger.warn("summary tile dropped: reading its media threw", {
15625
15668
  tags,
15626
15669
  meta: {
15627
- ruleId: rule.id,
15670
+ ruleId: input.rule.id,
15628
15671
  trackId: candidate.trackId,
15629
15672
  error: String(err)
15630
15673
  }
@@ -39832,8 +39875,9 @@ var OverlayDetailStateStore = class {
39832
39875
  *
39833
39876
  * See docs/superpowers/specs/2026-07-17-package-zones-design.md §3.3 +
39834
39877
  * docs/superpowers/specs/2026-07-17-detection-config-exposure-design.md
39835
- * (surface C). Dwell default is 15s per operator decision (the design's
39836
- * §3.3 draft said 45s).
39878
+ * (surface C). Confirmation is N frames (default 3); dwell default is 0
39879
+ * (an extra bar). The 15 s dwell plus a 5 s round-gap missed a real
39880
+ * parcel on device 615 (2026-08-24 13:40).
39837
39881
  */
39838
39882
  var PackageDropSettingsSchema = object({
39839
39883
  /**
@@ -39854,13 +39898,20 @@ var PackageDropSettingsSchema = object({
39854
39898
  */
39855
39899
  packageDropEnabled: boolean().default(true),
39856
39900
  /**
39857
- * Minimum OBSERVED dwell (seconds, since first-seen) before a newly
39858
- * promoted stationary package counts as a delivery. Kills a bag briefly
39859
- * set down and snatched back. Operator default 15s. Values beyond the
39860
- * stationary promotion window (30s) are satisfied only once the entry
39861
- * has actually been observed that long.
39901
+ * Frames the box must be re-seen on before it is a parked package.
39902
+ * Same bar as tracker `minHits`: one frame is a flicker, two can still
39903
+ * be a passer-by's bag, three is "it stayed". A settle burst of three
39904
+ * frames in ~1 s is enough; no second governor round after the courier
39905
+ * left. Device 615 2026-08-24 13:40 expired at 1 round / 1.5 s dwell.
39862
39906
  */
39863
- packageDropDwellSec: number().int().min(0).max(3600).default(15),
39907
+ packageDropMinFrames: number().int().min(1).max(20).default(3),
39908
+ /**
39909
+ * Extra OBSERVED dwell (seconds, since first-seen) on top of N frames.
39910
+ * 0 (the default) means the frame count is enough — a courier who drops
39911
+ * and leaves in 2 s still delivers. Raise this to reject a bag briefly
39912
+ * set down and snatched back.
39913
+ */
39914
+ packageDropDwellSec: number().int().min(0).max(3600).default(0),
39864
39915
  /** Reject tiny far-field blobs: min bbox area as a fraction of the frame. */
39865
39916
  packageDropMinBboxAreaFrac: number().min(0).max(1).default(.004),
39866
39917
  /**
@@ -39878,13 +39929,15 @@ var PackageDropSettingsSchema = object({
39878
39929
  packageDropPickupEnabled: boolean().default(true),
39879
39930
  /**
39880
39931
  * A parcel needs a deliverer: a stillness candidate only ever promotes to
39881
- * `delivered` if a non-package track (person/vehicle/animal) was active on
39882
- * the device within this many seconds BEFORE the box first appeared — and a
39883
- * departure only counts as `picked-up` with somebody around the
39884
- * disappearance. 0 disables both gates. Device 615's streetlight wedge
39885
- * cycled delivered→picked-up ~23×/night with nobody in frame, ever.
39886
- * Default 300 s: wide enough to absorb the motion-settle governor's cadence
39887
- * (the box is often first SEEN minutes after the courier left).
39932
+ * `delivered` if a non-package track (person/vehicle/animal) was active IN
39933
+ * A CONFIGURED PACKAGE DROP-OFF ZONE within this many seconds BEFORE the
39934
+ * box first appeared — and a departure only counts as `picked-up` with
39935
+ * somebody in that zone around the disappearance. Whole-camera motion (a
39936
+ * walker at the far gate) does not count. 0 disables both gates. Device
39937
+ * 615's streetlight wedge cycled delivered→picked-up ~23×/night with
39938
+ * nobody in the drop-off zone. Default 300 s: wide enough to absorb the
39939
+ * motion-settle governor's cadence (the box is often first SEEN minutes
39940
+ * after the courier left).
39888
39941
  */
39889
39942
  packageDropActivityWindowSec: number().int().min(0).max(3600).default(300),
39890
39943
  /**
@@ -39908,6 +39961,7 @@ function resolvePackageDropSettings(raw) {
39908
39961
  };
39909
39962
  return {
39910
39963
  packageDropEnabled: pick("packageDropEnabled"),
39964
+ packageDropMinFrames: pick("packageDropMinFrames"),
39911
39965
  packageDropDwellSec: pick("packageDropDwellSec"),
39912
39966
  packageDropMinBboxAreaFrac: pick("packageDropMinBboxAreaFrac"),
39913
39967
  packageDropMaxBboxAreaFrac: pick("packageDropMaxBboxAreaFrac"),
@@ -40165,29 +40219,6 @@ var DeferredBirthRegistry = class {
40165
40219
  this.byDevice.delete(deviceKey);
40166
40220
  }
40167
40221
  };
40168
- var AnalyticsGroupRecordSchema = object({
40169
- id: string(),
40170
- deviceId: number().int(),
40171
- openedAt: number().int(),
40172
- closedAt: number().int(),
40173
- timestamp: number().int(),
40174
- memberCount: number().int(),
40175
- memberTrackIds: array(string()).readonly(),
40176
- className: string(),
40177
- classes: array(string()).readonly(),
40178
- /** Relative event-media path, or null when the group has no picture yet. */
40179
- mediaUrl: string().nullable(),
40180
- singleton: boolean()
40181
- });
40182
- var AnalyticsGroupMemberSchema = object({
40183
- trackId: string(),
40184
- deviceId: number().int(),
40185
- className: string(),
40186
- firstSeen: number().int(),
40187
- lastSeen: number().int(),
40188
- mediaUrl: string().nullable()
40189
- });
40190
- var AnalyticsGroupDetailSchema = AnalyticsGroupRecordSchema.extend({ members: array(AnalyticsGroupMemberSchema).readonly() });
40191
40222
  var groupActions = defineCustomActions({
40192
40223
  "analytics.listGroups": customAction(object({
40193
40224
  deviceId: number().int().optional(),
@@ -40201,61 +40232,6 @@ var groupActions = defineCustomActions({
40201
40232
  })),
40202
40233
  "analytics.getGroup": customAction(object({ groupId: string().min(1) }), object({ group: AnalyticsGroupDetailSchema.nullable() }))
40203
40234
  });
40204
- //#endregion
40205
- //#region src/pipeline-analytics/group/group-query.ts
40206
- /**
40207
- * Stamp a relative event-media URL.
40208
- *
40209
- * Media keys contain `:` and the data-plane serves them by key. Track ids are
40210
- * bare and fall through to the existing event/track thumbnail resolver. Never
40211
- * percent-encode: the handler does not URL-decode the path.
40212
- */
40213
- function stampEventMediaUrl(baseUrl, id) {
40214
- if (baseUrl === null || baseUrl.length === 0) return null;
40215
- if (id === null || id === void 0 || id.length === 0) return null;
40216
- return `${baseUrl}/${id}`;
40217
- }
40218
- /** Union of every class on the members; `className` is the first, stable order. */
40219
- function aggregateGroupClasses(tracks) {
40220
- const seen = /* @__PURE__ */ new Set();
40221
- const classes = [];
40222
- for (const track of tracks) for (const name of [track.className, ...track.classes ?? []]) {
40223
- if (name.length === 0 || seen.has(name)) continue;
40224
- seen.add(name);
40225
- classes.push(name);
40226
- }
40227
- return {
40228
- className: classes[0] ?? "unknown",
40229
- classes
40230
- };
40231
- }
40232
- function toGroupRecord(input) {
40233
- const { className, classes } = aggregateGroupClasses(input.tracks);
40234
- const mediaId = input.group.mediaKey ?? (input.group.memberCount === 1 ? input.memberTrackIds[0] : null);
40235
- return {
40236
- id: input.group.id,
40237
- deviceId: input.group.deviceId,
40238
- openedAt: input.group.openedAt,
40239
- closedAt: input.group.closedAt,
40240
- timestamp: input.group.timestamp,
40241
- memberCount: input.memberTrackIds.length > 0 ? input.memberTrackIds.length : input.group.memberCount,
40242
- memberTrackIds: [...input.memberTrackIds],
40243
- className,
40244
- classes: [...classes],
40245
- mediaUrl: stampEventMediaUrl(input.eventMediaBaseUrl, mediaId),
40246
- singleton: input.memberTrackIds.length <= 1
40247
- };
40248
- }
40249
- function toGroupMember(member, track, eventMediaBaseUrl) {
40250
- return {
40251
- trackId: member.trackId,
40252
- deviceId: member.deviceId,
40253
- className: track?.className ?? "unknown",
40254
- firstSeen: track?.firstSeen ?? member.timestamp,
40255
- lastSeen: track?.lastSeen ?? member.timestamp,
40256
- mediaUrl: stampEventMediaUrl(eventMediaBaseUrl, member.trackId)
40257
- };
40258
- }
40259
40235
  function trackToLite(track) {
40260
40236
  return {
40261
40237
  trackId: track.trackId,
@@ -40265,116 +40241,43 @@ function trackToLite(track) {
40265
40241
  lastSeen: track.lastSeen
40266
40242
  };
40267
40243
  }
40268
- async function listGroupRecords(ports, input) {
40269
- const page = await ports.groupStore.listRecent(input);
40270
- const groups = [];
40271
- for (const group of page.groups) groups.push(await buildGroupRecord(ports, group));
40272
- return {
40273
- groups,
40274
- hasMore: page.hasMore
40275
- };
40276
- }
40277
- async function getGroupDetail(ports, groupId) {
40278
- const group = await ports.groupStore.get(groupId);
40279
- if (group === null) return null;
40280
- const members = await ports.groupStore.listMembers(groupId);
40281
- const lites = [];
40282
- const rows = [];
40283
- for (const member of members) {
40284
- const track = await ports.loadTrack(member.trackId);
40285
- const lite = track === null ? null : trackToLite(track);
40286
- if (lite !== null) lites.push(lite);
40287
- rows.push(toGroupMember(member, lite, ports.eventMediaBaseUrl()));
40288
- }
40289
- return {
40290
- ...toGroupRecord({
40291
- group,
40292
- memberTrackIds: members.map((m) => m.trackId),
40293
- tracks: lites,
40294
- eventMediaBaseUrl: ports.eventMediaBaseUrl()
40295
- }),
40296
- members: rows
40297
- };
40298
- }
40299
- async function buildGroupRecord(ports, group) {
40300
- const members = await ports.groupStore.listMembers(group.id);
40301
- const lites = [];
40302
- for (const member of members) {
40303
- const track = await ports.loadTrack(member.trackId);
40304
- if (track !== null) lites.push(trackToLite(track));
40305
- }
40306
- return toGroupRecord({
40307
- group,
40308
- memberTrackIds: members.map((m) => m.trackId),
40309
- tracks: lites,
40310
- eventMediaBaseUrl: ports.eventMediaBaseUrl()
40244
+ /**
40245
+ * Gallery and digest read the same ladder: groupCrop, then the native-frame
40246
+ * pointer, then a member's clean full frame. Plate / face / member
40247
+ * thumbnails never reach the picker — a plate read overwrites the vehicle
40248
+ * first-level crop under the same `thumbnail` kind. A multi with no crop
40249
+ * still has a picture if the sealer stored `nativeKey`.
40250
+ */
40251
+ var DIGEST_MEMBER_FALLBACK_KINDS = new Set([
40252
+ "keyFrame",
40253
+ "keyFrameSmall",
40254
+ "fullFrame"
40255
+ ]);
40256
+ function digestMediaRows(input) {
40257
+ const owned = [];
40258
+ if (input.mediaKey !== null && input.mediaKey.length > 0) owned.push({
40259
+ key: input.mediaKey,
40260
+ kind: "groupCrop",
40261
+ timestamp: input.timestamp
40311
40262
  });
40312
- }
40313
- //#endregion
40314
- //#region src/pipeline-analytics/group/group-crop.ts
40315
- /** Always containSubject: true. Never the 0.5 delivered threshold. */
40316
- function groupWideCentralSquareLayout(unionBbox, frame) {
40317
- return wideCentralSquareLayout(unionBbox, frame, { containSubject: true });
40318
- }
40319
- async function cropGroupFromRgb(rgb, frameWidth, frameHeight, unionBbox) {
40320
- const layout = groupWideCentralSquareLayout(unionBbox, {
40321
- W: frameWidth,
40322
- H: frameHeight
40263
+ if (input.nativeKey !== null && input.nativeKey.length > 0) owned.push({
40264
+ key: input.nativeKey,
40265
+ kind: "nativeFrame",
40266
+ timestamp: input.timestamp
40323
40267
  });
40324
- const { crop: slab } = await extractCrop(rgb, frameWidth, frameHeight, layout.fetch);
40325
- return composeWideCentralSquareThumbnail(slab, layout);
40268
+ if (owned.length > 0) return owned;
40269
+ return input.memberRows.filter((row) => DIGEST_MEMBER_FALLBACK_KINDS.has(row.kind));
40326
40270
  }
40327
40271
  var MATCH_WINDOW_MS = 1e3;
40328
40272
  var MOVE_EPS = .35;
40273
+ /** Live-assigner window: same predicate, recent positions only. */
40274
+ var LIVE_WINDOW_MS = 3e3;
40329
40275
  /** Registry stationary is out; audio has no bbox and is never a member or a singleton. */
40330
40276
  function isEligibleMember(track) {
40331
40277
  if (track.closedReason === "stationary-promotion") return false;
40332
40278
  if (track.className === "audio") return false;
40333
40279
  return true;
40334
40280
  }
40335
- function temporallyOverlap(a, b, slackMs) {
40336
- return a.firstSeen <= b.lastSeen + slackMs && b.firstSeen <= a.lastSeen + slackMs;
40337
- }
40338
- /** Connected components of temporal overlap (+ slack) per camera. */
40339
- function sessionsOf(tracks) {
40340
- const byDevice = /* @__PURE__ */ new Map();
40341
- for (const t of tracks) {
40342
- const list = byDevice.get(t.deviceId);
40343
- if (list) list.push(t);
40344
- else byDevice.set(t.deviceId, [t]);
40345
- }
40346
- const sessions = [];
40347
- for (const deviceTracks of byDevice.values()) {
40348
- const n = deviceTracks.length;
40349
- const parent = deviceTracks.map((_, i) => i);
40350
- const find = (i) => {
40351
- let cur = i;
40352
- while (parent[cur] !== cur) cur = parent[cur];
40353
- let walk = i;
40354
- while (parent[walk] !== cur) {
40355
- const next = parent[walk];
40356
- parent[walk] = cur;
40357
- walk = next;
40358
- }
40359
- return cur;
40360
- };
40361
- const union = (i, j) => {
40362
- const ri = find(i);
40363
- const rj = find(j);
40364
- if (ri !== rj) parent[ri] = rj;
40365
- };
40366
- for (let i = 0; i < n; i++) for (let j = i + 1; j < n; j++) if (temporallyOverlap(deviceTracks[i], deviceTracks[j], 3e3)) union(i, j);
40367
- const buckets = /* @__PURE__ */ new Map();
40368
- for (let i = 0; i < n; i++) {
40369
- const root = find(i);
40370
- const bucket = buckets.get(root);
40371
- if (bucket) bucket.push(deviceTracks[i]);
40372
- else buckets.set(root, [deviceTracks[i]]);
40373
- }
40374
- sessions.push(...buckets.values());
40375
- }
40376
- return sessions;
40377
- }
40378
40281
  function diag(bbox) {
40379
40282
  return Math.hypot(bbox.w, bbox.h);
40380
40283
  }
@@ -40396,9 +40299,21 @@ function meanDiag(positions) {
40396
40299
  for (const p of positions) sum += diag(p.bbox);
40397
40300
  return sum / positions.length;
40398
40301
  }
40399
- function netDisplacement(track) {
40400
- const first = track.positions[0];
40401
- const last = track.positions[track.positions.length - 1];
40302
+ function vecLen(v) {
40303
+ return Math.hypot(v.x, v.y);
40304
+ }
40305
+ function cosine(a, b) {
40306
+ const la = vecLen(a);
40307
+ const lb = vecLen(b);
40308
+ if (la === 0 || lb === 0) return 1;
40309
+ return (a.x * b.x + a.y * b.y) / (la * lb);
40310
+ }
40311
+ function positionsInWindow(track, start, end) {
40312
+ return track.positions.filter((p) => p.timestamp >= start && p.timestamp <= end);
40313
+ }
40314
+ function windowDisplacement(positions) {
40315
+ const first = positions[0];
40316
+ const last = positions[positions.length - 1];
40402
40317
  if (!first || !last) return {
40403
40318
  x: 0,
40404
40319
  y: 0
@@ -40410,24 +40325,19 @@ function netDisplacement(track) {
40410
40325
  y: b.y - a.y
40411
40326
  };
40412
40327
  }
40413
- function vecLen(v) {
40414
- return Math.hypot(v.x, v.y);
40415
- }
40416
- function cosine(a, b) {
40417
- const la = vecLen(a);
40418
- const lb = vecLen(b);
40419
- if (la === 0 || lb === 0) return 1;
40420
- return (a.x * b.x + a.y * b.y) / (la * lb);
40421
- }
40422
- function relativeMotion(track) {
40423
- const mean = meanDiag(track.positions);
40328
+ function windowRelativeMotion(positions) {
40329
+ const mean = meanDiag(positions);
40424
40330
  if (mean <= 0) return 0;
40425
- return vecLen(netDisplacement(track)) / mean;
40331
+ return vecLen(windowDisplacement(positions)) / mean;
40426
40332
  }
40427
- /** A ~ B iff cohesion ≥ 0.5 AND NOT (both move AND cos < 0.3). */
40428
- function pairTogether(a, b) {
40429
- const overlapStart = Math.max(a.firstSeen, b.firstSeen);
40430
- const overlapEnd = Math.min(a.lastSeen, b.lastSeen);
40333
+ /**
40334
+ * Same cohesion + crossing veto as {@link pairTogether}, over `[now − 3 s, now]`.
40335
+ * Displacement/cosine use the window, not the whole track.
40336
+ */
40337
+ function pairTogetherNow(a, b, now) {
40338
+ const start = now - LIVE_WINDOW_MS;
40339
+ const overlapStart = Math.max(a.firstSeen, b.firstSeen, start);
40340
+ const overlapEnd = Math.min(a.lastSeen, b.lastSeen, now);
40431
40341
  if (overlapEnd < overlapStart) return false;
40432
40342
  let joint = 0;
40433
40343
  let close = 0;
@@ -40444,55 +40354,245 @@ function pairTogether(a, b) {
40444
40354
  }
40445
40355
  if (joint === 0) return false;
40446
40356
  if (close / joint < .5) return false;
40447
- const aMoves = relativeMotion(a) >= MOVE_EPS;
40448
- const bMoves = relativeMotion(b) >= MOVE_EPS;
40449
- if (aMoves && bMoves && cosine(netDisplacement(a), netDisplacement(b)) < .3) return false;
40357
+ const wa = positionsInWindow(a, start, now);
40358
+ const wb = positionsInWindow(b, start, now);
40359
+ const aMoves = windowRelativeMotion(wa) >= MOVE_EPS;
40360
+ const bMoves = windowRelativeMotion(wb) >= MOVE_EPS;
40361
+ if (aMoves && bMoves && cosine(windowDisplacement(wa), windowDisplacement(wb)) < .3) return false;
40450
40362
  return true;
40451
40363
  }
40452
- function clusterGroups(tracks) {
40453
- const eligible = tracks.filter(isEligibleMember);
40454
- const out = [];
40455
- for (const session of sessionsOf(eligible)) {
40456
- const pairable = [];
40457
- for (const t of session) if (t.positions.length < 2) out.push({
40458
- deviceId: t.deviceId,
40459
- memberTrackIds: [t.trackId]
40460
- });
40461
- else pairable.push(t);
40462
- if (pairable.length === 0) continue;
40463
- const parent = pairable.map((_, i) => i);
40464
- const find = (i) => {
40465
- let cur = i;
40466
- while (parent[cur] !== cur) cur = parent[cur];
40467
- let walk = i;
40468
- while (parent[walk] !== cur) {
40469
- const next = parent[walk];
40470
- parent[walk] = cur;
40471
- walk = next;
40472
- }
40473
- return cur;
40474
- };
40475
- const union = (i, j) => {
40476
- const ri = find(i);
40477
- const rj = find(j);
40478
- if (ri !== rj) parent[ri] = rj;
40479
- };
40480
- for (let i = 0; i < pairable.length; i++) for (let j = i + 1; j < pairable.length; j++) if (pairTogether(pairable[i], pairable[j])) union(i, j);
40481
- const buckets = /* @__PURE__ */ new Map();
40482
- for (let i = 0; i < pairable.length; i++) {
40483
- const root = find(i);
40484
- const bucket = buckets.get(root);
40485
- const id = pairable[i].trackId;
40486
- if (bucket) bucket.push(id);
40487
- else buckets.set(root, [id]);
40488
- }
40489
- const deviceId = pairable[0].deviceId;
40490
- for (const memberTrackIds of buckets.values()) out.push({
40491
- deviceId,
40492
- memberTrackIds
40364
+ var GroupLiveAssigner = class {
40365
+ nextId;
40366
+ snapshots = /* @__PURE__ */ new Map();
40367
+ groups = /* @__PURE__ */ new Map();
40368
+ membership = /* @__PURE__ */ new Map();
40369
+ /** `${trackId}>${groupId}` → first instant the join predicate held. */
40370
+ joinSince = /* @__PURE__ */ new Map();
40371
+ /** trackId → first instant they failed pair against every remaining member. */
40372
+ leaveSince = /* @__PURE__ */ new Map();
40373
+ constructor(nextId = () => randomUUID()) {
40374
+ this.nextId = nextId;
40375
+ }
40376
+ observe(track, now) {
40377
+ this.snapshots.set(track.trackId, track);
40378
+ if (!isEligibleMember(track)) {
40379
+ this.removeFromGroup(track.trackId);
40380
+ return;
40381
+ }
40382
+ if (!this.membership.has(track.trackId)) this.birthSingleton(track, now);
40383
+ this.considerLeave(track, now);
40384
+ const groupId = this.membership.get(track.trackId);
40385
+ const group = groupId !== void 0 ? this.groups.get(groupId) : void 0;
40386
+ if (group) for (const id of [...group.members]) {
40387
+ if (id === track.trackId) continue;
40388
+ const snap = this.snapshots.get(id);
40389
+ if (snap) this.considerLeave(snap, now);
40390
+ }
40391
+ this.considerJoin(track, now);
40392
+ }
40393
+ groupOf(trackId) {
40394
+ return this.membership.get(trackId);
40395
+ }
40396
+ snapshot(trackId) {
40397
+ return this.snapshots.get(trackId);
40398
+ }
40399
+ liveGroups() {
40400
+ return [...this.groups.values()].map((g) => this.toLive(g));
40401
+ }
40402
+ idleGroups(now, idleMs) {
40403
+ return this.liveGroups().filter((g) => now >= g.lastActiveAt + idleMs);
40404
+ }
40405
+ drop(ids) {
40406
+ for (const id of ids) {
40407
+ const g = this.groups.get(id);
40408
+ if (!g) continue;
40409
+ for (const member of g.members) this.membership.delete(member);
40410
+ this.groups.delete(id);
40411
+ }
40412
+ }
40413
+ birthSingleton(track, now) {
40414
+ const id = this.nextId();
40415
+ this.groups.set(id, {
40416
+ id,
40417
+ deviceId: track.deviceId,
40418
+ openedAt: now,
40419
+ members: new Set([track.trackId])
40493
40420
  });
40421
+ this.membership.set(track.trackId, id);
40494
40422
  }
40495
- return out;
40423
+ considerLeave(track, now) {
40424
+ const groupId = this.membership.get(track.trackId);
40425
+ if (groupId === void 0) return;
40426
+ const group = this.groups.get(groupId);
40427
+ if (!group || group.members.size < 2) {
40428
+ this.leaveSince.delete(track.trackId);
40429
+ return;
40430
+ }
40431
+ if ([...group.members].filter((id) => id !== track.trackId).some((id) => {
40432
+ const other = this.snapshots.get(id);
40433
+ return other !== void 0 && pairTogetherNow(track, other, now);
40434
+ })) {
40435
+ this.leaveSince.delete(track.trackId);
40436
+ return;
40437
+ }
40438
+ const since = this.leaveSince.get(track.trackId) ?? now;
40439
+ this.leaveSince.set(track.trackId, since);
40440
+ if (now - since < 3e3) return;
40441
+ this.peel(track, now);
40442
+ }
40443
+ peel(track, now) {
40444
+ this.removeFromGroup(track.trackId);
40445
+ this.leaveSince.delete(track.trackId);
40446
+ this.birthSingleton(track, now);
40447
+ }
40448
+ considerJoin(track, now) {
40449
+ const mineId = this.membership.get(track.trackId);
40450
+ if (mineId === void 0) return;
40451
+ const mine = this.groups.get(mineId);
40452
+ if (!mine || mine.members.size !== 1) {
40453
+ this.clearJoinClocks(track.trackId);
40454
+ return;
40455
+ }
40456
+ let best;
40457
+ for (const group of this.groups.values()) {
40458
+ if (group.id === mineId || group.deviceId !== track.deviceId) continue;
40459
+ let gap = Number.POSITIVE_INFINITY;
40460
+ let together = false;
40461
+ for (const memberId of group.members) {
40462
+ const other = this.snapshots.get(memberId);
40463
+ if (!other) continue;
40464
+ if (pairTogetherNow(track, other, now)) {
40465
+ together = true;
40466
+ gap = Math.min(gap, centroidGap(track, other, now));
40467
+ }
40468
+ }
40469
+ if (!together) continue;
40470
+ if (best === void 0 || gap < best.gap) best = {
40471
+ groupId: group.id,
40472
+ gap
40473
+ };
40474
+ }
40475
+ if (best === void 0) {
40476
+ this.clearJoinClocks(track.trackId);
40477
+ return;
40478
+ }
40479
+ const key = `${track.trackId}>${best.groupId}`;
40480
+ for (const existing of [...this.joinSince.keys()]) if (existing.startsWith(`${track.trackId}>`) && existing !== key) this.joinSince.delete(existing);
40481
+ const since = this.joinSince.get(key) ?? now;
40482
+ this.joinSince.set(key, since);
40483
+ if (now - since < 1500) return;
40484
+ this.absorb(track.trackId, mineId, best.groupId);
40485
+ }
40486
+ absorb(trackId, fromId, intoId) {
40487
+ const into = this.groups.get(intoId);
40488
+ if (!into) return;
40489
+ into.members.add(trackId);
40490
+ this.membership.set(trackId, intoId);
40491
+ this.groups.delete(fromId);
40492
+ this.clearJoinClocks(trackId);
40493
+ this.leaveSince.delete(trackId);
40494
+ }
40495
+ removeFromGroup(trackId) {
40496
+ const groupId = this.membership.get(trackId);
40497
+ this.membership.delete(trackId);
40498
+ this.clearJoinClocks(trackId);
40499
+ this.leaveSince.delete(trackId);
40500
+ if (groupId === void 0) return;
40501
+ const group = this.groups.get(groupId);
40502
+ if (!group) return;
40503
+ group.members.delete(trackId);
40504
+ if (group.members.size === 0) this.groups.delete(groupId);
40505
+ }
40506
+ clearJoinClocks(trackId) {
40507
+ for (const key of [...this.joinSince.keys()]) if (key.startsWith(`${trackId}>`)) this.joinSince.delete(key);
40508
+ }
40509
+ toLive(group) {
40510
+ let lastActiveAt = group.openedAt;
40511
+ for (const id of group.members) {
40512
+ const snap = this.snapshots.get(id);
40513
+ if (snap && snap.lastSeen > lastActiveAt) lastActiveAt = snap.lastSeen;
40514
+ }
40515
+ return {
40516
+ id: group.id,
40517
+ deviceId: group.deviceId,
40518
+ memberTrackIds: [...group.members],
40519
+ openedAt: group.openedAt,
40520
+ lastActiveAt
40521
+ };
40522
+ }
40523
+ };
40524
+ function centroidGap(a, b, now) {
40525
+ const pa = a.positions.reduce((best, p) => Math.abs(p.timestamp - now) < Math.abs(best.timestamp - now) ? p : best, a.positions[0]);
40526
+ const pb = b.positions.reduce((best, p) => Math.abs(p.timestamp - now) < Math.abs(best.timestamp - now) ? p : best, b.positions[0]);
40527
+ if (!pa || !pb) return Number.POSITIVE_INFINITY;
40528
+ const ca = bboxCentroid(pa.bbox);
40529
+ const cb = bboxCentroid(pb.bbox);
40530
+ return Math.hypot(ca.x - cb.x, ca.y - cb.y);
40531
+ }
40532
+ //#endregion
40533
+ //#region src/pipeline-analytics/group/group-session.ts
40534
+ /** Same idle as the notification burst (`NC_GROUP_DEFAULT_IDLE_SEC`). */
40535
+ var GROUP_SESSION_IDLE_MS = 3e4;
40536
+ //#endregion
40537
+ //#region src/pipeline-analytics/group/group-live-buffer.ts
40538
+ function toGroupableTrack(track) {
40539
+ return {
40540
+ trackId: track.trackId,
40541
+ deviceId: track.deviceId,
40542
+ className: track.className,
40543
+ firstSeen: track.firstSeen,
40544
+ lastSeen: track.lastSeen,
40545
+ positions: track.positions.map((p) => ({
40546
+ ...p,
40547
+ bbox: { ...p.bbox }
40548
+ })),
40549
+ ...track.closedReason !== void 0 ? { closedReason: track.closedReason } : {}
40550
+ };
40551
+ }
40552
+ var GroupLiveBuffer = class {
40553
+ assigner;
40554
+ primed = /* @__PURE__ */ new Set();
40555
+ constructor(nextId) {
40556
+ this.assigner = new GroupLiveAssigner(nextId);
40557
+ }
40558
+ observe(track, now) {
40559
+ this.assigner.observe(track, now);
40560
+ }
40561
+ groupOf(trackId) {
40562
+ return this.assigner.groupOf(trackId);
40563
+ }
40564
+ snapshot(trackId) {
40565
+ return this.assigner.snapshot(trackId);
40566
+ }
40567
+ liveGroups() {
40568
+ return this.assigner.liveGroups();
40569
+ }
40570
+ idleGroups(now, idleMs = GROUP_SESSION_IDLE_MS) {
40571
+ return this.assigner.idleGroups(now, idleMs);
40572
+ }
40573
+ drop(ids) {
40574
+ this.assigner.drop(ids);
40575
+ }
40576
+ isPrimed(deviceId) {
40577
+ return this.primed.has(deviceId);
40578
+ }
40579
+ markPrimed(deviceId) {
40580
+ this.primed.add(deviceId);
40581
+ }
40582
+ };
40583
+ //#endregion
40584
+ //#region src/pipeline-analytics/group/group-crop.ts
40585
+ /** Always containSubject: true. Never the 0.5 delivered threshold. */
40586
+ function groupWideCentralSquareLayout(unionBbox, frame) {
40587
+ return wideCentralSquareLayout(unionBbox, frame, { containSubject: true });
40588
+ }
40589
+ async function cropGroupFromRgb(rgb, frameWidth, frameHeight, unionBbox) {
40590
+ const layout = groupWideCentralSquareLayout(unionBbox, {
40591
+ W: frameWidth,
40592
+ H: frameHeight
40593
+ });
40594
+ const { crop: slab } = await extractCrop(rgb, frameWidth, frameHeight, layout.fetch);
40595
+ return composeWideCentralSquareThumbnail(slab, layout);
40496
40596
  }
40497
40597
  //#endregion
40498
40598
  //#region src/pipeline-analytics/group/group-instant.ts
@@ -40509,6 +40609,7 @@ function clusterGroups(tracks) {
40509
40609
  *
40510
40610
  * Retrospective. No new capture path.
40511
40611
  */
40612
+ var GROUP_MEDIA_MATCH_MS = 2e3;
40512
40613
  function nearest(positions, t, windowMs) {
40513
40614
  let best;
40514
40615
  let bestDt = windowMs + 1;
@@ -40617,55 +40718,85 @@ function chooseAnchorTrackId(members, ownsCleanFrame) {
40617
40718
  return life !== 0 ? life : a.trackId.localeCompare(b.trackId);
40618
40719
  })[0].trackId;
40619
40720
  }
40620
- //#endregion
40621
- //#region src/pipeline-analytics/group/group-session.ts
40622
40721
  /**
40623
- * Session close → sealed groups. A group is born here, never live.
40722
+ * The RGB source for a group crop is a full native frame. Thumbnails are
40723
+ * already single-subject crops and cannot substitute for union-bbox geometry.
40624
40724
  *
40625
- * Session = temporal chain + 3 s slack, per camera. Close = every overlapping
40626
- * track is persisted AND the newest lastSeen is older than the idle cutoff
40627
- * (the Phase-1 burst's 30 s, reused — not a second clock).
40628
- *
40629
- * Merge and split cannot occur: connected components of a closed session is a
40630
- * partition. Say it out loud so a later live-group attempt does not reach
40631
- * for them here.
40725
+ * Prefer a keyFrame within {@link GROUP_MEDIA_MATCH_MS} of the chosen instant;
40726
+ * if none, take the nearest keyFrame anyway — dropping the crop because the
40727
+ * best-shot was 3 s off the instant is how a recognised person left the
40728
+ * digest with no crop.
40632
40729
  */
40633
- /** Same idle as the notification burst (`NC_GROUP_DEFAULT_IDLE_SEC`). */
40634
- var GROUP_SESSION_IDLE_MS = 3e4;
40635
- function overlaps(a, b) {
40636
- return a.firstSeen <= b.lastSeen + 3e3 && b.firstSeen <= a.lastSeen + 3e3;
40730
+ function pickCleanPixelRow(rows, instantMs) {
40731
+ const ranked = (maxDt) => {
40732
+ const candidates = [];
40733
+ for (const row of rows) {
40734
+ const rank = row.kind === "keyFrame" ? 0 : row.kind === "keyFrameSmall" ? 1 : -1;
40735
+ if (rank < 0) continue;
40736
+ const dt = Math.abs(row.timestamp - instantMs);
40737
+ if (maxDt !== null && dt > maxDt) continue;
40738
+ candidates.push({
40739
+ key: row.key,
40740
+ dt,
40741
+ rank
40742
+ });
40743
+ }
40744
+ return candidates.toSorted((a, b) => a.rank - b.rank || a.dt - b.dt)[0];
40745
+ };
40746
+ const near = ranked(GROUP_MEDIA_MATCH_MS);
40747
+ if (near !== void 0) return {
40748
+ key: near.key,
40749
+ usedFallback: false
40750
+ };
40751
+ const any = ranked(null);
40752
+ if (any === void 0) return null;
40753
+ return {
40754
+ key: any.key,
40755
+ usedFallback: true
40756
+ };
40637
40757
  }
40638
- function sessionSpan(tracks) {
40758
+ //#endregion
40759
+ //#region src/pipeline-analytics/group/group-live-replay.ts
40760
+ function sliceTrackAt(track, now) {
40761
+ const positions = track.positions.filter((p) => p.timestamp <= now);
40762
+ const last = positions[positions.length - 1];
40639
40763
  return {
40640
- firstSeen: Math.min(...tracks.map((t) => t.firstSeen)),
40641
- lastSeen: Math.max(...tracks.map((t) => t.lastSeen))
40764
+ ...track,
40765
+ lastSeen: last?.timestamp ?? track.firstSeen,
40766
+ positions
40642
40767
  };
40643
40768
  }
40644
- /**
40645
- * Sessions whose members are all in `closed` and whose newest lastSeen is
40646
- * older than `idleMs`. An overlapping ACTIVE track keeps the session open.
40647
- */
40648
- function sessionsReadyToSeal(closed, active, now, idleMs = GROUP_SESSION_IDLE_MS) {
40649
- const byDevice = /* @__PURE__ */ new Map();
40650
- for (const t of closed) {
40651
- const list = byDevice.get(t.deviceId);
40652
- if (list) list.push(t);
40653
- else byDevice.set(t.deviceId, [t]);
40654
- }
40655
- const ready = [];
40656
- for (const [deviceId, tracks] of byDevice) {
40657
- const deviceActive = active.filter((a) => a.deviceId === deviceId);
40658
- for (const session of sessionsOf(tracks)) {
40659
- const span = sessionSpan(session);
40660
- if (now < span.lastSeen + idleMs) continue;
40661
- if (deviceActive.some((a) => overlaps(span, a))) continue;
40662
- ready.push(session);
40769
+ function replayInto(tracks, observer) {
40770
+ const events = [];
40771
+ const byId = /* @__PURE__ */ new Map();
40772
+ for (const track of tracks) {
40773
+ byId.set(track.trackId, track);
40774
+ if (track.positions.length === 0) {
40775
+ events.push({
40776
+ t: track.lastSeen,
40777
+ trackId: track.trackId
40778
+ });
40779
+ continue;
40663
40780
  }
40781
+ for (const p of track.positions) events.push({
40782
+ t: p.timestamp,
40783
+ trackId: track.trackId
40784
+ });
40785
+ }
40786
+ events.sort((a, b) => a.t - b.t || a.trackId.localeCompare(b.trackId));
40787
+ let lastKey = "";
40788
+ for (const e of events) {
40789
+ const key = `${e.trackId}@${e.t}`;
40790
+ if (key === lastKey) continue;
40791
+ lastKey = key;
40792
+ const track = byId.get(e.trackId);
40793
+ if (!track) continue;
40794
+ observer.observe(sliceTrackAt(track, e.t), e.t);
40664
40795
  }
40665
- return ready;
40666
40796
  }
40667
- function partitionsForSealedSessions(closed, active, now, idleMs = GROUP_SESSION_IDLE_MS) {
40668
- return clusterGroups(sessionsReadyToSeal(closed, active, now, idleMs).flat());
40797
+ function replayLive(tracks, assigner = new GroupLiveAssigner()) {
40798
+ replayInto(tracks, assigner);
40799
+ return assigner;
40669
40800
  }
40670
40801
  //#endregion
40671
40802
  //#region src/pipeline-analytics/group/group-sealer.ts
@@ -40673,9 +40804,9 @@ var DEFAULT_FRAME = {
40673
40804
  width: 640,
40674
40805
  height: 360
40675
40806
  };
40676
- var SEAL_LOOKBACK_MS = 10 * 6e4;
40677
- var BACKFILL_LOOKBACK_MS = 2880 * 6e4;
40678
40807
  var QUERY_LIMIT = 2e3;
40808
+ /** Crash recover: replay this camera only, on its next activity. */
40809
+ var GROUP_CRASH_LOOKBACK_MS = 2 * 6e4;
40679
40810
  function toInstant(t) {
40680
40811
  return {
40681
40812
  trackId: t.trackId,
@@ -40689,93 +40820,164 @@ function toInstant(t) {
40689
40820
  confidence: t.importance
40690
40821
  };
40691
40822
  }
40823
+ function memberBoxesAt(members, at, frame) {
40824
+ const w = Math.max(frame.width, 1);
40825
+ const h = Math.max(frame.height, 1);
40826
+ const boxes = [];
40827
+ for (const member of members) {
40828
+ let best = member.positions[0];
40829
+ let bestDt = Number.POSITIVE_INFINITY;
40830
+ for (const p of member.positions) {
40831
+ const dt = Math.abs(p.timestamp - at);
40832
+ if (dt <= 1e3 && dt < bestDt) {
40833
+ best = p;
40834
+ bestDt = dt;
40835
+ }
40836
+ }
40837
+ if (best === void 0 || bestDt === Number.POSITIVE_INFINITY) continue;
40838
+ boxes.push({
40839
+ bbox: {
40840
+ x: best.bbox.x / w,
40841
+ y: best.bbox.y / h,
40842
+ w: best.bbox.w / w,
40843
+ h: best.bbox.h / h
40844
+ },
40845
+ label: member.className,
40846
+ observedAt: best.timestamp
40847
+ });
40848
+ }
40849
+ return boxes;
40850
+ }
40851
+ function toSealer(track) {
40852
+ return {
40853
+ trackId: track.trackId,
40854
+ deviceId: track.deviceId,
40855
+ className: track.className,
40856
+ firstSeen: track.firstSeen,
40857
+ lastSeen: track.lastSeen,
40858
+ positions: track.positions,
40859
+ ...track.closedReason !== void 0 ? { closedReason: track.closedReason } : {}
40860
+ };
40861
+ }
40692
40862
  var GroupSealer = class {
40693
40863
  deps;
40694
40864
  constructor(deps) {
40695
40865
  this.deps = deps;
40696
40866
  }
40697
- async sealRecent() {
40698
- const now = this.deps.now();
40699
- return this.sealSince(now - SEAL_LOOKBACK_MS, now);
40700
- }
40701
- async backfill() {
40702
- const now = this.deps.now();
40703
- return this.sealSince(now - BACKFILL_LOOKBACK_MS, now);
40867
+ get processEnabled() {
40868
+ return this.deps.enabled ?? false;
40704
40869
  }
40705
- async sealSince(since, now) {
40870
+ async flushIdle(buffer, now = this.deps.now()) {
40871
+ if (!this.processEnabled) return 0;
40706
40872
  let written = 0;
40707
- let devices;
40873
+ for (const group of buffer.idleGroups(now, GROUP_SESSION_IDLE_MS)) if (await this.persistLiveGroup(group, buffer)) {
40874
+ buffer.drop([group.id]);
40875
+ written += 1;
40876
+ }
40877
+ return written;
40878
+ }
40879
+ /**
40880
+ * First activity on camera D after a crash: replay 2 minutes of that camera
40881
+ * into the live buffer, persist whoever is already idle, keep the rest in RAM.
40882
+ */
40883
+ async recoverCamera(deviceId, buffer) {
40884
+ if (!this.processEnabled) return 0;
40885
+ if (buffer.isPrimed(deviceId)) return 0;
40886
+ const now = this.deps.now();
40708
40887
  try {
40709
- devices = await this.deps.trackStore.listDeviceIds();
40888
+ replayInto(await this.deps.trackStore.queryHistorical({
40889
+ deviceId,
40890
+ since: now - GROUP_CRASH_LOOKBACK_MS,
40891
+ until: now,
40892
+ limit: QUERY_LIMIT,
40893
+ projection: "full"
40894
+ }), buffer);
40710
40895
  } catch (err) {
40711
- this.deps.logger.warn("group sealer: listDeviceIds failed", { meta: { error: String(err) } });
40712
- return 0;
40896
+ this.deps.logger.warn("group sealer: lookback failed", {
40897
+ tags: { deviceId },
40898
+ meta: { error: String(err) }
40899
+ });
40900
+ } finally {
40901
+ buffer.markPrimed(deviceId);
40713
40902
  }
40714
- for (const deviceId of devices) try {
40715
- written += await this.sealDevice(deviceId, since, now);
40903
+ return this.flushIdle(buffer, now);
40904
+ }
40905
+ /**
40906
+ * Seal ONE camera over a caller-owned window via live replay.
40907
+ *
40908
+ * This is how a digest recovers groups without a boot scan: the producer
40909
+ * already knows the window. Walking every camera for 48h at initialize()
40910
+ * is what wedged hub-main.
40911
+ */
40912
+ async sealWindow(deviceId, since, until) {
40913
+ if (!this.processEnabled) return 0;
40914
+ try {
40915
+ const closed = await this.deps.trackStore.queryHistorical({
40916
+ deviceId,
40917
+ since,
40918
+ until,
40919
+ limit: QUERY_LIMIT,
40920
+ projection: "full"
40921
+ });
40922
+ if (closed.length === 0) return 0;
40923
+ const already = new Set((await this.deps.groupStore.membersOfTracks(closed.map((t) => t.trackId))).map((m) => m.trackId));
40924
+ const pending = closed.filter((t) => !already.has(t.trackId));
40925
+ if (pending.length === 0) return 0;
40926
+ const assigner = replayLive(pending);
40927
+ const now = this.deps.now();
40928
+ const byId = new Map(pending.map((t) => [t.trackId, t]));
40929
+ let written = 0;
40930
+ for (const group of assigner.idleGroups(now, GROUP_SESSION_IDLE_MS)) {
40931
+ const members = group.memberTrackIds.map((id) => byId.get(id)).filter((t) => t !== void 0);
40932
+ if (members.length === 0) continue;
40933
+ try {
40934
+ await this.persistPartition(members, group.openedAt);
40935
+ written += 1;
40936
+ } catch (err) {
40937
+ this.deps.logger.warn("group sealer: persist failed", {
40938
+ tags: { deviceId },
40939
+ meta: {
40940
+ members: members.map((m) => m.trackId),
40941
+ error: String(err)
40942
+ }
40943
+ });
40944
+ }
40945
+ }
40946
+ return written;
40716
40947
  } catch (err) {
40717
40948
  this.deps.logger.warn("group sealer: device failed", {
40718
40949
  tags: { deviceId },
40719
40950
  meta: { error: String(err) }
40720
40951
  });
40952
+ return 0;
40721
40953
  }
40722
- return written;
40723
40954
  }
40724
- async sealDevice(deviceId, since, now) {
40725
- const closed = await this.deps.trackStore.queryHistorical({
40726
- deviceId,
40727
- since,
40728
- until: now,
40729
- limit: QUERY_LIMIT,
40730
- projection: "full"
40731
- });
40732
- if (closed.length === 0) return 0;
40733
- const already = new Set((await this.deps.groupStore.membersOfTracks(closed.map((t) => t.trackId))).map((m) => m.trackId));
40734
- const pending = closed.filter((t) => !already.has(t.trackId));
40735
- if (pending.length === 0) return 0;
40736
- const active = this.deps.trackStore.getActive(deviceId).map((t) => ({
40737
- deviceId,
40738
- firstSeen: t.firstSeen,
40739
- lastSeen: t.lastSeen
40740
- }));
40741
- const partitions = partitionsForSealedSessions(pending.map(toInstant), active, now, GROUP_SESSION_IDLE_MS);
40742
- if (partitions.length === 0) {
40743
- if (pending.filter((t) => t.closedReason !== "stationary-promotion" && t.className !== "audio").length === 0 && pending.length > 0) this.deps.logger.info("group session produced no groups", {
40955
+ async persistLiveGroup(group, buffer) {
40956
+ const deviceId = group.deviceId;
40957
+ const snaps = group.memberTrackIds.map((id) => buffer.snapshot(id)).filter((t) => t !== void 0);
40958
+ if (snaps.length === 0) return true;
40959
+ const already = new Set((await this.deps.groupStore.membersOfTracks(snaps.map((t) => t.trackId))).map((m) => m.trackId));
40960
+ const pending = snaps.filter((t) => !already.has(t.trackId)).map(toSealer);
40961
+ if (pending.length === 0) return true;
40962
+ try {
40963
+ await this.persistPartition(pending, group.openedAt);
40964
+ return true;
40965
+ } catch (err) {
40966
+ this.deps.logger.warn("group sealer: persist failed", {
40744
40967
  tags: { deviceId },
40745
40968
  meta: {
40746
- dropped: pending.length,
40747
- reason: "no-eligible-members"
40969
+ members: pending.map((m) => m.trackId),
40970
+ error: String(err)
40748
40971
  }
40749
40972
  });
40750
- return 0;
40751
- }
40752
- const byId = new Map(pending.map((t) => [t.trackId, t]));
40753
- let written = 0;
40754
- for (const part of partitions) {
40755
- const members = part.memberTrackIds.map((id) => byId.get(id)).filter((t) => t !== void 0);
40756
- if (members.length === 0) continue;
40757
- if (members.every((m) => already.has(m.trackId))) continue;
40758
- try {
40759
- await this.persistPartition(members);
40760
- written += 1;
40761
- } catch (err) {
40762
- this.deps.logger.warn("group sealer: persist failed", {
40763
- tags: { deviceId },
40764
- meta: {
40765
- members: members.map((m) => m.trackId),
40766
- error: String(err)
40767
- }
40768
- });
40769
- }
40973
+ return false;
40770
40974
  }
40771
- return written;
40772
40975
  }
40773
- async persistPartition(members) {
40976
+ async persistPartition(members, openedAt) {
40774
40977
  const deviceId = members[0].deviceId;
40775
40978
  const frame = this.deps.frameDims(deviceId) ?? DEFAULT_FRAME;
40776
40979
  const instantMembers = members.map(toInstant);
40777
40980
  const instant = chooseGroupInstant(instantMembers, frame);
40778
- const openedAt = Math.min(...members.map((m) => m.firstSeen));
40779
40981
  const closedAt = Math.max(...members.map((m) => m.lastSeen));
40780
40982
  const timestamp = instant?.timestamp ?? closedAt;
40781
40983
  const mediaInfos = await Promise.all(members.map(async (m) => ({
@@ -40811,29 +41013,66 @@ var GroupSealer = class {
40811
41013
  nativeKey
40812
41014
  });
40813
41015
  if (members.length < 2 || instant === null) return;
40814
- const source = await this.pickCleanPixels(mediaInfos, timestamp);
40815
- if (source === null) return;
41016
+ const source = await this.pickCleanPixels(mediaInfos, timestamp, deviceId);
41017
+ if (source === null) {
41018
+ this.deps.logger.info("group sealer: no clean pixels for crop", {
41019
+ tags: { deviceId },
41020
+ meta: {
41021
+ groupId: draft.id,
41022
+ members: members.map((m) => m.trackId)
41023
+ }
41024
+ });
41025
+ return;
41026
+ }
40816
41027
  try {
40817
- const raw = await sharp(source.jpeg).removeAlpha().raw().toBuffer({ resolveWithObject: true });
40818
- if (raw.info.channels !== 3) return;
40819
- const union = {
40820
- x: instant.ux * raw.info.width,
40821
- y: instant.uy * raw.info.height,
40822
- w: instant.uw * raw.info.width,
40823
- h: instant.uh * raw.info.height
40824
- };
40825
- const crop = await cropGroupFromRgb(raw.data, raw.info.width, raw.info.height, union);
40826
- const mediaKey = await this.deps.mediaStore.put({
41028
+ const raw = await sharp(source.jpeg).removeAlpha().toColorspace("srgb").raw().toBuffer({ resolveWithObject: true });
41029
+ if (raw.info.channels === 3) {
41030
+ const union = {
41031
+ x: instant.ux * raw.info.width,
41032
+ y: instant.uy * raw.info.height,
41033
+ w: instant.uw * raw.info.width,
41034
+ h: instant.uh * raw.info.height
41035
+ };
41036
+ const crop = await cropGroupFromRgb(raw.data, raw.info.width, raw.info.height, union);
41037
+ const mediaKey = await this.deps.mediaStore.put({
41038
+ deviceId,
41039
+ ownerKind: "group",
41040
+ ownerId: draft.id,
41041
+ kind: "thumbnail",
41042
+ timestamp,
41043
+ data: crop
41044
+ });
41045
+ await this.deps.groupStore.setMediaKey(draft.id, mediaKey);
41046
+ }
41047
+ } catch (err) {
41048
+ this.deps.logger.warn("group sealer: crop failed", {
41049
+ tags: { deviceId },
41050
+ meta: {
41051
+ groupId: draft.id,
41052
+ error: String(err)
41053
+ }
41054
+ });
41055
+ }
41056
+ try {
41057
+ const composed = await composeGroupFrame({
41058
+ jpeg: source.jpeg,
41059
+ boxes: memberBoxesAt(instantMembers, timestamp, frame),
41060
+ at: timestamp,
41061
+ logger: this.deps.logger,
41062
+ deviceId,
41063
+ groupKey: draft.id
41064
+ });
41065
+ if (composed === null) return;
41066
+ await this.deps.mediaStore.put({
40827
41067
  deviceId,
40828
41068
  ownerKind: "group",
40829
41069
  ownerId: draft.id,
40830
- kind: "thumbnail",
41070
+ kind: "fullFrameBoxed",
40831
41071
  timestamp,
40832
- data: crop
41072
+ data: composed.bytes
40833
41073
  });
40834
- await this.deps.groupStore.setMediaKey(draft.id, mediaKey);
40835
41074
  } catch (err) {
40836
- this.deps.logger.warn("group sealer: crop failed", {
41075
+ this.deps.logger.warn("group sealer: boxed frame failed", {
40837
41076
  tags: { deviceId },
40838
41077
  meta: {
40839
41078
  groupId: draft.id,
@@ -40842,27 +41081,53 @@ var GroupSealer = class {
40842
41081
  });
40843
41082
  }
40844
41083
  }
40845
- async pickCleanPixels(mediaInfos, instantMs) {
40846
- const candidates = [];
40847
- for (const info of mediaInfos) for (const row of info.rows) {
40848
- const rank = row.kind === "keyFrame" ? 0 : row.kind === "keyFrameSmall" ? 1 : -1;
40849
- if (rank < 0) continue;
40850
- const dt = Math.abs(row.timestamp - instantMs);
40851
- if (dt > 2e3) continue;
40852
- candidates.push({
40853
- key: row.key,
40854
- dt,
40855
- rank
40856
- });
40857
- }
40858
- const chosen = candidates.toSorted((a, b) => a.rank - b.rank || a.dt - b.dt)[0];
40859
- if (chosen === void 0) return null;
41084
+ async pickCleanPixels(mediaInfos, instantMs, deviceId) {
41085
+ const chosen = pickCleanPixelRow(mediaInfos.flatMap((info) => info.rows), instantMs);
41086
+ if (chosen === null) return null;
41087
+ if (chosen.usedFallback) this.deps.logger.info("group sealer: crop pixels not within 2s of instant, using nearest keyFrame", {
41088
+ tags: { deviceId },
41089
+ meta: {
41090
+ instantMs,
41091
+ mediaKey: chosen.key
41092
+ }
41093
+ });
40860
41094
  const file = await this.deps.mediaStore.getByKey(chosen.key);
40861
41095
  if (file === null) return null;
40862
41096
  return { jpeg: Buffer.from(file.base64, "base64") };
40863
41097
  }
40864
41098
  };
40865
41099
  //#endregion
41100
+ //#region src/pipeline-analytics/group/group-cursor.ts
41101
+ function encodeGroupCursor(cursor) {
41102
+ return Buffer.from(JSON.stringify({
41103
+ c: cursor.closedAt,
41104
+ i: cursor.groupId
41105
+ }), "utf8").toString("base64url");
41106
+ }
41107
+ function decodeGroupCursor(raw) {
41108
+ let parsed;
41109
+ try {
41110
+ parsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
41111
+ } catch {
41112
+ throw new Error("listGroups: malformed cursor");
41113
+ }
41114
+ 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");
41115
+ return {
41116
+ closedAt: parsed.c,
41117
+ groupId: parsed.i
41118
+ };
41119
+ }
41120
+ function compareGroupDesc(a, b) {
41121
+ if (a.closedAt !== b.closedAt) return b.closedAt - a.closedAt;
41122
+ if (a.id === b.id) return 0;
41123
+ return a.id < b.id ? 1 : -1;
41124
+ }
41125
+ /** True when `row` sits strictly AFTER the cursor in DESC order. */
41126
+ function isAfterGroupCursor(row, cursor) {
41127
+ if (row.closedAt < cursor.closedAt) return true;
41128
+ return row.closedAt === cursor.closedAt && row.id < cursor.groupId;
41129
+ }
41130
+ //#endregion
40866
41131
  //#region src/pipeline-analytics/group/group-store.ts
40867
41132
  /**
40868
41133
  * The group ENTITY store — a co-moving partition of one session, with one
@@ -40884,8 +41149,8 @@ var GroupSealer = class {
40884
41149
  */
40885
41150
  /**
40886
41151
  * @durable class=ledger owner=pipeline-analytics
40887
- * write="one row per co-moving group at session close — sealed, never live.
40888
- * Membership is a partition of one camera's eligible tracks. mediaKey is
41152
+ * write="one row per co-moving group, sealed at idle — never a live row.
41153
+ * Membership is the RAM members at persist, not a session partition. mediaKey is
40889
41154
  * the group's own crop; nativeKey is a POINTER to the anchor member's
40890
41155
  * keyFrame, nulled if that member is deleted alone. anchorTrackId and
40891
41156
  * nativeKey are the only fields rewritten after birth."
@@ -41124,6 +41389,24 @@ var TrackGroupStore = class {
41124
41389
  data: r.data
41125
41390
  }));
41126
41391
  }
41392
+ /**
41393
+ * Membership for a page of groups in ONE query. `listGroupRecords` used to
41394
+ * call {@link listMembers} per row — 60 groups is 60 settings-store RPCs,
41395
+ * ~20 s, and the viewer feed times out into a retry loop.
41396
+ */
41397
+ async listMembersForGroups(groupIds) {
41398
+ if (groupIds.length === 0) return [];
41399
+ return (await this.store.query.query({
41400
+ collection: TRACK_GROUP_MEMBERS_COLLECTION,
41401
+ filter: {
41402
+ whereIn: { groupId: [...groupIds] },
41403
+ limit: 1e4
41404
+ }
41405
+ })).map((r) => this.normalizeMember({
41406
+ id: r.id,
41407
+ data: r.data
41408
+ }));
41409
+ }
41127
41410
  async membersOfTracks(trackIds) {
41128
41411
  const out = [];
41129
41412
  for (const trackId of trackIds) {
@@ -41139,20 +41422,29 @@ var TrackGroupStore = class {
41139
41422
  return out;
41140
41423
  }
41141
41424
  /**
41142
- * Newest-closed page for the Library Groups tab.
41425
+ * Newest-closed page for the Groups feed.
41143
41426
  *
41144
41427
  * Time filter is overlap (`closedAt >= since` and `openedAt <= until`),
41145
41428
  * same as {@link listOverlapping}. Pagination is applied AFTER the window
41146
41429
  * so a day cursor is honest rather than a raw-row offset that skips live
41147
- * groups on a busy camera.
41430
+ * groups on a busy camera. `deviceIds: []` is an empty page (same as
41431
+ * `listRecentTracks`); omit the field for every camera. `deviceId` stays
41432
+ * as the single-camera alias the custom-action bridge still sends.
41148
41433
  */
41149
41434
  async listRecent(input) {
41150
41435
  const limit = Math.min(Math.max(input.limit ?? 40, 1), 100);
41151
41436
  const offset = Math.max(input.offset ?? 0, 0);
41152
- const slice = (await this.store.query.query({
41437
+ const deviceIds = input.deviceIds !== void 0 ? input.deviceIds : input.deviceId !== void 0 ? [input.deviceId] : void 0;
41438
+ if (deviceIds !== void 0 && deviceIds.length === 0) return {
41439
+ groups: [],
41440
+ hasMore: false,
41441
+ nextCursor: null
41442
+ };
41443
+ const cursor = input.cursor !== void 0 && input.cursor.length > 0 ? decodeGroupCursor(input.cursor) : null;
41444
+ const filtered = (await this.store.query.query({
41153
41445
  collection: TRACK_GROUPS_COLLECTION,
41154
41446
  filter: {
41155
- ...input.deviceId !== void 0 ? { where: { deviceId: input.deviceId } } : {},
41447
+ ...deviceIds !== void 0 && deviceIds.length === 1 ? { where: { deviceId: deviceIds[0] } } : deviceIds !== void 0 ? { whereIn: { deviceId: [...deviceIds] } } : {},
41156
41448
  orderBy: {
41157
41449
  field: "closedAt",
41158
41450
  direction: "desc"
@@ -41165,11 +41457,20 @@ var TrackGroupStore = class {
41165
41457
  })).filter((g) => {
41166
41458
  if (input.sinceMs !== void 0 && g.closedAt < input.sinceMs) return false;
41167
41459
  if (input.untilMs !== void 0 && g.openedAt > input.untilMs) return false;
41460
+ if (cursor !== null && !isAfterGroupCursor(g, cursor)) return false;
41168
41461
  return true;
41169
- }).slice(offset, offset + limit + 1);
41462
+ }).toSorted(compareGroupDesc);
41463
+ const slice = (cursor !== null ? filtered : filtered.slice(offset)).slice(0, limit + 1);
41464
+ const groups = slice.slice(0, limit);
41465
+ const hasMore = slice.length > limit;
41466
+ const last = groups[groups.length - 1];
41170
41467
  return {
41171
- groups: slice.slice(0, limit),
41172
- hasMore: slice.length > limit
41468
+ groups,
41469
+ hasMore,
41470
+ nextCursor: hasMore && last !== void 0 ? encodeGroupCursor({
41471
+ closedAt: last.closedAt,
41472
+ groupId: last.id
41473
+ }) : null
41173
41474
  };
41174
41475
  }
41175
41476
  /** Groups whose life overlaps `[since, until]` on one camera. */
@@ -42501,7 +42802,7 @@ function evaluateStationaryPromotion(input) {
42501
42802
  };
42502
42803
  }
42503
42804
  var DEFAULT_PACKAGE_STILLNESS_CONFIG = {
42504
- minRounds: 2,
42805
+ minHits: 3,
42505
42806
  roundGapMs: 5e3,
42506
42807
  ttlMs: 5 * 6e4,
42507
42808
  assocIou: .3
@@ -42534,8 +42835,9 @@ var PackageStillnessTracker = class {
42534
42835
  const prev = next[index];
42535
42836
  if (prev === void 0) continue;
42536
42837
  consumed.add(prev.id);
42537
- const updated = confirm(prev, input, s, this.config.roundGapMs);
42538
- if (!(updated.promotedAt === null && updated.activityBackedAtOpen && updated.rounds >= this.config.minRounds && input.timestamp - updated.firstSeenAt >= input.minDwellMs)) {
42838
+ const updated = confirm(prev, input, s);
42839
+ const minHits = input.minHits ?? this.config.minHits;
42840
+ if (!(updated.promotedAt === null && updated.activityBackedAtOpen && updated.hits >= minHits && input.timestamp - updated.firstSeenAt >= input.minDwellMs)) {
42539
42841
  next[index] = updated;
42540
42842
  continue;
42541
42843
  }
@@ -42559,7 +42861,7 @@ var PackageStillnessTracker = class {
42559
42861
  id: c.id,
42560
42862
  className: c.className,
42561
42863
  bbox: { ...c.bbox },
42562
- rounds: c.rounds,
42864
+ hits: c.hits,
42563
42865
  firstSeenAt: c.firstSeenAt,
42564
42866
  lastSeenAt: c.lastSeenAt,
42565
42867
  activityBackedAtOpen: c.activityBackedAtOpen
@@ -42617,7 +42919,7 @@ var PackageStillnessTracker = class {
42617
42919
  frameHeight: input.frameHeight,
42618
42920
  firstSeenAt: input.timestamp,
42619
42921
  lastSeenAt: input.timestamp,
42620
- rounds: 1,
42922
+ hits: 1,
42621
42923
  promotedAt: null,
42622
42924
  activityBackedAtOpen,
42623
42925
  ...s.trackId !== void 0 ? { sourceTrackId: s.trackId } : {},
@@ -42627,18 +42929,16 @@ var PackageStillnessTracker = class {
42627
42929
  };
42628
42930
  }
42629
42931
  };
42630
- /** Re-confirm a candidate with this frame's box. A new ROUND is only counted
42631
- * when the gap since the last sighting clears `roundGapMs`, so a burst of
42632
- * frames inside one governor round counts once. */
42633
- function confirm(prev, input, s, roundGapMs) {
42634
- const newRound = input.timestamp - prev.lastSeenAt >= roundGapMs;
42932
+ /** Re-confirm a candidate with this frame's box. Every sighting is a hit —
42933
+ * a settle burst of three frames in one second is three confirmations. */
42934
+ function confirm(prev, input, s) {
42635
42935
  return {
42636
42936
  ...prev,
42637
42937
  bbox: { ...s.bbox },
42638
42938
  frameWidth: input.frameWidth,
42639
42939
  frameHeight: input.frameHeight,
42640
42940
  lastSeenAt: input.timestamp,
42641
- rounds: newRound ? prev.rounds + 1 : prev.rounds,
42941
+ hits: prev.hits + 1,
42642
42942
  ...s.trackId !== void 0 ? { sourceTrackId: s.trackId } : {},
42643
42943
  ...s.label !== void 0 ? { label: s.label } : {},
42644
42944
  ...s.confidence !== void 0 ? { confidence: s.confidence } : {},
@@ -42663,6 +42963,31 @@ function toEntry(c) {
42663
42963
  };
42664
42964
  }
42665
42965
  //#endregion
42966
+ //#region src/pipeline-analytics/pipeline/package/package-zone-activity.ts
42967
+ /**
42968
+ * Whether a live track is motion inside a configured package drop-off zone.
42969
+ *
42970
+ * The stillness activity gate used to stamp ANY non-package track on the
42971
+ * camera. A walker at the far gate then "delivered" the streetlight wedge
42972
+ * on the doormat. The operator's prerequisite is motion in the drop-off
42973
+ * zone the package rule points at — the same zones `PackageDropDetector`
42974
+ * uses for the centroid gate.
42975
+ */
42976
+ function isDelivererInPackageZone(trackZones, packageZoneIds) {
42977
+ if (packageZoneIds.size === 0 || trackZones.length === 0) return false;
42978
+ for (const z of trackZones) if (packageZoneIds.has(z)) return true;
42979
+ return false;
42980
+ }
42981
+ /** Zone ids referenced by enabled `package`-stage rules. Disabled rules do not count. */
42982
+ function collectPackageZoneIds(rules) {
42983
+ const ids = /* @__PURE__ */ new Set();
42984
+ for (const r of rules) {
42985
+ if (r.enabled === false) continue;
42986
+ for (const z of r.zoneIds) ids.add(z);
42987
+ }
42988
+ return ids;
42989
+ }
42990
+ //#endregion
42666
42991
  //#region src/pipeline-analytics/pipeline/stationary/stationary-zones.ts
42667
42992
  /**
42668
42993
  * Zone ids whose 0–1 polygon contains the entry's normalised bbox centroid.
@@ -49287,10 +49612,21 @@ var TrackStore = class {
49287
49612
  * in-memory dedup (the query cap has no DISTINCT); best-effort.
49288
49613
  */
49289
49614
  async listDeviceIds() {
49615
+ return this.collectDeviceIds(0);
49616
+ }
49617
+ /**
49618
+ * Distinct deviceIds with a persisted track whose `lastSeen` is at least
49619
+ * `since`. The group sealer's 10-minute window. Walking from epoch 0 is how
49620
+ * `sealRecent` wedged hub-main every TTL sweep.
49621
+ */
49622
+ async listDeviceIdsSince(since) {
49623
+ return this.collectDeviceIds(Math.max(0, Math.floor(since)));
49624
+ }
49625
+ async collectDeviceIds(fromLastSeen) {
49290
49626
  const seenDevices = /* @__PURE__ */ new Set();
49291
49627
  const seenIds = /* @__PURE__ */ new Set();
49292
49628
  const PAGE = 500;
49293
- let cursor = 0;
49629
+ let cursor = fromLastSeen;
49294
49630
  try {
49295
49631
  for (;;) {
49296
49632
  const rows = await this.store.query.query({
@@ -49555,6 +49891,28 @@ var TrackStore = class {
49555
49891
  return this.rowToTrack(row.id, row.data);
49556
49892
  }
49557
49893
  /**
49894
+ * Batch read of persisted tracks. `listGroupRecords` cannot call
49895
+ * {@link getPersistedByTrackId} per member — that is one settings-store RPC
49896
+ * per row and a 60-group page takes ~20 s, which the viewer feed times out.
49897
+ */
49898
+ async getPersistedByTrackIds(trackIds) {
49899
+ if (trackIds.length === 0) return [];
49900
+ const PAGE = 500;
49901
+ const out = [];
49902
+ for (let i = 0; i < trackIds.length; i += PAGE) {
49903
+ const chunk = trackIds.slice(i, i + PAGE);
49904
+ const rows = await this.store.query.query({
49905
+ collection: TRACKS_COLLECTION,
49906
+ filter: {
49907
+ whereIn: { id: [...chunk] },
49908
+ limit: chunk.length
49909
+ }
49910
+ });
49911
+ for (const row of rows) out.push(this.rowToTrack(row.id, row.data));
49912
+ }
49913
+ return out;
49914
+ }
49915
+ /**
49558
49916
  * Persist a SYNTHETIC track (a sensor/control event projected into the
49559
49917
  * unified track store). Unlike `persistCompleted` there is no live in-RAM
49560
49918
  * state and no trajectory envelope (synthetic tracks carry no positions) —
@@ -54627,11 +54985,22 @@ function buildGlobalSettingsSchema(cap) {
54627
54985
  description: "Also emit a package-picked-up event when the delivered package leaves.",
54628
54986
  default: PACKAGE_DROP_DEFAULTS.packageDropPickupEnabled
54629
54987
  },
54988
+ {
54989
+ type: "slider",
54990
+ key: "packageDropMinFrames",
54991
+ label: "Confirmation frames",
54992
+ description: "How many frames the box must be seen on before it counts as a delivery. One is a flicker; two can still be a passer-by; three is the default bar against false positives. Independent of dwell.",
54993
+ min: 1,
54994
+ max: 10,
54995
+ step: 1,
54996
+ default: PACKAGE_DROP_DEFAULTS.packageDropMinFrames,
54997
+ showValue: true
54998
+ },
54630
54999
  {
54631
55000
  type: "slider",
54632
55001
  key: "packageDropDwellSec",
54633
55002
  label: "Minimum dwell",
54634
- description: "How long a package must stay parked before it counts as a delivery. Higher = fewer false positives from bags briefly set down.",
55003
+ description: "Extra time the package must stay parked after the confirmation frames. 0 (default) means N frames is enough — a courier who drops and leaves in a couple of seconds still delivers. Raise to reject a bag briefly set down.",
54635
55004
  min: 0,
54636
55005
  max: 300,
54637
55006
  step: 5,
@@ -54665,7 +55034,7 @@ function buildGlobalSettingsSchema(cap) {
54665
55034
  type: "slider",
54666
55035
  key: "packageDropActivityWindowSec",
54667
55036
  label: "Deliverer window",
54668
- description: "A delivery only counts if a person or vehicle was active on this camera within this many seconds before the package appeared — and a pick-up only with somebody around when it vanished. 0 disables the requirement.",
55037
+ description: "A delivery only counts if a person or vehicle was in the configured package drop-off zone within this many seconds before the package appeared — and a pick-up only with somebody in that zone when it vanished. Motion elsewhere on the camera does not count. 0 disables the requirement.",
54669
55038
  min: 0,
54670
55039
  max: 1800,
54671
55040
  step: 30,
@@ -56677,6 +57046,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
56677
57046
  plateStore = null;
56678
57047
  groupStore = null;
56679
57048
  groupSealer = null;
57049
+ groupLive = null;
57050
+ /** Serialise first-activity lookback per camera, then observe in order. */
57051
+ groupObserveTail = /* @__PURE__ */ new Map();
56680
57052
  plateRecognizer = null;
56681
57053
  objectEmbeddingStore = null;
56682
57054
  /** Live state of the detached embedding rebuild — see `rebuildObjectEmbeddings`. */
@@ -57087,11 +57459,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
57087
57459
  */
57088
57460
  packageStillness = new PackageStillnessTracker({ newId: () => randomUUID() });
57089
57461
  /**
57090
- * Frame-clock timestamp of the last NON-package track seen per device — the
57091
- * activity mirror behind the package activity gate ("a parcel needs a
57092
- * deliverer"). Deliberately not {@link lastTrackActivityMs}: that map is
57093
- * stamped by ANY live track, including the package-class FP itself, which
57094
- * would let the streetlight wedge vouch for its own delivery.
57462
+ * Frame-clock timestamp of the last NON-package track seen IN A PACKAGE
57463
+ * DROP-OFF ZONE per device — the activity mirror behind the package
57464
+ * activity gate ("a parcel needs a deliverer"). A walker at the far gate
57465
+ * does not stamp this. Deliberately not {@link lastTrackActivityMs}: that
57466
+ * map is stamped by ANY live track, including the package-class FP itself,
57467
+ * which would let the streetlight wedge vouch for its own delivery.
57095
57468
  */
57096
57469
  lastNonPackageActivityAt = /* @__PURE__ */ new Map();
57097
57470
  /** Last time the oversize-sighting drop was logged per device — one line
@@ -58001,6 +58374,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
58001
58374
  media: mediaStore
58002
58375
  });
58003
58376
  this.groupStore = groupStore;
58377
+ this.groupLive = new GroupLiveBuffer();
58004
58378
  this.groupSealer = new GroupSealer({
58005
58379
  trackStore,
58006
58380
  groupStore,
@@ -58015,9 +58389,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
58015
58389
  },
58016
58390
  now: () => Date.now()
58017
58391
  });
58018
- this.groupSealer.backfill().catch((err) => {
58019
- logger.warn("group sealer backfill failed", { meta: { error: errMsg(err) } });
58020
- });
58021
58392
  return {
58022
58393
  trackStore,
58023
58394
  mediaStore,
@@ -58460,12 +58831,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
58460
58831
  buildSummaryPorts(stores) {
58461
58832
  return {
58462
58833
  listTracks: async ({ deviceId, sinceMs, untilMs, limit }) => {
58463
- const groups = await stores.groupStore.listOverlapping({
58464
- deviceId,
58465
- sinceMs,
58466
- untilMs,
58467
- limit
58468
- });
58469
58834
  const windowTracks = await stores.trackStore.queryHistorical({
58470
58835
  deviceId,
58471
58836
  since: sinceMs,
@@ -58473,51 +58838,42 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
58473
58838
  projection: "slim",
58474
58839
  limit: Math.max(limit, 2e3)
58475
58840
  });
58476
- const trackById = new Map(windowTracks.map((t) => [t.trackId, t]));
58477
58841
  const { minHits } = await this.resolveDeviceDetectionSensitivitySettings(deviceId);
58478
- const out = [];
58479
- for (const group of groups) {
58480
- const members = await stores.groupStore.listMembers(group.id);
58481
- const tracks = members.map((m) => trackById.get(m.trackId)).filter((t) => t !== void 0);
58482
- if (tracks.length === 0) continue;
58483
- const classes = [...new Set(tracks.flatMap((t) => [t.className, ...t.classes ?? []]))];
58484
- const zones = [...new Set(tracks.flatMap((t) => [...t.zonesVisited]))];
58485
- const importance = Math.max(0, ...tracks.map((t) => t.importance ?? 0));
58486
- const primary = tracks.reduce((best, t) => (t.importance ?? 0) > (best.importance ?? 0) ? t : best);
58487
- out.push({
58488
- trackId: group.id,
58489
- groupId: group.id,
58490
- memberTrackIds: members.map((m) => m.trackId),
58491
- deviceId,
58492
- className: primary.className,
58493
- firstSeen: group.openedAt,
58494
- lastSeen: group.closedAt,
58495
- importance,
58496
- zones,
58497
- classes,
58498
- ...tracks.every((t) => t.closedReason === "stationary-promotion") ? { stationary: true } : {},
58499
- confirmed: tracks.some((t) => t.observations !== void 0 && t.observations >= minHits)
58500
- });
58501
- }
58502
- return out;
58842
+ return windowTracks.map((t) => ({
58843
+ trackId: t.trackId,
58844
+ deviceId,
58845
+ className: t.className,
58846
+ firstSeen: t.firstSeen,
58847
+ lastSeen: t.lastSeen,
58848
+ zones: [...t.zonesVisited],
58849
+ ...t.importance !== void 0 ? { importance: t.importance } : {},
58850
+ ...t.classes !== void 0 && t.classes.length > 0 ? { classes: t.classes } : {},
58851
+ ...t.closedReason === "stationary-promotion" ? { stationary: true } : {},
58852
+ confirmed: t.observations !== void 0 && t.observations >= minHits
58853
+ }));
58503
58854
  },
58504
58855
  listMediaInfo: async (id) => {
58505
58856
  const group = await stores.groupStore.get(id);
58506
- if (group?.mediaKey) return [{
58507
- key: group.mediaKey,
58508
- kind: "groupCrop",
58509
- timestamp: group.timestamp
58510
- }];
58511
- if (group && group.memberCount === 1) {
58512
- const memberId = (await stores.groupStore.listMembers(group.id))[0]?.trackId;
58513
- if (!memberId) return [];
58514
- return (await stores.mediaStore.listInfoByOwner("track", memberId)).map((r) => ({
58857
+ if (group) {
58858
+ if (group.mediaKey || group.nativeKey) return digestMediaRows({
58859
+ mediaKey: group.mediaKey,
58860
+ nativeKey: group.nativeKey,
58861
+ timestamp: group.timestamp,
58862
+ memberRows: []
58863
+ });
58864
+ const members = await stores.groupStore.listMembers(group.id);
58865
+ const memberRows = (await Promise.all(members.map((m) => stores.mediaStore.listInfoByOwner("track", m.trackId)))).flatMap((rows) => rows.map((r) => ({
58515
58866
  key: r.key,
58516
58867
  kind: r.kind,
58517
58868
  timestamp: r.timestamp
58518
- }));
58869
+ })));
58870
+ return digestMediaRows({
58871
+ mediaKey: group.mediaKey,
58872
+ nativeKey: group.nativeKey,
58873
+ timestamp: group.timestamp,
58874
+ memberRows
58875
+ });
58519
58876
  }
58520
- if (group) return [];
58521
58877
  return (await stores.mediaStore.listInfoByOwner("track", id)).map((r) => ({
58522
58878
  key: r.key,
58523
58879
  kind: r.kind,
@@ -59763,6 +60119,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
59763
60119
  state: t.state
59764
60120
  });
59765
60121
  positionsCountById.set(t.trackId, upserted.positions.length);
60122
+ this.enqueueLiveGroup(upserted, result.timestamp);
59766
60123
  }
59767
60124
  if (result.rawTrackedDetections.length > 0) {
59768
60125
  const { minHits } = await this.resolveDeviceDetectionSensitivitySettings(deviceId);
@@ -59976,6 +60333,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
59976
60333
  deviceId,
59977
60334
  tracked: result.tracked,
59978
60335
  packageClasses,
60336
+ packageZoneIds: collectPackageZoneIds(proxy?.state.zoneRules.value?.package ?? []),
59979
60337
  settings: packageSettings,
59980
60338
  frameWidth: result.frameWidth,
59981
60339
  frameHeight: result.frameHeight,
@@ -61686,8 +62044,11 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
61686
62044
  async sweepExpiredTracks() {
61687
62045
  if (this.shuttingDown || !this.trackStore) return;
61688
62046
  await this.trackCloser.sweep();
61689
- this.groupSealer?.sealRecent();
62047
+ if (this.isPostProcessingNode && this.groupLive) this.groupSealer?.flushIdle(this.groupLive);
61690
62048
  }
62049
+ /** Hub-only: assign this sighting in RAM. First activity on a camera
62050
+ * replays 2 minutes of that camera (crash recover), then observe is sync. */
62051
+ enqueueLiveGroup(track, now) {}
61691
62052
  /** Unified retention (Phase 1): earliest retained footage per device — the
61692
62053
  * follow-recordings cutoff source. Cached 10 min: availability answers in
61693
62054
  * ~70 ms but the sweep asks once per device per pass. `null` = no footage
@@ -62133,7 +62494,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
62133
62494
  runPackageStillness(input) {
62134
62495
  if (this.packageDropDetector === null) return;
62135
62496
  const tags = { deviceId: input.deviceId };
62136
- for (const t of input.tracked) if (!input.packageClasses.has(t.className)) {
62497
+ for (const t of input.tracked) {
62498
+ if (input.packageClasses.has(t.className)) continue;
62499
+ if (!isDelivererInPackageZone(t.zones, input.packageZoneIds)) continue;
62137
62500
  this.lastNonPackageActivityAt.set(input.deviceId, input.timestamp);
62138
62501
  break;
62139
62502
  }
@@ -62180,6 +62543,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
62180
62543
  frameWidth: input.frameWidth,
62181
62544
  frameHeight: input.frameHeight,
62182
62545
  timestamp: input.timestamp,
62546
+ minHits: input.settings.packageDropMinFrames,
62183
62547
  minDwellMs: input.settings.packageDropDwellSec * 1e3,
62184
62548
  activityWindowMs: input.settings.packageDropActivityWindowSec * 1e3,
62185
62549
  ...lastActivityAt !== void 0 ? { lastActivityAt } : {}
@@ -62283,9 +62647,10 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
62283
62647
  for (const ex of outcome.expired) {
62284
62648
  const observedDwellMs = ex.lastSeenAt - ex.firstSeenAt;
62285
62649
  const requiredDwellMs = input.settings.packageDropDwellSec * 1e3;
62650
+ const requiredHits = input.settings.packageDropMinFrames;
62286
62651
  const reasons = [];
62287
62652
  if (!ex.activityBackedAtOpen) reasons.push("no-activity-before-open");
62288
- if (ex.rounds < 2) reasons.push("rounds");
62653
+ if (ex.hits < requiredHits) reasons.push("hits");
62289
62654
  if (observedDwellMs < requiredDwellMs) reasons.push("dwell");
62290
62655
  this.ctx.logger.info("package candidate expired without promotion", {
62291
62656
  tags,
@@ -62293,8 +62658,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
62293
62658
  candidateId: ex.id,
62294
62659
  className: ex.className,
62295
62660
  bbox: ex.bbox,
62296
- rounds: ex.rounds,
62297
- minRounds: 2,
62661
+ hits: ex.hits,
62662
+ minHits: requiredHits,
62298
62663
  observedDwellMs,
62299
62664
  requiredDwellMs,
62300
62665
  activityBackedAtOpen: ex.activityBackedAtOpen,
@@ -62331,6 +62696,16 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
62331
62696
  };
62332
62697
  if (plausible) this.stationaryRegistry?.promote(entry);
62333
62698
  processor.dropTrack(track.trackId);
62699
+ const peek = this.trackStore?.peekActive(track.trackId);
62700
+ if (peek) this.enqueueLiveGroup(toGroupableTrack({
62701
+ trackId: track.trackId,
62702
+ deviceId,
62703
+ className: track.className,
62704
+ firstSeen: peek.firstSeen,
62705
+ lastSeen: timestamp,
62706
+ positions: peek.positions,
62707
+ closedReason: "stationary-promotion"
62708
+ }), timestamp);
62334
62709
  this.trackStore?.closeActive(track.trackId, "stationary-promotion").catch((err) => {
62335
62710
  this.ctx.logger.warn("stationary promotion: closing the source track failed — its media, events and vector are now unowned", {
62336
62711
  tags: { deviceId },
@@ -62546,6 +62921,15 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
62546
62921
  async listRecentTracks(input) {
62547
62922
  return this.queryFacade.listRecentTracks(input);
62548
62923
  }
62924
+ async listGroups(input) {
62925
+ return {
62926
+ groups: [],
62927
+ nextCursor: null
62928
+ };
62929
+ }
62930
+ async getGroup(input) {
62931
+ return null;
62932
+ }
62549
62933
  /**
62550
62934
  * Every event kind the device can produce: built-ins (motion + audio),
62551
62935
  * detection classes actually observed on the device, and sensor kinds
@@ -63690,33 +64074,38 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
63690
64074
  buildFaceRescoreActionHandlers() {
63691
64075
  return { "face.rescoreTrack": (input) => this.rescoreFaceForTrack(input) };
63692
64076
  }
63693
- buildGroupActionHandlers() {
63694
- const ports = () => {
63695
- const groupStore = this.groupStore;
63696
- if (groupStore === null) return null;
63697
- return {
63698
- groupStore,
63699
- loadTrack: async (trackId) => {
63700
- const tracks = this.trackStore;
63701
- if (!tracks) return null;
63702
- return tracks.getActiveByTrack(trackId) ?? await tracks.getPersistedByTrackId(trackId);
63703
- },
63704
- eventMediaBaseUrl: () => this.eventMediaBaseUrl
63705
- };
64077
+ groupQueryPorts() {
64078
+ const groupStore = this.groupStore;
64079
+ if (groupStore === null) return null;
64080
+ return {
64081
+ groupStore,
64082
+ loadTracks: async (trackIds) => {
64083
+ const map = /* @__PURE__ */ new Map();
64084
+ const tracks = this.trackStore;
64085
+ if (!tracks || trackIds.length === 0) return map;
64086
+ const missing = [];
64087
+ for (const id of trackIds) {
64088
+ const active = tracks.getActiveByTrack(id);
64089
+ if (active !== null) map.set(id, trackToLite(active));
64090
+ else missing.push(id);
64091
+ }
64092
+ if (missing.length === 0) return map;
64093
+ for (const persisted of await tracks.getPersistedByTrackIds(missing)) map.set(persisted.trackId, trackToLite(persisted));
64094
+ return map;
64095
+ },
64096
+ eventMediaBaseUrl: () => this.eventMediaBaseUrl
63706
64097
  };
64098
+ }
64099
+ buildGroupActionHandlers() {
63707
64100
  return {
63708
64101
  "analytics.listGroups": async (input) => {
63709
- const ready = ports();
63710
- if (ready === null) return {
64102
+ return {
63711
64103
  groups: [],
63712
64104
  hasMore: false
63713
64105
  };
63714
- return listGroupRecords(ready, input);
63715
64106
  },
63716
64107
  "analytics.getGroup": async (input) => {
63717
- const ready = ports();
63718
- if (ready === null) return { group: null };
63719
- return { group: await getGroupDetail(ready, input.groupId) };
64108
+ return { group: null };
63720
64109
  }
63721
64110
  };
63722
64111
  }