@camstack/addon-post-analysis 1.2.231 → 1.2.232

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 TimelapseRuleSchema, $t as boolean, A as NcConditionDescriptorSchema, At as plateGalleryCapability, B as NcTaxonomySchema, Bt as videoclipsCapability, C as MOTION_CLOSE_AFTER_MS, Ct as isSourceCap, D as NC_DEFAULT_SNOOZE_MINUTES, E as NC_CONDITION_CATALOG, Et as notificationRulesCapability, F as NcScheduleSchema, Ft as sceneMonitorCapability, Gt as DeviceType, Ht as errMsg$1, I as NcSnoozeInputSchema, It as storageOccupancyCapability, J as SCENE_DIVERGED, Jt as isDeviceScopedCap, K as SCENE_DEFAULT_ANCHOR_THRESHOLD, Kt as createEvent, L as NcSnoozeSchema, Lt as subKindsOf, M as NcRulePatchSchema, Mt as readTimelapseGeneratedAt, N as NcRuleSchema, Nt as resolveLocationMode, Ot as pickClusterStepModels, P as NcRuleTargetSchema, Q as TimelapseRulePatchSchema, Qt as array, R as NcSnoozeSuppressedSchema, Rt as systemEventFilterApplies, St as isScheduleActive, T as NC_ALARM_SYSTEM_EVENT_KINDS, Tt as mayWriteToLocation, U as RECORDING_EXPORT_MAX_READ_BYTES, Ut as BaseAddon, V as OpsLogEntrySchema, Vt as zoneAnalyticsCapability, W as RetrainStatusSchema, Wt as CamProfileSchema, X as TIMELAPSE_DENSE_FLOOR_SEC, Xt as sleep$1, Y as SceneMonitorSchema, Yt as nodePin, Z as TimelapseRuleInputSchema, Zt as _enum, _ as FULL_IMAGE_BBOX, _t as failureContributionCapability, a as CLUSTER_MODEL_SCOPED_STEPS, an as record, at as audioMetricsCapability, b as MAX_ARCHIVED_DEBUG_NOTES, bt as isEventOwnerType, cn as EventCategory, ct as cosineSimilarity$1, dt as deriveRecordingMode, en as discriminatedUnion, et as TrackSourceSchema, f as DeclaredDevices, g as FIRST_LEVEL_MACRO_CLASSES, gt as faceGalleryCapability, h as EVENT_PAD_MS, ht as evictionPolicyOfLocation, i as BirthDecisionRecordSchema, in as partialRecord, it as assertTimelapseCadences, j as NcRuleInputSchema, jt as readDeviceStateFrom, k as NC_TAXONOMY, kt as pipelineAnalyticsCapability, lt as customAction, m as EVENT_OWNER_TYPES, mt as evaluateSensorEdge, n as ArchivedDebugNoteSchema, nn as number, nt as addonWidgetsSourceCapability, o as COCO_TO_MACRO, on as string, ot as audioModeOf, p as EVENT_KIND_BY_CAP, pt as encodeVectorBase64, q as SCENE_DEFAULT_UNCOVERED_POLICY, qt as hydrateSchema, r as BaseDevice, rn as object, rt as alarmPanelCapability, s as DEFAULT_EVENT_COLOR, sn as unknown, st as buildEventKindDescriptor, t as AUDIO_MACRO_LABELS, tn as literal, tt as VISIT_MERGE_GAP_MS, u as DETECTION_MACRO_CLASSES, ut as defineCustomActions, v as FailureCounters, w as MediaFileKindEnum, wt as kebabToCamel, x as MAX_BIRTH_DECISION_RECORDS, xt as isMediaOwnerType, y as LabelAttributionSchema, yt as isDetectionMacroClass, z as NcSystemEventKindSchema, zt as vectorDimFromBase64 } from "../dist-QjlwKSkb.mjs";
1
+ import { $ as TimelapseRuleSchema, $t as boolean, A as NcConditionDescriptorSchema, At as plateGalleryCapability, B as NcTaxonomySchema, Bt as videoclipsCapability, C as MOTION_CLOSE_AFTER_MS, Ct as isSourceCap, D as NC_DEFAULT_SNOOZE_MINUTES, E as NC_CONDITION_CATALOG, Et as notificationRulesCapability, F as NcScheduleSchema, Ft as sceneMonitorCapability, Gt as DeviceType, Ht as errMsg$1, I as NcSnoozeInputSchema, It as storageOccupancyCapability, J as SCENE_DIVERGED, Jt as isDeviceScopedCap, K as SCENE_DEFAULT_ANCHOR_THRESHOLD, Kt as createEvent, L as NcSnoozeSchema, Lt as subKindsOf, M as NcRulePatchSchema, Mt as readTimelapseGeneratedAt, N as NcRuleSchema, Nt as resolveLocationMode, Ot as pickClusterStepModels, P as NcRuleTargetSchema, Q as TimelapseRulePatchSchema, Qt as array, R as NcSnoozeSuppressedSchema, Rt as systemEventFilterApplies, St as isScheduleActive, T as NC_ALARM_SYSTEM_EVENT_KINDS, Tt as mayWriteToLocation, U as RECORDING_EXPORT_MAX_READ_BYTES, Ut as BaseAddon, V as OpsLogEntrySchema, Vt as zoneAnalyticsCapability, W as RetrainStatusSchema, Wt as CamProfileSchema, X as TIMELAPSE_DENSE_FLOOR_SEC, Xt as sleep$1, Y as SceneMonitorSchema, Yt as nodePin, Z as TimelapseRuleInputSchema, Zt as _enum, _ as FULL_IMAGE_BBOX, _t as failureContributionCapability, a as CLUSTER_MODEL_SCOPED_STEPS, an as record, at as audioMetricsCapability, b as MAX_ARCHIVED_DEBUG_NOTES, bt as isEventOwnerType, cn as EventCategory, ct as cosineSimilarity$1, dt as deriveRecordingMode, en as discriminatedUnion, et as TrackSourceSchema, f as DeclaredDevices, g as FIRST_LEVEL_MACRO_CLASSES, gt as faceGalleryCapability, h as EVENT_PAD_MS, ht as evictionPolicyOfLocation, i as BirthDecisionRecordSchema, in as partialRecord, it as assertTimelapseCadences, j as NcRuleInputSchema, jt as readDeviceStateFrom, k as NC_TAXONOMY, kt as pipelineAnalyticsCapability, lt as customAction, m as EVENT_OWNER_TYPES, mt as evaluateSensorEdge, n as ArchivedDebugNoteSchema, nn as number, nt as addonWidgetsSourceCapability, o as COCO_TO_MACRO, on as string, ot as audioModeOf, p as EVENT_KIND_BY_CAP, pt as encodeVectorBase64, q as SCENE_DEFAULT_UNCOVERED_POLICY, qt as hydrateSchema, r as BaseDevice, rn as object, rt as alarmPanelCapability, s as DEFAULT_EVENT_COLOR, sn as unknown, st as buildEventKindDescriptor, t as AUDIO_MACRO_LABELS, tn as literal, tt as VISIT_MERGE_GAP_MS, u as DETECTION_MACRO_CLASSES, ut as defineCustomActions, v as FailureCounters, w as MediaFileKindEnum, wt as kebabToCamel, x as MAX_BIRTH_DECISION_RECORDS, xt as isMediaOwnerType, y as LabelAttributionSchema, yt as isDetectionMacroClass, z as NcSystemEventKindSchema, zt as vectorDimFromBase64 } from "../dist-yyQ1H5yE.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";
@@ -1965,8 +1965,8 @@ var NC_ARTIFACTS_INDEXES = [
1965
1965
  }
1966
1966
  ];
1967
1967
  /** Upper bound for a half-open `whereBetween` — the store has no `>=`. */
1968
- var TIME_MAX = 864e13;
1969
- var TIME_MIN = -864e13;
1968
+ var TIME_MAX$1 = 864e13;
1969
+ var TIME_MIN$1 = -864e13;
1970
1970
  /** Prune scan ceiling — the index is bounded by the shelf it mirrors (2000
1971
1971
  * entries by default), so this only has to be comfortably above it. */
1972
1972
  var NC_ARTIFACT_PRUNE_SCAN_LIMIT = 2e4;
@@ -2206,7 +2206,7 @@ var NcArtifactIndex = class {
2206
2206
  try {
2207
2207
  const expired = await this.store.deleteWhere.mutate({
2208
2208
  collection: NC_ARTIFACTS_COLLECTION,
2209
- filter: { whereBetween: { expiresAt: [TIME_MIN, this.now() - 1] } }
2209
+ filter: { whereBetween: { expiresAt: [TIME_MIN$1, this.now() - 1] } }
2210
2210
  });
2211
2211
  removed += expired.deleted;
2212
2212
  if (keptIds === null) {
@@ -2248,8 +2248,8 @@ var NcArtifactIndex = class {
2248
2248
  whereIn["kind"] = query.kinds !== void 0 && query.kinds.length > 0 ? [...query.kinds] : [...NC_GALLERY_ARTIFACT_KINDS];
2249
2249
  if (query.deviceIds !== void 0 && query.deviceIds.length > 0) whereIn["deviceId"] = [...query.deviceIds];
2250
2250
  const whereBetween = {
2251
- capturedAt: [query.fromMs ?? TIME_MIN, query.toMs ?? TIME_MAX],
2252
- expiresAt: [this.now(), TIME_MAX]
2251
+ capturedAt: [query.fromMs ?? TIME_MIN$1, query.toMs ?? TIME_MAX$1],
2252
+ expiresAt: [this.now(), TIME_MAX$1]
2253
2253
  };
2254
2254
  return {
2255
2255
  ...query.ruleId !== void 0 ? { where: { ruleId: query.ruleId } } : {},
@@ -2274,6 +2274,23 @@ var NcArtifactIndex = class {
2274
2274
  return out;
2275
2275
  }
2276
2276
  };
2277
+ /**
2278
+ * A sentence explaining why this node must not keep the shelf, or `null` when
2279
+ * it may. The sentence is the log line: an operator reading it should learn
2280
+ * WHICH node owns the shelf without opening the storage page.
2281
+ */
2282
+ function artifactShelfRefusal(locations, localNodeId) {
2283
+ if (localNodeId === null || localNodeId.length === 0) return null;
2284
+ const here = localNodeId.includes("/") ? localNodeId.split("/")[0] ?? "" : localNodeId;
2285
+ if (here.length === 0) return null;
2286
+ for (const location of locations) {
2287
+ if (!location.id.startsWith("artifacts")) continue;
2288
+ const owner = location.nodeId;
2289
+ if (owner === void 0 || owner.length === 0 || owner === here) continue;
2290
+ return `the artefact shelf lives on node "${owner}" (location "${location.id}"), not on "${here}" — the path it resolves to is that node's disk, and creating it here would make a directory of the same name that nothing reads. Artefacts are produced, stored and served by "${owner}".`;
2291
+ }
2292
+ return null;
2293
+ }
2277
2294
  //#endregion
2278
2295
  //#region ../types/dist/node.mjs
2279
2296
  var node_exports = /* @__PURE__ */ __exportAll({
@@ -18686,6 +18703,52 @@ function buildSummaryOutboxInputs(input) {
18686
18703
  });
18687
18704
  }
18688
18705
  //#endregion
18706
+ //#region src/notification-center/summary/summary-group-collapse.ts
18707
+ function collapseToGroups(candidates, groups) {
18708
+ const claim = /* @__PURE__ */ new Map();
18709
+ for (const group of groups) for (const trackId of group.memberTrackIds) if (!claim.has(trackId)) claim.set(trackId, group);
18710
+ const survivorsByGroup = /* @__PURE__ */ new Map();
18711
+ for (const candidate of candidates) {
18712
+ const group = claim.get(candidate.trackId);
18713
+ if (group === void 0) continue;
18714
+ const bucket = survivorsByGroup.get(group.summaryId);
18715
+ if (bucket === void 0) survivorsByGroup.set(group.summaryId, [candidate]);
18716
+ else bucket.push(candidate);
18717
+ }
18718
+ const representative = /* @__PURE__ */ new Map();
18719
+ for (const [summaryId, survivors] of survivorsByGroup) {
18720
+ const group = groups.find((g) => g.summaryId === summaryId);
18721
+ if (group === void 0) continue;
18722
+ const chosen = survivors.find((c) => c.trackId === group.primaryTrackId) ?? survivors[0];
18723
+ if (chosen === void 0) continue;
18724
+ representative.set(summaryId, {
18725
+ ...chosen,
18726
+ groupSize: survivors.length,
18727
+ ...group.groupShotMediaId !== null ? { groupShotMediaId: group.groupShotMediaId } : {}
18728
+ });
18729
+ }
18730
+ const kept = [];
18731
+ let collapsed = 0;
18732
+ for (const candidate of candidates) {
18733
+ const group = claim.get(candidate.trackId);
18734
+ if (group === void 0) {
18735
+ kept.push(candidate);
18736
+ continue;
18737
+ }
18738
+ const chosen = representative.get(group.summaryId);
18739
+ if (chosen === void 0) {
18740
+ kept.push(candidate);
18741
+ continue;
18742
+ }
18743
+ if (chosen.trackId === candidate.trackId) kept.push(chosen);
18744
+ else collapsed += 1;
18745
+ }
18746
+ return {
18747
+ kept,
18748
+ collapsed
18749
+ };
18750
+ }
18751
+ //#endregion
18689
18752
  //#region src/notification-center/summary/summary-selection.ts
18690
18753
  /**
18691
18754
  * Which tracks become tiles — filter, rank, and the per-camera quota.
@@ -19258,7 +19321,8 @@ var NcSummaryProducer = class {
19258
19321
  try {
19259
19322
  const candidates = await this.collectCandidates(rule, window);
19260
19323
  const filtered = filterSummaryCandidates(candidates, rule.filters, this.deps.zoneOwner);
19261
- const selection = selectSummaryTiles(filtered.kept, {
19324
+ const grouped = await this.collapseIfGrouping(rule, window, filtered.kept);
19325
+ const selection = selectSummaryTiles(grouped.kept, {
19262
19326
  maxTiles: rule.maxTiles,
19263
19327
  rank: rule.rank
19264
19328
  });
@@ -19287,7 +19351,7 @@ var NcSummaryProducer = class {
19287
19351
  const tileLoad = await this.loadTiles({
19288
19352
  rule,
19289
19353
  preferred: selection.tiles,
19290
- refill: remainingSummaryCandidates(filtered.kept, selection.tiles, rule.rank),
19354
+ refill: remainingSummaryCandidates(grouped.kept, selection.tiles, rule.rank),
19291
19355
  maxTiles: rule.maxTiles
19292
19356
  });
19293
19357
  const loaded = tileLoad.tiles;
@@ -19328,7 +19392,7 @@ var NcSummaryProducer = class {
19328
19392
  priority: rule.priority,
19329
19393
  tileCount: loaded.length,
19330
19394
  cameraCount: new Set(loaded.map((t) => t.candidate.deviceId)).size,
19331
- matchedCount: filtered.kept.length,
19395
+ matchedCount: grouped.kept.length,
19332
19396
  detections,
19333
19397
  ...mosaic.ref !== null ? { mosaic: mosaic.ref } : {},
19334
19398
  generatedAt: this.deps.now(),
@@ -19340,7 +19404,7 @@ var NcSummaryProducer = class {
19340
19404
  this.deps.logger.info("summary produced", { meta: {
19341
19405
  ...meta,
19342
19406
  scanned: candidates.length,
19343
- matched: filtered.kept.length,
19407
+ matched: grouped.kept.length,
19344
19408
  droppedStationary: filtered.droppedStationary,
19345
19409
  droppedUnconfirmed: filtered.droppedUnconfirmed,
19346
19410
  unknownConfirmation: filtered.unknownConfirmation,
@@ -19363,7 +19427,7 @@ var NcSummaryProducer = class {
19363
19427
  window,
19364
19428
  tiles: loaded.length,
19365
19429
  cameras: new Set(loaded.map((t) => t.candidate.deviceId)).size,
19366
- matched: filtered.kept.length,
19430
+ matched: grouped.kept.length,
19367
19431
  droppedStationary: filtered.droppedStationary,
19368
19432
  enqueued,
19369
19433
  ...mosaic.bytes > 0 ? { mosaicBytes: mosaic.bytes } : {},
@@ -19384,6 +19448,54 @@ var NcSummaryProducer = class {
19384
19448
  };
19385
19449
  }
19386
19450
  }
19451
+ /**
19452
+ * Fold each session summary's surviving members into one entry — or do
19453
+ * nothing at all, which is what `tracks` (the default) means.
19454
+ *
19455
+ * A failure to read the groups does NOT fail the digest: it falls back to
19456
+ * subjects and says so. A digest of four lines instead of one is a worse
19457
+ * reading, not a lost one, and losing the night's record over a store read
19458
+ * is the wrong trade.
19459
+ */
19460
+ async collapseIfGrouping(rule, window, kept) {
19461
+ if (rule.subject !== "groups") return {
19462
+ kept,
19463
+ collapsed: 0
19464
+ };
19465
+ const listGroups = this.deps.listSummaryGroups;
19466
+ if (listGroups === void 0) {
19467
+ this.deps.logger.warn("summary rule asks for groups but this node cannot read them", { meta: { ruleId: rule.id } });
19468
+ return {
19469
+ kept,
19470
+ collapsed: 0
19471
+ };
19472
+ }
19473
+ const groups = [];
19474
+ for (const deviceId of rule.deviceIds) try {
19475
+ groups.push(...await listGroups({
19476
+ deviceId,
19477
+ sinceMs: window.startMs,
19478
+ untilMs: window.endMs
19479
+ }));
19480
+ } catch (err) {
19481
+ this.deps.logger.warn("summary could not read a camera’s groups — read as subjects", {
19482
+ tags: { deviceId },
19483
+ meta: {
19484
+ ruleId: rule.id,
19485
+ error: String(err)
19486
+ }
19487
+ });
19488
+ }
19489
+ const outcome = collapseToGroups(kept, groups);
19490
+ this.deps.logger.info("summary read as groups", { meta: {
19491
+ ruleId: rule.id,
19492
+ groups: groups.length,
19493
+ subjects: kept.length,
19494
+ lines: outcome.kept.length,
19495
+ collapsed: outcome.collapsed
19496
+ } });
19497
+ return outcome;
19498
+ }
19387
19499
  /** Every camera's tracks in the window, capped per camera and counted. */
19388
19500
  async collectCandidates(rule, window) {
19389
19501
  const all = [];
@@ -19433,6 +19545,25 @@ var NcSummaryProducer = class {
19433
19545
  seenIds.add(candidate.trackId);
19434
19546
  const tags = { deviceId: candidate.deviceId };
19435
19547
  try {
19548
+ const groupShot = candidate.groupShotMediaId;
19549
+ if (groupShot !== void 0) {
19550
+ const jpeg = await this.deps.readMedia(groupShot);
19551
+ if (jpeg !== null && jpeg.byteLength > 0) {
19552
+ tiles.push({
19553
+ candidate,
19554
+ jpeg
19555
+ });
19556
+ continue;
19557
+ }
19558
+ this.deps.logger.info("summary tile: group shot unreadable — using a member", {
19559
+ tags,
19560
+ meta: {
19561
+ ruleId: input.rule.id,
19562
+ trackId: candidate.trackId,
19563
+ mediaId: groupShot
19564
+ }
19565
+ });
19566
+ }
19436
19567
  const rows = await this.deps.listMediaInfo(candidate.trackId);
19437
19568
  const picked = pickSummaryMedia(rows, (candidate.firstSeen + candidate.lastSeen) / 2);
19438
19569
  if (picked === null) {
@@ -19895,6 +20026,21 @@ var NcSummaryRuleInputSchema = object({
19895
20026
  deviceIds: DeviceIdsField,
19896
20027
  window: NcSummaryWindowSchema,
19897
20028
  filters: NcSummaryFiltersSchema.optional(),
20029
+ /**
20030
+ * What ONE line of this digest is: a subject, or a group of subjects that
20031
+ * arrived together.
20032
+ *
20033
+ * `tracks` is what the digest has always done and stays the default — the
20034
+ * operator's instruction when groups were added was that the first behaviour
20035
+ * remains. `groups` collapses the members of each session summary to one
20036
+ * entry, so a family walking in is one line and not four
20037
+ * (`summary-group-collapse.ts`).
20038
+ *
20039
+ * `.default('tracks')` is safe here where `NcSummaryFiltersSchema` fields are
20040
+ * not: this is a field of the RULE, not of the partial `filters` object that
20041
+ * a patch materialises wholesale.
20042
+ */
20043
+ subject: _enum(["tracks", "groups"]).default("tracks"),
19898
20044
  maxTiles: MaxTilesField.default(12),
19899
20045
  maxAiImages: MaxAiImagesField.default(6),
19900
20046
  rank: NcSummaryRankSchema.default("importance"),
@@ -19929,6 +20075,7 @@ var NcSummaryRulePatchSchema = object({
19929
20075
  maxTiles: MaxTilesField.optional(),
19930
20076
  maxAiImages: MaxAiImagesField.optional(),
19931
20077
  rank: NcSummaryRankSchema.optional(),
20078
+ subject: _enum(["tracks", "groups"]).optional(),
19932
20079
  barrierSeconds: BarrierSecondsField.optional(),
19933
20080
  targets: TargetsField.optional(),
19934
20081
  template: NcSummaryTemplateSchema.nullable().optional(),
@@ -61255,15 +61402,48 @@ var SUMMARY_MEMBERS_COLUMNS = [
61255
61402
  {
61256
61403
  name: "className",
61257
61404
  type: "TEXT"
61405
+ }),
61406
+ (
61407
+ /**
61408
+ * The member's tier-1 label (`dog`, `van`) and tier-2 instance (a name, a
61409
+ * plate), STAMPED AT SEAL.
61410
+ *
61411
+ * Not at the join, and the timing is the whole point: recognition lands
61412
+ * during a track's life, almost always after it joined, so a label written at
61413
+ * the join would be empty on exactly the members worth naming. At the seal
61414
+ * every member's track has closed, so its label is final — there is nothing
61415
+ * left to change it.
61416
+ *
61417
+ * They are COLUMNS because a filter has to be able to ask for them: "show me
61418
+ * the arrivals with a dog" is a query over members, and answering it from the
61419
+ * tracks would mean reading every track of every summary in the window.
61420
+ */
61421
+ {
61422
+ name: "label",
61423
+ type: "TEXT"
61424
+ }),
61425
+ {
61426
+ name: "subLabel",
61427
+ type: "TEXT"
61428
+ }
61429
+ ];
61430
+ var SUMMARY_MEMBERS_INDEXES = [
61431
+ {
61432
+ name: "summary_members_summary",
61433
+ columns: ["summaryId"]
61434
+ },
61435
+ {
61436
+ name: "summary_members_track",
61437
+ columns: ["trackId"]
61438
+ },
61439
+ (
61440
+ /** The filter's own index: "which groups hold an `animal`" walks members by
61441
+ * class, never summaries by hope. */
61442
+ {
61443
+ name: "summary_members_class",
61444
+ columns: ["className"]
61258
61445
  })
61259
61446
  ];
61260
- var SUMMARY_MEMBERS_INDEXES = [{
61261
- name: "summary_members_summary",
61262
- columns: ["summaryId"]
61263
- }, {
61264
- name: "summary_members_track",
61265
- columns: ["trackId"]
61266
- }];
61267
61447
  /**
61268
61448
  * A stored role, or `null`. An unrecognised value reads as `null` rather than
61269
61449
  * being passed through: a consumer switching on this must not receive a string
@@ -61273,6 +61453,16 @@ var SUMMARY_MEMBERS_INDEXES = [{
61273
61453
  function readRole(raw) {
61274
61454
  return raw === "primary" || raw === "sibling" || raw === "child" ? raw : null;
61275
61455
  }
61456
+ /** The two stamped labels, present only when they hold something. An empty
61457
+ * string is not a label and must never reach a filter as one. */
61458
+ function labelFields(data) {
61459
+ const label = data["label"];
61460
+ const subLabel = data["subLabel"];
61461
+ return {
61462
+ ...typeof label === "string" && label.length > 0 ? { label } : {},
61463
+ ...typeof subLabel === "string" && subLabel.length > 0 ? { subLabel } : {}
61464
+ };
61465
+ }
61276
61466
  function readClassName(raw) {
61277
61467
  return typeof raw === "string" && raw.length > 0 ? raw : null;
61278
61468
  }
@@ -61286,6 +61476,22 @@ function readClassName(raw) {
61286
61476
  * case on a gate camera, not a race. With the track in the key a retry still
61287
61477
  * collapses (same birth, same track) and two births stay two groups.
61288
61478
  */
61479
+ /** The widest a JS Date can name — the open end of a half-bounded window. */
61480
+ var TIME_MIN = -864e13;
61481
+ var TIME_MAX = 864e13;
61482
+ /**
61483
+ * How many rows one paged read may materialise before the merge.
61484
+ *
61485
+ * The page is cut AFTER ordering, so the read must be able to serve any cursor
61486
+ * position — but it must also be bounded, because an unbounded read of this
61487
+ * table on a busy hub is the shape D447 measured holding the settings thread
61488
+ * for seconds. Summaries are far fewer than tracks by construction (14 940
61489
+ * against 30 583 on this hub), so this ceiling is generous.
61490
+ */
61491
+ var RECENT_SUMMARY_SCAN_LIMIT = 5e3;
61492
+ /** How many member rows one filter pass may read per tier. Bounded for the same
61493
+ * reason as the scan above, and generous: members are one row per subject. */
61494
+ var MEMBER_MATCH_SCAN_LIMIT = 2e4;
61289
61495
  function summaryId(deviceId, openedAt, foundingTrackId) {
61290
61496
  return `sum-${deviceId}-${openedAt}-${foundingTrackId}`;
61291
61497
  }
@@ -61370,7 +61576,8 @@ var SummaryStore = class {
61370
61576
  deviceId: num$1(r.data["deviceId"]),
61371
61577
  joinedAt: num$1(r.data["timestamp"]),
61372
61578
  role: readRole(r.data["role"]),
61373
- className: readClassName(r.data["className"])
61579
+ className: readClassName(r.data["className"]),
61580
+ ...labelFields(r.data)
61374
61581
  }));
61375
61582
  }
61376
61583
  /**
@@ -61558,6 +61765,23 @@ var SummaryStore = class {
61558
61765
  memberCount: leaving.length
61559
61766
  };
61560
61767
  }
61768
+ /** Stamp a member's final labels. Best-effort and loud: a missing stamp
61769
+ * costs a filter a match, never a membership. */
61770
+ async stampMemberLabels(summaryIdValue, trackId, labels) {
61771
+ try {
61772
+ await this.store.update.mutate({
61773
+ collection: SUMMARY_MEMBERS_COLLECTION$1,
61774
+ id: memberRowId(summaryIdValue, trackId),
61775
+ data: { ...labels }
61776
+ });
61777
+ } catch (err) {
61778
+ this.logger.warn("summary: could not stamp a member label", { meta: {
61779
+ summaryId: summaryIdValue,
61780
+ trackId,
61781
+ error: String(err)
61782
+ } });
61783
+ }
61784
+ }
61561
61785
  /**
61562
61786
  * Put every OTHER member back to `sibling` after a new primary took the role.
61563
61787
  *
@@ -61683,6 +61907,8 @@ var SummaryStore = class {
61683
61907
  */
61684
61908
  async listRecent(input) {
61685
61909
  if (input.deviceIds.length === 0) return [];
61910
+ const matching = await this.summaryIdsMatching(input.classes);
61911
+ if (matching !== null && matching.size === 0) return [];
61686
61912
  const limit = Math.max(1, input.limit);
61687
61913
  return (await this.store.query.query({
61688
61914
  collection: SUMMARIES_COLLECTION,
@@ -61695,9 +61921,132 @@ var SummaryStore = class {
61695
61921
  },
61696
61922
  limit
61697
61923
  }
61698
- })).map((r) => toRow$1(r.id, r.data)).sort((a, b) => b.openedAt - a.openedAt).slice(0, limit);
61924
+ })).map((r) => toRow$1(r.id, r.data)).filter((row) => matching === null || matching.has(row.id)).sort((a, b) => b.openedAt - a.openedAt).slice(0, limit);
61699
61925
  }
61700
61926
  /** One summary with its members, or `null`. */
61927
+ /**
61928
+ * One PAGE of summaries across a camera set, newest activity first.
61929
+ *
61930
+ * The same contract as `listRecentTracks` and for the same consumers: the
61931
+ * reel on the cameras page and the Events gallery both ride a cursor, so a
61932
+ * feed that could only answer "the last N" would make groups available in the
61933
+ * timeline and nowhere else. `recent-cursor.ts` is reused verbatim — the
61934
+ * ordering is the stable total order (`lastActiveAt` DESC, id DESC), and a
61935
+ * second implementation of that is a second place for two feeds to disagree
61936
+ * about what "newest" means.
61937
+ *
61938
+ * The window is on LAST ACTIVITY, not on the open: a session that started
61939
+ * before the window and ran into it belongs to the window, because it
61940
+ * happened there.
61941
+ *
61942
+ * Members for the WHOLE page come back in one read. One query per row is how
61943
+ * a page of two hundred becomes two hundred round trips — the shape that made
61944
+ * the old digest read two thousand tracks a camera.
61945
+ */
61946
+ async queryRecent(input) {
61947
+ const deviceIds = [...new Set(input.deviceIds)];
61948
+ if (deviceIds.length === 0) return {
61949
+ summaries: [],
61950
+ nextCursor: null
61951
+ };
61952
+ const cursor = input.cursor === void 0 ? null : decodeRecentCursor(input.cursor);
61953
+ const matching = await this.summaryIdsMatching(input.classes);
61954
+ if (matching !== null && matching.size === 0) return {
61955
+ summaries: [],
61956
+ nextCursor: null
61957
+ };
61958
+ const ordered = (await this.store.query.query({
61959
+ collection: SUMMARIES_COLLECTION,
61960
+ filter: {
61961
+ whereIn: { deviceId: deviceIds },
61962
+ ...input.since !== void 0 || input.until !== void 0 ? { whereBetween: { lastActiveAt: [input.since ?? TIME_MIN, input.until ?? TIME_MAX] } } : {},
61963
+ limit: RECENT_SUMMARY_SCAN_LIMIT
61964
+ }
61965
+ })).map((r) => toRow$1(r.id, r.data)).filter((row) => matching === null || matching.has(row.id)).map((row) => ({
61966
+ row,
61967
+ key: {
61968
+ lastSeen: row.lastActiveAt,
61969
+ id: row.id
61970
+ }
61971
+ })).filter((entry) => cursor === null || isAfterCursor(entry.key, cursor)).toSorted((a, b) => compareRecentDesc(a.key, b.key));
61972
+ const page = ordered.slice(0, input.limit);
61973
+ const last = page.at(-1);
61974
+ const nextCursor = last !== void 0 && ordered.length > page.length ? encodeRecentCursor({
61975
+ lastSeen: last.row.lastActiveAt,
61976
+ trackId: last.row.id
61977
+ }) : null;
61978
+ const members = await this.membersOfMany(page.map((e) => e.row.id));
61979
+ return {
61980
+ summaries: page.map((e) => ({
61981
+ summary: e.row,
61982
+ members: members.get(e.row.id) ?? []
61983
+ })),
61984
+ nextCursor
61985
+ };
61986
+ }
61987
+ /**
61988
+ * The summaries whose MEMBERS match any of `classes`, or `null` for no filter.
61989
+ *
61990
+ * Narrows first, on the members table, and only then reads summary rows.
61991
+ * Filtering the other way round — a page of summaries, then their members —
61992
+ * returns a short or empty page while matches sit further down the list, and
61993
+ * the operator reads that as "there were none".
61994
+ *
61995
+ * Three queries because the filter compiler has no OR across fields, and one
61996
+ * term can be any of the three tiers: `animal` is a class, `dog` is a
61997
+ * sub-class, `Roberta` is an instance. The caller does not have to know
61998
+ * which — that is the whole point of the operator's example.
61999
+ */
62000
+ async summaryIdsMatching(classes) {
62001
+ const wanted = (classes ?? []).filter((t) => t.length > 0);
62002
+ if (wanted.length === 0) return null;
62003
+ const ids = /* @__PURE__ */ new Set();
62004
+ for (const field of [
62005
+ "className",
62006
+ "label",
62007
+ "subLabel"
62008
+ ]) {
62009
+ const rows = await this.store.query.query({
62010
+ collection: SUMMARY_MEMBERS_COLLECTION$1,
62011
+ filter: {
62012
+ whereIn: { [field]: [...wanted] },
62013
+ limit: MEMBER_MATCH_SCAN_LIMIT
62014
+ }
62015
+ });
62016
+ for (const row of rows) ids.add(String(row.data["summaryId"] ?? ""));
62017
+ }
62018
+ ids.delete("");
62019
+ return ids;
62020
+ }
62021
+ /** Members of many summaries, in ONE read, grouped by summary id. */
62022
+ async membersOfMany(summaryIds) {
62023
+ const out = /* @__PURE__ */ new Map();
62024
+ if (summaryIds.length === 0) return out;
62025
+ const rows = await this.store.query.query({
62026
+ collection: SUMMARY_MEMBERS_COLLECTION$1,
62027
+ filter: {
62028
+ whereIn: { summaryId: [...summaryIds] },
62029
+ limit: 1e4
62030
+ }
62031
+ });
62032
+ for (const r of rows) {
62033
+ const summaryIdValue = String(r.data["summaryId"] ?? "");
62034
+ const member = {
62035
+ summaryId: summaryIdValue,
62036
+ trackId: String(r.data["trackId"] ?? ""),
62037
+ deviceId: num$1(r.data["deviceId"]),
62038
+ joinedAt: num$1(r.data["timestamp"]),
62039
+ role: readRole(r.data["role"]),
62040
+ className: readClassName(r.data["className"]),
62041
+ ...labelFields(r.data)
62042
+ };
62043
+ const bucket = out.get(summaryIdValue);
62044
+ if (bucket === void 0) out.set(summaryIdValue, [member]);
62045
+ else bucket.push(member);
62046
+ }
62047
+ for (const bucket of out.values()) bucket.sort((a, b) => a.joinedAt - b.joinedAt);
62048
+ return out;
62049
+ }
61701
62050
  async get(id) {
61702
62051
  const raw = await this.store.get.query({
61703
62052
  collection: SUMMARIES_COLLECTION,
@@ -71988,6 +72337,33 @@ async function persistSummaryGroupShot(deps, input) {
71988
72337
  }
71989
72338
  }
71990
72339
  //#endregion
72340
+ //#region src/pipeline-analytics/summary/summary-composition.ts
72341
+ function summaryComposition(members) {
72342
+ const counts = /* @__PURE__ */ new Map();
72343
+ for (const member of members) counts.set(member.className, (counts.get(member.className) ?? 0) + 1);
72344
+ return { classes: [...counts].map(([className, count]) => ({
72345
+ className,
72346
+ count
72347
+ })).toSorted((a, b) => a.count !== b.count ? b.count - a.count : (a.className ?? "").localeCompare(b.className ?? "")) };
72348
+ }
72349
+ //#endregion
72350
+ //#region src/pipeline-analytics/summary/summary-member-labels.ts
72351
+ function present(value) {
72352
+ return typeof value === "string" && value.length > 0;
72353
+ }
72354
+ function withMemberLabels(members, tracks) {
72355
+ const byId = new Map(tracks.map((t) => [t.trackId, t]));
72356
+ return members.map((member) => {
72357
+ const track = byId.get(member.trackId);
72358
+ return {
72359
+ ...member,
72360
+ className: member.className ?? (present(track?.className) ? track.className : null),
72361
+ ...present(track?.label) ? { label: track.label } : {},
72362
+ ...present(track?.subLabel) ? { subLabel: track.subLabel } : {}
72363
+ };
72364
+ });
72365
+ }
72366
+ //#endregion
71991
72367
  //#region src/pipeline-analytics/summary/summary-join.ts
71992
72368
  /**
71993
72369
  * Which open group a newborn track joins — or none, and it opens its own.
@@ -72333,7 +72709,21 @@ var SummarySealer = class {
72333
72709
  return null;
72334
72710
  }
72335
72711
  }
72336
- async sealIfDone(summary) {
72712
+ /**
72713
+ * Is this group finished?
72714
+ *
72715
+ * Both questions are about THIS group's members, not about the camera. A
72716
+ * camera holds as many groups as it has (D489), so a camera-wide gate means
72717
+ * no group seals until every subject on the camera has gone — and on a busy
72718
+ * camera they never all go at once. Measured: eight summaries open on one
72719
+ * camera over seventeen minutes, none sealed, each new subject opening a
72720
+ * ninth because a group whose members have closed has no positions left for
72721
+ * the proximity rule to judge.
72722
+ *
72723
+ * `null` means "could not find out", and the caller treats that as NOT done —
72724
+ * a read that failed must never seal a live session (D49).
72725
+ */
72726
+ async isGroupFinished(summary, members) {
72337
72727
  const { id: summaryId, deviceId } = summary;
72338
72728
  let openTracks;
72339
72729
  try {
@@ -72346,12 +72736,13 @@ var SummarySealer = class {
72346
72736
  error: errMsg(err)
72347
72737
  }
72348
72738
  });
72349
- return;
72739
+ return null;
72350
72740
  }
72351
- if (openTracks.length > 0) return;
72352
- let quiet;
72741
+ const stillOpen = new Set(openTracks);
72742
+ if (members.some((m) => stillOpen.has(m))) return false;
72743
+ let parked;
72353
72744
  try {
72354
- quiet = await this.deps.isCameraQuiet(deviceId);
72745
+ parked = await this.deps.parkedTrackIds(deviceId);
72355
72746
  } catch (err) {
72356
72747
  this.deps.logger.warn("summary sweep: could not read the stationary registry — seal aborted this sweep", {
72357
72748
  tags: { deviceId },
@@ -72360,10 +72751,28 @@ var SummarySealer = class {
72360
72751
  error: errMsg(err)
72361
72752
  }
72362
72753
  });
72754
+ return null;
72755
+ }
72756
+ return !members.some((m) => parked.has(m));
72757
+ }
72758
+ async sealIfDone(summary) {
72759
+ const { id: summaryId, deviceId } = summary;
72760
+ let members;
72761
+ try {
72762
+ members = (await this.deps.summaries.membersOf(summaryId)).map((m) => m.trackId);
72763
+ } catch (err) {
72764
+ this.deps.logger.warn("summary sweep: could not read members — seal aborted this sweep", {
72765
+ tags: { deviceId },
72766
+ meta: {
72767
+ summaryId,
72768
+ error: errMsg(err)
72769
+ }
72770
+ });
72363
72771
  return;
72364
72772
  }
72365
- if (!quiet) return;
72773
+ if (await this.isGroupFinished(summary, members) !== true) return;
72366
72774
  const sealedAt = this.deps.now();
72775
+ await this.stampLabels(summary, members);
72367
72776
  try {
72368
72777
  if (await this.deps.summaries.seal(summary, sealedAt)) {
72369
72778
  const members = this.forgetGroup(deviceId, summaryId);
@@ -72396,6 +72805,37 @@ var SummarySealer = class {
72396
72805
  if (members === void 0) groups.set(summaryIdValue, new Set([trackId]));
72397
72806
  else members.add(trackId);
72398
72807
  }
72808
+ /**
72809
+ * Write each member's final label onto its row.
72810
+ *
72811
+ * At the seal every member's track has closed, so what the pipeline decided
72812
+ * about it is final — this is the first and last moment the stamp can be
72813
+ * both complete and correct.
72814
+ */
72815
+ async stampLabels(summary, members) {
72816
+ const read = this.deps.labelsFor;
72817
+ if (read === void 0 || members.length === 0) return;
72818
+ try {
72819
+ const labels = await read(members);
72820
+ for (const [trackId, value] of labels) {
72821
+ const label = value.label;
72822
+ const subLabel = value.subLabel;
72823
+ if ((label === void 0 || label.length === 0) && (subLabel === void 0 || subLabel.length === 0)) continue;
72824
+ await this.deps.summaries.stampMemberLabels(summary.id, trackId, {
72825
+ ...label !== void 0 && label.length > 0 ? { label } : {},
72826
+ ...subLabel !== void 0 && subLabel.length > 0 ? { subLabel } : {}
72827
+ });
72828
+ }
72829
+ } catch (err) {
72830
+ this.deps.logger.warn("summary: could not stamp member labels — the seal stands", {
72831
+ tags: { deviceId: summary.deviceId },
72832
+ meta: {
72833
+ summaryId: summary.id,
72834
+ error: errMsg(err)
72835
+ }
72836
+ });
72837
+ }
72838
+ }
72399
72839
  /** Follows a split in the mirror, so the frame path scores each half on its
72400
72840
  * own members from the very next frame. */
72401
72841
  moveMirrorMembers(deviceId, fromId, toId, trackIds) {
@@ -73968,6 +74408,21 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
73968
74408
  await secretState.set(secret);
73969
74409
  }
73970
74410
  const shelfWindows = await this.pruneWindows();
74411
+ const index = new NcArtifactIndex({
74412
+ store: this.ctx.api.settingsStore,
74413
+ logger: this.ctx.logger.child("nc-artifacts"),
74414
+ addonId: this.ctx.id,
74415
+ retentionMs: shelfWindows.artifactShelfMs
74416
+ });
74417
+ this.ncArtifactIndex = index;
74418
+ const refusal = artifactShelfRefusal(await this.ctx.api.storage.listLocations.query({}), this.ctx.kernel.localNodeId ?? null);
74419
+ if (refusal !== null) {
74420
+ this.ctx.logger.info("nc-artifact shelf not served on this node", { meta: {
74421
+ reason: refusal,
74422
+ indexServed: true
74423
+ } });
74424
+ return;
74425
+ }
73971
74426
  const store = new NcArtifactStore({
73972
74427
  rootDir: await this.resolveNcArtifactRoot(),
73973
74428
  logger: this.ctx.logger.child("nc-artifacts"),
@@ -73975,13 +74430,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
73975
74430
  });
73976
74431
  await store.start();
73977
74432
  this.ncArtifactStore = store;
73978
- const index = new NcArtifactIndex({
73979
- store: this.ctx.api.settingsStore,
73980
- logger: this.ctx.logger.child("nc-artifacts"),
73981
- addonId: this.ctx.id,
73982
- retentionMs: store.retentionMs
73983
- });
73984
- this.ncArtifactIndex = index;
73985
74433
  const plane = new NcArtifactPlane({
73986
74434
  store,
73987
74435
  secret,
@@ -75341,7 +75789,25 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
75341
75789
  summaries: this.summaryStore,
75342
75790
  analysedDeviceIds: () => [...this.lastFrameDimsByDevice.keys()],
75343
75791
  openTrackIds: async (deviceId) => this.residents.trackIdsForDevice(deviceId),
75344
- isCameraQuiet: async (deviceId) => (this.stationaryRegistry?.count(deviceId) ?? 0) === 0,
75792
+ parkedTrackIds: async (deviceId) => {
75793
+ const ids = /* @__PURE__ */ new Set();
75794
+ for (const entry of this.stationaryRegistry?.list(deviceId) ?? []) if (entry.sourceTrackId !== void 0) ids.add(entry.sourceTrackId);
75795
+ return ids;
75796
+ },
75797
+ labelsFor: async (trackIds) => {
75798
+ const out = /* @__PURE__ */ new Map();
75799
+ const tracks = await this.trackStore?.getPersistedByTrackIds(trackIds) ?? [];
75800
+ for (const track of tracks) {
75801
+ const label = track.label;
75802
+ const subLabel = track.subLabel;
75803
+ if (label === void 0 && subLabel === void 0) continue;
75804
+ out.set(track.trackId, {
75805
+ ...label !== void 0 ? { label } : {},
75806
+ ...subLabel !== void 0 ? { subLabel } : {}
75807
+ });
75808
+ }
75809
+ return out;
75810
+ },
75345
75811
  now: () => Date.now(),
75346
75812
  logger: this.ctx.logger.child("summary-sealer"),
75347
75813
  frameSize: (deviceId) => {
@@ -76046,6 +76512,30 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
76046
76512
  confirmed: t.observations !== void 0 && t.observations >= minHits
76047
76513
  }));
76048
76514
  },
76515
+ listSummaryGroups: async ({ deviceId, sinceMs, untilMs }) => {
76516
+ const summaryStore = this.summaryStore;
76517
+ if (!summaryStore) return [];
76518
+ const rows = await summaryStore.listRecent({
76519
+ deviceIds: [deviceId],
76520
+ since: sinceMs,
76521
+ until: untilMs,
76522
+ limit: 500
76523
+ });
76524
+ const out = [];
76525
+ for (const row of rows) {
76526
+ const members = await summaryStore.membersOf(row.id);
76527
+ const files = await this.mediaStore?.listInfoByOwner("summary", row.id) ?? [];
76528
+ const shot = files.find((f) => f.kind === "keyFrameSmall") ?? files.find((f) => f.kind === "keyFrame");
76529
+ out.push({
76530
+ summaryId: row.id,
76531
+ deviceId: row.deviceId,
76532
+ memberTrackIds: members.map((m) => m.trackId),
76533
+ primaryTrackId: members.find((m) => m.role === "primary")?.trackId ?? null,
76534
+ groupShotMediaId: shot?.mediaId ?? null
76535
+ });
76536
+ }
76537
+ return out;
76538
+ },
76049
76539
  listMediaInfo: async (id) => {
76050
76540
  return (await stores.mediaStore.listInfoByOwner("track", id)).map((r) => ({
76051
76541
  mediaId: r.mediaId,
@@ -81194,23 +81684,96 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
81194
81684
  deviceIds: input.deviceIds,
81195
81685
  ...input.since !== void 0 ? { since: input.since } : {},
81196
81686
  ...input.until !== void 0 ? { until: input.until } : {},
81197
- limit: input.limit ?? 50
81687
+ limit: input.limit ?? 50,
81688
+ ...input.classes !== void 0 ? { classes: input.classes } : {}
81198
81689
  });
81199
- return { summaries: await Promise.all(rows.map((row) => this.toSummary(row))) };
81690
+ return { summaries: await this.listItemsFor(rows) };
81200
81691
  }
81201
- async getSummary(input) {
81202
- const detail = await this.summaryStore?.get(input.id);
81203
- if (!detail) return null;
81692
+ /**
81693
+ * Rows → what a LIST draws: the envelope plus the composition.
81694
+ *
81695
+ * Two bulk reads for the whole page, never per row: the members in one, their
81696
+ * tracks (for the names) in one. The MEMBERS themselves stay off the list —
81697
+ * a page of two hundred groups would otherwise carry a thousand records so
81698
+ * the client could count them. `getSummary` is where the links live.
81699
+ */
81700
+ async listItemsFor(rows) {
81701
+ const store = this.summaryStore;
81702
+ if (!store || rows.length === 0) return [];
81703
+ const members = await store.membersOfMany(rows.map((r) => r.id));
81704
+ return await Promise.all(rows.map(async (row) => ({
81705
+ summary: await this.toSummary(row),
81706
+ ...summaryComposition(members.get(row.id) ?? [])
81707
+ })));
81708
+ }
81709
+ /** The members of a page of summaries, with the labels a card can show —
81710
+ * in ONE read of the members and ONE of their tracks. */
81711
+ async labelledMembersFor(rows) {
81712
+ const out = /* @__PURE__ */ new Map();
81713
+ const store = this.summaryStore;
81714
+ if (!store || rows.length === 0) return out;
81715
+ const membersBySummary = await store.membersOfMany(rows.map((r) => r.id));
81716
+ const trackIds = [...new Set([...membersBySummary.values()].flatMap((ms) => ms.map((m) => m.trackId)))];
81717
+ let tracks = [];
81718
+ try {
81719
+ tracks = await this.trackStore?.getPersistedByTrackIds(trackIds) ?? [];
81720
+ } catch (err) {
81721
+ this.ctx.logger.warn("summary members: labels unavailable for this page", { meta: {
81722
+ summaries: rows.length,
81723
+ members: trackIds.length,
81724
+ error: errMsg$1(err)
81725
+ } });
81726
+ }
81727
+ for (const row of rows) out.set(row.id, withMemberLabels(membersBySummary.get(row.id) ?? [], tracks));
81728
+ return out;
81729
+ }
81730
+ /**
81731
+ * Rows → the shape every summary surface answers with: the envelope, its
81732
+ * members, and what a thumbnail can render for each of them.
81733
+ *
81734
+ * THREE reads for the whole page, never per row: the members in one, their
81735
+ * tracks in one, and the group shots through `toSummary`. A read per member
81736
+ * is what made a sixty-group page take twenty seconds on the events feed.
81737
+ */
81738
+ /** ONE summary, with every member and the link each of them carries. */
81739
+ async detailFor(row) {
81740
+ const labelled = (await this.labelledMembersFor([row])).get(row.id) ?? [];
81204
81741
  return {
81205
- summary: await this.toSummary(detail.summary),
81206
- members: detail.members.map((m) => ({
81742
+ summary: await this.toSummary(row),
81743
+ members: labelled.map((m) => ({
81207
81744
  trackId: m.trackId,
81208
81745
  className: m.className,
81209
81746
  role: m.role,
81210
- joinedAt: m.joinedAt
81747
+ joinedAt: m.joinedAt,
81748
+ ...m.label !== void 0 ? { label: m.label } : {},
81749
+ ...m.subLabel !== void 0 ? { subLabel: m.subLabel } : {}
81211
81750
  }))
81212
81751
  };
81213
81752
  }
81753
+ async listRecentSummaries(input) {
81754
+ const store = this.summaryStore;
81755
+ if (!store) return {
81756
+ summaries: [],
81757
+ nextCursor: null
81758
+ };
81759
+ const page = await store.queryRecent({
81760
+ deviceIds: input.deviceIds,
81761
+ ...input.since !== void 0 ? { since: input.since } : {},
81762
+ ...input.until !== void 0 ? { until: input.until } : {},
81763
+ limit: input.limit,
81764
+ ...input.cursor !== void 0 ? { cursor: input.cursor } : {},
81765
+ ...input.classes !== void 0 ? { classes: input.classes } : {}
81766
+ });
81767
+ return {
81768
+ summaries: await this.listItemsFor(page.summaries.map((d) => d.summary)),
81769
+ nextCursor: page.nextCursor
81770
+ };
81771
+ }
81772
+ async getSummary(input) {
81773
+ const detail = await this.summaryStore?.get(input.id);
81774
+ if (!detail) return null;
81775
+ return await this.detailFor(detail.summary);
81776
+ }
81214
81777
  /** The row plus its group shot: the web companion when it landed, else the native key frame. */
81215
81778
  async toSummary(row) {
81216
81779
  const files = await this.mediaStore?.listInfoByOwner("summary", row.id) ?? [];