@camstack/addon-post-analysis 1.2.231 → 1.2.233

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(),
@@ -43115,6 +43262,10 @@ function trackColumnsFor(projection, needsPositions = false) {
43115
43262
  * whatever order the engine felt like and a cursor that cannot resume.
43116
43263
  */
43117
43264
  var RECENT_IDS_COLUMNS = ["lastSeen"];
43265
+ /** How many track ids one filter pass may collect per tier. Bounded for the
43266
+ * same reason every bulk read here is: an unbounded scan of this table is
43267
+ * what D447 measured holding the settings thread for seconds. */
43268
+ var TRACK_MATCH_SCAN_LIMIT = 2e4;
43118
43269
  /** Spreadable `columns` for a store query — omitted entirely for `full`. */
43119
43270
  function projectionArg(projection, needsPositions = false) {
43120
43271
  const columns = trackColumnsFor(projection, needsPositions);
@@ -44202,6 +44353,42 @@ var TrackStore = class {
44202
44353
  * current subject out from under it. The orphan audit's grace window would
44203
44354
  * hide this for an hour and then stop hiding it.
44204
44355
  */
44356
+ /**
44357
+ * Track ids whose CLASS, sub-class or instance matches any of `terms`.
44358
+ *
44359
+ * The summaries' filter asks its question here rather than on a copy: a
44360
+ * label is a fact about the TRACK, it is decided while the track lives and
44361
+ * can still be re-decided afterwards (class voting, a late face match, an
44362
+ * operator enrolling a face and renaming that person). A copy stamped onto
44363
+ * the summary would answer with whatever was true at one instant and never
44364
+ * learn — which is the whole reason this lookup exists instead.
44365
+ *
44366
+ * Three queries because the filter compiler has no OR across fields, and one
44367
+ * term can be any of the three tiers: `animal` is a class, `dog` is a
44368
+ * sub-class, `Roberta` is an instance. Bounded, like every other bulk read
44369
+ * here.
44370
+ */
44371
+ async trackIdsMatching(terms) {
44372
+ const wanted = terms.filter((t) => t.length > 0);
44373
+ if (wanted.length === 0) return [];
44374
+ const ids = /* @__PURE__ */ new Set();
44375
+ for (const field of [
44376
+ "className",
44377
+ "label",
44378
+ "subLabel"
44379
+ ]) {
44380
+ const rows = await this.store.query.query({
44381
+ collection: TRACKS_COLLECTION,
44382
+ filter: {
44383
+ whereIn: { [field]: [...wanted] },
44384
+ limit: TRACK_MATCH_SCAN_LIMIT
44385
+ },
44386
+ columns: ["id"]
44387
+ });
44388
+ for (const row of rows) ids.add(row.id);
44389
+ }
44390
+ return [...ids];
44391
+ }
44205
44392
  async existingTrackIds(trackIds) {
44206
44393
  const PAGE = 500;
44207
44394
  const found = /* @__PURE__ */ new Set();
@@ -61257,13 +61444,23 @@ var SUMMARY_MEMBERS_COLUMNS = [
61257
61444
  type: "TEXT"
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
- }];
61447
+ var SUMMARY_MEMBERS_INDEXES = [
61448
+ {
61449
+ name: "summary_members_summary",
61450
+ columns: ["summaryId"]
61451
+ },
61452
+ {
61453
+ name: "summary_members_track",
61454
+ columns: ["trackId"]
61455
+ },
61456
+ (
61457
+ /** The filter's own index: "which groups hold an `animal`" walks members by
61458
+ * class, never summaries by hope. */
61459
+ {
61460
+ name: "summary_members_class",
61461
+ columns: ["className"]
61462
+ })
61463
+ ];
61267
61464
  /**
61268
61465
  * A stored role, or `null`. An unrecognised value reads as `null` rather than
61269
61466
  * being passed through: a consumer switching on this must not receive a string
@@ -61286,6 +61483,22 @@ function readClassName(raw) {
61286
61483
  * case on a gate camera, not a race. With the track in the key a retry still
61287
61484
  * collapses (same birth, same track) and two births stay two groups.
61288
61485
  */
61486
+ /** The widest a JS Date can name — the open end of a half-bounded window. */
61487
+ var TIME_MIN = -864e13;
61488
+ var TIME_MAX = 864e13;
61489
+ /**
61490
+ * How many rows one paged read may materialise before the merge.
61491
+ *
61492
+ * The page is cut AFTER ordering, so the read must be able to serve any cursor
61493
+ * position — but it must also be bounded, because an unbounded read of this
61494
+ * table on a busy hub is the shape D447 measured holding the settings thread
61495
+ * for seconds. Summaries are far fewer than tracks by construction (14 940
61496
+ * against 30 583 on this hub), so this ceiling is generous.
61497
+ */
61498
+ var RECENT_SUMMARY_SCAN_LIMIT = 5e3;
61499
+ /** How many member rows one filter pass may read per tier. Bounded for the same
61500
+ * reason as the scan above, and generous: members are one row per subject. */
61501
+ var MEMBER_MATCH_SCAN_LIMIT = 2e4;
61289
61502
  function summaryId(deviceId, openedAt, foundingTrackId) {
61290
61503
  return `sum-${deviceId}-${openedAt}-${foundingTrackId}`;
61291
61504
  }
@@ -61312,10 +61525,12 @@ var SummaryStore = class {
61312
61525
  store;
61313
61526
  logger;
61314
61527
  onEvict;
61528
+ trackIdsMatching;
61315
61529
  constructor(deps) {
61316
61530
  this.store = deps.store;
61317
61531
  this.logger = deps.logger;
61318
61532
  this.onEvict = deps.onEvict;
61533
+ this.trackIdsMatching = deps.trackIdsMatching;
61319
61534
  }
61320
61535
  static async declare(store) {
61321
61536
  await Promise.all([store.declareCollection.mutate({
@@ -61683,6 +61898,8 @@ var SummaryStore = class {
61683
61898
  */
61684
61899
  async listRecent(input) {
61685
61900
  if (input.deviceIds.length === 0) return [];
61901
+ const matching = await this.summaryIdsMatching(input.classes);
61902
+ if (matching !== null && matching.size === 0) return [];
61686
61903
  const limit = Math.max(1, input.limit);
61687
61904
  return (await this.store.query.query({
61688
61905
  collection: SUMMARIES_COLLECTION,
@@ -61695,9 +61912,138 @@ var SummaryStore = class {
61695
61912
  },
61696
61913
  limit
61697
61914
  }
61698
- })).map((r) => toRow$1(r.id, r.data)).sort((a, b) => b.openedAt - a.openedAt).slice(0, limit);
61915
+ })).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
61916
  }
61700
61917
  /** One summary with its members, or `null`. */
61918
+ /**
61919
+ * One PAGE of summaries across a camera set, newest activity first.
61920
+ *
61921
+ * The same contract as `listRecentTracks` and for the same consumers: the
61922
+ * reel on the cameras page and the Events gallery both ride a cursor, so a
61923
+ * feed that could only answer "the last N" would make groups available in the
61924
+ * timeline and nowhere else. `recent-cursor.ts` is reused verbatim — the
61925
+ * ordering is the stable total order (`lastActiveAt` DESC, id DESC), and a
61926
+ * second implementation of that is a second place for two feeds to disagree
61927
+ * about what "newest" means.
61928
+ *
61929
+ * The window is on LAST ACTIVITY, not on the open: a session that started
61930
+ * before the window and ran into it belongs to the window, because it
61931
+ * happened there.
61932
+ *
61933
+ * Members for the WHOLE page come back in one read. One query per row is how
61934
+ * a page of two hundred becomes two hundred round trips — the shape that made
61935
+ * the old digest read two thousand tracks a camera.
61936
+ */
61937
+ async queryRecent(input) {
61938
+ const deviceIds = [...new Set(input.deviceIds)];
61939
+ if (deviceIds.length === 0) return {
61940
+ summaries: [],
61941
+ nextCursor: null
61942
+ };
61943
+ const cursor = input.cursor === void 0 ? null : decodeRecentCursor(input.cursor);
61944
+ const matching = await this.summaryIdsMatching(input.classes);
61945
+ if (matching !== null && matching.size === 0) return {
61946
+ summaries: [],
61947
+ nextCursor: null
61948
+ };
61949
+ const ordered = (await this.store.query.query({
61950
+ collection: SUMMARIES_COLLECTION,
61951
+ filter: {
61952
+ whereIn: { deviceId: deviceIds },
61953
+ ...input.since !== void 0 || input.until !== void 0 ? { whereBetween: { lastActiveAt: [input.since ?? TIME_MIN, input.until ?? TIME_MAX] } } : {},
61954
+ limit: RECENT_SUMMARY_SCAN_LIMIT
61955
+ }
61956
+ })).map((r) => toRow$1(r.id, r.data)).filter((row) => matching === null || matching.has(row.id)).map((row) => ({
61957
+ row,
61958
+ key: {
61959
+ lastSeen: row.lastActiveAt,
61960
+ id: row.id
61961
+ }
61962
+ })).filter((entry) => cursor === null || isAfterCursor(entry.key, cursor)).toSorted((a, b) => compareRecentDesc(a.key, b.key));
61963
+ const page = ordered.slice(0, input.limit);
61964
+ const last = page.at(-1);
61965
+ const nextCursor = last !== void 0 && ordered.length > page.length ? encodeRecentCursor({
61966
+ lastSeen: last.row.lastActiveAt,
61967
+ trackId: last.row.id
61968
+ }) : null;
61969
+ const members = await this.membersOfMany(page.map((e) => e.row.id));
61970
+ return {
61971
+ summaries: page.map((e) => ({
61972
+ summary: e.row,
61973
+ members: members.get(e.row.id) ?? []
61974
+ })),
61975
+ nextCursor
61976
+ };
61977
+ }
61978
+ /**
61979
+ * The summaries whose MEMBERS match any of `classes`, or `null` for no filter.
61980
+ *
61981
+ * TRACKS first, then the members that name them, then the summaries. The
61982
+ * label is a fact about the track and it is asked of the track — a summary
61983
+ * that stored its own copy would answer with whatever was true when it
61984
+ * sealed, and would never learn that a face was enrolled and a person named
61985
+ * an hour later. The operator's rule: *a summary keeps the minimum
61986
+ * information necessary*, and a duplicated label is not the minimum, it is a
61987
+ * second truth with an expiry date.
61988
+ *
61989
+ * Narrows before the page is cut. Filtering the other way round — a page of
61990
+ * summaries, then their members — returns a short or empty page while matches
61991
+ * sit further down the list, and the operator reads that as "there were none".
61992
+ */
61993
+ async summaryIdsMatching(classes) {
61994
+ const wanted = (classes ?? []).filter((t) => t.length > 0);
61995
+ if (wanted.length === 0) return null;
61996
+ const matchTracks = this.trackIdsMatching;
61997
+ if (matchTracks === void 0) {
61998
+ this.logger.warn("summary filter: no track matcher wired — refusing to answer unfiltered", { meta: { classes: wanted.length } });
61999
+ return /* @__PURE__ */ new Set();
62000
+ }
62001
+ const trackIds = await matchTracks(wanted);
62002
+ if (trackIds.length === 0) return /* @__PURE__ */ new Set();
62003
+ const ids = /* @__PURE__ */ new Set();
62004
+ const PAGE = 500;
62005
+ for (let i = 0; i < trackIds.length; i += PAGE) {
62006
+ const chunk = trackIds.slice(i, i + PAGE);
62007
+ const rows = await this.store.query.query({
62008
+ collection: SUMMARY_MEMBERS_COLLECTION$1,
62009
+ filter: {
62010
+ whereIn: { trackId: [...chunk] },
62011
+ limit: MEMBER_MATCH_SCAN_LIMIT
62012
+ }
62013
+ });
62014
+ for (const row of rows) ids.add(String(row.data["summaryId"] ?? ""));
62015
+ }
62016
+ ids.delete("");
62017
+ return ids;
62018
+ }
62019
+ /** Members of many summaries, in ONE read, grouped by summary id. */
62020
+ async membersOfMany(summaryIds) {
62021
+ const out = /* @__PURE__ */ new Map();
62022
+ if (summaryIds.length === 0) return out;
62023
+ const rows = await this.store.query.query({
62024
+ collection: SUMMARY_MEMBERS_COLLECTION$1,
62025
+ filter: {
62026
+ whereIn: { summaryId: [...summaryIds] },
62027
+ limit: 1e4
62028
+ }
62029
+ });
62030
+ for (const r of rows) {
62031
+ const summaryIdValue = String(r.data["summaryId"] ?? "");
62032
+ const member = {
62033
+ summaryId: summaryIdValue,
62034
+ trackId: String(r.data["trackId"] ?? ""),
62035
+ deviceId: num$1(r.data["deviceId"]),
62036
+ joinedAt: num$1(r.data["timestamp"]),
62037
+ role: readRole(r.data["role"]),
62038
+ className: readClassName(r.data["className"])
62039
+ };
62040
+ const bucket = out.get(summaryIdValue);
62041
+ if (bucket === void 0) out.set(summaryIdValue, [member]);
62042
+ else bucket.push(member);
62043
+ }
62044
+ for (const bucket of out.values()) bucket.sort((a, b) => a.joinedAt - b.joinedAt);
62045
+ return out;
62046
+ }
61701
62047
  async get(id) {
61702
62048
  const raw = await this.store.get.query({
61703
62049
  collection: SUMMARIES_COLLECTION,
@@ -71988,6 +72334,33 @@ async function persistSummaryGroupShot(deps, input) {
71988
72334
  }
71989
72335
  }
71990
72336
  //#endregion
72337
+ //#region src/pipeline-analytics/summary/summary-composition.ts
72338
+ function summaryComposition(members) {
72339
+ const counts = /* @__PURE__ */ new Map();
72340
+ for (const member of members) counts.set(member.className, (counts.get(member.className) ?? 0) + 1);
72341
+ return { classes: [...counts].map(([className, count]) => ({
72342
+ className,
72343
+ count
72344
+ })).toSorted((a, b) => a.count !== b.count ? b.count - a.count : (a.className ?? "").localeCompare(b.className ?? "")) };
72345
+ }
72346
+ //#endregion
72347
+ //#region src/pipeline-analytics/summary/summary-member-labels.ts
72348
+ function present(value) {
72349
+ return typeof value === "string" && value.length > 0;
72350
+ }
72351
+ function withMemberLabels(members, tracks) {
72352
+ const byId = new Map(tracks.map((t) => [t.trackId, t]));
72353
+ return members.map((member) => {
72354
+ const track = byId.get(member.trackId);
72355
+ return {
72356
+ ...member,
72357
+ className: member.className ?? (present(track?.className) ? track.className : null),
72358
+ ...present(track?.label) ? { label: track.label } : {},
72359
+ ...present(track?.subLabel) ? { subLabel: track.subLabel } : {}
72360
+ };
72361
+ });
72362
+ }
72363
+ //#endregion
71991
72364
  //#region src/pipeline-analytics/summary/summary-join.ts
71992
72365
  /**
71993
72366
  * Which open group a newborn track joins — or none, and it opens its own.
@@ -72333,7 +72706,21 @@ var SummarySealer = class {
72333
72706
  return null;
72334
72707
  }
72335
72708
  }
72336
- async sealIfDone(summary) {
72709
+ /**
72710
+ * Is this group finished?
72711
+ *
72712
+ * Both questions are about THIS group's members, not about the camera. A
72713
+ * camera holds as many groups as it has (D489), so a camera-wide gate means
72714
+ * no group seals until every subject on the camera has gone — and on a busy
72715
+ * camera they never all go at once. Measured: eight summaries open on one
72716
+ * camera over seventeen minutes, none sealed, each new subject opening a
72717
+ * ninth because a group whose members have closed has no positions left for
72718
+ * the proximity rule to judge.
72719
+ *
72720
+ * `null` means "could not find out", and the caller treats that as NOT done —
72721
+ * a read that failed must never seal a live session (D49).
72722
+ */
72723
+ async isGroupFinished(summary, members) {
72337
72724
  const { id: summaryId, deviceId } = summary;
72338
72725
  let openTracks;
72339
72726
  try {
@@ -72346,12 +72733,13 @@ var SummarySealer = class {
72346
72733
  error: errMsg(err)
72347
72734
  }
72348
72735
  });
72349
- return;
72736
+ return null;
72350
72737
  }
72351
- if (openTracks.length > 0) return;
72352
- let quiet;
72738
+ const stillOpen = new Set(openTracks);
72739
+ if (members.some((m) => stillOpen.has(m))) return false;
72740
+ let parked;
72353
72741
  try {
72354
- quiet = await this.deps.isCameraQuiet(deviceId);
72742
+ parked = await this.deps.parkedTrackIds(deviceId);
72355
72743
  } catch (err) {
72356
72744
  this.deps.logger.warn("summary sweep: could not read the stationary registry — seal aborted this sweep", {
72357
72745
  tags: { deviceId },
@@ -72360,9 +72748,26 @@ var SummarySealer = class {
72360
72748
  error: errMsg(err)
72361
72749
  }
72362
72750
  });
72751
+ return null;
72752
+ }
72753
+ return !members.some((m) => parked.has(m));
72754
+ }
72755
+ async sealIfDone(summary) {
72756
+ const { id: summaryId, deviceId } = summary;
72757
+ let members;
72758
+ try {
72759
+ members = (await this.deps.summaries.membersOf(summaryId)).map((m) => m.trackId);
72760
+ } catch (err) {
72761
+ this.deps.logger.warn("summary sweep: could not read members — seal aborted this sweep", {
72762
+ tags: { deviceId },
72763
+ meta: {
72764
+ summaryId,
72765
+ error: errMsg(err)
72766
+ }
72767
+ });
72363
72768
  return;
72364
72769
  }
72365
- if (!quiet) return;
72770
+ if (await this.isGroupFinished(summary, members) !== true) return;
72366
72771
  const sealedAt = this.deps.now();
72367
72772
  try {
72368
72773
  if (await this.deps.summaries.seal(summary, sealedAt)) {
@@ -73968,6 +74373,21 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
73968
74373
  await secretState.set(secret);
73969
74374
  }
73970
74375
  const shelfWindows = await this.pruneWindows();
74376
+ const index = new NcArtifactIndex({
74377
+ store: this.ctx.api.settingsStore,
74378
+ logger: this.ctx.logger.child("nc-artifacts"),
74379
+ addonId: this.ctx.id,
74380
+ retentionMs: shelfWindows.artifactShelfMs
74381
+ });
74382
+ this.ncArtifactIndex = index;
74383
+ const refusal = artifactShelfRefusal(await this.ctx.api.storage.listLocations.query({}), this.ctx.kernel.localNodeId ?? null);
74384
+ if (refusal !== null) {
74385
+ this.ctx.logger.info("nc-artifact shelf not served on this node", { meta: {
74386
+ reason: refusal,
74387
+ indexServed: true
74388
+ } });
74389
+ return;
74390
+ }
73971
74391
  const store = new NcArtifactStore({
73972
74392
  rootDir: await this.resolveNcArtifactRoot(),
73973
74393
  logger: this.ctx.logger.child("nc-artifacts"),
@@ -73975,13 +74395,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
73975
74395
  });
73976
74396
  await store.start();
73977
74397
  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
74398
  const plane = new NcArtifactPlane({
73986
74399
  store,
73987
74400
  secret,
@@ -75331,6 +75744,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
75331
75744
  logger: logger.child("SensorEventStore")
75332
75745
  });
75333
75746
  this.summaryStore = new SummaryStore({
75747
+ trackIdsMatching: async (terms) => await this.trackStore?.trackIdsMatching(terms) ?? [],
75334
75748
  store: api.settingsStore,
75335
75749
  logger: this.ctx.logger.child("summary"),
75336
75750
  onEvict: async (ids) => {
@@ -75341,7 +75755,11 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
75341
75755
  summaries: this.summaryStore,
75342
75756
  analysedDeviceIds: () => [...this.lastFrameDimsByDevice.keys()],
75343
75757
  openTrackIds: async (deviceId) => this.residents.trackIdsForDevice(deviceId),
75344
- isCameraQuiet: async (deviceId) => (this.stationaryRegistry?.count(deviceId) ?? 0) === 0,
75758
+ parkedTrackIds: async (deviceId) => {
75759
+ const ids = /* @__PURE__ */ new Set();
75760
+ for (const entry of this.stationaryRegistry?.list(deviceId) ?? []) if (entry.sourceTrackId !== void 0) ids.add(entry.sourceTrackId);
75761
+ return ids;
75762
+ },
75345
75763
  now: () => Date.now(),
75346
75764
  logger: this.ctx.logger.child("summary-sealer"),
75347
75765
  frameSize: (deviceId) => {
@@ -76046,6 +76464,30 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
76046
76464
  confirmed: t.observations !== void 0 && t.observations >= minHits
76047
76465
  }));
76048
76466
  },
76467
+ listSummaryGroups: async ({ deviceId, sinceMs, untilMs }) => {
76468
+ const summaryStore = this.summaryStore;
76469
+ if (!summaryStore) return [];
76470
+ const rows = await summaryStore.listRecent({
76471
+ deviceIds: [deviceId],
76472
+ since: sinceMs,
76473
+ until: untilMs,
76474
+ limit: 500
76475
+ });
76476
+ const out = [];
76477
+ for (const row of rows) {
76478
+ const members = await summaryStore.membersOf(row.id);
76479
+ const files = await this.mediaStore?.listInfoByOwner("summary", row.id) ?? [];
76480
+ const shot = files.find((f) => f.kind === "keyFrameSmall") ?? files.find((f) => f.kind === "keyFrame");
76481
+ out.push({
76482
+ summaryId: row.id,
76483
+ deviceId: row.deviceId,
76484
+ memberTrackIds: members.map((m) => m.trackId),
76485
+ primaryTrackId: members.find((m) => m.role === "primary")?.trackId ?? null,
76486
+ groupShotMediaId: shot?.mediaId ?? null
76487
+ });
76488
+ }
76489
+ return out;
76490
+ },
76049
76491
  listMediaInfo: async (id) => {
76050
76492
  return (await stores.mediaStore.listInfoByOwner("track", id)).map((r) => ({
76051
76493
  mediaId: r.mediaId,
@@ -81194,23 +81636,96 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
81194
81636
  deviceIds: input.deviceIds,
81195
81637
  ...input.since !== void 0 ? { since: input.since } : {},
81196
81638
  ...input.until !== void 0 ? { until: input.until } : {},
81197
- limit: input.limit ?? 50
81639
+ limit: input.limit ?? 50,
81640
+ ...input.classes !== void 0 ? { classes: input.classes } : {}
81198
81641
  });
81199
- return { summaries: await Promise.all(rows.map((row) => this.toSummary(row))) };
81642
+ return { summaries: await this.listItemsFor(rows) };
81200
81643
  }
81201
- async getSummary(input) {
81202
- const detail = await this.summaryStore?.get(input.id);
81203
- if (!detail) return null;
81644
+ /**
81645
+ * Rows → what a LIST draws: the envelope plus the composition.
81646
+ *
81647
+ * Two bulk reads for the whole page, never per row: the members in one, their
81648
+ * tracks (for the names) in one. The MEMBERS themselves stay off the list —
81649
+ * a page of two hundred groups would otherwise carry a thousand records so
81650
+ * the client could count them. `getSummary` is where the links live.
81651
+ */
81652
+ async listItemsFor(rows) {
81653
+ const store = this.summaryStore;
81654
+ if (!store || rows.length === 0) return [];
81655
+ const members = await store.membersOfMany(rows.map((r) => r.id));
81656
+ return await Promise.all(rows.map(async (row) => ({
81657
+ summary: await this.toSummary(row),
81658
+ ...summaryComposition(members.get(row.id) ?? [])
81659
+ })));
81660
+ }
81661
+ /** The members of a page of summaries, with the labels a card can show —
81662
+ * in ONE read of the members and ONE of their tracks. */
81663
+ async labelledMembersFor(rows) {
81664
+ const out = /* @__PURE__ */ new Map();
81665
+ const store = this.summaryStore;
81666
+ if (!store || rows.length === 0) return out;
81667
+ const membersBySummary = await store.membersOfMany(rows.map((r) => r.id));
81668
+ const trackIds = [...new Set([...membersBySummary.values()].flatMap((ms) => ms.map((m) => m.trackId)))];
81669
+ let tracks = [];
81670
+ try {
81671
+ tracks = await this.trackStore?.getPersistedByTrackIds(trackIds) ?? [];
81672
+ } catch (err) {
81673
+ this.ctx.logger.warn("summary members: labels unavailable for this page", { meta: {
81674
+ summaries: rows.length,
81675
+ members: trackIds.length,
81676
+ error: errMsg$1(err)
81677
+ } });
81678
+ }
81679
+ for (const row of rows) out.set(row.id, withMemberLabels(membersBySummary.get(row.id) ?? [], tracks));
81680
+ return out;
81681
+ }
81682
+ /**
81683
+ * Rows → the shape every summary surface answers with: the envelope, its
81684
+ * members, and what a thumbnail can render for each of them.
81685
+ *
81686
+ * THREE reads for the whole page, never per row: the members in one, their
81687
+ * tracks in one, and the group shots through `toSummary`. A read per member
81688
+ * is what made a sixty-group page take twenty seconds on the events feed.
81689
+ */
81690
+ /** ONE summary, with every member and the link each of them carries. */
81691
+ async detailFor(row) {
81692
+ const labelled = (await this.labelledMembersFor([row])).get(row.id) ?? [];
81204
81693
  return {
81205
- summary: await this.toSummary(detail.summary),
81206
- members: detail.members.map((m) => ({
81694
+ summary: await this.toSummary(row),
81695
+ members: labelled.map((m) => ({
81207
81696
  trackId: m.trackId,
81208
81697
  className: m.className,
81209
81698
  role: m.role,
81210
- joinedAt: m.joinedAt
81699
+ joinedAt: m.joinedAt,
81700
+ ...m.label !== void 0 ? { label: m.label } : {},
81701
+ ...m.subLabel !== void 0 ? { subLabel: m.subLabel } : {}
81211
81702
  }))
81212
81703
  };
81213
81704
  }
81705
+ async listRecentSummaries(input) {
81706
+ const store = this.summaryStore;
81707
+ if (!store) return {
81708
+ summaries: [],
81709
+ nextCursor: null
81710
+ };
81711
+ const page = await store.queryRecent({
81712
+ deviceIds: input.deviceIds,
81713
+ ...input.since !== void 0 ? { since: input.since } : {},
81714
+ ...input.until !== void 0 ? { until: input.until } : {},
81715
+ limit: input.limit,
81716
+ ...input.cursor !== void 0 ? { cursor: input.cursor } : {},
81717
+ ...input.classes !== void 0 ? { classes: input.classes } : {}
81718
+ });
81719
+ return {
81720
+ summaries: await this.listItemsFor(page.summaries.map((d) => d.summary)),
81721
+ nextCursor: page.nextCursor
81722
+ };
81723
+ }
81724
+ async getSummary(input) {
81725
+ const detail = await this.summaryStore?.get(input.id);
81726
+ if (!detail) return null;
81727
+ return await this.detailFor(detail.summary);
81728
+ }
81214
81729
  /** The row plus its group shot: the web companion when it landed, else the native key frame. */
81215
81730
  async toSummary(row) {
81216
81731
  const files = await this.mediaStore?.listInfoByOwner("summary", row.id) ?? [];