@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.
@@ -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(),
@@ -43140,6 +43287,10 @@ function trackColumnsFor(projection, needsPositions = false) {
43140
43287
  * whatever order the engine felt like and a cursor that cannot resume.
43141
43288
  */
43142
43289
  var RECENT_IDS_COLUMNS = ["lastSeen"];
43290
+ /** How many track ids one filter pass may collect per tier. Bounded for the
43291
+ * same reason every bulk read here is: an unbounded scan of this table is
43292
+ * what D447 measured holding the settings thread for seconds. */
43293
+ var TRACK_MATCH_SCAN_LIMIT = 2e4;
43143
43294
  /** Spreadable `columns` for a store query — omitted entirely for `full`. */
43144
43295
  function projectionArg(projection, needsPositions = false) {
43145
43296
  const columns = trackColumnsFor(projection, needsPositions);
@@ -44227,6 +44378,42 @@ var TrackStore = class {
44227
44378
  * current subject out from under it. The orphan audit's grace window would
44228
44379
  * hide this for an hour and then stop hiding it.
44229
44380
  */
44381
+ /**
44382
+ * Track ids whose CLASS, sub-class or instance matches any of `terms`.
44383
+ *
44384
+ * The summaries' filter asks its question here rather than on a copy: a
44385
+ * label is a fact about the TRACK, it is decided while the track lives and
44386
+ * can still be re-decided afterwards (class voting, a late face match, an
44387
+ * operator enrolling a face and renaming that person). A copy stamped onto
44388
+ * the summary would answer with whatever was true at one instant and never
44389
+ * learn — which is the whole reason this lookup exists instead.
44390
+ *
44391
+ * Three queries because the filter compiler has no OR across fields, and one
44392
+ * term can be any of the three tiers: `animal` is a class, `dog` is a
44393
+ * sub-class, `Roberta` is an instance. Bounded, like every other bulk read
44394
+ * here.
44395
+ */
44396
+ async trackIdsMatching(terms) {
44397
+ const wanted = terms.filter((t) => t.length > 0);
44398
+ if (wanted.length === 0) return [];
44399
+ const ids = /* @__PURE__ */ new Set();
44400
+ for (const field of [
44401
+ "className",
44402
+ "label",
44403
+ "subLabel"
44404
+ ]) {
44405
+ const rows = await this.store.query.query({
44406
+ collection: TRACKS_COLLECTION,
44407
+ filter: {
44408
+ whereIn: { [field]: [...wanted] },
44409
+ limit: TRACK_MATCH_SCAN_LIMIT
44410
+ },
44411
+ columns: ["id"]
44412
+ });
44413
+ for (const row of rows) ids.add(row.id);
44414
+ }
44415
+ return [...ids];
44416
+ }
44230
44417
  async existingTrackIds(trackIds) {
44231
44418
  const PAGE = 500;
44232
44419
  const found = /* @__PURE__ */ new Set();
@@ -61331,13 +61518,23 @@ var SUMMARY_MEMBERS_COLUMNS = [
61331
61518
  type: "TEXT"
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
- }];
61521
+ var SUMMARY_MEMBERS_INDEXES = [
61522
+ {
61523
+ name: "summary_members_summary",
61524
+ columns: ["summaryId"]
61525
+ },
61526
+ {
61527
+ name: "summary_members_track",
61528
+ columns: ["trackId"]
61529
+ },
61530
+ (
61531
+ /** The filter's own index: "which groups hold an `animal`" walks members by
61532
+ * class, never summaries by hope. */
61533
+ {
61534
+ name: "summary_members_class",
61535
+ columns: ["className"]
61536
+ })
61537
+ ];
61341
61538
  /**
61342
61539
  * A stored role, or `null`. An unrecognised value reads as `null` rather than
61343
61540
  * being passed through: a consumer switching on this must not receive a string
@@ -61360,6 +61557,22 @@ function readClassName(raw) {
61360
61557
  * case on a gate camera, not a race. With the track in the key a retry still
61361
61558
  * collapses (same birth, same track) and two births stay two groups.
61362
61559
  */
61560
+ /** The widest a JS Date can name — the open end of a half-bounded window. */
61561
+ var TIME_MIN = -864e13;
61562
+ var TIME_MAX = 864e13;
61563
+ /**
61564
+ * How many rows one paged read may materialise before the merge.
61565
+ *
61566
+ * The page is cut AFTER ordering, so the read must be able to serve any cursor
61567
+ * position — but it must also be bounded, because an unbounded read of this
61568
+ * table on a busy hub is the shape D447 measured holding the settings thread
61569
+ * for seconds. Summaries are far fewer than tracks by construction (14 940
61570
+ * against 30 583 on this hub), so this ceiling is generous.
61571
+ */
61572
+ var RECENT_SUMMARY_SCAN_LIMIT = 5e3;
61573
+ /** How many member rows one filter pass may read per tier. Bounded for the same
61574
+ * reason as the scan above, and generous: members are one row per subject. */
61575
+ var MEMBER_MATCH_SCAN_LIMIT = 2e4;
61363
61576
  function summaryId(deviceId, openedAt, foundingTrackId) {
61364
61577
  return `sum-${deviceId}-${openedAt}-${foundingTrackId}`;
61365
61578
  }
@@ -61386,10 +61599,12 @@ var SummaryStore = class {
61386
61599
  store;
61387
61600
  logger;
61388
61601
  onEvict;
61602
+ trackIdsMatching;
61389
61603
  constructor(deps) {
61390
61604
  this.store = deps.store;
61391
61605
  this.logger = deps.logger;
61392
61606
  this.onEvict = deps.onEvict;
61607
+ this.trackIdsMatching = deps.trackIdsMatching;
61393
61608
  }
61394
61609
  static async declare(store) {
61395
61610
  await Promise.all([store.declareCollection.mutate({
@@ -61757,6 +61972,8 @@ var SummaryStore = class {
61757
61972
  */
61758
61973
  async listRecent(input) {
61759
61974
  if (input.deviceIds.length === 0) return [];
61975
+ const matching = await this.summaryIdsMatching(input.classes);
61976
+ if (matching !== null && matching.size === 0) return [];
61760
61977
  const limit = Math.max(1, input.limit);
61761
61978
  return (await this.store.query.query({
61762
61979
  collection: SUMMARIES_COLLECTION,
@@ -61769,9 +61986,138 @@ var SummaryStore = class {
61769
61986
  },
61770
61987
  limit
61771
61988
  }
61772
- })).map((r) => toRow$1(r.id, r.data)).sort((a, b) => b.openedAt - a.openedAt).slice(0, limit);
61989
+ })).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
61990
  }
61774
61991
  /** One summary with its members, or `null`. */
61992
+ /**
61993
+ * One PAGE of summaries across a camera set, newest activity first.
61994
+ *
61995
+ * The same contract as `listRecentTracks` and for the same consumers: the
61996
+ * reel on the cameras page and the Events gallery both ride a cursor, so a
61997
+ * feed that could only answer "the last N" would make groups available in the
61998
+ * timeline and nowhere else. `recent-cursor.ts` is reused verbatim — the
61999
+ * ordering is the stable total order (`lastActiveAt` DESC, id DESC), and a
62000
+ * second implementation of that is a second place for two feeds to disagree
62001
+ * about what "newest" means.
62002
+ *
62003
+ * The window is on LAST ACTIVITY, not on the open: a session that started
62004
+ * before the window and ran into it belongs to the window, because it
62005
+ * happened there.
62006
+ *
62007
+ * Members for the WHOLE page come back in one read. One query per row is how
62008
+ * a page of two hundred becomes two hundred round trips — the shape that made
62009
+ * the old digest read two thousand tracks a camera.
62010
+ */
62011
+ async queryRecent(input) {
62012
+ const deviceIds = [...new Set(input.deviceIds)];
62013
+ if (deviceIds.length === 0) return {
62014
+ summaries: [],
62015
+ nextCursor: null
62016
+ };
62017
+ const cursor = input.cursor === void 0 ? null : decodeRecentCursor(input.cursor);
62018
+ const matching = await this.summaryIdsMatching(input.classes);
62019
+ if (matching !== null && matching.size === 0) return {
62020
+ summaries: [],
62021
+ nextCursor: null
62022
+ };
62023
+ const ordered = (await this.store.query.query({
62024
+ collection: SUMMARIES_COLLECTION,
62025
+ filter: {
62026
+ whereIn: { deviceId: deviceIds },
62027
+ ...input.since !== void 0 || input.until !== void 0 ? { whereBetween: { lastActiveAt: [input.since ?? TIME_MIN, input.until ?? TIME_MAX] } } : {},
62028
+ limit: RECENT_SUMMARY_SCAN_LIMIT
62029
+ }
62030
+ })).map((r) => toRow$1(r.id, r.data)).filter((row) => matching === null || matching.has(row.id)).map((row) => ({
62031
+ row,
62032
+ key: {
62033
+ lastSeen: row.lastActiveAt,
62034
+ id: row.id
62035
+ }
62036
+ })).filter((entry) => cursor === null || isAfterCursor(entry.key, cursor)).toSorted((a, b) => compareRecentDesc(a.key, b.key));
62037
+ const page = ordered.slice(0, input.limit);
62038
+ const last = page.at(-1);
62039
+ const nextCursor = last !== void 0 && ordered.length > page.length ? encodeRecentCursor({
62040
+ lastSeen: last.row.lastActiveAt,
62041
+ trackId: last.row.id
62042
+ }) : null;
62043
+ const members = await this.membersOfMany(page.map((e) => e.row.id));
62044
+ return {
62045
+ summaries: page.map((e) => ({
62046
+ summary: e.row,
62047
+ members: members.get(e.row.id) ?? []
62048
+ })),
62049
+ nextCursor
62050
+ };
62051
+ }
62052
+ /**
62053
+ * The summaries whose MEMBERS match any of `classes`, or `null` for no filter.
62054
+ *
62055
+ * TRACKS first, then the members that name them, then the summaries. The
62056
+ * label is a fact about the track and it is asked of the track — a summary
62057
+ * that stored its own copy would answer with whatever was true when it
62058
+ * sealed, and would never learn that a face was enrolled and a person named
62059
+ * an hour later. The operator's rule: *a summary keeps the minimum
62060
+ * information necessary*, and a duplicated label is not the minimum, it is a
62061
+ * second truth with an expiry date.
62062
+ *
62063
+ * Narrows before the page is cut. Filtering the other way round — a page of
62064
+ * summaries, then their members — returns a short or empty page while matches
62065
+ * sit further down the list, and the operator reads that as "there were none".
62066
+ */
62067
+ async summaryIdsMatching(classes) {
62068
+ const wanted = (classes ?? []).filter((t) => t.length > 0);
62069
+ if (wanted.length === 0) return null;
62070
+ const matchTracks = this.trackIdsMatching;
62071
+ if (matchTracks === void 0) {
62072
+ this.logger.warn("summary filter: no track matcher wired — refusing to answer unfiltered", { meta: { classes: wanted.length } });
62073
+ return /* @__PURE__ */ new Set();
62074
+ }
62075
+ const trackIds = await matchTracks(wanted);
62076
+ if (trackIds.length === 0) return /* @__PURE__ */ new Set();
62077
+ const ids = /* @__PURE__ */ new Set();
62078
+ const PAGE = 500;
62079
+ for (let i = 0; i < trackIds.length; i += PAGE) {
62080
+ const chunk = trackIds.slice(i, i + PAGE);
62081
+ const rows = await this.store.query.query({
62082
+ collection: SUMMARY_MEMBERS_COLLECTION$1,
62083
+ filter: {
62084
+ whereIn: { trackId: [...chunk] },
62085
+ limit: MEMBER_MATCH_SCAN_LIMIT
62086
+ }
62087
+ });
62088
+ for (const row of rows) ids.add(String(row.data["summaryId"] ?? ""));
62089
+ }
62090
+ ids.delete("");
62091
+ return ids;
62092
+ }
62093
+ /** Members of many summaries, in ONE read, grouped by summary id. */
62094
+ async membersOfMany(summaryIds) {
62095
+ const out = /* @__PURE__ */ new Map();
62096
+ if (summaryIds.length === 0) return out;
62097
+ const rows = await this.store.query.query({
62098
+ collection: SUMMARY_MEMBERS_COLLECTION$1,
62099
+ filter: {
62100
+ whereIn: { summaryId: [...summaryIds] },
62101
+ limit: 1e4
62102
+ }
62103
+ });
62104
+ for (const r of rows) {
62105
+ const summaryIdValue = String(r.data["summaryId"] ?? "");
62106
+ const member = {
62107
+ summaryId: summaryIdValue,
62108
+ trackId: String(r.data["trackId"] ?? ""),
62109
+ deviceId: num$1(r.data["deviceId"]),
62110
+ joinedAt: num$1(r.data["timestamp"]),
62111
+ role: readRole(r.data["role"]),
62112
+ className: readClassName(r.data["className"])
62113
+ };
62114
+ const bucket = out.get(summaryIdValue);
62115
+ if (bucket === void 0) out.set(summaryIdValue, [member]);
62116
+ else bucket.push(member);
62117
+ }
62118
+ for (const bucket of out.values()) bucket.sort((a, b) => a.joinedAt - b.joinedAt);
62119
+ return out;
62120
+ }
61775
62121
  async get(id) {
61776
62122
  const raw = await this.store.get.query({
61777
62123
  collection: SUMMARIES_COLLECTION,
@@ -72062,6 +72408,33 @@ async function persistSummaryGroupShot(deps, input) {
72062
72408
  }
72063
72409
  }
72064
72410
  //#endregion
72411
+ //#region src/pipeline-analytics/summary/summary-composition.ts
72412
+ function summaryComposition(members) {
72413
+ const counts = /* @__PURE__ */ new Map();
72414
+ for (const member of members) counts.set(member.className, (counts.get(member.className) ?? 0) + 1);
72415
+ return { classes: [...counts].map(([className, count]) => ({
72416
+ className,
72417
+ count
72418
+ })).toSorted((a, b) => a.count !== b.count ? b.count - a.count : (a.className ?? "").localeCompare(b.className ?? "")) };
72419
+ }
72420
+ //#endregion
72421
+ //#region src/pipeline-analytics/summary/summary-member-labels.ts
72422
+ function present(value) {
72423
+ return typeof value === "string" && value.length > 0;
72424
+ }
72425
+ function withMemberLabels(members, tracks) {
72426
+ const byId = new Map(tracks.map((t) => [t.trackId, t]));
72427
+ return members.map((member) => {
72428
+ const track = byId.get(member.trackId);
72429
+ return {
72430
+ ...member,
72431
+ className: member.className ?? (present(track?.className) ? track.className : null),
72432
+ ...present(track?.label) ? { label: track.label } : {},
72433
+ ...present(track?.subLabel) ? { subLabel: track.subLabel } : {}
72434
+ };
72435
+ });
72436
+ }
72437
+ //#endregion
72065
72438
  //#region src/pipeline-analytics/summary/summary-join.ts
72066
72439
  /**
72067
72440
  * Which open group a newborn track joins — or none, and it opens its own.
@@ -72407,7 +72780,21 @@ var SummarySealer = class {
72407
72780
  return null;
72408
72781
  }
72409
72782
  }
72410
- async sealIfDone(summary) {
72783
+ /**
72784
+ * Is this group finished?
72785
+ *
72786
+ * Both questions are about THIS group's members, not about the camera. A
72787
+ * camera holds as many groups as it has (D489), so a camera-wide gate means
72788
+ * no group seals until every subject on the camera has gone — and on a busy
72789
+ * camera they never all go at once. Measured: eight summaries open on one
72790
+ * camera over seventeen minutes, none sealed, each new subject opening a
72791
+ * ninth because a group whose members have closed has no positions left for
72792
+ * the proximity rule to judge.
72793
+ *
72794
+ * `null` means "could not find out", and the caller treats that as NOT done —
72795
+ * a read that failed must never seal a live session (D49).
72796
+ */
72797
+ async isGroupFinished(summary, members) {
72411
72798
  const { id: summaryId, deviceId } = summary;
72412
72799
  let openTracks;
72413
72800
  try {
@@ -72420,12 +72807,13 @@ var SummarySealer = class {
72420
72807
  error: errMsg(err)
72421
72808
  }
72422
72809
  });
72423
- return;
72810
+ return null;
72424
72811
  }
72425
- if (openTracks.length > 0) return;
72426
- let quiet;
72812
+ const stillOpen = new Set(openTracks);
72813
+ if (members.some((m) => stillOpen.has(m))) return false;
72814
+ let parked;
72427
72815
  try {
72428
- quiet = await this.deps.isCameraQuiet(deviceId);
72816
+ parked = await this.deps.parkedTrackIds(deviceId);
72429
72817
  } catch (err) {
72430
72818
  this.deps.logger.warn("summary sweep: could not read the stationary registry — seal aborted this sweep", {
72431
72819
  tags: { deviceId },
@@ -72434,9 +72822,26 @@ var SummarySealer = class {
72434
72822
  error: errMsg(err)
72435
72823
  }
72436
72824
  });
72825
+ return null;
72826
+ }
72827
+ return !members.some((m) => parked.has(m));
72828
+ }
72829
+ async sealIfDone(summary) {
72830
+ const { id: summaryId, deviceId } = summary;
72831
+ let members;
72832
+ try {
72833
+ members = (await this.deps.summaries.membersOf(summaryId)).map((m) => m.trackId);
72834
+ } catch (err) {
72835
+ this.deps.logger.warn("summary sweep: could not read members — seal aborted this sweep", {
72836
+ tags: { deviceId },
72837
+ meta: {
72838
+ summaryId,
72839
+ error: errMsg(err)
72840
+ }
72841
+ });
72437
72842
  return;
72438
72843
  }
72439
- if (!quiet) return;
72844
+ if (await this.isGroupFinished(summary, members) !== true) return;
72440
72845
  const sealedAt = this.deps.now();
72441
72846
  try {
72442
72847
  if (await this.deps.summaries.seal(summary, sealedAt)) {
@@ -74042,6 +74447,21 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
74042
74447
  await secretState.set(secret);
74043
74448
  }
74044
74449
  const shelfWindows = await this.pruneWindows();
74450
+ const index = new NcArtifactIndex({
74451
+ store: this.ctx.api.settingsStore,
74452
+ logger: this.ctx.logger.child("nc-artifacts"),
74453
+ addonId: this.ctx.id,
74454
+ retentionMs: shelfWindows.artifactShelfMs
74455
+ });
74456
+ this.ncArtifactIndex = index;
74457
+ const refusal = artifactShelfRefusal(await this.ctx.api.storage.listLocations.query({}), this.ctx.kernel.localNodeId ?? null);
74458
+ if (refusal !== null) {
74459
+ this.ctx.logger.info("nc-artifact shelf not served on this node", { meta: {
74460
+ reason: refusal,
74461
+ indexServed: true
74462
+ } });
74463
+ return;
74464
+ }
74045
74465
  const store = new NcArtifactStore({
74046
74466
  rootDir: await this.resolveNcArtifactRoot(),
74047
74467
  logger: this.ctx.logger.child("nc-artifacts"),
@@ -74049,13 +74469,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
74049
74469
  });
74050
74470
  await store.start();
74051
74471
  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
74472
  const plane = new NcArtifactPlane({
74060
74473
  store,
74061
74474
  secret,
@@ -75405,6 +75818,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
75405
75818
  logger: logger.child("SensorEventStore")
75406
75819
  });
75407
75820
  this.summaryStore = new SummaryStore({
75821
+ trackIdsMatching: async (terms) => await this.trackStore?.trackIdsMatching(terms) ?? [],
75408
75822
  store: api.settingsStore,
75409
75823
  logger: this.ctx.logger.child("summary"),
75410
75824
  onEvict: async (ids) => {
@@ -75415,7 +75829,11 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
75415
75829
  summaries: this.summaryStore,
75416
75830
  analysedDeviceIds: () => [...this.lastFrameDimsByDevice.keys()],
75417
75831
  openTrackIds: async (deviceId) => this.residents.trackIdsForDevice(deviceId),
75418
- isCameraQuiet: async (deviceId) => (this.stationaryRegistry?.count(deviceId) ?? 0) === 0,
75832
+ parkedTrackIds: async (deviceId) => {
75833
+ const ids = /* @__PURE__ */ new Set();
75834
+ for (const entry of this.stationaryRegistry?.list(deviceId) ?? []) if (entry.sourceTrackId !== void 0) ids.add(entry.sourceTrackId);
75835
+ return ids;
75836
+ },
75419
75837
  now: () => Date.now(),
75420
75838
  logger: this.ctx.logger.child("summary-sealer"),
75421
75839
  frameSize: (deviceId) => {
@@ -76120,6 +76538,30 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
76120
76538
  confirmed: t.observations !== void 0 && t.observations >= minHits
76121
76539
  }));
76122
76540
  },
76541
+ listSummaryGroups: async ({ deviceId, sinceMs, untilMs }) => {
76542
+ const summaryStore = this.summaryStore;
76543
+ if (!summaryStore) return [];
76544
+ const rows = await summaryStore.listRecent({
76545
+ deviceIds: [deviceId],
76546
+ since: sinceMs,
76547
+ until: untilMs,
76548
+ limit: 500
76549
+ });
76550
+ const out = [];
76551
+ for (const row of rows) {
76552
+ const members = await summaryStore.membersOf(row.id);
76553
+ const files = await this.mediaStore?.listInfoByOwner("summary", row.id) ?? [];
76554
+ const shot = files.find((f) => f.kind === "keyFrameSmall") ?? files.find((f) => f.kind === "keyFrame");
76555
+ out.push({
76556
+ summaryId: row.id,
76557
+ deviceId: row.deviceId,
76558
+ memberTrackIds: members.map((m) => m.trackId),
76559
+ primaryTrackId: members.find((m) => m.role === "primary")?.trackId ?? null,
76560
+ groupShotMediaId: shot?.mediaId ?? null
76561
+ });
76562
+ }
76563
+ return out;
76564
+ },
76123
76565
  listMediaInfo: async (id) => {
76124
76566
  return (await stores.mediaStore.listInfoByOwner("track", id)).map((r) => ({
76125
76567
  mediaId: r.mediaId,
@@ -81268,23 +81710,96 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
81268
81710
  deviceIds: input.deviceIds,
81269
81711
  ...input.since !== void 0 ? { since: input.since } : {},
81270
81712
  ...input.until !== void 0 ? { until: input.until } : {},
81271
- limit: input.limit ?? 50
81713
+ limit: input.limit ?? 50,
81714
+ ...input.classes !== void 0 ? { classes: input.classes } : {}
81272
81715
  });
81273
- return { summaries: await Promise.all(rows.map((row) => this.toSummary(row))) };
81716
+ return { summaries: await this.listItemsFor(rows) };
81274
81717
  }
81275
- async getSummary(input) {
81276
- const detail = await this.summaryStore?.get(input.id);
81277
- if (!detail) return null;
81718
+ /**
81719
+ * Rows → what a LIST draws: the envelope plus the composition.
81720
+ *
81721
+ * Two bulk reads for the whole page, never per row: the members in one, their
81722
+ * tracks (for the names) in one. The MEMBERS themselves stay off the list —
81723
+ * a page of two hundred groups would otherwise carry a thousand records so
81724
+ * the client could count them. `getSummary` is where the links live.
81725
+ */
81726
+ async listItemsFor(rows) {
81727
+ const store = this.summaryStore;
81728
+ if (!store || rows.length === 0) return [];
81729
+ const members = await store.membersOfMany(rows.map((r) => r.id));
81730
+ return await Promise.all(rows.map(async (row) => ({
81731
+ summary: await this.toSummary(row),
81732
+ ...summaryComposition(members.get(row.id) ?? [])
81733
+ })));
81734
+ }
81735
+ /** The members of a page of summaries, with the labels a card can show —
81736
+ * in ONE read of the members and ONE of their tracks. */
81737
+ async labelledMembersFor(rows) {
81738
+ const out = /* @__PURE__ */ new Map();
81739
+ const store = this.summaryStore;
81740
+ if (!store || rows.length === 0) return out;
81741
+ const membersBySummary = await store.membersOfMany(rows.map((r) => r.id));
81742
+ const trackIds = [...new Set([...membersBySummary.values()].flatMap((ms) => ms.map((m) => m.trackId)))];
81743
+ let tracks = [];
81744
+ try {
81745
+ tracks = await this.trackStore?.getPersistedByTrackIds(trackIds) ?? [];
81746
+ } catch (err) {
81747
+ this.ctx.logger.warn("summary members: labels unavailable for this page", { meta: {
81748
+ summaries: rows.length,
81749
+ members: trackIds.length,
81750
+ error: require_dist.errMsg(err)
81751
+ } });
81752
+ }
81753
+ for (const row of rows) out.set(row.id, withMemberLabels(membersBySummary.get(row.id) ?? [], tracks));
81754
+ return out;
81755
+ }
81756
+ /**
81757
+ * Rows → the shape every summary surface answers with: the envelope, its
81758
+ * members, and what a thumbnail can render for each of them.
81759
+ *
81760
+ * THREE reads for the whole page, never per row: the members in one, their
81761
+ * tracks in one, and the group shots through `toSummary`. A read per member
81762
+ * is what made a sixty-group page take twenty seconds on the events feed.
81763
+ */
81764
+ /** ONE summary, with every member and the link each of them carries. */
81765
+ async detailFor(row) {
81766
+ const labelled = (await this.labelledMembersFor([row])).get(row.id) ?? [];
81278
81767
  return {
81279
- summary: await this.toSummary(detail.summary),
81280
- members: detail.members.map((m) => ({
81768
+ summary: await this.toSummary(row),
81769
+ members: labelled.map((m) => ({
81281
81770
  trackId: m.trackId,
81282
81771
  className: m.className,
81283
81772
  role: m.role,
81284
- joinedAt: m.joinedAt
81773
+ joinedAt: m.joinedAt,
81774
+ ...m.label !== void 0 ? { label: m.label } : {},
81775
+ ...m.subLabel !== void 0 ? { subLabel: m.subLabel } : {}
81285
81776
  }))
81286
81777
  };
81287
81778
  }
81779
+ async listRecentSummaries(input) {
81780
+ const store = this.summaryStore;
81781
+ if (!store) return {
81782
+ summaries: [],
81783
+ nextCursor: null
81784
+ };
81785
+ const page = await store.queryRecent({
81786
+ deviceIds: input.deviceIds,
81787
+ ...input.since !== void 0 ? { since: input.since } : {},
81788
+ ...input.until !== void 0 ? { until: input.until } : {},
81789
+ limit: input.limit,
81790
+ ...input.cursor !== void 0 ? { cursor: input.cursor } : {},
81791
+ ...input.classes !== void 0 ? { classes: input.classes } : {}
81792
+ });
81793
+ return {
81794
+ summaries: await this.listItemsFor(page.summaries.map((d) => d.summary)),
81795
+ nextCursor: page.nextCursor
81796
+ };
81797
+ }
81798
+ async getSummary(input) {
81799
+ const detail = await this.summaryStore?.get(input.id);
81800
+ if (!detail) return null;
81801
+ return await this.detailFor(detail.summary);
81802
+ }
81288
81803
  /** The row plus its group shot: the web companion when it landed, else the native key frame. */
81289
81804
  async toSummary(row) {
81290
81805
  const files = await this.mediaStore?.listInfoByOwner("summary", row.id) ?? [];