@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.
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-DAXBbWmc.js");
5
+ const require_dist = require("../dist-Dbf6ceXh.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_dist.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -1972,8 +1972,8 @@ var NC_ARTIFACTS_INDEXES = [
1972
1972
  }
1973
1973
  ];
1974
1974
  /** Upper bound for a half-open `whereBetween` — the store has no `>=`. */
1975
- var TIME_MAX = 864e13;
1976
- var TIME_MIN = -864e13;
1975
+ var TIME_MAX$1 = 864e13;
1976
+ var TIME_MIN$1 = -864e13;
1977
1977
  /** Prune scan ceiling — the index is bounded by the shelf it mirrors (2000
1978
1978
  * entries by default), so this only has to be comfortably above it. */
1979
1979
  var NC_ARTIFACT_PRUNE_SCAN_LIMIT = 2e4;
@@ -2213,7 +2213,7 @@ var NcArtifactIndex = class {
2213
2213
  try {
2214
2214
  const expired = await this.store.deleteWhere.mutate({
2215
2215
  collection: NC_ARTIFACTS_COLLECTION,
2216
- filter: { whereBetween: { expiresAt: [TIME_MIN, this.now() - 1] } }
2216
+ filter: { whereBetween: { expiresAt: [TIME_MIN$1, this.now() - 1] } }
2217
2217
  });
2218
2218
  removed += expired.deleted;
2219
2219
  if (keptIds === null) {
@@ -2255,8 +2255,8 @@ var NcArtifactIndex = class {
2255
2255
  whereIn["kind"] = query.kinds !== void 0 && query.kinds.length > 0 ? [...query.kinds] : [...NC_GALLERY_ARTIFACT_KINDS];
2256
2256
  if (query.deviceIds !== void 0 && query.deviceIds.length > 0) whereIn["deviceId"] = [...query.deviceIds];
2257
2257
  const whereBetween = {
2258
- capturedAt: [query.fromMs ?? TIME_MIN, query.toMs ?? TIME_MAX],
2259
- expiresAt: [this.now(), TIME_MAX]
2258
+ capturedAt: [query.fromMs ?? TIME_MIN$1, query.toMs ?? TIME_MAX$1],
2259
+ expiresAt: [this.now(), TIME_MAX$1]
2260
2260
  };
2261
2261
  return {
2262
2262
  ...query.ruleId !== void 0 ? { where: { ruleId: query.ruleId } } : {},
@@ -2281,6 +2281,23 @@ var NcArtifactIndex = class {
2281
2281
  return out;
2282
2282
  }
2283
2283
  };
2284
+ /**
2285
+ * A sentence explaining why this node must not keep the shelf, or `null` when
2286
+ * it may. The sentence is the log line: an operator reading it should learn
2287
+ * WHICH node owns the shelf without opening the storage page.
2288
+ */
2289
+ function artifactShelfRefusal(locations, localNodeId) {
2290
+ if (localNodeId === null || localNodeId.length === 0) return null;
2291
+ const here = localNodeId.includes("/") ? localNodeId.split("/")[0] ?? "" : localNodeId;
2292
+ if (here.length === 0) return null;
2293
+ for (const location of locations) {
2294
+ if (!location.id.startsWith("artifacts")) continue;
2295
+ const owner = location.nodeId;
2296
+ if (owner === void 0 || owner.length === 0 || owner === here) continue;
2297
+ 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}".`;
2298
+ }
2299
+ return null;
2300
+ }
2284
2301
  //#endregion
2285
2302
  //#region ../types/dist/node.mjs
2286
2303
  var node_exports = /* @__PURE__ */ require_dist.__exportAll({
@@ -18693,6 +18710,52 @@ function buildSummaryOutboxInputs(input) {
18693
18710
  });
18694
18711
  }
18695
18712
  //#endregion
18713
+ //#region src/notification-center/summary/summary-group-collapse.ts
18714
+ function collapseToGroups(candidates, groups) {
18715
+ const claim = /* @__PURE__ */ new Map();
18716
+ for (const group of groups) for (const trackId of group.memberTrackIds) if (!claim.has(trackId)) claim.set(trackId, group);
18717
+ const survivorsByGroup = /* @__PURE__ */ new Map();
18718
+ for (const candidate of candidates) {
18719
+ const group = claim.get(candidate.trackId);
18720
+ if (group === void 0) continue;
18721
+ const bucket = survivorsByGroup.get(group.summaryId);
18722
+ if (bucket === void 0) survivorsByGroup.set(group.summaryId, [candidate]);
18723
+ else bucket.push(candidate);
18724
+ }
18725
+ const representative = /* @__PURE__ */ new Map();
18726
+ for (const [summaryId, survivors] of survivorsByGroup) {
18727
+ const group = groups.find((g) => g.summaryId === summaryId);
18728
+ if (group === void 0) continue;
18729
+ const chosen = survivors.find((c) => c.trackId === group.primaryTrackId) ?? survivors[0];
18730
+ if (chosen === void 0) continue;
18731
+ representative.set(summaryId, {
18732
+ ...chosen,
18733
+ groupSize: survivors.length,
18734
+ ...group.groupShotMediaId !== null ? { groupShotMediaId: group.groupShotMediaId } : {}
18735
+ });
18736
+ }
18737
+ const kept = [];
18738
+ let collapsed = 0;
18739
+ for (const candidate of candidates) {
18740
+ const group = claim.get(candidate.trackId);
18741
+ if (group === void 0) {
18742
+ kept.push(candidate);
18743
+ continue;
18744
+ }
18745
+ const chosen = representative.get(group.summaryId);
18746
+ if (chosen === void 0) {
18747
+ kept.push(candidate);
18748
+ continue;
18749
+ }
18750
+ if (chosen.trackId === candidate.trackId) kept.push(chosen);
18751
+ else collapsed += 1;
18752
+ }
18753
+ return {
18754
+ kept,
18755
+ collapsed
18756
+ };
18757
+ }
18758
+ //#endregion
18696
18759
  //#region src/notification-center/summary/summary-selection.ts
18697
18760
  /**
18698
18761
  * Which tracks become tiles — filter, rank, and the per-camera quota.
@@ -19265,7 +19328,8 @@ var NcSummaryProducer = class {
19265
19328
  try {
19266
19329
  const candidates = await this.collectCandidates(rule, window);
19267
19330
  const filtered = filterSummaryCandidates(candidates, rule.filters, this.deps.zoneOwner);
19268
- const selection = selectSummaryTiles(filtered.kept, {
19331
+ const grouped = await this.collapseIfGrouping(rule, window, filtered.kept);
19332
+ const selection = selectSummaryTiles(grouped.kept, {
19269
19333
  maxTiles: rule.maxTiles,
19270
19334
  rank: rule.rank
19271
19335
  });
@@ -19294,7 +19358,7 @@ var NcSummaryProducer = class {
19294
19358
  const tileLoad = await this.loadTiles({
19295
19359
  rule,
19296
19360
  preferred: selection.tiles,
19297
- refill: remainingSummaryCandidates(filtered.kept, selection.tiles, rule.rank),
19361
+ refill: remainingSummaryCandidates(grouped.kept, selection.tiles, rule.rank),
19298
19362
  maxTiles: rule.maxTiles
19299
19363
  });
19300
19364
  const loaded = tileLoad.tiles;
@@ -19335,7 +19399,7 @@ var NcSummaryProducer = class {
19335
19399
  priority: rule.priority,
19336
19400
  tileCount: loaded.length,
19337
19401
  cameraCount: new Set(loaded.map((t) => t.candidate.deviceId)).size,
19338
- matchedCount: filtered.kept.length,
19402
+ matchedCount: grouped.kept.length,
19339
19403
  detections,
19340
19404
  ...mosaic.ref !== null ? { mosaic: mosaic.ref } : {},
19341
19405
  generatedAt: this.deps.now(),
@@ -19347,7 +19411,7 @@ var NcSummaryProducer = class {
19347
19411
  this.deps.logger.info("summary produced", { meta: {
19348
19412
  ...meta,
19349
19413
  scanned: candidates.length,
19350
- matched: filtered.kept.length,
19414
+ matched: grouped.kept.length,
19351
19415
  droppedStationary: filtered.droppedStationary,
19352
19416
  droppedUnconfirmed: filtered.droppedUnconfirmed,
19353
19417
  unknownConfirmation: filtered.unknownConfirmation,
@@ -19370,7 +19434,7 @@ var NcSummaryProducer = class {
19370
19434
  window,
19371
19435
  tiles: loaded.length,
19372
19436
  cameras: new Set(loaded.map((t) => t.candidate.deviceId)).size,
19373
- matched: filtered.kept.length,
19437
+ matched: grouped.kept.length,
19374
19438
  droppedStationary: filtered.droppedStationary,
19375
19439
  enqueued,
19376
19440
  ...mosaic.bytes > 0 ? { mosaicBytes: mosaic.bytes } : {},
@@ -19391,6 +19455,54 @@ var NcSummaryProducer = class {
19391
19455
  };
19392
19456
  }
19393
19457
  }
19458
+ /**
19459
+ * Fold each session summary's surviving members into one entry — or do
19460
+ * nothing at all, which is what `tracks` (the default) means.
19461
+ *
19462
+ * A failure to read the groups does NOT fail the digest: it falls back to
19463
+ * subjects and says so. A digest of four lines instead of one is a worse
19464
+ * reading, not a lost one, and losing the night's record over a store read
19465
+ * is the wrong trade.
19466
+ */
19467
+ async collapseIfGrouping(rule, window, kept) {
19468
+ if (rule.subject !== "groups") return {
19469
+ kept,
19470
+ collapsed: 0
19471
+ };
19472
+ const listGroups = this.deps.listSummaryGroups;
19473
+ if (listGroups === void 0) {
19474
+ this.deps.logger.warn("summary rule asks for groups but this node cannot read them", { meta: { ruleId: rule.id } });
19475
+ return {
19476
+ kept,
19477
+ collapsed: 0
19478
+ };
19479
+ }
19480
+ const groups = [];
19481
+ for (const deviceId of rule.deviceIds) try {
19482
+ groups.push(...await listGroups({
19483
+ deviceId,
19484
+ sinceMs: window.startMs,
19485
+ untilMs: window.endMs
19486
+ }));
19487
+ } catch (err) {
19488
+ this.deps.logger.warn("summary could not read a camera’s groups — read as subjects", {
19489
+ tags: { deviceId },
19490
+ meta: {
19491
+ ruleId: rule.id,
19492
+ error: String(err)
19493
+ }
19494
+ });
19495
+ }
19496
+ const outcome = collapseToGroups(kept, groups);
19497
+ this.deps.logger.info("summary read as groups", { meta: {
19498
+ ruleId: rule.id,
19499
+ groups: groups.length,
19500
+ subjects: kept.length,
19501
+ lines: outcome.kept.length,
19502
+ collapsed: outcome.collapsed
19503
+ } });
19504
+ return outcome;
19505
+ }
19394
19506
  /** Every camera's tracks in the window, capped per camera and counted. */
19395
19507
  async collectCandidates(rule, window) {
19396
19508
  const all = [];
@@ -19440,6 +19552,25 @@ var NcSummaryProducer = class {
19440
19552
  seenIds.add(candidate.trackId);
19441
19553
  const tags = { deviceId: candidate.deviceId };
19442
19554
  try {
19555
+ const groupShot = candidate.groupShotMediaId;
19556
+ if (groupShot !== void 0) {
19557
+ const jpeg = await this.deps.readMedia(groupShot);
19558
+ if (jpeg !== null && jpeg.byteLength > 0) {
19559
+ tiles.push({
19560
+ candidate,
19561
+ jpeg
19562
+ });
19563
+ continue;
19564
+ }
19565
+ this.deps.logger.info("summary tile: group shot unreadable — using a member", {
19566
+ tags,
19567
+ meta: {
19568
+ ruleId: input.rule.id,
19569
+ trackId: candidate.trackId,
19570
+ mediaId: groupShot
19571
+ }
19572
+ });
19573
+ }
19443
19574
  const rows = await this.deps.listMediaInfo(candidate.trackId);
19444
19575
  const picked = pickSummaryMedia(rows, (candidate.firstSeen + candidate.lastSeen) / 2);
19445
19576
  if (picked === null) {
@@ -19902,6 +20033,21 @@ var NcSummaryRuleInputSchema = require_dist.object({
19902
20033
  deviceIds: DeviceIdsField,
19903
20034
  window: NcSummaryWindowSchema,
19904
20035
  filters: NcSummaryFiltersSchema.optional(),
20036
+ /**
20037
+ * What ONE line of this digest is: a subject, or a group of subjects that
20038
+ * arrived together.
20039
+ *
20040
+ * `tracks` is what the digest has always done and stays the default — the
20041
+ * operator's instruction when groups were added was that the first behaviour
20042
+ * remains. `groups` collapses the members of each session summary to one
20043
+ * entry, so a family walking in is one line and not four
20044
+ * (`summary-group-collapse.ts`).
20045
+ *
20046
+ * `.default('tracks')` is safe here where `NcSummaryFiltersSchema` fields are
20047
+ * not: this is a field of the RULE, not of the partial `filters` object that
20048
+ * a patch materialises wholesale.
20049
+ */
20050
+ subject: require_dist._enum(["tracks", "groups"]).default("tracks"),
19905
20051
  maxTiles: MaxTilesField.default(12),
19906
20052
  maxAiImages: MaxAiImagesField.default(6),
19907
20053
  rank: NcSummaryRankSchema.default("importance"),
@@ -19936,6 +20082,7 @@ var NcSummaryRulePatchSchema = require_dist.object({
19936
20082
  maxTiles: MaxTilesField.optional(),
19937
20083
  maxAiImages: MaxAiImagesField.optional(),
19938
20084
  rank: NcSummaryRankSchema.optional(),
20085
+ subject: require_dist._enum(["tracks", "groups"]).optional(),
19939
20086
  barrierSeconds: BarrierSecondsField.optional(),
19940
20087
  targets: TargetsField.optional(),
19941
20088
  template: NcSummaryTemplateSchema.nullable().optional(),
@@ -61329,15 +61476,48 @@ var SUMMARY_MEMBERS_COLUMNS = [
61329
61476
  {
61330
61477
  name: "className",
61331
61478
  type: "TEXT"
61479
+ }),
61480
+ (
61481
+ /**
61482
+ * The member's tier-1 label (`dog`, `van`) and tier-2 instance (a name, a
61483
+ * plate), STAMPED AT SEAL.
61484
+ *
61485
+ * Not at the join, and the timing is the whole point: recognition lands
61486
+ * during a track's life, almost always after it joined, so a label written at
61487
+ * the join would be empty on exactly the members worth naming. At the seal
61488
+ * every member's track has closed, so its label is final — there is nothing
61489
+ * left to change it.
61490
+ *
61491
+ * They are COLUMNS because a filter has to be able to ask for them: "show me
61492
+ * the arrivals with a dog" is a query over members, and answering it from the
61493
+ * tracks would mean reading every track of every summary in the window.
61494
+ */
61495
+ {
61496
+ name: "label",
61497
+ type: "TEXT"
61498
+ }),
61499
+ {
61500
+ name: "subLabel",
61501
+ type: "TEXT"
61502
+ }
61503
+ ];
61504
+ var SUMMARY_MEMBERS_INDEXES = [
61505
+ {
61506
+ name: "summary_members_summary",
61507
+ columns: ["summaryId"]
61508
+ },
61509
+ {
61510
+ name: "summary_members_track",
61511
+ columns: ["trackId"]
61512
+ },
61513
+ (
61514
+ /** The filter's own index: "which groups hold an `animal`" walks members by
61515
+ * class, never summaries by hope. */
61516
+ {
61517
+ name: "summary_members_class",
61518
+ columns: ["className"]
61332
61519
  })
61333
61520
  ];
61334
- var SUMMARY_MEMBERS_INDEXES = [{
61335
- name: "summary_members_summary",
61336
- columns: ["summaryId"]
61337
- }, {
61338
- name: "summary_members_track",
61339
- columns: ["trackId"]
61340
- }];
61341
61521
  /**
61342
61522
  * A stored role, or `null`. An unrecognised value reads as `null` rather than
61343
61523
  * being passed through: a consumer switching on this must not receive a string
@@ -61347,6 +61527,16 @@ var SUMMARY_MEMBERS_INDEXES = [{
61347
61527
  function readRole(raw) {
61348
61528
  return raw === "primary" || raw === "sibling" || raw === "child" ? raw : null;
61349
61529
  }
61530
+ /** The two stamped labels, present only when they hold something. An empty
61531
+ * string is not a label and must never reach a filter as one. */
61532
+ function labelFields(data) {
61533
+ const label = data["label"];
61534
+ const subLabel = data["subLabel"];
61535
+ return {
61536
+ ...typeof label === "string" && label.length > 0 ? { label } : {},
61537
+ ...typeof subLabel === "string" && subLabel.length > 0 ? { subLabel } : {}
61538
+ };
61539
+ }
61350
61540
  function readClassName(raw) {
61351
61541
  return typeof raw === "string" && raw.length > 0 ? raw : null;
61352
61542
  }
@@ -61360,6 +61550,22 @@ function readClassName(raw) {
61360
61550
  * case on a gate camera, not a race. With the track in the key a retry still
61361
61551
  * collapses (same birth, same track) and two births stay two groups.
61362
61552
  */
61553
+ /** The widest a JS Date can name — the open end of a half-bounded window. */
61554
+ var TIME_MIN = -864e13;
61555
+ var TIME_MAX = 864e13;
61556
+ /**
61557
+ * How many rows one paged read may materialise before the merge.
61558
+ *
61559
+ * The page is cut AFTER ordering, so the read must be able to serve any cursor
61560
+ * position — but it must also be bounded, because an unbounded read of this
61561
+ * table on a busy hub is the shape D447 measured holding the settings thread
61562
+ * for seconds. Summaries are far fewer than tracks by construction (14 940
61563
+ * against 30 583 on this hub), so this ceiling is generous.
61564
+ */
61565
+ var RECENT_SUMMARY_SCAN_LIMIT = 5e3;
61566
+ /** How many member rows one filter pass may read per tier. Bounded for the same
61567
+ * reason as the scan above, and generous: members are one row per subject. */
61568
+ var MEMBER_MATCH_SCAN_LIMIT = 2e4;
61363
61569
  function summaryId(deviceId, openedAt, foundingTrackId) {
61364
61570
  return `sum-${deviceId}-${openedAt}-${foundingTrackId}`;
61365
61571
  }
@@ -61444,7 +61650,8 @@ var SummaryStore = class {
61444
61650
  deviceId: num$1(r.data["deviceId"]),
61445
61651
  joinedAt: num$1(r.data["timestamp"]),
61446
61652
  role: readRole(r.data["role"]),
61447
- className: readClassName(r.data["className"])
61653
+ className: readClassName(r.data["className"]),
61654
+ ...labelFields(r.data)
61448
61655
  }));
61449
61656
  }
61450
61657
  /**
@@ -61632,6 +61839,23 @@ var SummaryStore = class {
61632
61839
  memberCount: leaving.length
61633
61840
  };
61634
61841
  }
61842
+ /** Stamp a member's final labels. Best-effort and loud: a missing stamp
61843
+ * costs a filter a match, never a membership. */
61844
+ async stampMemberLabels(summaryIdValue, trackId, labels) {
61845
+ try {
61846
+ await this.store.update.mutate({
61847
+ collection: SUMMARY_MEMBERS_COLLECTION$1,
61848
+ id: memberRowId(summaryIdValue, trackId),
61849
+ data: { ...labels }
61850
+ });
61851
+ } catch (err) {
61852
+ this.logger.warn("summary: could not stamp a member label", { meta: {
61853
+ summaryId: summaryIdValue,
61854
+ trackId,
61855
+ error: String(err)
61856
+ } });
61857
+ }
61858
+ }
61635
61859
  /**
61636
61860
  * Put every OTHER member back to `sibling` after a new primary took the role.
61637
61861
  *
@@ -61757,6 +61981,8 @@ var SummaryStore = class {
61757
61981
  */
61758
61982
  async listRecent(input) {
61759
61983
  if (input.deviceIds.length === 0) return [];
61984
+ const matching = await this.summaryIdsMatching(input.classes);
61985
+ if (matching !== null && matching.size === 0) return [];
61760
61986
  const limit = Math.max(1, input.limit);
61761
61987
  return (await this.store.query.query({
61762
61988
  collection: SUMMARIES_COLLECTION,
@@ -61769,9 +61995,132 @@ var SummaryStore = class {
61769
61995
  },
61770
61996
  limit
61771
61997
  }
61772
- })).map((r) => toRow$1(r.id, r.data)).sort((a, b) => b.openedAt - a.openedAt).slice(0, limit);
61998
+ })).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);
61773
61999
  }
61774
62000
  /** One summary with its members, or `null`. */
62001
+ /**
62002
+ * One PAGE of summaries across a camera set, newest activity first.
62003
+ *
62004
+ * The same contract as `listRecentTracks` and for the same consumers: the
62005
+ * reel on the cameras page and the Events gallery both ride a cursor, so a
62006
+ * feed that could only answer "the last N" would make groups available in the
62007
+ * timeline and nowhere else. `recent-cursor.ts` is reused verbatim — the
62008
+ * ordering is the stable total order (`lastActiveAt` DESC, id DESC), and a
62009
+ * second implementation of that is a second place for two feeds to disagree
62010
+ * about what "newest" means.
62011
+ *
62012
+ * The window is on LAST ACTIVITY, not on the open: a session that started
62013
+ * before the window and ran into it belongs to the window, because it
62014
+ * happened there.
62015
+ *
62016
+ * Members for the WHOLE page come back in one read. One query per row is how
62017
+ * a page of two hundred becomes two hundred round trips — the shape that made
62018
+ * the old digest read two thousand tracks a camera.
62019
+ */
62020
+ async queryRecent(input) {
62021
+ const deviceIds = [...new Set(input.deviceIds)];
62022
+ if (deviceIds.length === 0) return {
62023
+ summaries: [],
62024
+ nextCursor: null
62025
+ };
62026
+ const cursor = input.cursor === void 0 ? null : decodeRecentCursor(input.cursor);
62027
+ const matching = await this.summaryIdsMatching(input.classes);
62028
+ if (matching !== null && matching.size === 0) return {
62029
+ summaries: [],
62030
+ nextCursor: null
62031
+ };
62032
+ const ordered = (await this.store.query.query({
62033
+ collection: SUMMARIES_COLLECTION,
62034
+ filter: {
62035
+ whereIn: { deviceId: deviceIds },
62036
+ ...input.since !== void 0 || input.until !== void 0 ? { whereBetween: { lastActiveAt: [input.since ?? TIME_MIN, input.until ?? TIME_MAX] } } : {},
62037
+ limit: RECENT_SUMMARY_SCAN_LIMIT
62038
+ }
62039
+ })).map((r) => toRow$1(r.id, r.data)).filter((row) => matching === null || matching.has(row.id)).map((row) => ({
62040
+ row,
62041
+ key: {
62042
+ lastSeen: row.lastActiveAt,
62043
+ id: row.id
62044
+ }
62045
+ })).filter((entry) => cursor === null || isAfterCursor(entry.key, cursor)).toSorted((a, b) => compareRecentDesc(a.key, b.key));
62046
+ const page = ordered.slice(0, input.limit);
62047
+ const last = page.at(-1);
62048
+ const nextCursor = last !== void 0 && ordered.length > page.length ? encodeRecentCursor({
62049
+ lastSeen: last.row.lastActiveAt,
62050
+ trackId: last.row.id
62051
+ }) : null;
62052
+ const members = await this.membersOfMany(page.map((e) => e.row.id));
62053
+ return {
62054
+ summaries: page.map((e) => ({
62055
+ summary: e.row,
62056
+ members: members.get(e.row.id) ?? []
62057
+ })),
62058
+ nextCursor
62059
+ };
62060
+ }
62061
+ /**
62062
+ * The summaries whose MEMBERS match any of `classes`, or `null` for no filter.
62063
+ *
62064
+ * Narrows first, on the members table, and only then reads summary rows.
62065
+ * Filtering the other way round — a page of summaries, then their members —
62066
+ * returns a short or empty page while matches sit further down the list, and
62067
+ * the operator reads that as "there were none".
62068
+ *
62069
+ * Three queries because the filter compiler has no OR across fields, and one
62070
+ * term can be any of the three tiers: `animal` is a class, `dog` is a
62071
+ * sub-class, `Roberta` is an instance. The caller does not have to know
62072
+ * which — that is the whole point of the operator's example.
62073
+ */
62074
+ async summaryIdsMatching(classes) {
62075
+ const wanted = (classes ?? []).filter((t) => t.length > 0);
62076
+ if (wanted.length === 0) return null;
62077
+ const ids = /* @__PURE__ */ new Set();
62078
+ for (const field of [
62079
+ "className",
62080
+ "label",
62081
+ "subLabel"
62082
+ ]) {
62083
+ const rows = await this.store.query.query({
62084
+ collection: SUMMARY_MEMBERS_COLLECTION$1,
62085
+ filter: {
62086
+ whereIn: { [field]: [...wanted] },
62087
+ limit: MEMBER_MATCH_SCAN_LIMIT
62088
+ }
62089
+ });
62090
+ for (const row of rows) ids.add(String(row.data["summaryId"] ?? ""));
62091
+ }
62092
+ ids.delete("");
62093
+ return ids;
62094
+ }
62095
+ /** Members of many summaries, in ONE read, grouped by summary id. */
62096
+ async membersOfMany(summaryIds) {
62097
+ const out = /* @__PURE__ */ new Map();
62098
+ if (summaryIds.length === 0) return out;
62099
+ const rows = await this.store.query.query({
62100
+ collection: SUMMARY_MEMBERS_COLLECTION$1,
62101
+ filter: {
62102
+ whereIn: { summaryId: [...summaryIds] },
62103
+ limit: 1e4
62104
+ }
62105
+ });
62106
+ for (const r of rows) {
62107
+ const summaryIdValue = String(r.data["summaryId"] ?? "");
62108
+ const member = {
62109
+ summaryId: summaryIdValue,
62110
+ trackId: String(r.data["trackId"] ?? ""),
62111
+ deviceId: num$1(r.data["deviceId"]),
62112
+ joinedAt: num$1(r.data["timestamp"]),
62113
+ role: readRole(r.data["role"]),
62114
+ className: readClassName(r.data["className"]),
62115
+ ...labelFields(r.data)
62116
+ };
62117
+ const bucket = out.get(summaryIdValue);
62118
+ if (bucket === void 0) out.set(summaryIdValue, [member]);
62119
+ else bucket.push(member);
62120
+ }
62121
+ for (const bucket of out.values()) bucket.sort((a, b) => a.joinedAt - b.joinedAt);
62122
+ return out;
62123
+ }
61775
62124
  async get(id) {
61776
62125
  const raw = await this.store.get.query({
61777
62126
  collection: SUMMARIES_COLLECTION,
@@ -72062,6 +72411,33 @@ async function persistSummaryGroupShot(deps, input) {
72062
72411
  }
72063
72412
  }
72064
72413
  //#endregion
72414
+ //#region src/pipeline-analytics/summary/summary-composition.ts
72415
+ function summaryComposition(members) {
72416
+ const counts = /* @__PURE__ */ new Map();
72417
+ for (const member of members) counts.set(member.className, (counts.get(member.className) ?? 0) + 1);
72418
+ return { classes: [...counts].map(([className, count]) => ({
72419
+ className,
72420
+ count
72421
+ })).toSorted((a, b) => a.count !== b.count ? b.count - a.count : (a.className ?? "").localeCompare(b.className ?? "")) };
72422
+ }
72423
+ //#endregion
72424
+ //#region src/pipeline-analytics/summary/summary-member-labels.ts
72425
+ function present(value) {
72426
+ return typeof value === "string" && value.length > 0;
72427
+ }
72428
+ function withMemberLabels(members, tracks) {
72429
+ const byId = new Map(tracks.map((t) => [t.trackId, t]));
72430
+ return members.map((member) => {
72431
+ const track = byId.get(member.trackId);
72432
+ return {
72433
+ ...member,
72434
+ className: member.className ?? (present(track?.className) ? track.className : null),
72435
+ ...present(track?.label) ? { label: track.label } : {},
72436
+ ...present(track?.subLabel) ? { subLabel: track.subLabel } : {}
72437
+ };
72438
+ });
72439
+ }
72440
+ //#endregion
72065
72441
  //#region src/pipeline-analytics/summary/summary-join.ts
72066
72442
  /**
72067
72443
  * Which open group a newborn track joins — or none, and it opens its own.
@@ -72407,7 +72783,21 @@ var SummarySealer = class {
72407
72783
  return null;
72408
72784
  }
72409
72785
  }
72410
- async sealIfDone(summary) {
72786
+ /**
72787
+ * Is this group finished?
72788
+ *
72789
+ * Both questions are about THIS group's members, not about the camera. A
72790
+ * camera holds as many groups as it has (D489), so a camera-wide gate means
72791
+ * no group seals until every subject on the camera has gone — and on a busy
72792
+ * camera they never all go at once. Measured: eight summaries open on one
72793
+ * camera over seventeen minutes, none sealed, each new subject opening a
72794
+ * ninth because a group whose members have closed has no positions left for
72795
+ * the proximity rule to judge.
72796
+ *
72797
+ * `null` means "could not find out", and the caller treats that as NOT done —
72798
+ * a read that failed must never seal a live session (D49).
72799
+ */
72800
+ async isGroupFinished(summary, members) {
72411
72801
  const { id: summaryId, deviceId } = summary;
72412
72802
  let openTracks;
72413
72803
  try {
@@ -72420,12 +72810,13 @@ var SummarySealer = class {
72420
72810
  error: errMsg(err)
72421
72811
  }
72422
72812
  });
72423
- return;
72813
+ return null;
72424
72814
  }
72425
- if (openTracks.length > 0) return;
72426
- let quiet;
72815
+ const stillOpen = new Set(openTracks);
72816
+ if (members.some((m) => stillOpen.has(m))) return false;
72817
+ let parked;
72427
72818
  try {
72428
- quiet = await this.deps.isCameraQuiet(deviceId);
72819
+ parked = await this.deps.parkedTrackIds(deviceId);
72429
72820
  } catch (err) {
72430
72821
  this.deps.logger.warn("summary sweep: could not read the stationary registry — seal aborted this sweep", {
72431
72822
  tags: { deviceId },
@@ -72434,10 +72825,28 @@ var SummarySealer = class {
72434
72825
  error: errMsg(err)
72435
72826
  }
72436
72827
  });
72828
+ return null;
72829
+ }
72830
+ return !members.some((m) => parked.has(m));
72831
+ }
72832
+ async sealIfDone(summary) {
72833
+ const { id: summaryId, deviceId } = summary;
72834
+ let members;
72835
+ try {
72836
+ members = (await this.deps.summaries.membersOf(summaryId)).map((m) => m.trackId);
72837
+ } catch (err) {
72838
+ this.deps.logger.warn("summary sweep: could not read members — seal aborted this sweep", {
72839
+ tags: { deviceId },
72840
+ meta: {
72841
+ summaryId,
72842
+ error: errMsg(err)
72843
+ }
72844
+ });
72437
72845
  return;
72438
72846
  }
72439
- if (!quiet) return;
72847
+ if (await this.isGroupFinished(summary, members) !== true) return;
72440
72848
  const sealedAt = this.deps.now();
72849
+ await this.stampLabels(summary, members);
72441
72850
  try {
72442
72851
  if (await this.deps.summaries.seal(summary, sealedAt)) {
72443
72852
  const members = this.forgetGroup(deviceId, summaryId);
@@ -72470,6 +72879,37 @@ var SummarySealer = class {
72470
72879
  if (members === void 0) groups.set(summaryIdValue, new Set([trackId]));
72471
72880
  else members.add(trackId);
72472
72881
  }
72882
+ /**
72883
+ * Write each member's final label onto its row.
72884
+ *
72885
+ * At the seal every member's track has closed, so what the pipeline decided
72886
+ * about it is final — this is the first and last moment the stamp can be
72887
+ * both complete and correct.
72888
+ */
72889
+ async stampLabels(summary, members) {
72890
+ const read = this.deps.labelsFor;
72891
+ if (read === void 0 || members.length === 0) return;
72892
+ try {
72893
+ const labels = await read(members);
72894
+ for (const [trackId, value] of labels) {
72895
+ const label = value.label;
72896
+ const subLabel = value.subLabel;
72897
+ if ((label === void 0 || label.length === 0) && (subLabel === void 0 || subLabel.length === 0)) continue;
72898
+ await this.deps.summaries.stampMemberLabels(summary.id, trackId, {
72899
+ ...label !== void 0 && label.length > 0 ? { label } : {},
72900
+ ...subLabel !== void 0 && subLabel.length > 0 ? { subLabel } : {}
72901
+ });
72902
+ }
72903
+ } catch (err) {
72904
+ this.deps.logger.warn("summary: could not stamp member labels — the seal stands", {
72905
+ tags: { deviceId: summary.deviceId },
72906
+ meta: {
72907
+ summaryId: summary.id,
72908
+ error: errMsg(err)
72909
+ }
72910
+ });
72911
+ }
72912
+ }
72473
72913
  /** Follows a split in the mirror, so the frame path scores each half on its
72474
72914
  * own members from the very next frame. */
72475
72915
  moveMirrorMembers(deviceId, fromId, toId, trackIds) {
@@ -74042,6 +74482,21 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
74042
74482
  await secretState.set(secret);
74043
74483
  }
74044
74484
  const shelfWindows = await this.pruneWindows();
74485
+ const index = new NcArtifactIndex({
74486
+ store: this.ctx.api.settingsStore,
74487
+ logger: this.ctx.logger.child("nc-artifacts"),
74488
+ addonId: this.ctx.id,
74489
+ retentionMs: shelfWindows.artifactShelfMs
74490
+ });
74491
+ this.ncArtifactIndex = index;
74492
+ const refusal = artifactShelfRefusal(await this.ctx.api.storage.listLocations.query({}), this.ctx.kernel.localNodeId ?? null);
74493
+ if (refusal !== null) {
74494
+ this.ctx.logger.info("nc-artifact shelf not served on this node", { meta: {
74495
+ reason: refusal,
74496
+ indexServed: true
74497
+ } });
74498
+ return;
74499
+ }
74045
74500
  const store = new NcArtifactStore({
74046
74501
  rootDir: await this.resolveNcArtifactRoot(),
74047
74502
  logger: this.ctx.logger.child("nc-artifacts"),
@@ -74049,13 +74504,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
74049
74504
  });
74050
74505
  await store.start();
74051
74506
  this.ncArtifactStore = store;
74052
- const index = new NcArtifactIndex({
74053
- store: this.ctx.api.settingsStore,
74054
- logger: this.ctx.logger.child("nc-artifacts"),
74055
- addonId: this.ctx.id,
74056
- retentionMs: store.retentionMs
74057
- });
74058
- this.ncArtifactIndex = index;
74059
74507
  const plane = new NcArtifactPlane({
74060
74508
  store,
74061
74509
  secret,
@@ -75415,7 +75863,25 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
75415
75863
  summaries: this.summaryStore,
75416
75864
  analysedDeviceIds: () => [...this.lastFrameDimsByDevice.keys()],
75417
75865
  openTrackIds: async (deviceId) => this.residents.trackIdsForDevice(deviceId),
75418
- isCameraQuiet: async (deviceId) => (this.stationaryRegistry?.count(deviceId) ?? 0) === 0,
75866
+ parkedTrackIds: async (deviceId) => {
75867
+ const ids = /* @__PURE__ */ new Set();
75868
+ for (const entry of this.stationaryRegistry?.list(deviceId) ?? []) if (entry.sourceTrackId !== void 0) ids.add(entry.sourceTrackId);
75869
+ return ids;
75870
+ },
75871
+ labelsFor: async (trackIds) => {
75872
+ const out = /* @__PURE__ */ new Map();
75873
+ const tracks = await this.trackStore?.getPersistedByTrackIds(trackIds) ?? [];
75874
+ for (const track of tracks) {
75875
+ const label = track.label;
75876
+ const subLabel = track.subLabel;
75877
+ if (label === void 0 && subLabel === void 0) continue;
75878
+ out.set(track.trackId, {
75879
+ ...label !== void 0 ? { label } : {},
75880
+ ...subLabel !== void 0 ? { subLabel } : {}
75881
+ });
75882
+ }
75883
+ return out;
75884
+ },
75419
75885
  now: () => Date.now(),
75420
75886
  logger: this.ctx.logger.child("summary-sealer"),
75421
75887
  frameSize: (deviceId) => {
@@ -76120,6 +76586,30 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
76120
76586
  confirmed: t.observations !== void 0 && t.observations >= minHits
76121
76587
  }));
76122
76588
  },
76589
+ listSummaryGroups: async ({ deviceId, sinceMs, untilMs }) => {
76590
+ const summaryStore = this.summaryStore;
76591
+ if (!summaryStore) return [];
76592
+ const rows = await summaryStore.listRecent({
76593
+ deviceIds: [deviceId],
76594
+ since: sinceMs,
76595
+ until: untilMs,
76596
+ limit: 500
76597
+ });
76598
+ const out = [];
76599
+ for (const row of rows) {
76600
+ const members = await summaryStore.membersOf(row.id);
76601
+ const files = await this.mediaStore?.listInfoByOwner("summary", row.id) ?? [];
76602
+ const shot = files.find((f) => f.kind === "keyFrameSmall") ?? files.find((f) => f.kind === "keyFrame");
76603
+ out.push({
76604
+ summaryId: row.id,
76605
+ deviceId: row.deviceId,
76606
+ memberTrackIds: members.map((m) => m.trackId),
76607
+ primaryTrackId: members.find((m) => m.role === "primary")?.trackId ?? null,
76608
+ groupShotMediaId: shot?.mediaId ?? null
76609
+ });
76610
+ }
76611
+ return out;
76612
+ },
76123
76613
  listMediaInfo: async (id) => {
76124
76614
  return (await stores.mediaStore.listInfoByOwner("track", id)).map((r) => ({
76125
76615
  mediaId: r.mediaId,
@@ -81268,23 +81758,96 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
81268
81758
  deviceIds: input.deviceIds,
81269
81759
  ...input.since !== void 0 ? { since: input.since } : {},
81270
81760
  ...input.until !== void 0 ? { until: input.until } : {},
81271
- limit: input.limit ?? 50
81761
+ limit: input.limit ?? 50,
81762
+ ...input.classes !== void 0 ? { classes: input.classes } : {}
81272
81763
  });
81273
- return { summaries: await Promise.all(rows.map((row) => this.toSummary(row))) };
81764
+ return { summaries: await this.listItemsFor(rows) };
81274
81765
  }
81275
- async getSummary(input) {
81276
- const detail = await this.summaryStore?.get(input.id);
81277
- if (!detail) return null;
81766
+ /**
81767
+ * Rows → what a LIST draws: the envelope plus the composition.
81768
+ *
81769
+ * Two bulk reads for the whole page, never per row: the members in one, their
81770
+ * tracks (for the names) in one. The MEMBERS themselves stay off the list —
81771
+ * a page of two hundred groups would otherwise carry a thousand records so
81772
+ * the client could count them. `getSummary` is where the links live.
81773
+ */
81774
+ async listItemsFor(rows) {
81775
+ const store = this.summaryStore;
81776
+ if (!store || rows.length === 0) return [];
81777
+ const members = await store.membersOfMany(rows.map((r) => r.id));
81778
+ return await Promise.all(rows.map(async (row) => ({
81779
+ summary: await this.toSummary(row),
81780
+ ...summaryComposition(members.get(row.id) ?? [])
81781
+ })));
81782
+ }
81783
+ /** The members of a page of summaries, with the labels a card can show —
81784
+ * in ONE read of the members and ONE of their tracks. */
81785
+ async labelledMembersFor(rows) {
81786
+ const out = /* @__PURE__ */ new Map();
81787
+ const store = this.summaryStore;
81788
+ if (!store || rows.length === 0) return out;
81789
+ const membersBySummary = await store.membersOfMany(rows.map((r) => r.id));
81790
+ const trackIds = [...new Set([...membersBySummary.values()].flatMap((ms) => ms.map((m) => m.trackId)))];
81791
+ let tracks = [];
81792
+ try {
81793
+ tracks = await this.trackStore?.getPersistedByTrackIds(trackIds) ?? [];
81794
+ } catch (err) {
81795
+ this.ctx.logger.warn("summary members: labels unavailable for this page", { meta: {
81796
+ summaries: rows.length,
81797
+ members: trackIds.length,
81798
+ error: require_dist.errMsg(err)
81799
+ } });
81800
+ }
81801
+ for (const row of rows) out.set(row.id, withMemberLabels(membersBySummary.get(row.id) ?? [], tracks));
81802
+ return out;
81803
+ }
81804
+ /**
81805
+ * Rows → the shape every summary surface answers with: the envelope, its
81806
+ * members, and what a thumbnail can render for each of them.
81807
+ *
81808
+ * THREE reads for the whole page, never per row: the members in one, their
81809
+ * tracks in one, and the group shots through `toSummary`. A read per member
81810
+ * is what made a sixty-group page take twenty seconds on the events feed.
81811
+ */
81812
+ /** ONE summary, with every member and the link each of them carries. */
81813
+ async detailFor(row) {
81814
+ const labelled = (await this.labelledMembersFor([row])).get(row.id) ?? [];
81278
81815
  return {
81279
- summary: await this.toSummary(detail.summary),
81280
- members: detail.members.map((m) => ({
81816
+ summary: await this.toSummary(row),
81817
+ members: labelled.map((m) => ({
81281
81818
  trackId: m.trackId,
81282
81819
  className: m.className,
81283
81820
  role: m.role,
81284
- joinedAt: m.joinedAt
81821
+ joinedAt: m.joinedAt,
81822
+ ...m.label !== void 0 ? { label: m.label } : {},
81823
+ ...m.subLabel !== void 0 ? { subLabel: m.subLabel } : {}
81285
81824
  }))
81286
81825
  };
81287
81826
  }
81827
+ async listRecentSummaries(input) {
81828
+ const store = this.summaryStore;
81829
+ if (!store) return {
81830
+ summaries: [],
81831
+ nextCursor: null
81832
+ };
81833
+ const page = await store.queryRecent({
81834
+ deviceIds: input.deviceIds,
81835
+ ...input.since !== void 0 ? { since: input.since } : {},
81836
+ ...input.until !== void 0 ? { until: input.until } : {},
81837
+ limit: input.limit,
81838
+ ...input.cursor !== void 0 ? { cursor: input.cursor } : {},
81839
+ ...input.classes !== void 0 ? { classes: input.classes } : {}
81840
+ });
81841
+ return {
81842
+ summaries: await this.listItemsFor(page.summaries.map((d) => d.summary)),
81843
+ nextCursor: page.nextCursor
81844
+ };
81845
+ }
81846
+ async getSummary(input) {
81847
+ const detail = await this.summaryStore?.get(input.id);
81848
+ if (!detail) return null;
81849
+ return await this.detailFor(detail.summary);
81850
+ }
81288
81851
  /** The row plus its group shot: the web companion when it landed, else the native key frame. */
81289
81852
  async toSummary(row) {
81290
81853
  const files = await this.mediaStore?.listInfoByOwner("summary", row.id) ?? [];