@camstack/addon-post-analysis 1.2.297 → 1.2.299

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_clip_model_registry = require("../clip-model-registry-BTpYUJzK.js");
5
+ const require_clip_model_registry = require("../clip-model-registry-hTbsQ-z7.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_clip_model_registry.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -12,6 +12,8 @@ let node_crypto = require("node:crypto");
12
12
  let node_child_process = require("node:child_process");
13
13
  let _camstack_system_addon_utils = require("@camstack/system/addon-utils");
14
14
  let node_stream_promises = require("node:stream/promises");
15
+ let node_fs_promises = require("node:fs/promises");
16
+ node_fs_promises = require_clip_model_registry.__toESM(node_fs_promises, 1);
15
17
  let sharp = require("sharp");
16
18
  sharp = require_clip_model_registry.__toESM(sharp);
17
19
  let node_os = require("node:os");
@@ -2715,8 +2717,8 @@ var NC_ARTIFACTS_INDEXES = [
2715
2717
  }
2716
2718
  ];
2717
2719
  /** Upper bound for a half-open `whereBetween` — the store has no `>=`. */
2718
- var TIME_MAX$1 = 864e13;
2719
- var TIME_MIN$1 = -864e13;
2720
+ var TIME_MAX = 864e13;
2721
+ var TIME_MIN = -864e13;
2720
2722
  /** Prune scan ceiling — the index is bounded by the shelf it mirrors (2000
2721
2723
  * entries by default), so this only has to be comfortably above it. */
2722
2724
  var NC_ARTIFACT_PRUNE_SCAN_LIMIT = 2e4;
@@ -2956,7 +2958,7 @@ var NcArtifactIndex = class {
2956
2958
  try {
2957
2959
  const expired = await this.store.deleteWhere.mutate({
2958
2960
  collection: NC_ARTIFACTS_COLLECTION,
2959
- filter: { whereBetween: { expiresAt: [TIME_MIN$1, this.now() - 1] } }
2961
+ filter: { whereBetween: { expiresAt: [TIME_MIN, this.now() - 1] } }
2960
2962
  });
2961
2963
  removed += expired.deleted;
2962
2964
  if (keptIds === null) {
@@ -2998,8 +3000,8 @@ var NcArtifactIndex = class {
2998
3000
  whereIn["kind"] = query.kinds !== void 0 && query.kinds.length > 0 ? [...query.kinds] : [...NC_GALLERY_ARTIFACT_KINDS];
2999
3001
  if (query.deviceIds !== void 0 && query.deviceIds.length > 0) whereIn["deviceId"] = [...query.deviceIds];
3000
3002
  const whereBetween = {
3001
- capturedAt: [query.fromMs ?? TIME_MIN$1, query.toMs ?? TIME_MAX$1],
3002
- expiresAt: [this.now(), TIME_MAX$1]
3003
+ capturedAt: [query.fromMs ?? TIME_MIN, query.toMs ?? TIME_MAX],
3004
+ expiresAt: [this.now(), TIME_MAX]
3003
3005
  };
3004
3006
  return {
3005
3007
  ...query.ruleId !== void 0 ? { where: { ruleId: query.ruleId } } : {},
@@ -9980,14 +9982,25 @@ function occupancySubjectFromEdge(edge) {
9980
9982
  threshold: edge.threshold
9981
9983
  };
9982
9984
  }
9983
- /** Build the subject for a `package-event` evaluation (a persisted `package`
9984
- * object-event; `phase` derives from the event `state` at the call site). */
9985
+ /**
9986
+ * Build the subject for a `package-event` evaluation (a persisted `package`
9987
+ * object-event; `phase` derives from the event `state` at the call site).
9988
+ *
9989
+ * `observedAt` (D682, D684): a pick-up row's `timestamp` is when the departure
9990
+ * was CONCLUDED — five minutes of unseen observed time after the parcel went —
9991
+ * so the detector STORES the instant it happened on the row, and the footage is
9992
+ * cut around that (`media-anchor.ts`). Read from the row HERE, the one place
9993
+ * every path builds a package subject (live hook, boot reconcile, dry-run), so
9994
+ * none of them can anchor differently. `timestamp` keeps its meaning; a row
9995
+ * written before D684 has none, which the anchor reads as unknown, out loud.
9996
+ */
9985
9997
  function subjectFromPackageEvent(ev, phase) {
9986
9998
  return {
9987
9999
  kind: "package-event",
9988
10000
  recordId: String(ev.id),
9989
10001
  deviceId: ev.deviceId,
9990
10002
  timestamp: ev.timestamp,
10003
+ ...ev.observedAt !== void 0 ? { observedAt: ev.observedAt } : {},
9991
10004
  classNames: [ev.className],
9992
10005
  ...displayLabel(ev) !== void 0 ? { label: displayLabel(ev) } : {},
9993
10006
  ...recognitionPatch(ev.subLabelMeta),
@@ -10918,7 +10931,11 @@ function incomingFromAudioEvent(event, stillOwnerId) {
10918
10931
  })
10919
10932
  };
10920
10933
  }
10921
- /** Package object-event persist (delivered / picked-up). */
10934
+ /**
10935
+ * Package object-event persist (delivered / picked-up) — the live hook and the
10936
+ * boot reconcile alike. A pick-up's instant is read from the row
10937
+ * (`event.observedAt`, D684) inside `subjectFromPackageEvent`.
10938
+ */
10922
10939
  function incomingFromPackageEvent(event, phase) {
10923
10940
  return {
10924
10941
  subject: subjectFromPackageEvent(event, phase),
@@ -10928,7 +10945,8 @@ function incomingFromPackageEvent(event, phase) {
10928
10945
  tags: { deviceId: event.deviceId },
10929
10946
  meta: {
10930
10947
  eventId: event.id,
10931
- phase
10948
+ phase,
10949
+ ...event.observedAt !== void 0 ? { observedAt: event.observedAt } : {}
10932
10950
  }
10933
10951
  })
10934
10952
  };
@@ -15441,21 +15459,6 @@ var NcSummaryRuleInputSchema = require_clip_model_registry.object({
15441
15459
  deviceIds: DeviceIdsField,
15442
15460
  window: NcSummaryWindowSchema,
15443
15461
  filters: NcSummaryFiltersSchema.optional(),
15444
- /**
15445
- * What ONE line of this digest is: a subject, or a group of subjects that
15446
- * arrived together.
15447
- *
15448
- * `tracks` is what the digest has always done and stays the default — the
15449
- * operator's instruction when groups were added was that the first behaviour
15450
- * remains. `groups` collapses the members of each session summary to one
15451
- * entry, so a family walking in is one line and not four
15452
- * (`summary-group-collapse.ts`).
15453
- *
15454
- * `.default('tracks')` is safe here where `NcSummaryFiltersSchema` fields are
15455
- * not: this is a field of the RULE, not of the partial `filters` object that
15456
- * a patch materialises wholesale.
15457
- */
15458
- subject: require_clip_model_registry._enum(["tracks", "groups"]).default("tracks"),
15459
15462
  maxTiles: MaxTilesField.default(12),
15460
15463
  maxAiImages: MaxAiImagesField.default(6),
15461
15464
  rank: NcSummaryRankSchema.default("importance"),
@@ -15490,7 +15493,6 @@ var NcSummaryRulePatchSchema = require_clip_model_registry.object({
15490
15493
  maxTiles: MaxTilesField.optional(),
15491
15494
  maxAiImages: MaxAiImagesField.optional(),
15492
15495
  rank: NcSummaryRankSchema.optional(),
15493
- subject: require_clip_model_registry._enum(["tracks", "groups"]).optional(),
15494
15496
  barrierSeconds: BarrierSecondsField.optional(),
15495
15497
  targets: TargetsField.optional(),
15496
15498
  template: NcSummaryTemplateSchema.nullable().optional(),
@@ -19352,9 +19354,14 @@ function mediaAnchor(subject) {
19352
19354
  lagMs: subject.timestamp - observedAt
19353
19355
  };
19354
19356
  }
19357
+ function selfDatingKindOf(subject) {
19358
+ if (subject.occupancy !== void 0) return "occupancy";
19359
+ if (subject.packagePhase === "picked-up") return "package pick-up";
19360
+ return null;
19361
+ }
19355
19362
  /**
19356
19363
  * True when this anchor is the conclusion instant AND the subject was one that
19357
- * should have been able to date itself — today, an occupancy row.
19364
+ * should have been able to date itself — see {@link selfDatingKindOf}.
19358
19365
  *
19359
19366
  * Split from {@link mediaAnchor} because the anchor must stay pure of any
19360
19367
  * notion of subject KIND, while the log line that matters is exactly the one
@@ -20458,7 +20465,8 @@ var NcDispatcher = class {
20458
20465
  };
20459
20466
  const preRollSec = entry.payload.mediaClipPreRollSec ?? 4;
20460
20467
  const anchor = mediaAnchor(entry.payload.subject);
20461
- if (anchorFellBack(anchor, entry.payload.subject.occupancy !== void 0)) this.deps.logger.warn("occupancy footage anchored on the conclusion — moment unknown", {
20468
+ const selfDating = selfDatingKindOf(entry.payload.subject);
20469
+ if (anchorFellBack(anchor, selfDating !== null)) this.deps.logger.warn(`${selfDating ?? "occupancy"} footage anchored on the conclusion — moment unknown`, {
20462
20470
  tags: { deviceId: entry.payload.subject.deviceId },
20463
20471
  meta: {
20464
20472
  ruleId: entry.ruleId,
@@ -20486,7 +20494,7 @@ var NcDispatcher = class {
20486
20494
  preRollSec,
20487
20495
  retentionSec: this.ringRetentionSec(entry.payload.subject.deviceId),
20488
20496
  wanted: [...wantGif ? ["gif"] : [], ...wantMp4 ? ["video"] : []],
20489
- next: this.deps.cutRecordedClip !== void 0 ? "recorded-clip-follow-up" : "no-clip"
20497
+ next: this.recordedFollowUp(entry).kinds.length > 0 ? "recorded-clip-follow-up" : "no-clip"
20490
20498
  }
20491
20499
  });
20492
20500
  return {
@@ -20540,6 +20548,13 @@ var NcDispatcher = class {
20540
20548
  * alert for a complete one, which is precisely the wrong trade for the
20541
20549
  * notification that tells an operator someone is at the door.
20542
20550
  *
20551
+ * A GIF rule is served here too (D682): a package pick-up is concluded five
20552
+ * minutes after it happened, so a gif anchored on the pick-up is always
20553
+ * outside the ring. The gif is derived from THIS cut's bytes
20554
+ * (`deriveGifFromClip`) — one cut, never a second renderer — and a moment the
20555
+ * recordings cannot serve ships NO gif, by named reason, rather than a gif of
20556
+ * the wrong time.
20557
+ *
20543
20558
  * So the alert's latency is UNCHANGED, and **the clip is attached later**, as
20544
20559
  * a follow-up send to the same target carrying the same `tag` — a phone
20545
20560
  * collapses the two, so what the operator sees is the notification gaining
@@ -20562,7 +20577,9 @@ var NcDispatcher = class {
20562
20577
  */
20563
20578
  startRecordedFootage(entry, target, notification) {
20564
20579
  const cut = this.deps.cutRecordedClip;
20565
- if (cut === void 0 || entry.payload.mediaClip !== true) return;
20580
+ const plan = this.recordedFollowUp(entry);
20581
+ const wanted = plan.kinds;
20582
+ if (cut === void 0 || wanted.length === 0 && plan.gifRefused === void 0 && plan.videoRefused === void 0) return;
20566
20583
  const preRollSec = entry.payload.mediaClipPreRollSec ?? 4;
20567
20584
  const anchor = mediaAnchor(entry.payload.subject);
20568
20585
  if (footageSourceFor({
@@ -20572,34 +20589,70 @@ var NcDispatcher = class {
20572
20589
  ringRetentionSec: this.ringRetentionSec(entry.payload.subject.deviceId)
20573
20590
  }) !== "recordings") return;
20574
20591
  const deviceId = entry.payload.subject.deviceId;
20592
+ if (plan.gifRefused !== void 0) this.deps.logger.info("recorded gif follow-up: none — the gif is not cut from the recordings", {
20593
+ tags: { deviceId },
20594
+ meta: {
20595
+ ruleId: entry.ruleId,
20596
+ eventId: entry.recordId,
20597
+ targetId: target.id,
20598
+ reason: plan.gifRefused,
20599
+ anchoredOn: anchor.source,
20600
+ ...anchor.fallback !== void 0 ? { anchorFallback: anchor.fallback } : {},
20601
+ next: wanted
20602
+ }
20603
+ });
20604
+ if (plan.videoRefused !== void 0) this.deps.logger.info("recorded video follow-up: none — the video is not cut from the recordings", {
20605
+ tags: { deviceId },
20606
+ meta: {
20607
+ ruleId: entry.ruleId,
20608
+ eventId: entry.recordId,
20609
+ targetId: target.id,
20610
+ reason: plan.videoRefused,
20611
+ anchoredOn: anchor.source,
20612
+ ...anchor.fallback !== void 0 ? { anchorFallback: anchor.fallback } : {},
20613
+ next: wanted
20614
+ }
20615
+ });
20616
+ if (wanted.length === 0) return;
20617
+ const videoSpeed = entry.payload.mediaClipSpeed;
20575
20618
  const pending = (async () => {
20576
20619
  const result = await cut({
20577
20620
  deviceId,
20578
20621
  aroundMs: anchor.atMs,
20579
20622
  preRollSec,
20580
20623
  postRollSec: entry.payload.mediaClipPostRollSec ?? 6,
20581
- ...entry.payload.mediaClipSpeed !== void 0 ? { speed: entry.payload.mediaClipSpeed } : {},
20624
+ ...videoSpeed !== void 0 ? { speed: videoSpeed } : {},
20582
20625
  ruleId: entry.ruleId,
20583
20626
  recordId: entry.recordId
20584
20627
  });
20585
20628
  if (!result.ok) {
20586
- this.deps.logger.info("recorded clip follow-up: none — this delivery keeps no clip", {
20629
+ this.deps.logger.info("recorded footage follow-up: none — the recorded cut failed", {
20587
20630
  tags: { deviceId },
20588
20631
  meta: {
20589
20632
  ruleId: entry.ruleId,
20590
20633
  eventId: entry.recordId,
20591
20634
  targetId: target.id,
20592
- reason: result.reason
20635
+ reason: result.reason,
20636
+ wanted
20593
20637
  }
20594
20638
  });
20595
20639
  return;
20596
20640
  }
20597
- const withUrls = await this.withArtifactUrls(entry, [{
20598
- mediaType: "video",
20599
- bytes: result.mp4,
20600
- mime: "video/mp4",
20601
- name: "event.mp4"
20602
- }]);
20641
+ const attachments = await this.recordedAttachments(entry, wanted, result.mp4);
20642
+ if (attachments.length === 0) {
20643
+ this.deps.logger.info("recorded clip follow-up: none — the gif could not be derived", {
20644
+ tags: { deviceId },
20645
+ meta: {
20646
+ ruleId: entry.ruleId,
20647
+ eventId: entry.recordId,
20648
+ targetId: target.id,
20649
+ reason: "gif-derive-failed",
20650
+ wanted
20651
+ }
20652
+ });
20653
+ return;
20654
+ }
20655
+ const withUrls = await this.withArtifactUrls(entry, attachments);
20603
20656
  const sent = await this.deps.send({
20604
20657
  addonId: target.addonId,
20605
20658
  targetId: target.id,
@@ -20628,6 +20681,8 @@ var NcDispatcher = class {
20628
20681
  target: target.name,
20629
20682
  profile: result.profile,
20630
20683
  bytes: result.mp4.byteLength,
20684
+ attachments: attachments.map((a) => a.mediaType),
20685
+ anchoredOn: anchor.source,
20631
20686
  lagMs: this.now() - entry.updatedAt
20632
20687
  }
20633
20688
  });
@@ -20647,6 +20702,84 @@ var NcDispatcher = class {
20647
20702
  });
20648
20703
  }
20649
20704
  /**
20705
+ * What a recorded follow-up for this row would CARRY — the one answer both
20706
+ * the refusal line (`next`) and the follow-up read, so the log cannot promise
20707
+ * a follow-up the dispatcher will not send.
20708
+ *
20709
+ * A video when the rule asked for one. A gif only for a package PICK-UP
20710
+ * whose moment is KNOWN (D682): the gif is derived from the recorded cut's
20711
+ * own bytes, the same way the broker derives it from a ring cut — one cut,
20712
+ * two containers, never a second renderer.
20713
+ *
20714
+ * Narrow on purpose. A pick-up is the one kind whose moment is ALWAYS outside
20715
+ * the ring; every other kind keeps the gif behaviour it had (the ring's, or
20716
+ * none) rather than silently gaining a recorder export per notification. And
20717
+ * a pick-up that fell back to its conclusion gets no recorded footage at all,
20718
+ * gif OR video — cutting the recordings there is exactly the doormat D682
20719
+ * removed (see {@link NcRecordedGifRefusal}, {@link NcRecordedVideoRefusal}).
20720
+ */
20721
+ recordedFollowUp(entry) {
20722
+ if (this.deps.cutRecordedClip === void 0) return { kinds: [] };
20723
+ const gifRefused = this.recordedGifRefusal(entry);
20724
+ const videoRefused = this.recordedVideoRefusal(entry);
20725
+ const kinds = [];
20726
+ if (entry.payload.mediaGif === true && gifRefused === void 0) kinds.push("gif");
20727
+ if (entry.payload.mediaClip === true && videoRefused === void 0) kinds.push("video");
20728
+ return {
20729
+ kinds,
20730
+ ...gifRefused !== void 0 ? { gifRefused } : {},
20731
+ ...videoRefused !== void 0 ? { videoRefused } : {}
20732
+ };
20733
+ }
20734
+ /** Why this row's video is not cut from the recordings, or `undefined` when
20735
+ * it is (or when the rule asked for no video). */
20736
+ recordedVideoRefusal(entry) {
20737
+ if (entry.payload.mediaClip !== true) return void 0;
20738
+ const subject = entry.payload.subject;
20739
+ if (selfDatingKindOf(subject) !== "package pick-up") return void 0;
20740
+ if (anchorFellBack(mediaAnchor(subject), true)) return "moment-unknown";
20741
+ }
20742
+ /** Why this row's gif is not cut from the recordings, or `undefined` when it
20743
+ * is (or when the rule asked for no gif). */
20744
+ recordedGifRefusal(entry) {
20745
+ if (entry.payload.mediaGif !== true) return void 0;
20746
+ if (this.deps.deriveGifFromClip === void 0) return "no-derive";
20747
+ const subject = entry.payload.subject;
20748
+ if (selfDatingKindOf(subject) !== "package pick-up") return "not-a-pick-up";
20749
+ if (anchorFellBack(mediaAnchor(subject), true)) return "moment-unknown";
20750
+ }
20751
+ /**
20752
+ * The follow-up's attachments, gif first and video second — the order the
20753
+ * ring path ships them in. A gif that could not be derived is left out (its
20754
+ * reason already logged); the video, when asked for, still ships.
20755
+ */
20756
+ async recordedAttachments(entry, wanted, mp4) {
20757
+ const out = [];
20758
+ const derive = this.deps.deriveGifFromClip;
20759
+ if (wanted.includes("gif") && derive !== void 0) {
20760
+ const gif = await derive({
20761
+ deviceId: entry.payload.subject.deviceId,
20762
+ mp4,
20763
+ ...entry.payload.mediaClipSpeed !== void 0 ? { videoSpeed: entry.payload.mediaClipSpeed } : {},
20764
+ ruleId: entry.ruleId,
20765
+ recordId: entry.recordId
20766
+ });
20767
+ if (gif.ok) out.push({
20768
+ mediaType: "gif",
20769
+ bytes: gif.gif,
20770
+ mime: "image/gif",
20771
+ name: "event.gif"
20772
+ });
20773
+ }
20774
+ if (wanted.includes("video")) out.push({
20775
+ mediaType: "video",
20776
+ bytes: mp4,
20777
+ mime: "video/mp4",
20778
+ name: "event.mp4"
20779
+ });
20780
+ return out;
20781
+ }
20782
+ /**
20650
20783
  * Settle every recorded-clip follow-up this dispatcher has in flight.
20651
20784
  *
20652
20785
  * A TEST SEAM, and the honest kind: the follow-up is detached by design, so
@@ -26299,52 +26432,6 @@ function buildSummaryOutboxInputs(input) {
26299
26432
  });
26300
26433
  }
26301
26434
  //#endregion
26302
- //#region src/notification-center/summary/summary-group-collapse.ts
26303
- function collapseToGroups(candidates, groups) {
26304
- const claim = /* @__PURE__ */ new Map();
26305
- for (const group of groups) for (const trackId of group.memberTrackIds) if (!claim.has(trackId)) claim.set(trackId, group);
26306
- const survivorsByGroup = /* @__PURE__ */ new Map();
26307
- for (const candidate of candidates) {
26308
- const group = claim.get(candidate.trackId);
26309
- if (group === void 0) continue;
26310
- const bucket = survivorsByGroup.get(group.summaryId);
26311
- if (bucket === void 0) survivorsByGroup.set(group.summaryId, [candidate]);
26312
- else bucket.push(candidate);
26313
- }
26314
- const representative = /* @__PURE__ */ new Map();
26315
- for (const [summaryId, survivors] of survivorsByGroup) {
26316
- const group = groups.find((g) => g.summaryId === summaryId);
26317
- if (group === void 0) continue;
26318
- const chosen = survivors.find((c) => c.trackId === group.primaryTrackId) ?? survivors[0];
26319
- if (chosen === void 0) continue;
26320
- representative.set(summaryId, {
26321
- ...chosen,
26322
- groupSize: survivors.length,
26323
- ...group.groupShotMediaId !== null ? { groupShotMediaId: group.groupShotMediaId } : {}
26324
- });
26325
- }
26326
- const kept = [];
26327
- let collapsed = 0;
26328
- for (const candidate of candidates) {
26329
- const group = claim.get(candidate.trackId);
26330
- if (group === void 0) {
26331
- kept.push(candidate);
26332
- continue;
26333
- }
26334
- const chosen = representative.get(group.summaryId);
26335
- if (chosen === void 0) {
26336
- kept.push(candidate);
26337
- continue;
26338
- }
26339
- if (chosen.trackId === candidate.trackId) kept.push(chosen);
26340
- else collapsed += 1;
26341
- }
26342
- return {
26343
- kept,
26344
- collapsed
26345
- };
26346
- }
26347
- //#endregion
26348
26435
  //#region src/notification-center/summary/summary-selection.ts
26349
26436
  /**
26350
26437
  * Which tracks become tiles — filter, rank, and the per-camera quota.
@@ -26938,8 +27025,7 @@ var NcSummaryProducer = class {
26938
27025
  try {
26939
27026
  const candidates = await this.collectCandidates(rule, window);
26940
27027
  const filtered = filterSummaryCandidates(candidates, rule.filters, this.deps.zoneOwner);
26941
- const grouped = await this.collapseIfGrouping(rule, window, filtered.kept);
26942
- const selection = selectSummaryTiles(grouped.kept, {
27028
+ const selection = selectSummaryTiles(filtered.kept, {
26943
27029
  maxTiles: rule.maxTiles,
26944
27030
  rank: rule.rank
26945
27031
  });
@@ -26968,7 +27054,7 @@ var NcSummaryProducer = class {
26968
27054
  const tileLoad = await this.loadTiles({
26969
27055
  rule,
26970
27056
  preferred: selection.tiles,
26971
- refill: remainingSummaryCandidates(grouped.kept, selection.tiles, rule.rank),
27057
+ refill: remainingSummaryCandidates(filtered.kept, selection.tiles, rule.rank),
26972
27058
  maxTiles: rule.maxTiles
26973
27059
  });
26974
27060
  const loaded = tileLoad.tiles;
@@ -27009,7 +27095,7 @@ var NcSummaryProducer = class {
27009
27095
  priority: rule.priority,
27010
27096
  tileCount: loaded.length,
27011
27097
  cameraCount: new Set(loaded.map((t) => t.candidate.deviceId)).size,
27012
- matchedCount: grouped.kept.length,
27098
+ matchedCount: filtered.kept.length,
27013
27099
  detections,
27014
27100
  ...mosaic.ref !== null ? { mosaic: mosaic.ref } : {},
27015
27101
  generatedAt: this.deps.now(),
@@ -27021,7 +27107,7 @@ var NcSummaryProducer = class {
27021
27107
  this.deps.logger.info("summary produced", { meta: {
27022
27108
  ...meta,
27023
27109
  scanned: candidates.length,
27024
- matched: grouped.kept.length,
27110
+ matched: filtered.kept.length,
27025
27111
  droppedStationary: filtered.droppedStationary,
27026
27112
  droppedUnconfirmed: filtered.droppedUnconfirmed,
27027
27113
  unknownConfirmation: filtered.unknownConfirmation,
@@ -27044,7 +27130,7 @@ var NcSummaryProducer = class {
27044
27130
  window,
27045
27131
  tiles: loaded.length,
27046
27132
  cameras: new Set(loaded.map((t) => t.candidate.deviceId)).size,
27047
- matched: grouped.kept.length,
27133
+ matched: filtered.kept.length,
27048
27134
  droppedStationary: filtered.droppedStationary,
27049
27135
  enqueued,
27050
27136
  ...mosaic.bytes > 0 ? { mosaicBytes: mosaic.bytes } : {},
@@ -27065,54 +27151,6 @@ var NcSummaryProducer = class {
27065
27151
  };
27066
27152
  }
27067
27153
  }
27068
- /**
27069
- * Fold each session summary's surviving members into one entry — or do
27070
- * nothing at all, which is what `tracks` (the default) means.
27071
- *
27072
- * A failure to read the groups does NOT fail the digest: it falls back to
27073
- * subjects and says so. A digest of four lines instead of one is a worse
27074
- * reading, not a lost one, and losing the night's record over a store read
27075
- * is the wrong trade.
27076
- */
27077
- async collapseIfGrouping(rule, window, kept) {
27078
- if (rule.subject !== "groups") return {
27079
- kept,
27080
- collapsed: 0
27081
- };
27082
- const listGroups = this.deps.listSummaryGroups;
27083
- if (listGroups === void 0) {
27084
- this.deps.logger.warn("summary rule asks for groups but this node cannot read them", { meta: { ruleId: rule.id } });
27085
- return {
27086
- kept,
27087
- collapsed: 0
27088
- };
27089
- }
27090
- const groups = [];
27091
- for (const deviceId of rule.deviceIds) try {
27092
- groups.push(...await listGroups({
27093
- deviceId,
27094
- sinceMs: window.startMs,
27095
- untilMs: window.endMs
27096
- }));
27097
- } catch (err) {
27098
- this.deps.logger.warn("summary could not read a camera’s groups — read as subjects", {
27099
- tags: { deviceId },
27100
- meta: {
27101
- ruleId: rule.id,
27102
- error: String(err)
27103
- }
27104
- });
27105
- }
27106
- const outcome = collapseToGroups(kept, groups);
27107
- this.deps.logger.info("summary read as groups", { meta: {
27108
- ruleId: rule.id,
27109
- groups: groups.length,
27110
- subjects: kept.length,
27111
- lines: outcome.kept.length,
27112
- collapsed: outcome.collapsed
27113
- } });
27114
- return outcome;
27115
- }
27116
27154
  /** Every camera's tracks in the window, capped per camera and counted. */
27117
27155
  async collectCandidates(rule, window) {
27118
27156
  const all = [];
@@ -27163,25 +27201,6 @@ var NcSummaryProducer = class {
27163
27201
  seenIds.add(candidate.trackId);
27164
27202
  const tags = { deviceId: candidate.deviceId };
27165
27203
  try {
27166
- const groupShot = candidate.groupShotMediaId;
27167
- if (groupShot !== void 0) {
27168
- const jpeg = await this.deps.readMedia(groupShot);
27169
- if (jpeg !== null && jpeg.byteLength > 0) {
27170
- tiles.push({
27171
- candidate,
27172
- jpeg
27173
- });
27174
- continue;
27175
- }
27176
- this.deps.logger.info("summary tile: group shot unreadable — using a member", {
27177
- tags,
27178
- meta: {
27179
- ruleId: input.rule.id,
27180
- trackId: candidate.trackId,
27181
- mediaId: groupShot
27182
- }
27183
- });
27184
- }
27185
27204
  const rows = await this.deps.listMediaInfo(candidate.trackId);
27186
27205
  const picked = pickSummaryMedia(rows, (candidate.firstSeen + candidate.lastSeen) / 2);
27187
27206
  if (picked === null) {
@@ -32688,6 +32707,9 @@ var NotificationCenter = class NotificationCenter {
32688
32707
  * delivered/picked-up), in the SAME moment as the durable insert. Feeds the
32689
32708
  * `package-event` trigger. Package events ARE object-event rows, so the boot
32690
32709
  * crash-gap reconcile re-covers them (routed by className in {@link reconcile}).
32710
+ *
32711
+ * When a pick-up HAPPENED is on the row (`event.observedAt`, D684), so this
32712
+ * hook and the reconcile read it from the same place.
32691
32713
  */
32692
32714
  onPackageEventPersisted(event, phase) {
32693
32715
  this.consumeEvent(incomingFromPackageEvent(event, phase));
@@ -35457,7 +35479,6 @@ var StoragePressureTracker = class {
35457
35479
  */
35458
35480
  var OWNER_KINDS = [
35459
35481
  "track",
35460
- "summary",
35461
35482
  "face",
35462
35483
  "identity",
35463
35484
  "plate",
@@ -35519,10 +35540,19 @@ function isWriterBoundedOwnerKind(value) {
35519
35540
  * `{ kind: 'retired-owner' }` and the orphan audit reclaims it under the same
35520
35541
  * grace window and ceilings as any other orphan.
35521
35542
  *
35543
+ * `'summary'` is the same case one entity later: the session summary (the
35544
+ * proximity group, D489/D490) was deleted on 2026-09-29 (D680) with
35545
+ * `pipeline-analytics:summaries` / `:summary-members`. Its group shots were
35546
+ * `summary`-owned media rows; they are reclaimed the same way.
35547
+ *
35522
35548
  * Deliberately NOT a member of {@link OWNER_KINDS}: the type is what stops a
35523
35549
  * new writer, and this list is only what the READ side must still recognise.
35524
35550
  */
35525
- var RETIRED_OWNER_KINDS = ["group", "event"];
35551
+ var RETIRED_OWNER_KINDS = [
35552
+ "group",
35553
+ "summary",
35554
+ "event"
35555
+ ];
35526
35556
  /** Is this an owner kind whose owning code is gone? See {@link RETIRED_OWNER_KINDS}. */
35527
35557
  function isRetiredMediaOwnerKind(value) {
35528
35558
  return RETIRED_OWNER_KINDS.includes(value);
@@ -42892,6 +42922,33 @@ var FaceSourceStats = class {
42892
42922
  };
42893
42923
  //#endregion
42894
42924
  //#region src/pipeline-analytics/pipeline/key-frame-capture.ts
42925
+ /**
42926
+ * Pure helpers for the per-track NATIVE-resolution `keyFrame` capture
42927
+ * (Design B — one native full-frame per track at its best-detection moment).
42928
+ *
42929
+ * ## Why this exists (the missing native keyFrame)
42930
+ *
42931
+ * `keyFrame` was historically captured ONLY inside the CLIP object-embedding
42932
+ * best path (`persistObjectEmbeddingBests`, gated by `isClipObjectEmbedding`).
42933
+ * Under the two-plane pipeline the root frame carries NO CLIP embedding (clip is
42934
+ * a per-track DETAIL served via `runDetailSubtree`, and is disabled cluster-
42935
+ * wide), so that gate was never satisfied and the native `keyFrame` was NEVER
42936
+ * produced — every stored frame stayed at the ≤640×360 detection resolution.
42937
+ *
42938
+ * The fix decouples the `keyFrame` from the clip path: it is selected on the
42939
+ * GENERAL best-frame signal (the same `bestThumbnail` decision that drives the
42940
+ * `thumbnail`) by {@link selectKeyFrameCaptures}.
42941
+ *
42942
+ * ## How the pixels arrive, since D422
42943
+ *
42944
+ * Not by handle. At the best instant the owning node PARKS the whole frame at
42945
+ * {@link KEYFRAME_NATIVE_MAX_WIDTH} (`track-park.ts` orders it; the runner
42946
+ * refuses a ≤640 `ram-fullframe` answer at park time), and `persistKeyFrames`
42947
+ * stores that parcel verbatim as `keyFrame` (`key-frame-persist.ts`). The web
42948
+ * companion is {@link deriveKeyFrameSmall} of the same bytes — never a second
42949
+ * fetch. A retrieval keyed by a track never falls back to a handle
42950
+ * (`scripts/check-parked-frame-no-handle-fallback.ts`).
42951
+ */
42895
42952
  /** Cap (px) on the width of the native KEY FRAME (full-frame native capture).
42896
42953
  * Native resolution is the point: a 4K cam (3840) resolves TRUE native (no
42897
42954
  * resize, 3840 ≤ 4096); an 8K cam is bounded to 4096 (still far past the retired
@@ -42917,47 +42974,10 @@ function selectKeyFrameCaptures(targets) {
42917
42974
  return selected;
42918
42975
  }
42919
42976
  /**
42920
- * Gate for the clean best-shot native `keyFrame` (design 2026-07-21): the keyFrame
42921
- * is the track's CLEAN native best frame and MUST NEVER be persisted from the
42922
- * ≤640 RAM fallback (`tier: 'ram-fullframe'`) — that would pin the stored keyFrame
42923
- * at detection resolution. On a `ram-fullframe` tier the caller drops the write
42924
- * and the per-frame re-fire retries a real native surface later.
42925
- *
42926
- * An ABSENT tier (`undefined`, from a pre-tier runner on a mixed-version cluster)
42927
- * is ACCEPTED exactly as before the tier field existed — back-compat, never break
42928
- * mixed-version clusters. Only an explicit `ram-fullframe` is rejected.
42929
- *
42930
- * NOTE: this gates the native `keyFrame` ONLY. The 960 boxed TIMELINE tiles keep
42931
- * accepting the ≤640 fallback (an honest low-res timeline is acceptable there).
42932
- */
42933
- function keyFrameAcceptsTier(tier) {
42934
- return tier !== "ram-fullframe";
42935
- }
42936
- /**
42937
- * Encode BOTH key-frame variants from a single decoded native full frame. No box
42938
- * is drawn (the `keyFrame` is the clean best full frame); `drawBoxedFrame([])`
42939
- * is used purely as the raw-rgb → JPEG (+ optional downscale) encoder so the two
42940
- * variants share one code path. The caller has ALREADY fetched the native frame
42941
- * at {@link KEYFRAME_NATIVE_MAX_WIDTH}, so `keyFrame` re-encodes it verbatim
42942
- * (no resize) and `keyFrameSmall` downscales the same buffer to ≤960 —
42943
- * `withoutEnlargement` never upscales a sub-960 native frame.
42944
- */
42945
- async function encodeKeyFrameVariants(native) {
42946
- return {
42947
- keyFrame: await drawBoxedFrame(native.data, native.width, native.height, [], { quality: 88 }),
42948
- keyFrameSmall: await drawBoxedFrame(native.data, native.width, native.height, [], {
42949
- quality: 88,
42950
- maxWidth: 960
42951
- })
42952
- };
42953
- }
42954
- /**
42955
42977
  * The web companion from the PARKED key frame's own JPEG (D422): the parcel is
42956
42978
  * stored verbatim as `keyFrame`, and `keyFrameSmall` is this — the same bytes
42957
42979
  * downscaled to {@link KEYFRAME_SMALL_MAX_WIDTH}. One decode, one encode,
42958
42980
  * never a second fetch; `withoutEnlargement` never upscales a sub-960 frame.
42959
- * The by-handle twin ({@link encodeKeyFrameVariants}) remains for the
42960
- * summary group shot, which still fetches a native surface by handle.
42961
42981
  */
42962
42982
  async function deriveKeyFrameSmall(keyFrameJpeg) {
42963
42983
  return (0, sharp.default)(keyFrameJpeg).resize(960, null, { withoutEnlargement: true }).jpeg({ quality: 88 }).toBuffer();
@@ -43180,7 +43200,7 @@ async function mapWithConcurrency(items, limit, mapper) {
43180
43200
  * all-signals-maxed track scores exactly 1.0 (the clamp is then a no-op) and an
43181
43201
  * all-zero track scores 0.
43182
43202
  */
43183
- var WEIGHT_CONFIDENCE$1 = .3;
43203
+ var WEIGHT_CONFIDENCE = .3;
43184
43204
  var WEIGHT_CLASS = .2;
43185
43205
  var WEIGHT_DWELL = .2;
43186
43206
  var WEIGHT_PROXIMITY = .12;
@@ -43218,7 +43238,7 @@ var ANIMAL_CLASSES = new Set([
43218
43238
  "cat"
43219
43239
  ]);
43220
43240
  /** Clamp to [0,1]. */
43221
- function clamp01$2(x) {
43241
+ function clamp01$1(x) {
43222
43242
  if (Number.isNaN(x)) return 0;
43223
43243
  if (x < 0) return 0;
43224
43244
  if (x > 1) return 1;
@@ -43252,7 +43272,7 @@ function computeImportance(input) {
43252
43272
  const terms = [
43253
43273
  {
43254
43274
  reason: "confidence",
43255
- value: WEIGHT_CONFIDENCE$1 * clamp01$2(input.peakConfidence)
43275
+ value: WEIGHT_CONFIDENCE * clamp01$1(input.peakConfidence)
43256
43276
  },
43257
43277
  {
43258
43278
  reason: "class",
@@ -43260,15 +43280,15 @@ function computeImportance(input) {
43260
43280
  },
43261
43281
  {
43262
43282
  reason: "dwell",
43263
- value: WEIGHT_DWELL * clamp01$2(input.durationMs / DWELL_FULL_MS)
43283
+ value: WEIGHT_DWELL * clamp01$1(input.durationMs / DWELL_FULL_MS)
43264
43284
  },
43265
43285
  {
43266
43286
  reason: "proximity",
43267
- value: WEIGHT_PROXIMITY * clamp01$2(input.peakBboxAreaFrac / SIZE_FULL)
43287
+ value: WEIGHT_PROXIMITY * clamp01$1(input.peakBboxAreaFrac / SIZE_FULL)
43268
43288
  },
43269
43289
  {
43270
43290
  reason: "travel",
43271
- value: WEIGHT_TRAVEL * clamp01$2(input.totalDistance / 2)
43291
+ value: WEIGHT_TRAVEL * clamp01$1(input.totalDistance / 2)
43272
43292
  },
43273
43293
  {
43274
43294
  reason: "zone",
@@ -43287,11 +43307,11 @@ function computeImportance(input) {
43287
43307
  }
43288
43308
  const identified = input.identityLabel !== void 0 && input.identityLabel.length > 0;
43289
43309
  if (input.netDisplacementFrac !== void 0 && input.pathSpanFrac !== void 0 && input.netDisplacementFrac < .02 && input.pathSpanFrac < .04 && !identified) return {
43290
- importance: clamp01$2(sum * STATIC_IMPORTANCE_MULTIPLIER),
43310
+ importance: clamp01$1(sum * STATIC_IMPORTANCE_MULTIPLIER),
43291
43311
  reason: best.reason
43292
43312
  };
43293
43313
  return {
43294
- importance: clamp01$2(sum),
43314
+ importance: clamp01$1(sum),
43295
43315
  reason: best.reason
43296
43316
  };
43297
43317
  }
@@ -46283,7 +46303,7 @@ var ClosedRowMediaGate = class {
46283
46303
  * it, so it can be loose enough to see the near misses.
46284
46304
  */
46285
46305
  var CO_MOVING_MIN_CONTAINMENT = .6;
46286
- function intersectionArea$4(a, b) {
46306
+ function intersectionArea$3(a, b) {
46287
46307
  return Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x)) * Math.max(0, Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y));
46288
46308
  }
46289
46309
  /**
@@ -46303,7 +46323,7 @@ function findCoMovingPairs(born, live, minContainment = CO_MOVING_MIN_CONTAINMEN
46303
46323
  if (o.className === b.className) continue;
46304
46324
  const areaO = o.bbox.w * o.bbox.h;
46305
46325
  if (areaO <= 0) continue;
46306
- const inter = intersectionArea$4(b.bbox, o.bbox);
46326
+ const inter = intersectionArea$3(b.bbox, o.bbox);
46307
46327
  if (inter <= 0) continue;
46308
46328
  const union = areaB + areaO - inter;
46309
46329
  const containment = inter / Math.min(areaB, areaO);
@@ -47303,7 +47323,7 @@ var LooseBindSampler = class {
47303
47323
  * diagonal = 2.085) with the same ~10% headroom the old 1.35 gave it.
47304
47324
  */
47305
47325
  var GROWTH_CENTROID_FACTOR = 2.3;
47306
- function intersectionArea$3(a, b) {
47326
+ function intersectionArea$2(a, b) {
47307
47327
  const x1 = Math.max(a.x, b.x);
47308
47328
  const y1 = Math.max(a.y, b.y);
47309
47329
  const x2 = Math.min(a.x + a.w, b.x + b.w);
@@ -47342,7 +47362,7 @@ function growthAdjacentMatch(track, det, ctx) {
47342
47362
  const larger = Math.max(trackArea, detArea);
47343
47363
  const smaller = Math.min(trackArea, detArea);
47344
47364
  if (larger / smaller < 1.5) return false;
47345
- if (intersectionArea$3(track, det) / smaller >= .5) return false;
47365
+ if (intersectionArea$2(track, det) / smaller >= .5) return false;
47346
47366
  const tc = bboxCentroid(track);
47347
47367
  const dc = bboxCentroid(det);
47348
47368
  return Math.hypot(tc.x - dc.x, tc.y - dc.y) <= GROWTH_CENTROID_FACTOR * Math.hypot(track.w, track.h);
@@ -50466,7 +50486,6 @@ var EMPTY_CASCADE_TALLY = {
50466
50486
  faces: 0,
50467
50487
  plates: 0,
50468
50488
  embeddings: 0,
50469
- summaries: 0,
50470
50489
  bytesReclaimed: 0
50471
50490
  };
50472
50491
  /** Fold a leaf store's receipts into a running tally. */
@@ -50510,13 +50529,6 @@ function addLeafCounts(tally, counts) {
50510
50529
  bytesReclaimed
50511
50530
  };
50512
50531
  break;
50513
- case "summaries":
50514
- next = {
50515
- ...next,
50516
- summaries: next.summaries + c.rows,
50517
- bytesReclaimed
50518
- };
50519
- break;
50520
50532
  }
50521
50533
  }
50522
50534
  return next;
@@ -50530,7 +50542,6 @@ function mergeCascadeTallies(a, b) {
50530
50542
  faces: a.faces + b.faces,
50531
50543
  plates: a.plates + b.plates,
50532
50544
  embeddings: a.embeddings + b.embeddings,
50533
- summaries: a.summaries + b.summaries,
50534
50545
  bytesReclaimed: a.bytesReclaimed + b.bytesReclaimed
50535
50546
  };
50536
50547
  }
@@ -50593,7 +50604,6 @@ function absentCascadeLegs(stores) {
50593
50604
  if (!stores.faceStore) absent.push("faces");
50594
50605
  if (!stores.plateStore) absent.push("plates");
50595
50606
  if (!stores.objectEmbeddingStore) absent.push("embeddings");
50596
- if (!stores.summaryStore) absent.push("summaries");
50597
50607
  return absent;
50598
50608
  }
50599
50609
  function buildAnalyticsCascadeRegistry(stores) {
@@ -50601,7 +50611,6 @@ function buildAnalyticsCascadeRegistry(stores) {
50601
50611
  if (stores.faceStore) registry.push(stores.faceStore);
50602
50612
  if (stores.plateStore) registry.push(stores.plateStore);
50603
50613
  if (stores.objectEmbeddingStore) registry.push(stores.objectEmbeddingStore);
50604
- if (stores.summaryStore) registry.push(stores.summaryStore);
50605
50614
  return registry;
50606
50615
  }
50607
50616
  /**
@@ -53198,7 +53207,9 @@ var MOTION_EVENTS_COLLECTION = "pipeline-analytics:motion-events";
53198
53207
  * @durable class=ledger owner=pipeline-analytics
53199
53208
  * write="one row per detected object per processed frame — every tracked
53200
53209
  * detection the frame processor emits, plus the appearance events and the
53201
- * synthetic package-drop events. Every row carries the trackId it belongs to."
53210
+ * synthetic package-drop events. Every row carries the trackId it belongs to.
53211
+ * A package PICK-UP row also carries observedAt, when the parcel went,
53212
+ * beside its timestamp, when the departure was concluded (D684)."
53202
53213
  * retention="TRACK-OWNED: it has no clock of its own. Rows go only when their
53203
53214
  * track goes, through EventStore.deleteByTracks — which folds in the deletion
53204
53215
  * of the event's child crops so a caller cannot take the events and leave the
@@ -53405,7 +53416,21 @@ var OBJECT_COLUMNS = [
53405
53416
  {
53406
53417
  name: "importance",
53407
53418
  type: "REAL"
53408
- }
53419
+ },
53420
+ (
53421
+ /**
53422
+ * When a package PICK-UP happened, beside `timestamp` (when it was
53423
+ * CONCLUDED, five minutes later) — see `ObjectEvent.observedAt` (D684).
53424
+ *
53425
+ * DECLARED, or the backend drops it on write (see `frameId`). Additive: the
53426
+ * collection gains the column in place (`ALTER TABLE … ADD COLUMN`), and every
53427
+ * row written before it reads SQL NULL → absent after `stripNulls`, which is
53428
+ * the truth about it: the moment is unknown. Only the package detector sets it.
53429
+ */
53430
+ {
53431
+ name: "observedAt",
53432
+ type: "INTEGER"
53433
+ })
53409
53434
  ];
53410
53435
  var AUDIO_COLUMNS = [
53411
53436
  ...COMMON_BASE_COLUMNS,
@@ -55122,10 +55147,6 @@ function trackColumnsFor(projection, needsPositions = false) {
55122
55147
  * whatever order the engine felt like and a cursor that cannot resume.
55123
55148
  */
55124
55149
  var RECENT_IDS_COLUMNS = ["lastSeen"];
55125
- /** How many track ids one filter pass may collect per tier. Bounded for the
55126
- * same reason every bulk read here is: an unbounded scan of this table is
55127
- * what D447 measured holding the settings thread for seconds. */
55128
- var TRACK_MATCH_SCAN_LIMIT = 2e4;
55129
55150
  /** Spreadable `columns` for a store query — omitted entirely for `full`. */
55130
55151
  function projectionArg(projection, needsPositions = false) {
55131
55152
  const columns = trackColumnsFor(projection, needsPositions);
@@ -56242,42 +56263,6 @@ var TrackStore = class {
56242
56263
  * current subject out from under it. The orphan audit's grace window would
56243
56264
  * hide this for an hour and then stop hiding it.
56244
56265
  */
56245
- /**
56246
- * Track ids whose CLASS, sub-class or instance matches any of `terms`.
56247
- *
56248
- * The summaries' filter asks its question here rather than on a copy: a
56249
- * label is a fact about the TRACK, it is decided while the track lives and
56250
- * can still be re-decided afterwards (class voting, a late face match, an
56251
- * operator enrolling a face and renaming that person). A copy stamped onto
56252
- * the summary would answer with whatever was true at one instant and never
56253
- * learn — which is the whole reason this lookup exists instead.
56254
- *
56255
- * Three queries because the filter compiler has no OR across fields, and one
56256
- * term can be any of the three tiers: `animal` is a class, `dog` is a
56257
- * sub-class, `Roberta` is an instance. Bounded, like every other bulk read
56258
- * here.
56259
- */
56260
- async trackIdsMatching(terms) {
56261
- const wanted = terms.filter((t) => t.length > 0);
56262
- if (wanted.length === 0) return [];
56263
- const ids = /* @__PURE__ */ new Set();
56264
- for (const field of [
56265
- "className",
56266
- "label",
56267
- "subLabel"
56268
- ]) {
56269
- const rows = await this.store.query.query({
56270
- collection: TRACKS_COLLECTION,
56271
- filter: {
56272
- whereIn: { [field]: [...wanted] },
56273
- limit: TRACK_MATCH_SCAN_LIMIT
56274
- },
56275
- columns: ["id"]
56276
- });
56277
- for (const row of rows) ids.add(row.id);
56278
- }
56279
- return [...ids];
56280
- }
56281
56266
  async existingTrackIds(trackIds) {
56282
56267
  const PAGE = 500;
56283
56268
  const found = /* @__PURE__ */ new Set();
@@ -59284,6 +59269,180 @@ function decodeBase64(base64) {
59284
59269
  }
59285
59270
  }
59286
59271
  //#endregion
59272
+ //#region src/notification-center/recorded-gif.ts
59273
+ /**
59274
+ * The notification GIF for a moment only the RECORDINGS still hold — derived
59275
+ * from the recorded clip's own bytes (D682).
59276
+ *
59277
+ * ## Why this exists
59278
+ *
59279
+ * A package pick-up is concluded five minutes after the parcel went
59280
+ * (`PACKAGE_STILLNESS_TTL_MS`). Once its footage is anchored on the pick-up
59281
+ * itself rather than on the conclusion (`media-anchor.ts`), the window is far
59282
+ * older than any clip ring (≤ 60 s, `NC_CLIP_RETENTION_MAX_SEC`), so the ring
59283
+ * refuses it. A gif-only rule then had NOTHING: the recorded follow-up served
59284
+ * only a rule that asked for a video. The honest fix for "the gif shows the
59285
+ * empty doormat" is not "no gif" — it is the gif of the pick-up, from the
59286
+ * recordings.
59287
+ *
59288
+ * ## One cut, and the gif is DERIVED from it
59289
+ *
59290
+ * The recorded clip (`recorded-clip.ts`, one `recordingExport` render) is the
59291
+ * only cut. The gif is derived from ITS bytes with the SAME filter chain the
59292
+ * broker uses on a ring cut (`gifFromMp4Args` in `@camstack/types`), so a gif
59293
+ * of an old moment and a gif of a live one look the same, and a rule that asks
59294
+ * for both a gif and a video gets two containers of ONE window — the guarantee
59295
+ * `NcEventMediaRequest` exists for.
59296
+ *
59297
+ * ## Never throws, every miss named
59298
+ *
59299
+ * `derive-failed` (ffmpeg could not produce it) and `stage-failed` (the clip
59300
+ * could not be written where ffmpeg can read it) are logged with
59301
+ * `tags: { deviceId }` (D400: a missing attachment names its reason).
59302
+ */
59303
+ /** Bound on one derive: a ~10 s window encodes in well under a second; a
59304
+ * derive that has not finished in 20 s is wedged. Same bound as the broker's. */
59305
+ var NC_RECORDED_GIF_TIMEOUT_MS = 2e4;
59306
+ /** A notification gif is a few hundred KB; this is headroom, not a budget. */
59307
+ var RECORDED_GIF_MAX_BUFFER = 32 * 1024 * 1024;
59308
+ /**
59309
+ * Derive the gif. Never throws; every `ok: false` has logged its reason.
59310
+ */
59311
+ async function deriveRecordedGif(ports, req) {
59312
+ const tags = { deviceId: req.deviceId };
59313
+ const common = {
59314
+ ruleId: req.ruleId,
59315
+ eventId: req.recordId,
59316
+ sourceBytes: req.mp4.byteLength
59317
+ };
59318
+ let file;
59319
+ try {
59320
+ file = await ports.writeTemp(req.mp4);
59321
+ } catch (err) {
59322
+ ports.logger.warn("recorded gif: could not stage the recorded clip — no gif", {
59323
+ tags,
59324
+ meta: {
59325
+ ...common,
59326
+ reason: "stage-failed",
59327
+ error: String(err)
59328
+ }
59329
+ });
59330
+ return {
59331
+ ok: false,
59332
+ reason: "stage-failed"
59333
+ };
59334
+ }
59335
+ try {
59336
+ const speed = require_clip_model_registry.gifDeriveSpeed(4, req.videoSpeed ?? 1);
59337
+ const args = require_clip_model_registry.gifFromMp4Args({
59338
+ maxWidth: 640,
59339
+ fps: 12,
59340
+ speed,
59341
+ input: file
59342
+ });
59343
+ const startedAt = Date.now();
59344
+ let ran;
59345
+ try {
59346
+ ran = await ports.run(await ports.resolveFfmpeg(), args);
59347
+ } catch (err) {
59348
+ ran = {
59349
+ ok: false,
59350
+ error: String(err)
59351
+ };
59352
+ }
59353
+ const out = ran.ok ? ran.stdout : null;
59354
+ if (out === null || out.byteLength === 0) {
59355
+ ports.logger.warn("recorded gif: the derive failed — the follow-up carries no gif", {
59356
+ tags,
59357
+ meta: {
59358
+ ...common,
59359
+ reason: "derive-failed",
59360
+ error: ran.ok ? "ffmpeg produced no bytes" : ran.error.slice(-400)
59361
+ }
59362
+ });
59363
+ return {
59364
+ ok: false,
59365
+ reason: "derive-failed"
59366
+ };
59367
+ }
59368
+ const gif = new Uint8Array(new ArrayBuffer(out.byteLength));
59369
+ gif.set(out);
59370
+ ports.logger.info("recorded gif: derived from the recorded clip", {
59371
+ tags,
59372
+ meta: {
59373
+ ...common,
59374
+ gifBytes: gif.byteLength,
59375
+ width: 640,
59376
+ fps: 12,
59377
+ speed,
59378
+ encodeMs: Date.now() - startedAt
59379
+ }
59380
+ });
59381
+ return {
59382
+ ok: true,
59383
+ gif
59384
+ };
59385
+ } finally {
59386
+ await ports.removeTemp(file).catch((err) => {
59387
+ ports.logger.warn("recorded gif: could not remove the staged clip", {
59388
+ tags,
59389
+ meta: {
59390
+ ...common,
59391
+ file,
59392
+ error: String(err)
59393
+ }
59394
+ });
59395
+ });
59396
+ }
59397
+ }
59398
+ /** The production ports: the pinned ffmpeg, a temp file, a bounded child. */
59399
+ function buildRecordedGifPorts(input) {
59400
+ return {
59401
+ logger: input.logger,
59402
+ resolveFfmpeg: input.resolveFfmpeg,
59403
+ run: (cmd, args) => new Promise((resolve) => {
59404
+ (0, node_child_process.execFile)(cmd, [...args], {
59405
+ encoding: "buffer",
59406
+ maxBuffer: RECORDED_GIF_MAX_BUFFER,
59407
+ timeout: NC_RECORDED_GIF_TIMEOUT_MS,
59408
+ killSignal: "SIGKILL"
59409
+ }, (err, stdout) => resolve(err !== null ? {
59410
+ ok: false,
59411
+ error: String(err)
59412
+ } : {
59413
+ ok: true,
59414
+ stdout
59415
+ }));
59416
+ }),
59417
+ writeTemp: (bytes) => stageTempClip(node_path.default.join(node_os.default.tmpdir(), `camstack-nc-gif-${(0, node_crypto.randomUUID)()}.mp4`), bytes, {
59418
+ write: (file, data) => node_fs_promises.default.writeFile(file, data),
59419
+ remove: (file) => node_fs_promises.default.rm(file, { force: true })
59420
+ }),
59421
+ removeTemp: (file) => node_fs_promises.default.rm(file, { force: true })
59422
+ };
59423
+ }
59424
+ /**
59425
+ * Write the clip to `file`, and on a failed write remove whatever landed.
59426
+ *
59427
+ * The path is named BEFORE the write, because a write can create the file and
59428
+ * then fail (ENOSPC half-way): the caller's `finally` only knows a path the
59429
+ * write RETURNED, so a partial `camstack-nc-gif-*.mp4` used to stay in the temp
59430
+ * directory for good. The write's error is re-thrown — the caller names it
59431
+ * (`stage-failed`, tagged per camera). A removal that ALSO fails is a real leak
59432
+ * and rides in that same error's message, so it reaches that one line rather
59433
+ * than a second, untagged one.
59434
+ */
59435
+ async function stageTempClip(file, bytes, io) {
59436
+ try {
59437
+ await io.write(file, bytes);
59438
+ return file;
59439
+ } catch (err) {
59440
+ const leaked = await io.remove(file).then(() => null, (removeErr) => String(removeErr));
59441
+ if (leaked === null) throw err;
59442
+ throw new Error(`${String(err)}; the partial file ${file} was NOT removed: ${leaked}`);
59443
+ }
59444
+ }
59445
+ //#endregion
59287
59446
  //#region src/shared/frame/crop-extractor.ts
59288
59447
  /**
59289
59448
  * The ONE reading of a normalized box against a frame: round, then clamp so
@@ -65216,7 +65375,7 @@ var RetrainAnnotationStore = class {
65216
65375
  };
65217
65376
  //#endregion
65218
65377
  //#region src/pipeline-analytics/retrain/retrain-geometry.ts
65219
- function clamp01$1(v) {
65378
+ function clamp01(v) {
65220
65379
  return v < 0 ? 0 : v > 1 ? 1 : v;
65221
65380
  }
65222
65381
  /** Is this a usable rectangle at all? A zero-area box is not a subject. */
@@ -65292,10 +65451,10 @@ function deriveZoneCropRect(bbox, zone) {
65292
65451
  function deriveSubjectCropRect(bbox, padding) {
65293
65452
  if (!isUsableRect(bbox)) return null;
65294
65453
  const pad = Number.isFinite(padding) && padding > 0 ? padding : 0;
65295
- const cropX = clamp01$1(bbox.x - bbox.w * pad);
65296
- const cropY = clamp01$1(bbox.y - bbox.h * pad);
65297
- const cropRight = clamp01$1(bbox.x + bbox.w * (1 + pad));
65298
- const cropBottom = clamp01$1(bbox.y + bbox.h * (1 + pad));
65454
+ const cropX = clamp01(bbox.x - bbox.w * pad);
65455
+ const cropY = clamp01(bbox.y - bbox.h * pad);
65456
+ const cropRight = clamp01(bbox.x + bbox.w * (1 + pad));
65457
+ const cropBottom = clamp01(bbox.y + bbox.h * (1 + pad));
65299
65458
  const cropW = cropRight - cropX;
65300
65459
  const cropH = cropBottom - cropY;
65301
65460
  if (!(cropW > 0) || !(cropH > 0)) return null;
@@ -69872,7 +70031,7 @@ function isOversizeBox(bbox, frameWidth, frameHeight) {
69872
70031
  const frame = frameWidth * frameHeight;
69873
70032
  return frame > 0 && bbox.w * bbox.h / frame >= .3;
69874
70033
  }
69875
- function intersectionArea$2(a, b) {
70034
+ function intersectionArea$1(a, b) {
69876
70035
  const ix1 = Math.max(a.x, b.x);
69877
70036
  const iy1 = Math.max(a.y, b.y);
69878
70037
  const ix2 = Math.min(a.x + a.w, b.x + b.w);
@@ -69885,7 +70044,7 @@ function detectionSwallowsEntry(det, parked) {
69885
70044
  const detArea = det.w * det.h;
69886
70045
  if (entryArea <= 0 || detArea < entryArea * 1.35) return false;
69887
70046
  if (!centroidInside(det, centroid(parked))) return false;
69888
- return intersectionArea$2(parked, det) / entryArea >= SWALLOW_MIN_COVERAGE;
70047
+ return intersectionArea$1(parked, det) / entryArea >= SWALLOW_MIN_COVERAGE;
69889
70048
  }
69890
70049
  /**
69891
70050
  * True when `det` is a FRAGMENT of the parked object — much smaller than the
@@ -69908,7 +70067,7 @@ function entrySwallowsDetection(parked, det) {
69908
70067
  const detArea = det.w * det.h;
69909
70068
  if (entryArea <= 0 || detArea <= 0) return false;
69910
70069
  if (detArea > entryArea / 1.35) return false;
69911
- return intersectionArea$2(parked, det) / detArea >= FRAGMENT_MIN_CONTAINMENT;
70070
+ return intersectionArea$1(parked, det) / detArea >= FRAGMENT_MIN_CONTAINMENT;
69912
70071
  }
69913
70072
  function parkedEntryHoldsDetection(entry, detBbox, config) {
69914
70073
  const overlap = iou(entry.bbox, detBbox);
@@ -70757,10 +70916,12 @@ var PackageDropDetector = class {
70757
70916
  pickupAt: entry.lastConfirmedAt
70758
70917
  }
70759
70918
  });
70919
+ const observedAt = collector?.observedAt ?? pickupInstantOf(entry);
70760
70920
  const draft = {
70761
70921
  kind: "object",
70762
70922
  deviceId: entry.deviceId,
70763
70923
  timestamp,
70924
+ observedAt,
70764
70925
  source: "pipeline",
70765
70926
  idempotencyKey: pickedUpKey,
70766
70927
  trackId: mediaTrackId,
@@ -70812,6 +70973,8 @@ var PackageDropDetector = class {
70812
70973
  idempotencyKey: pickedUpKey,
70813
70974
  deliveredEventId: delivered.id,
70814
70975
  mediaTrackId,
70976
+ observedAt,
70977
+ concludedLagMs: timestamp - observedAt,
70815
70978
  ...collector !== void 0 ? {
70816
70979
  collectorTrackId: collector.trackId,
70817
70980
  collectorClassName: collector.className,
@@ -70849,7 +71012,7 @@ var VEHICLE_CLASSES = [
70849
71012
  "vehicle"
70850
71013
  ];
70851
71014
  /** Area of the intersection of two boxes. `0` when they do not overlap. */
70852
- function intersectionArea$1(a, b) {
71015
+ function intersectionArea(a, b) {
70853
71016
  const left = Math.max(a.x, b.x);
70854
71017
  const right = Math.min(a.x + a.w, b.x + b.w);
70855
71018
  const top = Math.max(a.y, b.y);
@@ -70866,7 +71029,7 @@ function intersectionArea$1(a, b) {
70866
71029
  function containedFraction(person, vehicle) {
70867
71030
  const area = person.w * person.h;
70868
71031
  if (area <= 0) return 0;
70869
- return intersectionArea$1(person, vehicle) / area;
71032
+ return intersectionArea(person, vehicle) / area;
70870
71033
  }
70871
71034
  /**
70872
71035
  * Is this person box contained in any vehicle box in the same frame?
@@ -74462,895 +74625,6 @@ async function reconcileGallerySamples(deps, options) {
74462
74625
  stoppedByCeiling
74463
74626
  };
74464
74627
  }
74465
- //#endregion
74466
- //#region src/pipeline-analytics/summary/summary-membership.ts
74467
- /**
74468
- * Who belongs to a summary, and which member is its primary.
74469
- *
74470
- * ## What this corrects
74471
- *
74472
- * `openOrAbsorb` absorbed ANY track born while a summary was open on the
74473
- * camera, and `primary` went to whoever opened it. Both were measured wrong on
74474
- * the live hub on 2026-09-13: synthetic sensor tracks (`pa-synth-…`) sat inside
74475
- * summaries describing "who was here together", and a dog that walked in ahead
74476
- * of its owner outranked the person behind it.
74477
- *
74478
- * The operator's rule: "persona animale veicolo la priorità. Gli eventi extra
74479
- * sono sempre separati come eventi sintetici di sensori e pacchi."
74480
- *
74481
- * ## Why exclusion and priority live together
74482
- *
74483
- * They are the same question asked twice — "what is a subject in a group" —
74484
- * and answering it in two files is how the two drift. A class that stops being
74485
- * a member must not keep a rank, and a class that gains a rank must be a
74486
- * member.
74487
- *
74488
- * Pure, so the surprising cases are pinned rather than discovered: the dog that
74489
- * arrives first, the tie between two people, and the member whose class is not
74490
- * known yet.
74491
- */
74492
- /** The synthetic marker. `synthetic-track.ts` mints these and nothing parses it
74493
- * except this — which is the one place it must be parsed. */
74494
- var SYNTHETIC_TRACK_PREFIX = "pa-synth-";
74495
- /**
74496
- * Classes the operator keeps OUT of a group, by name.
74497
- *
74498
- * A delivered package is an event about a PLACE, not a member of something that
74499
- * arrived together — the operator named it alongside the sensors.
74500
- */
74501
- var NON_MEMBER_CLASSES = new Set(["package"]);
74502
- /**
74503
- * Is this track a member of a group at all?
74504
- *
74505
- * An unknown class is NOT a reason to refuse. The grouping is the point and the
74506
- * label is decoration, and nothing revisits this decision — a member dropped
74507
- * here is dropped for the life of the summary.
74508
- */
74509
- function isSummaryEligible(trackId, className) {
74510
- if (trackId.startsWith(SYNTHETIC_TRACK_PREFIX)) return false;
74511
- return className === null || className === void 0 || !NON_MEMBER_CLASSES.has(className);
74512
- }
74513
- /**
74514
- * The operator's order: person › animal › vehicle › everything else.
74515
- *
74516
- * Higher is more important. Everything unnamed — including a class that has not
74517
- * resolved yet — sits below all three rather than above them: promoting an
74518
- * unknown would let a momentary mis-read take the group's identity, and the
74519
- * group shot with it.
74520
- */
74521
- var CLASS_RANK = new Map([
74522
- ["person", 3],
74523
- ["animal", 2],
74524
- ["vehicle", 1]
74525
- ]);
74526
- function classPriority(className) {
74527
- if (className === null || className === void 0) return 0;
74528
- return CLASS_RANK.get(className) ?? 0;
74529
- }
74530
- /**
74531
- * Which member is the primary: highest class rank, ties broken by EARLIEST
74532
- * arrival.
74533
- *
74534
- * The tie-break matters more than it looks. Two people are equal, so without it
74535
- * the primary would flip on every join between equals — and the primary is what
74536
- * a consumer reads to name the group, so it would rename itself for no reason
74537
- * an operator could see.
74538
- *
74539
- * `null` for no members, never a fabricated id.
74540
- */
74541
- function primaryTrackId(members) {
74542
- let best = null;
74543
- for (const m of members) {
74544
- if (best === null) {
74545
- best = m;
74546
- continue;
74547
- }
74548
- const rank = classPriority(m.className);
74549
- const bestRank = classPriority(best.className);
74550
- if (rank > bestRank || rank === bestRank && m.joinedAt < best.joinedAt) best = m;
74551
- }
74552
- return best?.trackId ?? null;
74553
- }
74554
- //#endregion
74555
- //#region src/pipeline-analytics/summary/summary-store.ts
74556
- /**
74557
- * @durable class=ledger owner=pipeline-analytics
74558
- * write="one row per SUMMARY — a window of co-present tracks on one camera
74559
- * group. Opened by `openOrAbsorb` when the first member arrives, updated
74560
- * in place while the window absorbs more, and finalised by `seal` when the
74561
- * window closes. Per WINDOW, never per frame or per track: the sealed
74562
- * summary is what a notification rule reads."
74563
- * retention="age-keyed on `sealedAt`, swept with the rest of the analytics
74564
- * retention. An unsealed row belongs to a window still open and is never
74565
- * swept by age — it is closed or cascaded away with its members."
74566
- */
74567
- var SUMMARIES_COLLECTION = "pipeline-analytics:summaries";
74568
- /**
74569
- * @durable class=ledger owner=pipeline-analytics
74570
- * write="one row per (summary, track) membership, written as a window
74571
- * absorbs a track. The join that lets a sealed summary name its subjects
74572
- * without re-reading the track store."
74573
- * retention="cascaded — a member dies with its summary (`delete-track-cascade`
74574
- * counts them as leaves) or with the track it names. It carries no clock of
74575
- * its own: an age sweep here could outlive or predecease the summary the
74576
- * row only means anything inside of."
74577
- */
74578
- var SUMMARY_MEMBERS_COLLECTION$1 = "pipeline-analytics:summary-members";
74579
- var SUMMARIES_COLUMNS = [
74580
- {
74581
- name: "id",
74582
- type: "TEXT",
74583
- primaryKey: true,
74584
- notNull: true
74585
- },
74586
- {
74587
- name: "deviceId",
74588
- type: "INTEGER",
74589
- notNull: true
74590
- },
74591
- {
74592
- name: "openedAt",
74593
- type: "INTEGER",
74594
- notNull: true
74595
- },
74596
- {
74597
- name: "lastActiveAt",
74598
- type: "INTEGER",
74599
- notNull: true
74600
- },
74601
- {
74602
- name: "sealedAt",
74603
- type: "INTEGER"
74604
- },
74605
- (
74606
- /** `deviceId` while open, NULL once sealed — the unique "one open per camera". */
74607
- {
74608
- name: "openDeviceId",
74609
- type: "INTEGER"
74610
- }),
74611
- {
74612
- name: "memberCount",
74613
- type: "INTEGER",
74614
- notNull: true
74615
- }
74616
- ];
74617
- var SUMMARIES_INDEXES = [(
74618
- /**
74619
- * NOT unique since 2026-09-13. A summary is a GROUP, not a camera session, so
74620
- * one camera can hold several open at once — a family at the door while
74621
- * somebody else crosses the far end of the drive, or a group that split and
74622
- * whose halves are now two groups.
74623
- *
74624
- * The uniqueness was D359's third guarantee, and the other two survive it in
74625
- * a stricter form: an open is still idempotent, now keyed on the BIRTH
74626
- * (`deviceId`, `openedAt`, founding track) rather than on the camera and the
74627
- * millisecond — two subjects detected in one frame are two groups, not one —
74628
- * and sealing is still a conditional transition, addressed by id because
74629
- * `(openDeviceId, openedAt)` no longer names one row. Those are what stopped
74630
- * the 2026-08-24 incident of eleven sealed rows for one group; "one open per
74631
- * camera" was the part that also forbade the model the operator asked for.
74632
- *
74633
- * The index itself is create-only in this system — `declareCollection` emits
74634
- * `CREATE ... IF NOT EXISTS` and nothing anywhere drops an index — so on a
74635
- * database that already has the UNIQUE version this declaration changes
74636
- * nothing until that index is dropped by hand. Until then `openOrAbsorb`
74637
- * degrades to exactly the old behaviour (the unique violation is caught and
74638
- * the newcomer joins the winner), which is why the drop is a switch and not a
74639
- * prerequisite.
74640
- */
74641
- {
74642
- name: "summaries_open_device",
74643
- columns: ["openDeviceId"]
74644
- }), {
74645
- name: "summaries_device_opened",
74646
- columns: ["deviceId", "openedAt"]
74647
- }];
74648
- var SUMMARY_MEMBERS_COLUMNS = [
74649
- {
74650
- name: "id",
74651
- type: "TEXT",
74652
- primaryKey: true,
74653
- notNull: true
74654
- },
74655
- {
74656
- name: "summaryId",
74657
- type: "TEXT",
74658
- notNull: true
74659
- },
74660
- {
74661
- name: "trackId",
74662
- type: "TEXT",
74663
- notNull: true
74664
- },
74665
- {
74666
- name: "deviceId",
74667
- type: "INTEGER",
74668
- notNull: true
74669
- },
74670
- (
74671
- /** When the track joined — named `timestamp` so the orphan audit's declared-owner scope reads it. */
74672
- {
74673
- name: "timestamp",
74674
- type: "INTEGER",
74675
- notNull: true
74676
- }),
74677
- (
74678
- /**
74679
- * What this member IS to the summary: see {@link SummaryMemberRole}.
74680
- *
74681
- * NULLABLE, and not because a role is optional. `declareCollection` is
74682
- * additive — it ALTERs the column in — and SQLite refuses to add a `NOT NULL`
74683
- * column with no default to a table that already has rows. A default would be
74684
- * worse than null: it would stamp `primary` onto every member written before
74685
- * roles existed, including the siblings, and nothing downstream could tell
74686
- * the fabricated ones from the real ones.
74687
- */
74688
- {
74689
- name: "role",
74690
- type: "TEXT"
74691
- }),
74692
- (
74693
- /**
74694
- * The member's subject class, so "which classes were present" is answerable
74695
- * without joining `tracks`. Nullable for the same additive reason, AND
74696
- * because a birth genuinely may not know a class yet — the membership is the
74697
- * point, the label is decoration.
74698
- */
74699
- {
74700
- name: "className",
74701
- type: "TEXT"
74702
- })
74703
- ];
74704
- var SUMMARY_MEMBERS_INDEXES = [
74705
- {
74706
- name: "summary_members_summary",
74707
- columns: ["summaryId"]
74708
- },
74709
- {
74710
- name: "summary_members_track",
74711
- columns: ["trackId"]
74712
- },
74713
- (
74714
- /** The filter's own index: "which groups hold an `animal`" walks members by
74715
- * class, never summaries by hope. */
74716
- {
74717
- name: "summary_members_class",
74718
- columns: ["className"]
74719
- })
74720
- ];
74721
- /**
74722
- * A stored role, or `null`. An unrecognised value reads as `null` rather than
74723
- * being passed through: a consumer switching on this must not receive a string
74724
- * outside the union just because a row was hand-edited or written by a newer
74725
- * release.
74726
- */
74727
- function readRole(raw) {
74728
- return raw === "primary" || raw === "sibling" || raw === "child" ? raw : null;
74729
- }
74730
- function readClassName(raw) {
74731
- return typeof raw === "string" && raw.length > 0 ? raw : null;
74732
- }
74733
- /**
74734
- * The key of an open, derived from the birth that founded it.
74735
- *
74736
- * The founding TRACK is part of it, and that is the whole point: the key used
74737
- * to be `(deviceId, openedAt)`, which made an open idempotent — a retry writes
74738
- * the same row — but also made two subjects detected in the SAME frame one
74739
- * group, because they are born in the same millisecond. That is the ordinary
74740
- * case on a gate camera, not a race. With the track in the key a retry still
74741
- * collapses (same birth, same track) and two births stay two groups.
74742
- */
74743
- /** The widest a JS Date can name — the open end of a half-bounded window. */
74744
- var TIME_MIN = -864e13;
74745
- var TIME_MAX = 864e13;
74746
- /**
74747
- * How many rows one paged read may materialise before the merge.
74748
- *
74749
- * The page is cut AFTER ordering, so the read must be able to serve any cursor
74750
- * position — but it must also be bounded, because an unbounded read of this
74751
- * table on a busy hub is the shape D447 measured holding the settings thread
74752
- * for seconds. Summaries are far fewer than tracks by construction (14 940
74753
- * against 30 583 on this hub), so this ceiling is generous.
74754
- */
74755
- var RECENT_SUMMARY_SCAN_LIMIT = 5e3;
74756
- /** How many member rows one filter pass may read per tier. Bounded for the same
74757
- * reason as the scan above, and generous: members are one row per subject. */
74758
- var MEMBER_MATCH_SCAN_LIMIT = 2e4;
74759
- function summaryId(deviceId, openedAt, foundingTrackId) {
74760
- return `sum-${deviceId}-${openedAt}-${foundingTrackId}`;
74761
- }
74762
- function memberRowId(summaryIdValue, trackId) {
74763
- return `${summaryIdValue}:${trackId}`;
74764
- }
74765
- function num$1(value, fallback = 0) {
74766
- return typeof value === "number" && Number.isFinite(value) ? value : fallback;
74767
- }
74768
- function toRow$1(id, data) {
74769
- return {
74770
- id,
74771
- deviceId: num$1(data["deviceId"]),
74772
- openedAt: num$1(data["openedAt"]),
74773
- lastActiveAt: num$1(data["lastActiveAt"]),
74774
- sealedAt: typeof data["sealedAt"] === "number" ? data["sealedAt"] : null,
74775
- memberCount: num$1(data["memberCount"])
74776
- };
74777
- }
74778
- function isUniqueViolation(err) {
74779
- return err instanceof Error && /UNIQUE constraint failed/i.test(err.message);
74780
- }
74781
- var SummaryStore = class {
74782
- store;
74783
- logger;
74784
- onEvict;
74785
- trackIdsMatching;
74786
- constructor(deps) {
74787
- this.store = deps.store;
74788
- this.logger = deps.logger;
74789
- this.onEvict = deps.onEvict;
74790
- this.trackIdsMatching = deps.trackIdsMatching;
74791
- }
74792
- static async declare(store) {
74793
- await Promise.all([store.declareCollection.mutate({
74794
- collection: SUMMARIES_COLLECTION,
74795
- columns: [...SUMMARIES_COLUMNS],
74796
- indexes: [...SUMMARIES_INDEXES]
74797
- }), store.declareCollection.mutate({
74798
- collection: SUMMARY_MEMBERS_COLLECTION$1,
74799
- columns: [...SUMMARY_MEMBERS_COLUMNS],
74800
- indexes: [...SUMMARY_MEMBERS_INDEXES]
74801
- })]);
74802
- }
74803
- /** The camera's OPEN summary, from the row — `null` when none. */
74804
- async findOpen(deviceId) {
74805
- const first = (await this.store.query.query({
74806
- collection: SUMMARIES_COLLECTION,
74807
- filter: {
74808
- where: { openDeviceId: deviceId },
74809
- limit: 1
74810
- }
74811
- }))[0];
74812
- return first === void 0 ? null : toRow$1(first.id, first.data);
74813
- }
74814
- /**
74815
- * Every OPEN group, or just one camera's when `deviceId` is given.
74816
- *
74817
- * Plural per camera since 2026-09-13: a summary is a group, not a camera
74818
- * session, so "the open summary of this camera" stopped being a question with
74819
- * an answer. Ordered oldest-first, so a caller with no better rule takes the
74820
- * one that has been there longest — which is what `openOrAbsorb` does, and is
74821
- * byte-for-byte what shipped when only one could exist.
74822
- */
74823
- async listOpen(deviceId) {
74824
- return (await this.store.query.query({
74825
- collection: SUMMARIES_COLLECTION,
74826
- filter: {
74827
- where: deviceId === void 0 ? { sealedAt: null } : { openDeviceId: deviceId },
74828
- limit: 1e4
74829
- }
74830
- })).filter((r) => r.data["openDeviceId"] !== null).map((r) => toRow$1(r.id, r.data)).toSorted((a, b) => a.openedAt - b.openedAt);
74831
- }
74832
- async membersOf(summaryIdValue) {
74833
- return (await this.store.query.query({
74834
- collection: SUMMARY_MEMBERS_COLLECTION$1,
74835
- filter: {
74836
- where: { summaryId: summaryIdValue },
74837
- limit: 1e4
74838
- }
74839
- })).map((r) => ({
74840
- summaryId: summaryIdValue,
74841
- trackId: String(r.data["trackId"] ?? ""),
74842
- deviceId: num$1(r.data["deviceId"]),
74843
- joinedAt: num$1(r.data["timestamp"]),
74844
- role: readRole(r.data["role"]),
74845
- className: readClassName(r.data["className"])
74846
- }));
74847
- }
74848
- /**
74849
- * Put a track into the camera's open summary, opening one when there is
74850
- * none. Racing opens are settled by the store: the loser's insert hits the
74851
- * unique `openDeviceId` and is re-read as an absorb.
74852
- */
74853
- /**
74854
- * Open a NEW group for this track.
74855
- *
74856
- * The unique-violation branch is kept even though `summaries_open_device` is
74857
- * no longer UNIQUE: the id is `f(deviceId, openedAt)`, so two births in the
74858
- * SAME millisecond still collide on the primary key, and the right answer
74859
- * there is still "join the row that won" — it is the same group.
74860
- */
74861
- async openNew(input) {
74862
- const id = summaryId(input.deviceId, input.at, input.trackId);
74863
- try {
74864
- await this.store.set.mutate({
74865
- collection: SUMMARIES_COLLECTION,
74866
- key: id,
74867
- value: {
74868
- deviceId: input.deviceId,
74869
- openedAt: input.at,
74870
- lastActiveAt: input.at,
74871
- sealedAt: null,
74872
- openDeviceId: input.deviceId,
74873
- memberCount: 1
74874
- }
74875
- });
74876
- } catch (err) {
74877
- if (!isUniqueViolation(err)) throw err;
74878
- const winner = (await this.listOpen(input.deviceId)).find((s) => s.id === id);
74879
- if (winner === void 0) throw err;
74880
- return this.absorbInto(winner, input);
74881
- }
74882
- await this.addMember(id, input, "primary");
74883
- return {
74884
- id,
74885
- deviceId: input.deviceId,
74886
- openedAt: input.at,
74887
- lastActiveAt: input.at,
74888
- sealedAt: null,
74889
- memberCount: 1
74890
- };
74891
- }
74892
- /**
74893
- * Join the camera's oldest open group, or open one.
74894
- *
74895
- * The default policy, for callers with no better rule. The rule that DOES
74896
- * know better — proximity — lives with the caller that can see positions, and
74897
- * reaches the store through {@link listOpen} + {@link absorbInto} instead.
74898
- */
74899
- async openOrAbsorb(input) {
74900
- const first = (await this.listOpen(input.deviceId))[0];
74901
- return first === void 0 ? this.openNew(input) : this.absorbInto(first, input);
74902
- }
74903
- async absorbInto(summary, input) {
74904
- const members = await this.membersOf(summary.id);
74905
- if (members.some((m) => m.trackId === input.trackId)) return summary;
74906
- const joined = {
74907
- trackId: input.trackId,
74908
- className: input.className ?? null,
74909
- joinedAt: input.at
74910
- };
74911
- const primary = primaryTrackId([...members.map((m) => ({
74912
- trackId: m.trackId,
74913
- className: m.className,
74914
- joinedAt: m.joinedAt
74915
- })), joined]);
74916
- await this.addMember(summary.id, input, primary === input.trackId ? "primary" : "sibling");
74917
- if (primary === input.trackId) await this.demoteOtherPrimaries(summary.id, input.trackId, members);
74918
- const memberCount = members.length + 1;
74919
- const lastActiveAt = Math.max(summary.lastActiveAt, input.at);
74920
- await this.store.updateWhere.mutate({
74921
- collection: SUMMARIES_COLLECTION,
74922
- filter: { where: {
74923
- id: summary.id,
74924
- openDeviceId: summary.deviceId
74925
- } },
74926
- data: {
74927
- memberCount,
74928
- lastActiveAt
74929
- }
74930
- });
74931
- return {
74932
- ...summary,
74933
- memberCount,
74934
- lastActiveAt
74935
- };
74936
- }
74937
- /**
74938
- * Move a set of members out of an open group into a NEW open group.
74939
- *
74940
- * The two sides of a split. The departing members keep their join times, so
74941
- * the group that leaves opens at the moment its earliest member joined the
74942
- * old one — it existed then; what changed is that we can now tell.
74943
- *
74944
- * Order is deliberate: the new rows are written BEFORE the old ones are
74945
- * dropped. A failure between the two leaves a member in both groups, which a
74946
- * reader can see and a later pass can repair; the other order loses the
74947
- * membership, and a lost member is the one wrong this store exists to
74948
- * prevent (the same reasoning as {@link demoteOtherPrimaries}).
74949
- *
74950
- * `null` when there is nothing to do: no member matched, or ALL of them did.
74951
- * Moving everybody is not a split — it would leave an empty group behind and
74952
- * a new one holding exactly what the old one held.
74953
- */
74954
- async splitOff(summary, departingTrackIds, at) {
74955
- const members = await this.membersOf(summary.id);
74956
- const leaving = members.filter((m) => departingTrackIds.includes(m.trackId));
74957
- const staying = members.filter((m) => !departingTrackIds.includes(m.trackId));
74958
- if (leaving.length === 0 || staying.length === 0) return null;
74959
- const openedAt = Math.min(...leaving.map((m) => m.joinedAt));
74960
- const founder = leaving.find((m) => m.joinedAt === openedAt) ?? leaving[0];
74961
- const id = summaryId(summary.deviceId, openedAt, founder.trackId);
74962
- await this.store.set.mutate({
74963
- collection: SUMMARIES_COLLECTION,
74964
- key: id,
74965
- value: {
74966
- deviceId: summary.deviceId,
74967
- openedAt,
74968
- lastActiveAt: summary.lastActiveAt,
74969
- sealedAt: null,
74970
- openDeviceId: summary.deviceId,
74971
- memberCount: leaving.length
74972
- }
74973
- });
74974
- const newPrimary = primaryTrackId(leaving);
74975
- await this.store.insertMany.mutate({
74976
- collection: SUMMARY_MEMBERS_COLLECTION$1,
74977
- records: leaving.map((m) => ({
74978
- id: memberRowId(id, m.trackId),
74979
- data: {
74980
- summaryId: id,
74981
- trackId: m.trackId,
74982
- deviceId: m.deviceId,
74983
- timestamp: m.joinedAt,
74984
- role: m.trackId === newPrimary ? "primary" : "sibling",
74985
- className: m.className
74986
- }
74987
- }))
74988
- });
74989
- await this.store.deleteWhere.mutate({
74990
- collection: SUMMARY_MEMBERS_COLLECTION$1,
74991
- filter: {
74992
- where: { summaryId: summary.id },
74993
- whereIn: { trackId: leaving.map((m) => m.trackId) }
74994
- }
74995
- });
74996
- const keptPrimary = primaryTrackId(staying);
74997
- for (const m of staying) {
74998
- const role = m.trackId === keptPrimary ? "primary" : "sibling";
74999
- if (m.role === role) continue;
75000
- await this.store.update.mutate({
75001
- collection: SUMMARY_MEMBERS_COLLECTION$1,
75002
- id: memberRowId(summary.id, m.trackId),
75003
- data: { role }
75004
- });
75005
- }
75006
- await this.store.updateWhere.mutate({
75007
- collection: SUMMARIES_COLLECTION,
75008
- filter: { where: {
75009
- id: summary.id,
75010
- openDeviceId: summary.deviceId
75011
- } },
75012
- data: { memberCount: staying.length }
75013
- });
75014
- this.logger.info("summary: a group walked apart", {
75015
- tags: { deviceId: summary.deviceId },
75016
- meta: {
75017
- summaryId: summary.id,
75018
- bornId: id,
75019
- left: leaving.map((m) => m.trackId),
75020
- stayed: staying.map((m) => m.trackId),
75021
- at
75022
- }
75023
- });
75024
- return {
75025
- id,
75026
- deviceId: summary.deviceId,
75027
- openedAt,
75028
- lastActiveAt: summary.lastActiveAt,
75029
- sealedAt: null,
75030
- memberCount: leaving.length
75031
- };
75032
- }
75033
- /**
75034
- * Put every OTHER member back to `sibling` after a new primary took the role.
75035
- *
75036
- * Best-effort per row and loud about a row it could not move: two primaries
75037
- * is a wrong a consumer can see, but it is smaller than a lost member, so a
75038
- * failure here never propagates to the join.
75039
- */
75040
- async demoteOtherPrimaries(summaryIdValue, primaryTrack, previous) {
75041
- for (const m of previous) {
75042
- if (m.trackId === primaryTrack || m.role !== "primary") continue;
75043
- try {
75044
- await this.store.update.mutate({
75045
- collection: SUMMARY_MEMBERS_COLLECTION$1,
75046
- id: memberRowId(summaryIdValue, m.trackId),
75047
- data: { role: "sibling" }
75048
- });
75049
- } catch (err) {
75050
- this.logger.warn("summary: could not demote the previous primary — two remain", { meta: {
75051
- summaryId: summaryIdValue,
75052
- trackId: m.trackId,
75053
- error: String(err)
75054
- } });
75055
- }
75056
- }
75057
- }
75058
- async addMember(summaryIdValue, input, role) {
75059
- await this.store.insertMany.mutate({
75060
- collection: SUMMARY_MEMBERS_COLLECTION$1,
75061
- records: [{
75062
- id: memberRowId(summaryIdValue, input.trackId),
75063
- data: {
75064
- summaryId: summaryIdValue,
75065
- trackId: input.trackId,
75066
- deviceId: input.deviceId,
75067
- timestamp: input.at,
75068
- role,
75069
- className: input.className ?? null
75070
- }
75071
- }]
75072
- });
75073
- }
75074
- /**
75075
- * Seal in place. `true` when THIS call sealed it; `false` when the row was
75076
- * already sealed (or gone) — the second sealer learns, it does not write.
75077
- */
75078
- async seal(summary, sealedAt) {
75079
- const { updated } = await this.store.updateWhere.mutate({
75080
- collection: SUMMARIES_COLLECTION,
75081
- filter: { where: {
75082
- id: summary.id,
75083
- openDeviceId: summary.deviceId
75084
- } },
75085
- data: {
75086
- sealedAt,
75087
- openDeviceId: null
75088
- }
75089
- });
75090
- if (updated > 1) this.logger.error("summary seal touched more than one row — the open key is not unique", {
75091
- tags: { deviceId: summary.deviceId },
75092
- meta: {
75093
- summaryId: summary.id,
75094
- updated
75095
- }
75096
- });
75097
- return updated >= 1;
75098
- }
75099
- /**
75100
- * Track cascade leaf: a member row whose track is gone is gone too, and
75101
- * the summary's count follows. The envelope itself outlives its members
75102
- * — it is an age-keyed root (`RETENTION_ROOTS`, `summary`).
75103
- */
75104
- async deleteByTracks(trackIds) {
75105
- if (trackIds.length === 0) return [{
75106
- family: "summaries",
75107
- rows: 0,
75108
- bytes: 0
75109
- }];
75110
- const members = await this.store.query.query({
75111
- collection: SUMMARY_MEMBERS_COLLECTION$1,
75112
- filter: {
75113
- whereIn: { trackId: [...trackIds] },
75114
- limit: 1e4
75115
- }
75116
- });
75117
- if (members.length === 0) return [{
75118
- family: "summaries",
75119
- rows: 0,
75120
- bytes: 0
75121
- }];
75122
- const { deleted } = await this.store.deleteWhere.mutate({
75123
- collection: SUMMARY_MEMBERS_COLLECTION$1,
75124
- filter: { whereIn: { trackId: [...trackIds] } }
75125
- });
75126
- const touched = new Set(members.map((m) => String(m.data["summaryId"] ?? "")));
75127
- for (const summaryIdValue of touched) {
75128
- const remaining = await this.membersOf(summaryIdValue);
75129
- const raw = await this.store.get.query({
75130
- collection: SUMMARIES_COLLECTION,
75131
- key: summaryIdValue
75132
- });
75133
- if (typeof raw !== "object" || raw === null) continue;
75134
- const row = raw;
75135
- await this.store.updateWhere.mutate({
75136
- collection: SUMMARIES_COLLECTION,
75137
- filter: { where: {
75138
- deviceId: num$1(row["deviceId"]),
75139
- openedAt: num$1(row["openedAt"])
75140
- } },
75141
- data: { memberCount: remaining.length }
75142
- });
75143
- }
75144
- return [{
75145
- family: "summaries",
75146
- rows: deleted,
75147
- bytes: 0
75148
- }];
75149
- }
75150
- /**
75151
- * The surface's page (D359 phase 4): a camera set, newest first by
75152
- * `openedAt`, inside an optional window, bounded. Ordered and bounded HERE
75153
- * as well as in the query, so a store that ignores `orderBy` (the fake
75154
- * does) cannot hand the surface an unordered page.
75155
- */
75156
- async listRecent(input) {
75157
- if (input.deviceIds.length === 0) return [];
75158
- const matching = await this.summaryIdsMatching(input.classes);
75159
- if (matching !== null && matching.size === 0) return [];
75160
- const limit = Math.max(1, input.limit);
75161
- return (await this.store.query.query({
75162
- collection: SUMMARIES_COLLECTION,
75163
- filter: {
75164
- whereIn: { deviceId: [...input.deviceIds] },
75165
- ...input.since !== void 0 || input.until !== void 0 ? { whereBetween: { openedAt: [input.since ?? 0, input.until ?? Number.MAX_SAFE_INTEGER] } } : {},
75166
- orderBy: {
75167
- field: "openedAt",
75168
- direction: "desc"
75169
- },
75170
- limit
75171
- }
75172
- })).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);
75173
- }
75174
- /** One summary with its members, or `null`. */
75175
- /**
75176
- * One PAGE of summaries across a camera set, newest activity first.
75177
- *
75178
- * The same contract as `listRecentTracks` and for the same consumers: the
75179
- * reel on the cameras page and the Events gallery both ride a cursor, so a
75180
- * feed that could only answer "the last N" would make groups available in the
75181
- * timeline and nowhere else. `recent-cursor.ts` is reused verbatim — the
75182
- * ordering is the stable total order (`lastActiveAt` DESC, id DESC), and a
75183
- * second implementation of that is a second place for two feeds to disagree
75184
- * about what "newest" means.
75185
- *
75186
- * The window is on LAST ACTIVITY, not on the open: a session that started
75187
- * before the window and ran into it belongs to the window, because it
75188
- * happened there.
75189
- *
75190
- * Members for the WHOLE page come back in one read. One query per row is how
75191
- * a page of two hundred becomes two hundred round trips — the shape that made
75192
- * the old digest read two thousand tracks a camera.
75193
- */
75194
- async queryRecent(input) {
75195
- const deviceIds = [...new Set(input.deviceIds)];
75196
- if (deviceIds.length === 0) return {
75197
- summaries: [],
75198
- nextCursor: null
75199
- };
75200
- const cursor = input.cursor === void 0 ? null : decodeRecentCursor(input.cursor);
75201
- const matching = await this.summaryIdsMatching(input.classes);
75202
- if (matching !== null && matching.size === 0) return {
75203
- summaries: [],
75204
- nextCursor: null
75205
- };
75206
- const ordered = (await this.store.query.query({
75207
- collection: SUMMARIES_COLLECTION,
75208
- filter: {
75209
- whereIn: { deviceId: deviceIds },
75210
- ...input.since !== void 0 || input.until !== void 0 ? { whereBetween: { lastActiveAt: [input.since ?? TIME_MIN, input.until ?? TIME_MAX] } } : {},
75211
- limit: RECENT_SUMMARY_SCAN_LIMIT
75212
- }
75213
- })).map((r) => toRow$1(r.id, r.data)).filter((row) => matching === null || matching.has(row.id)).map((row) => ({
75214
- row,
75215
- key: {
75216
- lastSeen: row.lastActiveAt,
75217
- id: row.id
75218
- }
75219
- })).filter((entry) => cursor === null || isAfterCursor(entry.key, cursor)).toSorted((a, b) => compareRecentDesc(a.key, b.key));
75220
- const page = ordered.slice(0, input.limit);
75221
- const last = page.at(-1);
75222
- const nextCursor = last !== void 0 && ordered.length > page.length ? encodeRecentCursor({
75223
- lastSeen: last.row.lastActiveAt,
75224
- trackId: last.row.id
75225
- }) : null;
75226
- const members = await this.membersOfMany(page.map((e) => e.row.id));
75227
- return {
75228
- summaries: page.map((e) => ({
75229
- summary: e.row,
75230
- members: members.get(e.row.id) ?? []
75231
- })),
75232
- nextCursor
75233
- };
75234
- }
75235
- /**
75236
- * The summaries whose MEMBERS match any of `classes`, or `null` for no filter.
75237
- *
75238
- * TRACKS first, then the members that name them, then the summaries. The
75239
- * label is a fact about the track and it is asked of the track — a summary
75240
- * that stored its own copy would answer with whatever was true when it
75241
- * sealed, and would never learn that a face was enrolled and a person named
75242
- * an hour later. The operator's rule: *a summary keeps the minimum
75243
- * information necessary*, and a duplicated label is not the minimum, it is a
75244
- * second truth with an expiry date.
75245
- *
75246
- * Narrows before the page is cut. Filtering the other way round — a page of
75247
- * summaries, then their members — returns a short or empty page while matches
75248
- * sit further down the list, and the operator reads that as "there were none".
75249
- */
75250
- async summaryIdsMatching(classes) {
75251
- const wanted = (classes ?? []).filter((t) => t.length > 0);
75252
- if (wanted.length === 0) return null;
75253
- const matchTracks = this.trackIdsMatching;
75254
- if (matchTracks === void 0) {
75255
- this.logger.warn("summary filter: no track matcher wired — refusing to answer unfiltered", { meta: { classes: wanted.length } });
75256
- return /* @__PURE__ */ new Set();
75257
- }
75258
- const trackIds = await matchTracks(wanted);
75259
- if (trackIds.length === 0) return /* @__PURE__ */ new Set();
75260
- const ids = /* @__PURE__ */ new Set();
75261
- const PAGE = 500;
75262
- for (let i = 0; i < trackIds.length; i += PAGE) {
75263
- const chunk = trackIds.slice(i, i + PAGE);
75264
- const rows = await this.store.query.query({
75265
- collection: SUMMARY_MEMBERS_COLLECTION$1,
75266
- filter: {
75267
- whereIn: { trackId: [...chunk] },
75268
- limit: MEMBER_MATCH_SCAN_LIMIT
75269
- }
75270
- });
75271
- for (const row of rows) ids.add(String(row.data["summaryId"] ?? ""));
75272
- }
75273
- ids.delete("");
75274
- return ids;
75275
- }
75276
- /** Members of many summaries, in ONE read, grouped by summary id. */
75277
- async membersOfMany(summaryIds) {
75278
- const out = /* @__PURE__ */ new Map();
75279
- if (summaryIds.length === 0) return out;
75280
- const rows = await this.store.query.query({
75281
- collection: SUMMARY_MEMBERS_COLLECTION$1,
75282
- filter: {
75283
- whereIn: { summaryId: [...summaryIds] },
75284
- limit: 1e4
75285
- }
75286
- });
75287
- for (const r of rows) {
75288
- const summaryIdValue = String(r.data["summaryId"] ?? "");
75289
- const member = {
75290
- summaryId: summaryIdValue,
75291
- trackId: String(r.data["trackId"] ?? ""),
75292
- deviceId: num$1(r.data["deviceId"]),
75293
- joinedAt: num$1(r.data["timestamp"]),
75294
- role: readRole(r.data["role"]),
75295
- className: readClassName(r.data["className"])
75296
- };
75297
- const bucket = out.get(summaryIdValue);
75298
- if (bucket === void 0) out.set(summaryIdValue, [member]);
75299
- else bucket.push(member);
75300
- }
75301
- for (const bucket of out.values()) bucket.sort((a, b) => a.joinedAt - b.joinedAt);
75302
- return out;
75303
- }
75304
- async get(id) {
75305
- const raw = await this.store.get.query({
75306
- collection: SUMMARIES_COLLECTION,
75307
- key: id
75308
- });
75309
- if (typeof raw !== "object" || raw === null) return null;
75310
- return {
75311
- summary: toRow$1(id, raw),
75312
- members: await this.membersOf(id)
75313
- };
75314
- }
75315
- /** The subset of `ids` that still have a row — the orphan audit's probe. Throws on a failed read. */
75316
- async existingIds(ids) {
75317
- const found = /* @__PURE__ */ new Set();
75318
- await Promise.all(ids.map(async (id) => {
75319
- const raw = await this.store.get.query({
75320
- collection: SUMMARIES_COLLECTION,
75321
- key: id
75322
- });
75323
- if (typeof raw === "object" && raw !== null) found.add(id);
75324
- }));
75325
- return found;
75326
- }
75327
- /** Age sweep on the device window: everything opened before `cutoffMs`, members included. */
75328
- async evictBefore(deviceId, cutoffMs) {
75329
- const rows = await this.store.query.query({
75330
- collection: SUMMARIES_COLLECTION,
75331
- filter: {
75332
- where: { deviceId },
75333
- whereBetween: { openedAt: [0, cutoffMs - 1] },
75334
- limit: 1e4
75335
- }
75336
- });
75337
- if (rows.length === 0) return 0;
75338
- const ids = rows.map((r) => r.id);
75339
- await this.onEvict?.(ids);
75340
- await this.store.deleteWhere.mutate({
75341
- collection: SUMMARY_MEMBERS_COLLECTION$1,
75342
- filter: { whereIn: { summaryId: ids } }
75343
- });
75344
- const { deleted } = await this.store.deleteWhere.mutate({
75345
- collection: SUMMARIES_COLLECTION,
75346
- filter: {
75347
- where: { deviceId },
75348
- whereBetween: { openedAt: [0, cutoffMs - 1] }
75349
- }
75350
- });
75351
- return deleted;
75352
- }
75353
- };
75354
74628
  /**
75355
74629
  * Where the completion marker lives.
75356
74630
  *
@@ -78624,10 +77898,6 @@ var ANALYTICS_COLLECTIONS = [
78624
77898
  collection: PLATES_COLLECTION$1,
78625
77899
  classification: derived(PLATE_COLUMNS)
78626
77900
  },
78627
- {
78628
- collection: SUMMARY_MEMBERS_COLLECTION$1,
78629
- classification: derived(SUMMARY_MEMBERS_COLUMNS)
78630
- },
78631
77901
  {
78632
77902
  collection: MEDIA_COLLECTION,
78633
77903
  classification: {
@@ -78671,13 +77941,6 @@ var ANALYTICS_COLLECTIONS = [
78671
77941
  rootId: "ops-log"
78672
77942
  }
78673
77943
  },
78674
- {
78675
- collection: SUMMARIES_COLLECTION,
78676
- classification: {
78677
- kind: "retention-root",
78678
- rootId: "summary"
78679
- }
78680
- },
78681
77944
  {
78682
77945
  collection: IDENTITIES_COLLECTION,
78683
77946
  classification: {
@@ -78866,10 +78129,6 @@ function resolveMediaOwner(row) {
78866
78129
  table: ownerKind,
78867
78130
  eventId: ownerId
78868
78131
  };
78869
- case "summary": return {
78870
- kind: "summary",
78871
- summaryId: ownerId
78872
- };
78873
78132
  case "face": return ownerId.startsWith("face-") ? {
78874
78133
  kind: "track",
78875
78134
  trackId: ownerId.slice(FACE_MEDIA_OWNER_PREFIX.length)
@@ -78977,17 +78236,15 @@ var DEFAULT_ORPHAN_LIMITS = {
78977
78236
  function questionsFor(refs) {
78978
78237
  const trackIds = /* @__PURE__ */ new Set();
78979
78238
  const eventIds = /* @__PURE__ */ new Map();
78980
- const summaryIds = /* @__PURE__ */ new Set();
78981
78239
  for (const ref of refs) if (ref.kind === "track") trackIds.add(ref.trackId);
78982
78240
  else if (ref.kind === "event") {
78983
78241
  const perTable = eventIds.get(ref.table) ?? /* @__PURE__ */ new Set();
78984
78242
  perTable.add(ref.eventId);
78985
78243
  eventIds.set(ref.table, perTable);
78986
- } else if (ref.kind === "summary") summaryIds.add(ref.summaryId);
78244
+ }
78987
78245
  return {
78988
78246
  trackIds: [...trackIds],
78989
- eventIdsByTable: new Map([...eventIds].map(([table, ids]) => [table, [...ids]])),
78990
- summaryIds: [...summaryIds]
78247
+ eventIdsByTable: new Map([...eventIds].map(([table, ids]) => [table, [...ids]]))
78991
78248
  };
78992
78249
  }
78993
78250
  /**
@@ -79031,10 +78288,9 @@ async function auditScope(scope, deps, limits, cursor) {
79031
78288
  break;
79032
78289
  }
79033
78290
  const refs = rows.map((row) => scope.resolve(row));
79034
- const { trackIds, eventIdsByTable, summaryIds } = questionsFor(refs);
78291
+ const { trackIds, eventIdsByTable } = questionsFor(refs);
79035
78292
  let liveTracks;
79036
78293
  const liveEvents = /* @__PURE__ */ new Map();
79037
- let liveSummaries;
79038
78294
  let keptMedia = /* @__PURE__ */ new Set();
79039
78295
  try {
79040
78296
  liveTracks = trackIds.length > 0 ? await deps.liveTrackIds(trackIds) : /* @__PURE__ */ new Set();
@@ -79043,7 +78299,6 @@ async function auditScope(scope, deps, limits, cursor) {
79043
78299
  if (ids.length === 0) continue;
79044
78300
  liveEvents.set(table, await deps.liveEventIds(table, ids));
79045
78301
  }
79046
- liveSummaries = summaryIds.length > 0 ? await deps.liveSummaryIds(summaryIds) : /* @__PURE__ */ new Set();
79047
78302
  } catch (err) {
79048
78303
  deps.logger.warn("orphan audit: liveness probe failed — scope abandoned for this run", { meta: {
79049
78304
  collection: scope.collection,
@@ -79071,7 +78326,7 @@ async function auditScope(scope, deps, limits, cursor) {
79071
78326
  tally.unresolvable += 1;
79072
78327
  continue;
79073
78328
  }
79074
- if (ref.kind === "track" ? liveTracks.has(ref.trackId) || keptMedia.has(row.key) : ref.kind === "event" ? liveEvents.get(ref.table)?.has(ref.eventId) ?? false : ref.kind === "summary" ? liveSummaries.has(ref.summaryId) : false) continue;
78329
+ if (ref.kind === "track" ? liveTracks.has(ref.trackId) || keptMedia.has(row.key) : ref.kind === "event" ? liveEvents.get(ref.table)?.has(ref.eventId) ?? false : false) continue;
79075
78330
  const ts = num(row.data, scope.timestampField);
79076
78331
  if (ts !== null && ts > graceFloor) {
79077
78332
  tally.withinGrace += 1;
@@ -79241,11 +78496,7 @@ var ORPHAN_AUDIT_DEFAULTS = {
79241
78496
  intervalHours: OrphanAuditIntervalHoursSchema.parse(void 0),
79242
78497
  pageSize: OrphanAuditPageSizeSchema.parse(void 0)
79243
78498
  };
79244
- var RAW_DELETE_COLLECTIONS = new Set([
79245
- "pipeline-analytics:object-events",
79246
- "pipeline-analytics:plates",
79247
- "pipeline-analytics:summary-members"
79248
- ]);
78499
+ var RAW_DELETE_COLLECTIONS = new Set(["pipeline-analytics:object-events", "pipeline-analytics:plates"]);
79249
78500
  function buildOrphanReclaimer(ports) {
79250
78501
  return async (collection, key) => {
79251
78502
  if (collection === "pipeline-analytics:media") {
@@ -79386,14 +78637,12 @@ async function sweepTracklessRoots(deps) {
79386
78637
  cutoffMs
79387
78638
  });
79388
78639
  const sensor = deps.evictSensorEvents ? await deps.evictSensorEvents(deviceId, cutoffMs) : 0;
79389
- const summaries = deps.evictSummaries ? await deps.evictSummaries(deviceId, cutoffMs) : 0;
79390
78640
  const evicted = {
79391
78641
  motion: events.motion,
79392
78642
  audio: events.audio,
79393
- sensor,
79394
- summaries
78643
+ sensor
79395
78644
  };
79396
- if (evicted.motion.length + evicted.audio.length + evicted.sensor + evicted.summaries > 0) await deps.onEvicted(deviceId, evicted, cutoffs);
78645
+ if (evicted.motion.length + evicted.audio.length + evicted.sensor > 0) await deps.onEvicted(deviceId, evicted, cutoffs);
79397
78646
  } catch (err) {
79398
78647
  deps.onError(deviceId, err);
79399
78648
  }
@@ -80170,11 +79419,12 @@ function isFullFrameBbox(bbox) {
80170
79419
  /** Throttle for the native-crop HIT/FALLBACK metric window line. */
80171
79420
  var NATIVE_CROP_METRIC_INTERVAL_MS = 3e4;
80172
79421
  /**
80173
- * Above this `maxWidth`, a full-frame fetch is a NATIVE full frame (the
80174
- * `keyFrame`/group-shot rung, ~1–1.5 MB on a 4K camera) rather than a boxed
80175
- * timeline tile or a pinned ≤640 detection frame (tens of kB). The two belong
80176
- * in different counters or the window line says "full frames" and means two
80177
- * payload classes three orders of magnitude apart. (D470)
79422
+ * Above this `maxWidth`, a full-frame fetch is a NATIVE full frame (~1–1.5 MB
79423
+ * on a 4K camera) rather than a boxed timeline tile or a pinned ≤640 detection
79424
+ * frame (tens of kB). The two belong in different counters or the window line
79425
+ * says "full frames" and means two payload classes three orders of magnitude
79426
+ * apart. (D470) No caller asks for that rung since D422 (the key frame) and
79427
+ * D680 (the group shot); the counter stays so the next one is seen.
80178
79428
  */
80179
79429
  var FULL_FRAME_TILE_WIDTH_CEILING = 1920;
80180
79430
  function createNativeFrameTransport(deps) {
@@ -80251,8 +79501,8 @@ function createNativeFrameTransport(deps) {
80251
79501
  };
80252
79502
  };
80253
79503
  const getRemoteFrame = async (handle) => closeWindowAfter(requestNativeCrop(handle, FULL_FRAME_BBOX, handle.width, replyToFullFrame));
79504
+ const getNativeFullFrameRgb = async (handle, maxWidth) => closeWindowAfter(requestNativeCrop(handle, FULL_FRAME_BBOX, maxWidth, replyToFullFrame));
80254
79505
  const fetchNativeFullFrameTiered = async (handle, maxWidth) => closeWindowAfter(requestNativeCrop(handle, FULL_FRAME_BBOX, maxWidth, replyToTieredFullFrame));
80255
- const getNativeFullFrameRgb = async (handle, maxWidth) => (await fetchNativeFullFrameTiered(handle, maxWidth))?.frame ?? null;
80256
79506
  const cropMetricLogger = logger.child("NativeCrop");
80257
79507
  let nativeHits = 0;
80258
79508
  let nativeFallbacks = 0;
@@ -80268,8 +79518,8 @@ function createNativeFrameTransport(deps) {
80268
79518
  /**
80269
79519
  * Publish the window once `request` has SETTLED — never before, or the line
80270
79520
  * carries a window whose own outcome has not been counted yet and throttles
80271
- * away the one that has. `finally`, so the deliberate throw of
80272
- * `fetchNativeFullFrameTiered` still reaches `persistTrackKeyFrame`.
79521
+ * away the one that has. `finally`, so the deliberate throw of the
79522
+ * full-frame fetches still reaches their callers.
80273
79523
  */
80274
79524
  const closeWindowAfter = async (request) => {
80275
79525
  try {
@@ -80340,7 +79590,7 @@ function createNativeFrameTransport(deps) {
80340
79590
  bumpCropMetric(false);
80341
79591
  return null;
80342
79592
  }
80343
- if (!keyFrameAcceptsTier(native.tier)) {
79593
+ if (native.tier === "ram-fullframe") {
80344
79594
  bumpCropMetric(false);
80345
79595
  cropMetricLogger.info("subject crop refused: sub-native tier (will retry, no masquerade)", {
80346
79596
  ...deviceId !== void 0 ? { tags: { deviceId } } : {},
@@ -80360,7 +79610,6 @@ function createNativeFrameTransport(deps) {
80360
79610
  return {
80361
79611
  getRemoteFrame,
80362
79612
  getNativeFullFrameRgb,
80363
- getNativeKeyFrameRgb: fetchNativeFullFrameTiered,
80364
79613
  getNativeCropJpeg,
80365
79614
  captureCrop: createCaptureCrop({
80366
79615
  tryNativeCrop,
@@ -86436,947 +85685,6 @@ var RawGlobalStoreMirror = class {
86436
85685
  }
86437
85686
  };
86438
85687
  //#endregion
86439
- //#region src/pipeline-analytics/summary/group-crop.ts
86440
- /** Padding around the union, as a fraction of its own size. */
86441
- var GROUP_CROP_PADDING = .12;
86442
- /**
86443
- * Smallest crop, per axis, as a fraction of the frame.
86444
- *
86445
- * A quarter. Below that a thumbnail stops being legible at the sizes the
86446
- * gallery draws it, and the extra context costs nothing — the subjects stay the
86447
- * dominant thing in the picture at this size.
86448
- */
86449
- var MIN_CROP_FRACTION = .25;
86450
- /**
86451
- * The crop, or `null` when there is nothing to cut to — no visible member, or
86452
- * a frame with no dimensions. `null` means the caller keeps the full frame,
86453
- * which is the only honest answer when nobody could say where the group was.
86454
- */
86455
- function groupCropRect(input) {
86456
- const { frameWidth: fw, frameHeight: fh } = input;
86457
- if (fw <= 0 || fh <= 0) return null;
86458
- const boxes = input.boxes.filter((b) => b.width > 0 && b.height > 0);
86459
- if (boxes.length === 0) return null;
86460
- let left = Number.POSITIVE_INFINITY;
86461
- let top = Number.POSITIVE_INFINITY;
86462
- let right = Number.NEGATIVE_INFINITY;
86463
- let bottom = Number.NEGATIVE_INFINITY;
86464
- for (const b of boxes) {
86465
- left = Math.min(left, b.x);
86466
- top = Math.min(top, b.y);
86467
- right = Math.max(right, b.x + b.width);
86468
- bottom = Math.max(bottom, b.y + b.height);
86469
- }
86470
- const padX = (right - left) * GROUP_CROP_PADDING;
86471
- const padY = (bottom - top) * GROUP_CROP_PADDING;
86472
- let x0 = left - padX;
86473
- let y0 = top - padY;
86474
- let x1 = right + padX;
86475
- let y1 = bottom + padY;
86476
- const minW = fw * MIN_CROP_FRACTION;
86477
- const minH = fh * MIN_CROP_FRACTION;
86478
- if (x1 - x0 < minW) {
86479
- const cx = (x0 + x1) / 2;
86480
- x0 = cx - minW / 2;
86481
- x1 = cx + minW / 2;
86482
- }
86483
- if (y1 - y0 < minH) {
86484
- const cy = (y0 + y1) / 2;
86485
- y0 = cy - minH / 2;
86486
- y1 = cy + minH / 2;
86487
- }
86488
- const width = Math.min(x1 - x0, fw);
86489
- const height = Math.min(y1 - y0, fh);
86490
- const x = Math.max(0, Math.min(x0, fw - width));
86491
- const y = Math.max(0, Math.min(y0, fh - height));
86492
- return {
86493
- x: Math.round(x),
86494
- y: Math.round(y),
86495
- width: Math.round(width),
86496
- height: Math.round(height)
86497
- };
86498
- }
86499
- /**
86500
- * Carry a crop computed on the DETECTION frame onto the NATIVE surface.
86501
- *
86502
- * The two are different sizes by construction: detection runs on a downscaled
86503
- * frame and the group shot is fetched at up to 4096 px. Applying the rectangle
86504
- * raw would cut a corner of the picture and call it the group — the failure
86505
- * mode is silent and looks like a framing bug.
86506
- *
86507
- * Scaled per axis rather than by one factor, because the two surfaces are not
86508
- * guaranteed to share an aspect, and clamped to the native frame so rounding
86509
- * can never ask for a pixel that is not there.
86510
- */
86511
- function scaleCropToFrame(crop, from, to) {
86512
- if (from.width <= 0 || from.height <= 0 || to.width <= 0 || to.height <= 0) return null;
86513
- const sx = to.width / from.width;
86514
- const sy = to.height / from.height;
86515
- const x = Math.max(0, Math.min(Math.round(crop.x * sx), to.width - 1));
86516
- const y = Math.max(0, Math.min(Math.round(crop.y * sy), to.height - 1));
86517
- return {
86518
- x,
86519
- y,
86520
- width: Math.max(1, Math.min(Math.round(crop.width * sx), to.width - x)),
86521
- height: Math.max(1, Math.min(Math.round(crop.height * sy), to.height - y))
86522
- };
86523
- }
86524
- //#endregion
86525
- //#region src/pipeline-analytics/summary/summary-score.ts
86526
- /** A member whose own box is covered this much by others is hidden. */
86527
- var OCCLUDED_COVER_FRACTION = .5;
86528
- /** A box covering this share of the frame scores full marks on area. */
86529
- var FULL_AREA_SHARE = .25;
86530
- var WEIGHT_CONFIDENCE = .3;
86531
- var WEIGHT_AREA = .25;
86532
- var WEIGHT_RECOGNITION = .25;
86533
- var WEIGHT_PLACEMENT = .2;
86534
- function intersectionArea(a, b) {
86535
- const w = Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x);
86536
- const h = Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y);
86537
- return w > 0 && h > 0 ? w * h : 0;
86538
- }
86539
- /**
86540
- * Share of `own` covered by `others`, 0..1. Pairwise intersections summed and
86541
- * capped at the own area: when two others overlap each other on top of this
86542
- * box the sum over-counts, which only ever calls a member MORE hidden, never
86543
- * less — the safe side for a shot meant to show everyone.
86544
- */
86545
- function coveredFraction(own, others) {
86546
- const area = own.width * own.height;
86547
- if (area <= 0) return 1;
86548
- const covered = others.reduce((sum, other) => sum + intersectionArea(own, other), 0);
86549
- return Math.min(1, covered / area);
86550
- }
86551
- function clamp01(value) {
86552
- return Math.max(0, Math.min(1, value));
86553
- }
86554
- function placement(box, frameWidth, frameHeight) {
86555
- const cx = box.x + box.width / 2;
86556
- const cy = box.y + box.height / 2;
86557
- const dx = cx - frameWidth / 2;
86558
- const dy = cy - frameHeight / 2;
86559
- const halfDiagonal = Math.hypot(frameWidth, frameHeight) / 2;
86560
- return halfDiagonal <= 0 ? 0 : 1 - clamp01(Math.hypot(dx, dy) / halfDiagonal);
86561
- }
86562
- function memberQuality(d, frameWidth, frameHeight) {
86563
- const frameArea = frameWidth * frameHeight;
86564
- const cappedArea = clamp01((frameArea <= 0 ? 0 : d.bbox.width * d.bbox.height / frameArea) / FULL_AREA_SHARE);
86565
- return WEIGHT_CONFIDENCE * clamp01(d.confidence) + WEIGHT_AREA * cappedArea + WEIGHT_RECOGNITION * (d.recognized ? 1 : 0) + WEIGHT_PLACEMENT * placement(d.bbox, frameWidth, frameHeight);
86566
- }
86567
- /** Score one frame for one summary. Pure; never throws on empty input. */
86568
- function scoreSummaryFrame(input) {
86569
- const present = input.detections.filter((d) => input.members.has(d.trackId));
86570
- const visible = present.filter((d) => coveredFraction(d.bbox, present.filter((o) => o !== d).map((o) => o.bbox)) < OCCLUDED_COVER_FRACTION);
86571
- if (visible.length === 0) return {
86572
- visible: 0,
86573
- quality: 0,
86574
- crop: null
86575
- };
86576
- const quality = visible.reduce((sum, d) => sum + memberQuality(d, input.frameWidth, input.frameHeight), 0) / visible.length;
86577
- const crop = groupCropRect({
86578
- boxes: visible.map((d) => ({
86579
- x: d.bbox.x,
86580
- y: d.bbox.y,
86581
- width: d.bbox.width,
86582
- height: d.bbox.height
86583
- })),
86584
- frameWidth: input.frameWidth,
86585
- frameHeight: input.frameHeight
86586
- });
86587
- return {
86588
- visible: visible.length,
86589
- quality,
86590
- crop
86591
- };
86592
- }
86593
- /**
86594
- * Lexicographic: more visible members wins outright; only equal counts fall
86595
- * through to quality. Positive when `a` is the better shot.
86596
- */
86597
- function compareSummaryScores(a, b) {
86598
- if (a.visible !== b.visible) return a.visible - b.visible;
86599
- return a.quality - b.quality;
86600
- }
86601
- var GroupShotTracker = class {
86602
- states = /* @__PURE__ */ new Map();
86603
- offered = 0;
86604
- materialised = 0;
86605
- byGap = 0;
86606
- byMargin = 0;
86607
- byWorse = 0;
86608
- /**
86609
- * Offer a frame. Returns `true` when it is worth materialising NOW — the
86610
- * caller's cue to fetch the pixels for THIS frame, while its handle is
86611
- * still leased.
86612
- *
86613
- * The clock is `frame.timestamp`; the caller injects no second one.
86614
- */
86615
- offer(summaryId, score, frame) {
86616
- if (score.visible === 0) return false;
86617
- this.offered += 1;
86618
- const state = this.states.get(summaryId);
86619
- if (state === void 0) {
86620
- this.states.set(summaryId, {
86621
- delivered: null,
86622
- lastAttemptAt: frame.timestamp,
86623
- unlanded: 1
86624
- });
86625
- this.materialised += 1;
86626
- return true;
86627
- }
86628
- if (state.lastAttemptAt !== void 0 && frame.timestamp - state.lastAttemptAt < 2e3) {
86629
- this.byGap += 1;
86630
- return false;
86631
- }
86632
- if (!this.isWorthPixels(state, score)) return false;
86633
- state.lastAttemptAt = frame.timestamp;
86634
- state.unlanded += 1;
86635
- this.materialised += 1;
86636
- return true;
86637
- }
86638
- /**
86639
- * Nothing has landed yet → keep trying (D58) until the budget is spent.
86640
- * Something has landed → beat it, by the margin on the quality tiebreak.
86641
- */
86642
- isWorthPixels(state, score) {
86643
- const delivered = state.delivered;
86644
- if (delivered === null) {
86645
- if (state.unlanded >= 4) {
86646
- this.byMargin += 1;
86647
- return false;
86648
- }
86649
- return true;
86650
- }
86651
- if (compareSummaryScores(score, delivered.score) <= 0) {
86652
- this.byWorse += 1;
86653
- return false;
86654
- }
86655
- if (score.visible === delivered.score.visible && score.quality - delivered.score.quality < .05) {
86656
- this.byMargin += 1;
86657
- return false;
86658
- }
86659
- return true;
86660
- }
86661
- /**
86662
- * A materialisation LANDED. Only this advances the baseline the next
86663
- * candidate must beat — a fetch that missed leaves the summary exactly as
86664
- * picture-less as it was, and the next frame may be worse and still worth
86665
- * taking.
86666
- */
86667
- noteLanded(summaryId, score, frame) {
86668
- const state = this.states.get(summaryId);
86669
- if (state === void 0) return;
86670
- state.delivered = {
86671
- score,
86672
- frame
86673
- };
86674
- state.unlanded = 0;
86675
- }
86676
- /** The shot on disk for this summary, as this process last saw it land. */
86677
- deliveredOf(summaryId) {
86678
- return this.states.get(summaryId)?.delivered ?? null;
86679
- }
86680
- /** A sealed or evicted summary takes no more frames; drop its state. */
86681
- forget(summaryId) {
86682
- this.states.delete(summaryId);
86683
- }
86684
- /**
86685
- * What the gate did since the last read. Shipped WITH the gate: without it
86686
- * the only evidence for a fetch that did NOT happen is hub-main's byte
86687
- * counter, which answers a different question a minute later.
86688
- */
86689
- drainCounts() {
86690
- const counts = {
86691
- offered: this.offered,
86692
- materialised: this.materialised,
86693
- byGap: this.byGap,
86694
- byMargin: this.byMargin,
86695
- byWorse: this.byWorse
86696
- };
86697
- this.offered = 0;
86698
- this.materialised = 0;
86699
- this.byGap = 0;
86700
- this.byMargin = 0;
86701
- this.byWorse = 0;
86702
- return counts;
86703
- }
86704
- get size() {
86705
- return this.states.size;
86706
- }
86707
- };
86708
- //#endregion
86709
- //#region src/pipeline-analytics/summary/group-shot-materialiser.ts
86710
- /**
86711
- * Apply the group crop to the native surface, or hand it back untouched.
86712
- *
86713
- * Every reason to skip is a reason NOT to guess: no rectangle, no detection
86714
- * geometry to scale from, or a cropper this deployment does not have. The full
86715
- * frame is worse than a crop and far better than a wrong crop.
86716
- */
86717
- async function cropNative(native, input, deps) {
86718
- const crop = input.crop;
86719
- if (!crop || !deps.cropFrame) return native;
86720
- const fw = input.cropFrameWidth;
86721
- const fh = input.cropFrameHeight;
86722
- if (fw === void 0 || fh === void 0) return native;
86723
- const scaled = scaleCropToFrame(crop, {
86724
- width: fw,
86725
- height: fh
86726
- }, native);
86727
- if (scaled === null) return native;
86728
- if (scaled.width >= native.width && scaled.height >= native.height) return native;
86729
- try {
86730
- const cut = await deps.cropFrame(native, scaled);
86731
- if (cut === null) return native;
86732
- const expected = cut.width * cut.height * 3;
86733
- if (cut.format === "rgb" && cut.data.length !== expected) {
86734
- deps.logger.warn("group shot crop returned a surface that is not its geometry", {
86735
- tags: { deviceId: input.deviceId },
86736
- meta: {
86737
- summaryId: input.summaryId,
86738
- width: cut.width,
86739
- height: cut.height,
86740
- expectedBytes: expected,
86741
- gotBytes: cut.data.length
86742
- }
86743
- });
86744
- return native;
86745
- }
86746
- return cut;
86747
- } catch (err) {
86748
- deps.logger.warn("group shot crop failed — storing the full frame", {
86749
- tags: { deviceId: input.deviceId },
86750
- meta: {
86751
- summaryId: input.summaryId,
86752
- error: require_clip_model_registry.errMsg(err)
86753
- }
86754
- });
86755
- return native;
86756
- }
86757
- }
86758
- async function persistSummaryGroupShot(deps, input) {
86759
- const { deviceId, summaryId, frameHandle } = input;
86760
- const drop = {
86761
- tags: { deviceId },
86762
- meta: {
86763
- summaryId,
86764
- nodeId: frameHandle.nodeId
86765
- }
86766
- };
86767
- try {
86768
- const result = await deps.fetchNativeFullFrame(frameHandle, input.maxWidth);
86769
- const native = result?.frame;
86770
- if (!native || native.format !== "rgb" || native.width <= 0 || native.height <= 0) {
86771
- deps.logger.warn("group shot native miss — the previous shot stands", {
86772
- ...drop,
86773
- meta: {
86774
- ...drop.meta,
86775
- shmId: frameHandle.shmId
86776
- }
86777
- });
86778
- return { kind: "native-miss" };
86779
- }
86780
- if (!keyFrameAcceptsTier(result.tier)) {
86781
- deps.logger.warn("group shot RAM tier rejected — the previous shot stands", {
86782
- ...drop,
86783
- meta: {
86784
- ...drop.meta,
86785
- shmId: frameHandle.shmId,
86786
- tier: result.tier
86787
- }
86788
- });
86789
- return { kind: "ram-tier" };
86790
- }
86791
- const surface = await cropNative(native, input, deps);
86792
- const { keyFrame, keyFrameSmall } = await deps.encodeVariants(surface);
86793
- const keyFrameMediaId = await deps.media.putReplacing({
86794
- deviceId,
86795
- ownerKind: "summary",
86796
- ownerId: summaryId,
86797
- kind: "keyFrame",
86798
- timestamp: input.timestamp,
86799
- data: keyFrame
86800
- });
86801
- try {
86802
- await deps.media.putReplacing({
86803
- deviceId,
86804
- ownerKind: "summary",
86805
- ownerId: summaryId,
86806
- kind: "keyFrameSmall",
86807
- timestamp: input.timestamp,
86808
- data: keyFrameSmall
86809
- });
86810
- } catch (err) {
86811
- deps.logger.warn("group shot web companion write failed — the native shot is stored", {
86812
- ...drop,
86813
- meta: {
86814
- ...drop.meta,
86815
- error: require_clip_model_registry.errMsg(err)
86816
- }
86817
- });
86818
- }
86819
- return {
86820
- kind: "landed",
86821
- keyFrameMediaId
86822
- };
86823
- } catch (err) {
86824
- const error = require_clip_model_registry.errMsg(err);
86825
- deps.logger.warn("group shot capture failed", {
86826
- ...drop,
86827
- meta: {
86828
- ...drop.meta,
86829
- error
86830
- }
86831
- });
86832
- return {
86833
- kind: "error",
86834
- error
86835
- };
86836
- }
86837
- }
86838
- //#endregion
86839
- //#region src/pipeline-analytics/summary/summary-composition.ts
86840
- function summaryComposition(members) {
86841
- const counts = /* @__PURE__ */ new Map();
86842
- for (const member of members) counts.set(member.className, (counts.get(member.className) ?? 0) + 1);
86843
- return { classes: [...counts].map(([className, count]) => ({
86844
- className,
86845
- count
86846
- })).toSorted((a, b) => a.count !== b.count ? b.count - a.count : (a.className ?? "").localeCompare(b.className ?? "")) };
86847
- }
86848
- //#endregion
86849
- //#region src/pipeline-analytics/summary/summary-member-labels.ts
86850
- function present(value) {
86851
- return typeof value === "string" && value.length > 0;
86852
- }
86853
- function withMemberLabels(members, tracks) {
86854
- const byId = new Map(tracks.map((t) => [t.trackId, t]));
86855
- return members.map((member) => {
86856
- const track = byId.get(member.trackId);
86857
- return {
86858
- ...member,
86859
- className: member.className ?? (present(track?.className) ? track.className : null),
86860
- ...present(track?.label) ? { label: track.label } : {},
86861
- ...present(track?.subLabel) ? { subLabel: track.subLabel } : {}
86862
- };
86863
- });
86864
- }
86865
- //#endregion
86866
- //#region src/pipeline-analytics/summary/summary-proximity.ts
86867
- /**
86868
- * The measured gap, in fractions of the frame's diagonal-free normalisation
86869
- * (x by width, y by height). See the arithmetic above before moving it.
86870
- */
86871
- var DEFAULT_TOGETHER_FRACTION = .15;
86872
- /**
86873
- * Distance between two points as a fraction of the frame, or `null` when the
86874
- * frame is unknown. Each axis is normalised by its OWN dimension, so the answer
86875
- * does not change when a camera's aspect does.
86876
- */
86877
- function normalisedDistance(a, b, frame) {
86878
- if (!(frame.width > 0) || !(frame.height > 0)) return null;
86879
- const dx = (a.x - b.x) / frame.width;
86880
- const dy = (a.y - b.y) / frame.height;
86881
- return Math.sqrt(dx * dx + dy * dy);
86882
- }
86883
- //#endregion
86884
- //#region src/pipeline-analytics/summary/summary-join.ts
86885
- /**
86886
- * Which open group a newborn track joins — or none, and it opens its own.
86887
- *
86888
- * ## Why this is a separate decision from the store
86889
- *
86890
- * The store knows rows; only the caller can see where subjects ARE. Keeping the
86891
- * choice here means the store's `listOpen` + `absorbInto` stay a mechanism and
86892
- * this stays the policy, which is what let the policy change without the store
86893
- * changing underneath it.
86894
- *
86895
- * ## The two cases this exists for
86896
- *
86897
- * A summary used to be one per camera, so a subject at the far end of the drive
86898
- * was absorbed into the group at the door — a group it was never part of — and
86899
- * the halves of a group that split had nowhere to go. Both are the same need:
86900
- * a summary is a GROUP, not a camera session.
86901
- *
86902
- * ## Nearest, not first
86903
- *
86904
- * When two groups are within reach the closest wins. `listOpen` orders by
86905
- * `openedAt`, and which group opened first has nothing to do with who this
86906
- * subject is walking with — taking the first would make the answer depend on
86907
- * list order, which is the kind of tie-break that reads as random in a log.
86908
- *
86909
- * ## Knowing nothing is not a reason to take somebody in
86910
- *
86911
- * A group whose members have no position in RAM — one open across a restart —
86912
- * is skipped rather than joined. Joining on absent evidence is exactly how a
86913
- * far subject ends up in the wrong group, and nothing revisits that decision.
86914
- */
86915
- /**
86916
- * The id of the group to join, or `null` to open a new one.
86917
- *
86918
- * `null` on an unknown frame too: refusing is the recoverable direction, since
86919
- * the subject then gets its own group, while a wrong join merges two strangers.
86920
- */
86921
- function chooseGroupToJoin(subject, groups, frame, maxFraction) {
86922
- let bestId = null;
86923
- let bestDistance = Number.POSITIVE_INFINITY;
86924
- for (const g of groups) for (const m of g.memberPositions) {
86925
- const d = normalisedDistance(subject, m, frame);
86926
- if (d === null || d > maxFraction) continue;
86927
- if (d < bestDistance) {
86928
- bestDistance = d;
86929
- bestId = g.id;
86930
- }
86931
- }
86932
- return bestId;
86933
- }
86934
- //#endregion
86935
- //#region src/pipeline-analytics/summary/summary-split.ts
86936
- /**
86937
- * A group that walked apart — who leaves, and who keeps the summary.
86938
- *
86939
- * ## The case
86940
- *
86941
- * Membership is decided ONCE, at birth, by proximity (D489). That answers "who
86942
- * arrived together" and says nothing about what happens next: two people who
86943
- * meet at the gate and then go separate ways stay one group until the camera
86944
- * falls quiet, and the summary then claims they were together for the whole
86945
- * session. The operator asked for this case by name alongside the concurrent
86946
- * ones — "un gruppo che si divide" — and they are the same need, because the
86947
- * halves of a split have to be able to BE groups.
86948
- *
86949
- * ## Single linkage, deliberately
86950
- *
86951
- * A family in single file is a chain: the last of them is nowhere near the
86952
- * first, and requiring every pair to be close would tear the group apart the
86953
- * moment it grew past two. So two members are together when a chain of
86954
- * within-threshold hops connects them — the same "any, not all" rule
86955
- * {@link isTogetherWith} uses at birth, applied to the whole set.
86956
- *
86957
- * ## What an absent position means
86958
- *
86959
- * Nothing. A member whose track has closed has no live position, and that is
86960
- * not evidence it walked off — it is the ordinary end of a track. Unplaced
86961
- * members are never candidates to leave; they stay with whoever keeps the
86962
- * summary. Likewise an unknown frame yields a `null` distance, and a null is
86963
- * not "far": it produces ONE cluster, never a split (D393).
86964
- *
86965
- * ## Which side keeps the row
86966
- *
86967
- * The primary's cluster, because the summary's identity and its group shot are
86968
- * already the primary's. When the primary cannot be placed — its track closed —
86969
- * the largest cluster keeps it instead; refusing to split there would freeze
86970
- * every group the moment its primary left the frame, which is the common way
86971
- * for a group to break up in the first place.
86972
- */
86973
- /**
86974
- * Members grouped by single-linkage proximity. One cluster when nothing can be
86975
- * measured — an unknown frame is not a reason to tear a group in half.
86976
- */
86977
- function clusterByProximity(members, frame, maxFraction) {
86978
- const parent = members.map((_, i) => i);
86979
- const find = (i) => {
86980
- let root = i;
86981
- while (parent[root] !== root) root = parent[root];
86982
- return root;
86983
- };
86984
- const union = (a, b) => {
86985
- const ra = find(a);
86986
- const rb = find(b);
86987
- if (ra !== rb) parent[rb] = ra;
86988
- };
86989
- let measured = false;
86990
- for (let i = 0; i < members.length; i++) for (let j = i + 1; j < members.length; j++) {
86991
- const d = normalisedDistance(members[i].position, members[j].position, frame);
86992
- if (d === null) continue;
86993
- measured = true;
86994
- if (d <= maxFraction) union(i, j);
86995
- }
86996
- if (!measured && members.length > 1) return [members.map((m) => m.trackId)];
86997
- const byRoot = /* @__PURE__ */ new Map();
86998
- for (let i = 0; i < members.length; i++) {
86999
- const root = find(i);
87000
- const bucket = byRoot.get(root);
87001
- const trackId = members[i].trackId;
87002
- if (bucket === void 0) byRoot.set(root, [trackId]);
87003
- else bucket.push(trackId);
87004
- }
87005
- return [...byRoot.values()];
87006
- }
87007
- /**
87008
- * The departures, or `null` when the group is still whole.
87009
- *
87010
- * Order is the input's: the clusters come out in the order their first member
87011
- * appears, so a caller logging a split reads the same order twice running.
87012
- */
87013
- function planSplit(subject, frame, maxFraction) {
87014
- if (subject.placed.length < 2) return null;
87015
- const clusters = clusterByProximity(subject.placed, frame, maxFraction);
87016
- if (clusters.length < 2) return null;
87017
- const keeper = clusters.find((c) => subject.primaryTrackId !== null && c.includes(subject.primaryTrackId)) ?? largest(clusters);
87018
- return { departures: clusters.filter((c) => c !== keeper) };
87019
- }
87020
- /** The biggest cluster; ties go to the one that appeared first. */
87021
- function largest(clusters) {
87022
- let best = clusters[0];
87023
- for (const c of clusters) if (c.length > best.length) best = c;
87024
- return best;
87025
- }
87026
- function errMsg(err) {
87027
- return err instanceof Error ? err.message : String(err);
87028
- }
87029
- var SummarySealer = class {
87030
- deps;
87031
- sweepInFlight = false;
87032
- /**
87033
- * What the FRAME path may ask at frame-rate: EVERY open group of a camera,
87034
- * with the members it has seen THIS process open. Fed only by this class's
87035
- * own writes, never the authority (D224): a fresh process answers an empty
87036
- * list until a track opens, and a summary open across a restart is scored on
87037
- * the members that open after it — a weaker shot, never a wrong one.
87038
- *
87039
- * Keyed by summary id INSIDE the camera, because a camera holds as many
87040
- * groups as it has (D489). It was one entry per camera for one release, and
87041
- * that silently threw away the group shot of every group but the last to
87042
- * open — the frame path scored the newest group's members and no other.
87043
- */
87044
- openMirror = /* @__PURE__ */ new Map();
87045
- /**
87046
- * When each open group was FIRST seen divided, by summary id. Cleared the
87047
- * moment it is whole again, so a separation has to hold without interruption
87048
- * — a group that oscillates never accumulates its window.
87049
- *
87050
- * RAM only, and deliberately: after a restart a group that is still apart
87051
- * simply waits {@link SPLIT_CONFIRM_MS} again. Persisting it would buy one
87052
- * earlier split and cost a column nothing else reads.
87053
- */
87054
- apartSince = /* @__PURE__ */ new Map();
87055
- constructor(deps) {
87056
- this.deps = deps;
87057
- }
87058
- /** See {@link SUMMARY_GROUPS_ENABLED}; production never overrides it. */
87059
- get groupsEnabled() {
87060
- return this.deps.groupsEnabled ?? false;
87061
- }
87062
- /**
87063
- * The camera's open groups, oldest entry first. Empty when none is mirrored —
87064
- * which means "this process has opened none", not "the camera has none".
87065
- */
87066
- openGroupsFor(deviceId) {
87067
- const groups = this.openMirror.get(deviceId);
87068
- if (groups === void 0) return [];
87069
- return [...groups].map(([id, members]) => ({
87070
- id,
87071
- members
87072
- }));
87073
- }
87074
- /**
87075
- * Join the open group this subject is walking WITH, or open its own.
87076
- *
87077
- * Falls back to the store's own policy (the camera's oldest open group)
87078
- * whenever the evidence is missing — no birth position, no frame size, no
87079
- * member positions. That fallback is today's behaviour, so an unknowable
87080
- * case degrades to what shipped rather than to nothing.
87081
- */
87082
- async joinOrOpen(input, position) {
87083
- const frame = this.deps.frameSize(input.deviceId);
87084
- if (position === void 0 || frame === null) return await this.deps.summaries.openOrAbsorb(input);
87085
- const open = await this.deps.summaries.listOpen(input.deviceId);
87086
- if (open.length === 0) return await this.deps.summaries.openNew(input);
87087
- const groups = [];
87088
- for (const summary of open) {
87089
- const members = await this.deps.summaries.membersOf(summary.id);
87090
- const positions = await this.deps.latestPositions(members.map((m) => m.trackId));
87091
- groups.push({
87092
- id: summary.id,
87093
- memberPositions: [...positions.values()]
87094
- });
87095
- }
87096
- const chosenId = chooseGroupToJoin(position, groups, frame, this.deps.togetherFraction(input.deviceId));
87097
- const chosen = open.find((s) => s.id === chosenId);
87098
- if (chosen === void 0) {
87099
- this.deps.logger.debug("summary: nobody to walk with — opening a group", {
87100
- tags: { deviceId: input.deviceId },
87101
- meta: {
87102
- trackId: input.trackId,
87103
- openGroups: open.length
87104
- }
87105
- });
87106
- return await this.deps.summaries.openNew(input);
87107
- }
87108
- return await this.deps.summaries.absorbInto(chosen, input);
87109
- }
87110
- async onTrackOpened(input) {
87111
- if (!this.groupsEnabled) return;
87112
- try {
87113
- if (!isSummaryEligible(input.trackId, input.className)) {
87114
- this.deps.logger.debug("summary: track is not a group member — left standalone", {
87115
- tags: { deviceId: input.deviceId },
87116
- meta: {
87117
- trackId: input.trackId,
87118
- className: input.className ?? null
87119
- }
87120
- });
87121
- return;
87122
- }
87123
- const at = this.deps.now();
87124
- const open = {
87125
- deviceId: input.deviceId,
87126
- trackId: input.trackId,
87127
- at,
87128
- className: input.className
87129
- };
87130
- const row = await this.joinOrOpen(open, input.position);
87131
- this.rememberMember(input.deviceId, row.id, input.trackId);
87132
- } catch (err) {
87133
- this.deps.logger.warn("summary: could not open or absorb for a track", {
87134
- tags: { deviceId: input.deviceId },
87135
- meta: {
87136
- trackId: input.trackId,
87137
- error: errMsg(err)
87138
- }
87139
- });
87140
- }
87141
- }
87142
- /** One sweep at a time (THE latch). A tick that finds one in flight returns. */
87143
- async sweep() {
87144
- if (this.sweepInFlight) return;
87145
- this.sweepInFlight = true;
87146
- try {
87147
- const mine = this.deps.analysedDeviceIds();
87148
- const open = [];
87149
- for (const deviceId of mine) open.push(...await this.deps.summaries.listOpen(deviceId));
87150
- for (const summary of open) {
87151
- const born = await this.splitIfApart(summary);
87152
- await this.sealIfDone(summary);
87153
- if (born !== null) await this.sealIfDone(born);
87154
- }
87155
- } catch (err) {
87156
- this.deps.logger.warn("summary sweep: could not list open summaries", { meta: { error: errMsg(err) } });
87157
- } finally {
87158
- this.sweepInFlight = false;
87159
- }
87160
- }
87161
- /**
87162
- * Split an open group whose members have walked apart and STAYED apart.
87163
- *
87164
- * Returns the last group born, so the sweep can consider it for sealing in
87165
- * the same pass — a group that divided as everybody left would otherwise sit
87166
- * open until the next tick with nothing alive in it.
87167
- *
87168
- * Every failure here is swallowed into a log: a split is an improvement to a
87169
- * record that is already correct about WHO was present, and losing it is
87170
- * never worth losing the seal that follows.
87171
- */
87172
- async splitIfApart(summary) {
87173
- const { id: summaryId, deviceId } = summary;
87174
- try {
87175
- const frame = this.deps.frameSize(deviceId);
87176
- if (frame === null) {
87177
- this.apartSince.delete(summaryId);
87178
- return null;
87179
- }
87180
- const members = await this.deps.summaries.membersOf(summaryId);
87181
- if (members.length < 2) {
87182
- this.apartSince.delete(summaryId);
87183
- return null;
87184
- }
87185
- const positions = await this.deps.latestPositions(members.map((m) => m.trackId));
87186
- const placed = [];
87187
- for (const m of members) {
87188
- const position = positions.get(m.trackId);
87189
- if (position !== void 0) placed.push({
87190
- trackId: m.trackId,
87191
- position
87192
- });
87193
- }
87194
- const plan = planSplit({
87195
- placed,
87196
- primaryTrackId: members.find((m) => m.role === "primary")?.trackId ?? null
87197
- }, frame, this.deps.togetherFraction(deviceId));
87198
- if (plan === null) {
87199
- this.apartSince.delete(summaryId);
87200
- return null;
87201
- }
87202
- const firstSeen = this.apartSince.get(summaryId);
87203
- const now = this.deps.now();
87204
- if (firstSeen === void 0) {
87205
- this.apartSince.set(summaryId, now);
87206
- return null;
87207
- }
87208
- if (now - firstSeen < 0) return null;
87209
- let last = null;
87210
- let row = summary;
87211
- for (const departing of plan.departures) {
87212
- const born = await this.deps.summaries.splitOff(row, departing, now);
87213
- if (born === null) continue;
87214
- this.moveMirrorMembers(deviceId, summaryId, born.id, departing);
87215
- row = {
87216
- ...row,
87217
- memberCount: row.memberCount - departing.length
87218
- };
87219
- last = born;
87220
- }
87221
- this.apartSince.delete(summaryId);
87222
- return last;
87223
- } catch (err) {
87224
- this.deps.logger.warn("summary: could not divide a group that walked apart", {
87225
- tags: { deviceId },
87226
- meta: {
87227
- summaryId,
87228
- error: errMsg(err)
87229
- }
87230
- });
87231
- return null;
87232
- }
87233
- }
87234
- /**
87235
- * Is this group finished?
87236
- *
87237
- * Both questions are about THIS group's members, not about the camera. A
87238
- * camera holds as many groups as it has (D489), so a camera-wide gate means
87239
- * no group seals until every subject on the camera has gone — and on a busy
87240
- * camera they never all go at once. Measured: eight summaries open on one
87241
- * camera over seventeen minutes, none sealed, each new subject opening a
87242
- * ninth because a group whose members have closed has no positions left for
87243
- * the proximity rule to judge.
87244
- *
87245
- * `null` means "could not find out", and the caller treats that as NOT done —
87246
- * a read that failed must never seal a live session (D49).
87247
- */
87248
- async isGroupFinished(summary, members) {
87249
- const { id: summaryId, deviceId } = summary;
87250
- let openTracks;
87251
- try {
87252
- openTracks = await this.deps.openTrackIds(deviceId);
87253
- } catch (err) {
87254
- this.deps.logger.warn("summary sweep: could not read open tracks — seal aborted this sweep", {
87255
- tags: { deviceId },
87256
- meta: {
87257
- summaryId,
87258
- error: errMsg(err)
87259
- }
87260
- });
87261
- return null;
87262
- }
87263
- const stillOpen = new Set(openTracks);
87264
- if (members.some((m) => stillOpen.has(m))) return false;
87265
- let parked;
87266
- try {
87267
- parked = await this.deps.parkedTrackIds(deviceId);
87268
- } catch (err) {
87269
- this.deps.logger.warn("summary sweep: could not read the stationary registry — seal aborted this sweep", {
87270
- tags: { deviceId },
87271
- meta: {
87272
- summaryId,
87273
- error: errMsg(err)
87274
- }
87275
- });
87276
- return null;
87277
- }
87278
- return !members.some((m) => parked.has(m));
87279
- }
87280
- async sealIfDone(summary) {
87281
- const { id: summaryId, deviceId } = summary;
87282
- let members;
87283
- try {
87284
- members = (await this.deps.summaries.membersOf(summaryId)).map((m) => m.trackId);
87285
- } catch (err) {
87286
- this.deps.logger.warn("summary sweep: could not read members — seal aborted this sweep", {
87287
- tags: { deviceId },
87288
- meta: {
87289
- summaryId,
87290
- error: errMsg(err)
87291
- }
87292
- });
87293
- return;
87294
- }
87295
- if (await this.isGroupFinished(summary, members) !== true) return;
87296
- const sealedAt = this.deps.now();
87297
- try {
87298
- if (await this.deps.summaries.seal(summary, sealedAt)) {
87299
- const members = this.forgetGroup(deviceId, summaryId);
87300
- this.deps.onSealed?.(summaryId, members);
87301
- this.deps.logger.info("summary sealed", {
87302
- tags: { deviceId },
87303
- meta: {
87304
- summaryId,
87305
- sealedAt
87306
- }
87307
- });
87308
- }
87309
- } catch (err) {
87310
- this.deps.logger.warn("summary sweep: seal failed — the row stays open for the next sweep", {
87311
- tags: { deviceId },
87312
- meta: {
87313
- summaryId,
87314
- error: errMsg(err)
87315
- }
87316
- });
87317
- }
87318
- }
87319
- rememberMember(deviceId, summaryIdValue, trackId) {
87320
- let groups = this.openMirror.get(deviceId);
87321
- if (groups === void 0) {
87322
- groups = /* @__PURE__ */ new Map();
87323
- this.openMirror.set(deviceId, groups);
87324
- }
87325
- const members = groups.get(summaryIdValue);
87326
- if (members === void 0) groups.set(summaryIdValue, new Set([trackId]));
87327
- else members.add(trackId);
87328
- }
87329
- /** Follows a split in the mirror, so the frame path scores each half on its
87330
- * own members from the very next frame. */
87331
- moveMirrorMembers(deviceId, fromId, toId, trackIds) {
87332
- const groups = this.openMirror.get(deviceId);
87333
- const from = groups?.get(fromId);
87334
- for (const trackId of trackIds) {
87335
- from?.delete(trackId);
87336
- this.rememberMember(deviceId, toId, trackId);
87337
- }
87338
- if (from !== void 0 && from.size === 0) groups?.delete(fromId);
87339
- }
87340
- /** Drops one group from the mirror and returns the members it held. */
87341
- forgetGroup(deviceId, summaryIdValue) {
87342
- this.apartSince.delete(summaryIdValue);
87343
- const groups = this.openMirror.get(deviceId);
87344
- const members = groups?.get(summaryIdValue) ?? /* @__PURE__ */ new Set();
87345
- groups?.delete(summaryIdValue);
87346
- if (groups !== void 0 && groups.size === 0) this.openMirror.delete(deviceId);
87347
- return members;
87348
- }
87349
- };
87350
- //#endregion
87351
- //#region src/pipeline-analytics/summary-settings.ts
87352
- /**
87353
- * Per-device summary (group) settings. Cascade: a per-device override on top of
87354
- * the global default, resolved per field — an invalid or missing value falls
87355
- * back to its default and parsing never throws. Mirrors `stationary-settings` /
87356
- * `media-settings`.
87357
- *
87358
- * The default IS the constant the proximity rule shipped with, imported and not
87359
- * copied, so an unset value and a reset value both resolve to exactly today's
87360
- * behaviour. It is per camera because a threshold belongs to whoever generates
87361
- * the event: a gate camera framing three metres of path and a drive camera
87362
- * framing twenty disagree about what "walking together" looks like in a
87363
- * fraction of the frame, and the fleet measurement behind the default is a
87364
- * median across both kinds.
87365
- */
87366
- var SummarySettingsSchema = require_clip_model_registry.object({
87367
- /**
87368
- * How close two subjects must be to count as one group, as a fraction of the
87369
- * frame. The floor is not zero: zero means "only at the identical pixel",
87370
- * which ends grouping on that camera while looking like a tuning choice.
87371
- * See `summary/summary-proximity.ts` for where the default came from.
87372
- */
87373
- summaryTogetherFraction: require_clip_model_registry.number().min(.02).max(1).default(DEFAULT_TOGETHER_FRACTION) });
87374
- var SUMMARY_DEFAULTS = { togetherFraction: SummarySettingsSchema.parse({}).summaryTogetherFraction };
87375
- function resolveSummarySettings(raw) {
87376
- const parsed = SummarySettingsSchema.shape.summaryTogetherFraction.safeParse(raw["summaryTogetherFraction"]);
87377
- return { togetherFraction: parsed.success ? parsed.data : SUMMARY_DEFAULTS.togetherFraction };
87378
- }
87379
- //#endregion
87380
85688
  //#region src/pipeline-analytics/track-retention-sweep.ts
87381
85689
  /**
87382
85690
  * Sweep every device with persisted tracks: skip retention-disabled devices,
@@ -88382,8 +86690,6 @@ var TTL_SWEEP_INTERVAL_MS = 5e3;
88382
86690
  * the others) — a longer drain would be killed mid-way and log nothing.
88383
86691
  */
88384
86692
  var SENSOR_HANDLER_DRAIN_MS = 2700;
88385
- /** Throttle for the group-shot gate's window line (D470). */
88386
- var GROUP_SHOT_METRIC_INTERVAL_MS = 3e4;
88387
86693
  /**
88388
86694
  * How often the addon asks "is a gallery sample outside the cluster face
88389
86695
  * space?". A flip is rare and the check is two cheap reads — a minute keeps
@@ -88661,21 +86967,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
88661
86967
  storageMigrationLeaseId = null;
88662
86968
  /** Per-camera history of LINKED-device sensor state changes (Part B). */
88663
86969
  sensorEventStore = null;
88664
- /** The per-camera session envelope (D359). Bound with the other stores at boot. */
88665
- summaryStore = null;
88666
- /** The ONLY writer of a summary's lifecycle (D359). */
88667
- summarySealer = null;
88668
- /** The materialisation gate on every open summary's group shot (D359 phase 3, D470). RAM only. */
88669
- groupShots = new GroupShotTracker();
88670
- /** Throttle for the group-shot gate's window line (D470). */
88671
- lastGroupShotMetricAt = 0;
88672
- /**
88673
- * Tracks a gallery match has named, for the group-shot score's recognition
88674
- * term. "Recognised so far" rather than "at that instant": the match arrives
88675
- * on the detail plane, after the frame; the frames that follow carry it.
88676
- * Pruned when the track's summary seals, so it is bounded by open summaries.
88677
- */
88678
- recognizedTrackIds = /* @__PURE__ */ new Set();
88679
86970
  /** Ingest-side reverse index (sensor device → linked camera ids), TTL-cached
88680
86971
  * so the DeviceStateChanged handler stays cheap. */
88681
86972
  linkedCamerasCache = null;
@@ -89197,11 +87488,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
89197
87488
  * the partition + promotion + sweep call sites — an operator change takes
89198
87489
  * effect within one SETTINGS_CACHE_TTL_MS tick without an addon restart. */
89199
87490
  stationarySettingsCache = this.settingsCaches.registerDeviceCache("stationary", SETTINGS_CACHE_TTL_MS);
89200
- /** Per-device summary (group) settings — the proximity threshold. Read
89201
- * SYNCHRONOUSLY by the sealer's birth and split paths, so it is warmed on
89202
- * the frame path like the stationary one and a cold miss answers the
89203
- * measured default. */
89204
- summarySettingsCache = this.settingsCaches.registerDeviceCache("summary", SETTINGS_CACHE_TTL_MS);
89205
87491
  /** Per-device STILLNESS SCALE cache — the measured "how slow is settled" bar
89206
87492
  * (`stillness-settings.ts`). Read once per FrameProcessor build, so this is
89207
87493
  * a cold-path cache; it exists so a calibration applied through
@@ -89331,9 +87617,10 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
89331
87617
  * line/min/device instead of one per §5 cadence tick, see media-capture-log.ts). */
89332
87618
  mediaCaptureLog = new MediaCaptureLogAggregator();
89333
87619
  /** Per-device hit/miss window for the native `keyFrame` path (2026-08-06).
89334
- * Before it there was NO metric here at all — `fetchNativeFullFrameTiered`
89335
- * never touched `bumpCropMetric` — so whether a key-frame fix worked could
89336
- * only be answered by hand-counting Loki lines. See `key-frame-metrics.ts`. */
87620
+ * Before it there was NO metric here at all — the by-handle key-frame fetch
87621
+ * in `runtime/native-frame-transport.ts` (retired by D422) never touched
87622
+ * `bumpCropMetric` — so whether a key-frame fix worked could only be
87623
+ * answered by hand-counting Loki lines. See `key-frame-metrics.ts`. */
89337
87624
  keyFrameCaptureLog = new KeyFrameCaptureLog();
89338
87625
  /** The registry's own liveness answer, mirrored off `ClusterTopologySnapshot`,
89339
87626
  * so a capture is never dispatched at a node the hub already called dead.
@@ -89467,12 +87754,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
89467
87754
  });
89468
87755
  }
89469
87756
  captureCrop = null;
89470
- /** Full NATIVE frame by handle (downscaled worker-side to `maxWidth`), routed
89471
- * to the owning runner, PLUS the source `tier`. Captured in the async init
89472
- * block so `persistKeyFrames` can fetch the native full frame for the
89473
- * `keyFrame` + `keyFrameSmall` writes in the same live-frame window AND reject
89474
- * a ≤640 RAM fallback (`tier: 'ram-fullframe'`). Null until init completes. */
89475
- getNativeKeyFrameRgb = null;
89476
87757
  shuttingDown = false;
89477
87758
  /**
89478
87759
  * Sensor handlers the bus dispatched under `void` that have not finished.
@@ -89972,7 +88253,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
89972
88253
  logger,
89973
88254
  noteCropOutcome: (deviceId, reason) => this.failureReport.noteEnrichmentCrop(deviceId, reason)
89974
88255
  });
89975
- this.getNativeKeyFrameRgb = transport.getNativeKeyFrameRgb;
89976
88256
  this.parkedFrames = createParkedFrameClient({
89977
88257
  api: api.pipelineRunner,
89978
88258
  logger: logger.child("ParkedFrames"),
@@ -90309,7 +88589,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
90309
88589
  MediaStore.declare(api.settingsStore),
90310
88590
  EventStore.declare(api.settingsStore),
90311
88591
  SensorEventStore.declare(api.settingsStore),
90312
- SummaryStore.declare(api.settingsStore),
90313
88592
  IdentityStore.declare(api.settingsStore),
90314
88593
  FaceStore.declare(api.settingsStore),
90315
88594
  PlateStore.declare(api.settingsStore),
@@ -90617,49 +88896,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
90617
88896
  store: api.settingsStore,
90618
88897
  logger: logger.child("SensorEventStore")
90619
88898
  });
90620
- this.summaryStore = new SummaryStore({
90621
- trackIdsMatching: async (terms) => await this.trackStore?.trackIdsMatching(terms) ?? [],
90622
- store: api.settingsStore,
90623
- logger: this.ctx.logger.child("summary"),
90624
- onEvict: async (ids) => {
90625
- await this.mediaStore?.deleteForOwner("summary", ids);
90626
- }
90627
- });
90628
- this.summarySealer = new SummarySealer({
90629
- summaries: this.summaryStore,
90630
- analysedDeviceIds: () => [...this.lastFrameDimsByDevice.keys()],
90631
- openTrackIds: async (deviceId) => this.residents.trackIdsForDevice(deviceId),
90632
- parkedTrackIds: async (deviceId) => {
90633
- const ids = /* @__PURE__ */ new Set();
90634
- for (const entry of this.stationaryRegistry?.list(deviceId) ?? []) if (entry.sourceTrackId !== void 0) ids.add(entry.sourceTrackId);
90635
- return ids;
90636
- },
90637
- now: () => Date.now(),
90638
- logger: this.ctx.logger.child("summary-sealer"),
90639
- frameSize: (deviceId) => {
90640
- const d = this.lastFrameDimsByDevice.get(deviceId);
90641
- return d === void 0 ? null : {
90642
- width: d.w,
90643
- height: d.h
90644
- };
90645
- },
90646
- latestPositions: async (trackIds) => {
90647
- const out = /* @__PURE__ */ new Map();
90648
- for (const id of trackIds) {
90649
- const last = (this.trackStore?.peekActive(id))?.positions.at(-1);
90650
- if (last !== void 0) out.set(id, {
90651
- x: last.x,
90652
- y: last.y
90653
- });
90654
- }
90655
- return out;
90656
- },
90657
- togetherFraction: (deviceId) => this.summarySettingsFromCache(deviceId).togetherFraction,
90658
- onSealed: (summaryId, members) => {
90659
- this.groupShots.forget(summaryId);
90660
- for (const trackId of members) this.recognizedTrackIds.delete(trackId);
90661
- }
90662
- });
90663
88899
  this.packageDropDetector = new PackageDropDetector({
90664
88900
  events: eventStore,
90665
88901
  emit: {
@@ -90859,7 +89095,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
90859
89095
  score: face.matchScore ?? face.confidence,
90860
89096
  confidence: face.confidence,
90861
89097
  ...face.frameHandle !== void 0 ? { frameHandle: face.frameHandle } : {}
90862
- }, face.identityId !== void 0 ? "identified" : "evidence"),
89098
+ }),
90863
89099
  onLiveHierarchy: (input) => this.requestTrackHierarchy(input),
90864
89100
  faceSourceFrames: new FaceSourceFrameKeeper({
90865
89101
  port: createFaceSourceParkPort({
@@ -91253,6 +89489,10 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
91253
89489
  readExportBytes: (input) => api.recordingExport.readExportBytes.query(input),
91254
89490
  deleteExport: (input) => api.recordingExport.deleteExport.mutate(input)
91255
89491
  }, req),
89492
+ deriveGifFromClip: (req) => deriveRecordedGif(buildRecordedGifPorts({
89493
+ logger: logger.child("RecordedGif"),
89494
+ resolveFfmpeg: () => this.ctx.deps.ensureFfmpeg()
89495
+ }), req),
91256
89496
  publishArtifact: async (bytes, mime, deviceId) => await this.ncArtifactPlane?.publish(bytes, mime, deviceId) ?? null,
91257
89497
  viewerUrlBase: async () => pickArtifactBaseUrl(await this.ncEndpoints()),
91258
89498
  indexArtifact: async (bytes, mime, meta) => await this.ncArtifactPlane?.publishIndexed(bytes, mime, meta) ?? {
@@ -91387,30 +89627,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
91387
89627
  confirmed: t.observations !== void 0 && t.observations >= minHits
91388
89628
  }));
91389
89629
  },
91390
- listSummaryGroups: async ({ deviceId, sinceMs, untilMs }) => {
91391
- const summaryStore = this.summaryStore;
91392
- if (!summaryStore) return [];
91393
- const rows = await summaryStore.listRecent({
91394
- deviceIds: [deviceId],
91395
- since: sinceMs,
91396
- until: untilMs,
91397
- limit: 500
91398
- });
91399
- const out = [];
91400
- for (const row of rows) {
91401
- const members = await summaryStore.membersOf(row.id);
91402
- const files = await this.mediaStore?.listInfoByOwner("summary", row.id) ?? [];
91403
- const shot = files.find((f) => f.kind === "keyFrameSmall") ?? files.find((f) => f.kind === "keyFrame");
91404
- out.push({
91405
- summaryId: row.id,
91406
- deviceId: row.deviceId,
91407
- memberTrackIds: members.map((m) => m.trackId),
91408
- primaryTrackId: members.find((m) => m.role === "primary")?.trackId ?? null,
91409
- groupShotMediaId: shot?.mediaId ?? null
91410
- });
91411
- }
91412
- return out;
91413
- },
91414
89630
  listMediaInfo: async (id) => {
91415
89631
  return (await stores.mediaStore.listInfoByOwner("track", id)).map((r) => ({
91416
89632
  mediaId: r.mediaId,
@@ -92834,7 +91050,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
92834
91050
  if (planeRunsStationary(source) && this.stationaryRegistry) {
92835
91051
  await this.resolveDeviceStationarySettings(deviceId);
92836
91052
  await this.resolveDevicePackageDropSettings(deviceId);
92837
- await this.resolveDeviceSummarySettings(deviceId);
92838
91053
  }
92839
91054
  const processor = await this.getOrCreateProcessor(deviceId, source);
92840
91055
  const proxy = await this.ensureProxy(deviceId);
@@ -93210,15 +91425,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
93210
91425
  className: t.className
93211
91426
  }
93212
91427
  });
93213
- this.summarySealer?.onTrackOpened({
93214
- deviceId,
93215
- trackId: id,
93216
- className: t.className,
93217
- position: {
93218
- x: t.bbox.x + t.bbox.w / 2,
93219
- y: t.bbox.y + t.bbox.h / 2
93220
- }
93221
- });
93222
91428
  const startPayload = buildTrackLifecyclePayload({
93223
91429
  deviceId,
93224
91430
  trackId: id,
@@ -93454,7 +91660,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
93454
91660
  for (const crops of childCropsByEvent.values()) for (const c of crops) if (c.kind === "faceCrop") faceCrops += 1;
93455
91661
  else plateCrops += 1;
93456
91662
  const snapshotTargets = this.buildSnapshotTargets(deviceId, result.tracked, result.timestamp, mediaSettings, result.frameWidth, result.frameHeight, frameHandle);
93457
- this.offerGroupShot(deviceId, result, frameHandle);
93458
91663
  for (const retry of this.collectFirstFrameRetries(deviceId, result.tracked, result.timestamp, firstFrameTargets)) firstFrameTargets.push(retry);
93459
91664
  const targetedThisFrame = new Set([...firstFrameTargets.map((t) => t.trackId), ...snapshotTargets.map((s) => s.trackId)]);
93460
91665
  const rasterFallbackCandidates = [];
@@ -94170,15 +92375,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
94170
92375
  stationarySettingsFromCache(deviceId) {
94171
92376
  return this.stationarySettingsCache.peek(deviceId) ?? STATIONARY_DEFAULTS;
94172
92377
  }
94173
- /** Synchronous read of the per-device summary settings — warmed by
94174
- * `processFrame`. A cold miss is today's constant, never a zero: a zero
94175
- * threshold would quietly end grouping on that camera. */
94176
- summarySettingsFromCache(deviceId) {
94177
- return this.summarySettingsCache.peek(deviceId) ?? SUMMARY_DEFAULTS;
94178
- }
94179
- async resolveDeviceSummarySettings(deviceId) {
94180
- return this.summarySettingsCache.get(deviceId, (id) => this.readDeviceSettings(id, resolveSummarySettings));
94181
- }
94182
92378
  /** Synchronous read of the per-device package-drop settings — warmed by
94183
92379
  * `processFrame` before the promotion loop, exactly like the stationary one. */
94184
92380
  packageDropSettingsFromCache(deviceId) {
@@ -94262,12 +92458,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
94262
92458
  } } : {}
94263
92459
  });
94264
92460
  }
94265
- if (detailSemanticTrigger(d.className) === "recognition") this.promoteSemanticRecognition(semanticFrameForDetailAttribution(deviceId, trackId, d, frame), "identified");
92461
+ if (detailSemanticTrigger(d.className) === "recognition") this.promoteSemanticRecognition(semanticFrameForDetailAttribution(deviceId, trackId, d, frame));
94266
92462
  }
94267
92463
  const resolved = d.className === "plate" ? this.plateRecognizer?.resolveLabelWithVehicle(d.label, d.score) ?? null : { text: d.label };
94268
92464
  if (resolved !== null && resolved.text !== void 0) {
94269
92465
  const applied = await this.applyTrackEnrichmentLabel(deviceId, trackId, resolved.text, d, frame.timestamp, resolved.vehicleId);
94270
- if (detailSemanticTrigger(d.className) === "label-write" && applied.trackWritten) this.promoteSemanticRecognition(semanticFrameForDetailAttribution(deviceId, trackId, d, frame), "identified");
92466
+ if (detailSemanticTrigger(d.className) === "label-write" && applied.trackWritten) this.promoteSemanticRecognition(semanticFrameForDetailAttribution(deviceId, trackId, d, frame));
94271
92467
  }
94272
92468
  } else if (d.className === "plate") this.failureReport.notePlateRead(deviceId, d.bbox === void 0 ? REASON_NO_BBOX : REASON_EMPTY_READ);
94273
92469
  } catch (err) {
@@ -94460,139 +92656,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
94460
92656
  });
94461
92657
  }
94462
92658
  /**
94463
- * Capture the CLEAN best full frame per given track at this best-detection
94464
- * moment and store BOTH variants (`putReplacing` → one row per track, per
94465
- * kind): the native `keyFrame` (full frame fetched at `KEYFRAME_NATIVE_MAX_WIDTH`
94466
- * — true native on a 4K cam) plus its 960 companion `keyFrameSmall`, encoded
94467
- * from the SAME native surface (no second fetch). No box is drawn — these are
94468
- * the clean hero images (`keyFrameSmall` is the web-friendly one).
94469
- *
94470
- * NATIVE-OR-NOTHING: a miss writes nothing (no upscale, no ≤640 stand-in) and
94471
- * the track's BOUNDED catch-up retry re-fires on a later frame. Until
94472
- * 2026-08-06 the only re-fire was the next genuine new-best PEAK, which for
94473
- * 80% of the tracks that missed never came — see `key-frame-retry.ts`. The
94474
- * `keyFrame` key is recorded on the track's resident state so the face /
94475
- * plate / object-embedding rows LINK the SAME native key frame (Design B).
94476
- * Issued in the live-frame window so the native lease is still held.
94477
- * Best-effort (D8) — a per-track failure is logged and never thrown.
94478
- *
94479
- * The capture BODY lives in `pipeline/key-frame-persist.ts`; what stays here
94480
- * is the scheduling (coalescing, priority, budget) and the bookkeeping the
94481
- * retry gate reads.
94482
- */
94483
- /**
94484
- * Offer one processed frame to the camera's OPEN summary (D359 phase 3).
94485
- * Metadata only, at frame-rate: the frame is scored against the members the
94486
- * sealer has mirrored, and the pixels are fetched — by handle, at the owning
94487
- * node, as a native key frame — only when this frame beats the best so far.
94488
- */
94489
- offerGroupShot(deviceId, result, frameHandle) {
94490
- if (frameHandle === void 0) return;
94491
- const groups = this.summarySealer?.openGroupsFor(deviceId) ?? [];
94492
- for (const open of groups) this.offerGroupShotTo(deviceId, open, result, frameHandle);
94493
- if (groups.length > 0) this.logGroupShotWindow(deviceId);
94494
- }
94495
- /** One group's turn at the frame. Split out of {@link offerGroupShot} so the
94496
- * scoring reads the members of the group it is about, and nothing else. */
94497
- offerGroupShotTo(deviceId, open, result, frameHandle) {
94498
- const score = scoreSummaryFrame({
94499
- members: open.members,
94500
- frameWidth: result.frameWidth,
94501
- frameHeight: result.frameHeight,
94502
- detections: result.tracked.map((t) => ({
94503
- trackId: t.trackId,
94504
- bbox: {
94505
- x: t.bbox.x,
94506
- y: t.bbox.y,
94507
- width: t.bbox.w,
94508
- height: t.bbox.h
94509
- },
94510
- confidence: t.confidence,
94511
- recognized: this.recognizedTrackIds.has(t.trackId)
94512
- }))
94513
- });
94514
- const frameRef = { timestamp: result.timestamp };
94515
- if (!this.groupShots.offer(open.id, score, frameRef)) return;
94516
- const getNative = this.getNativeKeyFrameRgb;
94517
- const mediaStore = this.mediaStore;
94518
- if (!getNative || !mediaStore) return;
94519
- const summaryId = open.id;
94520
- this.captureScheduler.request({
94521
- deviceId,
94522
- kind: "keyFrame",
94523
- coalesceKey: `summary:${summaryId}:keyFrame`,
94524
- exec: () => persistSummaryGroupShot({
94525
- fetchNativeFullFrame: getNative,
94526
- encodeVariants: encodeKeyFrameVariants,
94527
- media: mediaStore,
94528
- logger: this.ctx.logger,
94529
- cropFrame: async (frame, rect) => {
94530
- const out = await extractCropRaw(frame.data, frame.width, frame.height, {
94531
- x: rect.x / frame.width,
94532
- y: rect.y / frame.height,
94533
- w: rect.width / frame.width,
94534
- h: rect.height / frame.height
94535
- });
94536
- return {
94537
- ...frame,
94538
- data: out.data,
94539
- width: out.width,
94540
- height: out.height
94541
- };
94542
- }
94543
- }, {
94544
- deviceId,
94545
- summaryId,
94546
- timestamp: result.timestamp,
94547
- frameHandle,
94548
- maxWidth: KEYFRAME_NATIVE_MAX_WIDTH,
94549
- crop: score.crop,
94550
- cropFrameWidth: result.frameWidth,
94551
- cropFrameHeight: result.frameHeight
94552
- })
94553
- }).then((outcome) => {
94554
- if (outcome?.kind === "landed") {
94555
- this.groupShots.noteLanded(summaryId, score, frameRef);
94556
- this.ctx.logger.debug("summary group shot landed", {
94557
- tags: { deviceId },
94558
- meta: {
94559
- summaryId,
94560
- visible: score.visible,
94561
- quality: Number(score.quality.toFixed(3))
94562
- }
94563
- });
94564
- }
94565
- }).catch(() => {});
94566
- }
94567
- /**
94568
- * The group-shot gate's window, throttled to one line per
94569
- * {@link GROUP_SHOT_METRIC_INTERVAL_MS}. Process-global counts, tagged with
94570
- * the device that happened to close the window — the counters are the
94571
- * gate's, not the camera's, and the tag is there because every line about a
94572
- * device carries one.
94573
- *
94574
- * `materialised` is the number of 4096-px native full-frame fetches this
94575
- * path actually spent; `byGap` + `byMargin` + `byWorse` is what it did not.
94576
- */
94577
- logGroupShotWindow(deviceId) {
94578
- const now = Date.now();
94579
- if (now - this.lastGroupShotMetricAt < GROUP_SHOT_METRIC_INTERVAL_MS) return;
94580
- this.lastGroupShotMetricAt = now;
94581
- const counts = this.groupShots.drainCounts();
94582
- if (counts.offered === 0) return;
94583
- this.ctx.logger.info("group shot window", {
94584
- tags: { deviceId },
94585
- meta: {
94586
- offered: counts.offered,
94587
- materialised: counts.materialised,
94588
- suppressedByGap: counts.byGap,
94589
- suppressedByMargin: counts.byMargin,
94590
- suppressedByWorse: counts.byWorse,
94591
- openSummaries: this.groupShots.size
94592
- }
94593
- });
94594
- }
94595
- /**
94596
92659
  * Order the parcels a best-frame decision wants (D422). Skipped entirely at
94597
92660
  * a node the registry already called offline (D58) — logged on the
94598
92661
  * transition by `noteCapturesGated`, counted in the key-frame window.
@@ -94629,6 +92692,13 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
94629
92692
  timestamp: parcel.timestamp
94630
92693
  };
94631
92694
  }
92695
+ /**
92696
+ * Store each given track's clean native `keyFrame` + `keyFrameSmall` from its
92697
+ * PARKED parcel (D422) — never a by-handle fetch. The body is
92698
+ * `pipeline/key-frame-persist.ts`; what stays here is the scheduling
92699
+ * (coalescing, priority, budget) and the bookkeeping the retry gate reads.
92700
+ * Best-effort (D8): a per-track failure is logged and never thrown.
92701
+ */
94632
92702
  async persistKeyFrames(deviceId, timestamp, captures) {
94633
92703
  const mediaStore = this.mediaStore;
94634
92704
  if (this.parkedFrames === null || !mediaStore) return;
@@ -94796,8 +92866,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
94796
92866
  * when it wins and the frame's pixels are still reachable. Callers: the
94797
92867
  * FaceRecognizer's per-frame auto-match, and `routeDetailResults`' plate /
94798
92868
  * fine-classification attribution. */
94799
- promoteSemanticRecognition(input, origin) {
94800
- if (origin === "identified") this.recognizedTrackIds.add(input.trackId);
92869
+ promoteSemanticRecognition(input) {
94801
92870
  const ctx = this.ctxIfReady;
94802
92871
  if (ctx === null) return;
94803
92872
  promoteSemanticBestFrame({
@@ -95694,7 +93763,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
95694
93763
  async sweepExpiredTracks() {
95695
93764
  if (this.shuttingDown || !this.trackStore) return;
95696
93765
  await this.trackCloser.sweep();
95697
- await this.summarySealer?.sweep();
95698
93766
  }
95699
93767
  /**
95700
93768
  * The follow-recordings floor per camera, read from the ARCHIVE's hour ledger
@@ -95781,7 +93849,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
95781
93849
  await this.runTrackRetentionSweep(deviceIds, now);
95782
93850
  const eventStore = this.eventStore;
95783
93851
  const sensorEventStore = this.sensorEventStore;
95784
- const summaryStore = this.summaryStore;
95785
93852
  await sweepTracklessRoots({
95786
93853
  deviceIds,
95787
93854
  nowMs: now,
@@ -95791,16 +93858,15 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
95791
93858
  cutoffMs
95792
93859
  }),
95793
93860
  evictSensorEvents: sensorEventStore ? (deviceId, cutoffMs) => sensorEventStore.evictBefore(deviceId, cutoffMs) : null,
95794
- evictSummaries: summaryStore ? (deviceId, cutoffMs) => summaryStore.evictBefore(deviceId, cutoffMs) : null,
95795
93861
  onEvicted: async (deviceId, evicted, cutoffs) => {
95796
- const total = evicted.motion.length + evicted.audio.length + evicted.sensor + evicted.summaries;
93862
+ const total = evicted.motion.length + evicted.audio.length + evicted.sensor;
95797
93863
  await this.eventsOpsLog?.append({
95798
93864
  op: "prune",
95799
93865
  reason: "retention",
95800
93866
  deviceId,
95801
93867
  itemsAffected: total,
95802
93868
  bytesReclaimed: 0,
95803
- detail: `trackless age sweep (${cutoffs.effectiveMode}): ${evicted.motion.length} motion, ${evicted.audio.length} audio, ${evicted.sensor} sensor, ${evicted.summaries} summaries`
93869
+ detail: `trackless age sweep (${cutoffs.effectiveMode}): ${evicted.motion.length} motion, ${evicted.audio.length} audio, ${evicted.sensor} sensor`
95804
93870
  });
95805
93871
  this.ctx.logger.info("analytics trackless event eviction (age sweep)", {
95806
93872
  tags: { deviceId },
@@ -95808,7 +93874,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
95808
93874
  motion: evicted.motion.length,
95809
93875
  audio: evicted.audio.length,
95810
93876
  sensor: evicted.sensor,
95811
- summaries: evicted.summaries,
95812
93877
  mode: cutoffs.effectiveMode,
95813
93878
  cutoffMs: cutoffs.cutoffMs
95814
93879
  }
@@ -96762,118 +94827,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
96762
94827
  async listRecentTracks(input) {
96763
94828
  return this.queryFacade.listRecentTracks(input);
96764
94829
  }
96765
- /** Newest first for a camera set; each row carries its group-shot media key. */
96766
- async listSummaries(input) {
96767
- const store = this.summaryStore;
96768
- if (!store) return { summaries: [] };
96769
- const rows = await store.listRecent({
96770
- deviceIds: input.deviceIds,
96771
- ...input.since !== void 0 ? { since: input.since } : {},
96772
- ...input.until !== void 0 ? { until: input.until } : {},
96773
- limit: input.limit ?? 50,
96774
- ...input.classes !== void 0 ? { classes: input.classes } : {}
96775
- });
96776
- return { summaries: await this.listItemsFor(rows) };
96777
- }
96778
- /**
96779
- * Rows → what a LIST draws: the envelope plus the composition.
96780
- *
96781
- * Two bulk reads for the whole page, never per row: the members in one, their
96782
- * tracks (for the names) in one. The MEMBERS themselves stay off the list —
96783
- * a page of two hundred groups would otherwise carry a thousand records so
96784
- * the client could count them. `getSummary` is where the links live.
96785
- */
96786
- async listItemsFor(rows) {
96787
- const store = this.summaryStore;
96788
- if (!store || rows.length === 0) return [];
96789
- const members = await store.membersOfMany(rows.map((r) => r.id));
96790
- return await Promise.all(rows.map(async (row) => ({
96791
- summary: await this.toSummary(row),
96792
- ...summaryComposition(members.get(row.id) ?? [])
96793
- })));
96794
- }
96795
- /** The members of a page of summaries, with the labels a card can show —
96796
- * in ONE read of the members and ONE of their tracks. */
96797
- async labelledMembersFor(rows) {
96798
- const out = /* @__PURE__ */ new Map();
96799
- const store = this.summaryStore;
96800
- if (!store || rows.length === 0) return out;
96801
- const membersBySummary = await store.membersOfMany(rows.map((r) => r.id));
96802
- const trackIds = [...new Set([...membersBySummary.values()].flatMap((ms) => ms.map((m) => m.trackId)))];
96803
- let tracks = [];
96804
- try {
96805
- tracks = await this.trackStore?.getPersistedByTrackIds(trackIds) ?? [];
96806
- } catch (err) {
96807
- this.ctx.logger.warn("summary members: labels unavailable for this page", { meta: {
96808
- summaries: rows.length,
96809
- members: trackIds.length,
96810
- error: require_clip_model_registry.errMsg(err)
96811
- } });
96812
- }
96813
- for (const row of rows) out.set(row.id, withMemberLabels(membersBySummary.get(row.id) ?? [], tracks));
96814
- return out;
96815
- }
96816
- /**
96817
- * Rows → the shape every summary surface answers with: the envelope, its
96818
- * members, and what a thumbnail can render for each of them.
96819
- *
96820
- * THREE reads for the whole page, never per row: the members in one, their
96821
- * tracks in one, and the group shots through `toSummary`. A read per member
96822
- * is what made a sixty-group page take twenty seconds on the events feed.
96823
- */
96824
- /** ONE summary, with every member and the link each of them carries. */
96825
- async detailFor(row) {
96826
- const labelled = (await this.labelledMembersFor([row])).get(row.id) ?? [];
96827
- return {
96828
- summary: await this.toSummary(row),
96829
- members: labelled.map((m) => ({
96830
- trackId: m.trackId,
96831
- className: m.className,
96832
- role: m.role,
96833
- joinedAt: m.joinedAt,
96834
- ...m.label !== void 0 ? { label: m.label } : {},
96835
- ...m.subLabel !== void 0 ? { subLabel: m.subLabel } : {}
96836
- }))
96837
- };
96838
- }
96839
- async listRecentSummaries(input) {
96840
- const store = this.summaryStore;
96841
- if (!store) return {
96842
- summaries: [],
96843
- nextCursor: null
96844
- };
96845
- const page = await store.queryRecent({
96846
- deviceIds: input.deviceIds,
96847
- ...input.since !== void 0 ? { since: input.since } : {},
96848
- ...input.until !== void 0 ? { until: input.until } : {},
96849
- limit: input.limit,
96850
- ...input.cursor !== void 0 ? { cursor: input.cursor } : {},
96851
- ...input.classes !== void 0 ? { classes: input.classes } : {}
96852
- });
96853
- return {
96854
- summaries: await this.listItemsFor(page.summaries.map((d) => d.summary)),
96855
- nextCursor: page.nextCursor
96856
- };
96857
- }
96858
- async getSummary(input) {
96859
- const detail = await this.summaryStore?.get(input.id);
96860
- if (!detail) return null;
96861
- return await this.detailFor(detail.summary);
96862
- }
96863
- /** The row plus its group shot: the web companion when it landed, else the native key frame. */
96864
- async toSummary(row) {
96865
- const files = await this.mediaStore?.listInfoByOwner("summary", row.id) ?? [];
96866
- const shot = files.find((f) => f.kind === "keyFrameSmall") ?? files.find((f) => f.kind === "keyFrame");
96867
- return {
96868
- id: row.id,
96869
- deviceId: row.deviceId,
96870
- openedAt: row.openedAt,
96871
- lastActiveAt: row.lastActiveAt,
96872
- sealedAt: row.sealedAt,
96873
- memberCount: row.memberCount,
96874
- groupShotMediaId: shot?.mediaId ?? null
96875
- };
96876
- }
96877
94830
  /**
96878
94831
  * Every event kind the device can produce: built-ins (motion + audio), the
96879
94832
  * detection classes its CONFIGURED pipeline can emit, and sensor kinds from
@@ -97657,11 +95610,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
97657
95610
  },
97658
95611
  liveTrackIds: (ids) => trackStore.existingTrackIds(ids),
97659
95612
  liveEventIds: (table, ids) => eventStore.existingEventIdsIn(table, ids),
97660
- liveSummaryIds: (ids) => {
97661
- const summaries = this.summaryStore;
97662
- if (!summaries) throw new Error("summary store not built — probe unavailable");
97663
- return summaries.existingIds(ids);
97664
- },
97665
95613
  keptMediaKeys: async (keys) => this.keptStationaryMediaKeys(keys),
97666
95614
  reclaim: buildOrphanReclaimer({
97667
95615
  deleteFace: async (faceId) => {
@@ -99350,8 +97298,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
99350
97298
  mediaStore,
99351
97299
  faceStore: this.faceStore,
99352
97300
  plateStore: this.plateStore,
99353
- objectEmbeddingStore: this.objectEmbeddingStore,
99354
- summaryStore: this.summaryStore
97301
+ objectEmbeddingStore: this.objectEmbeddingStore
99355
97302
  };
99356
97303
  const absent = absentCascadeLegs(stores);
99357
97304
  if (absent.length > 0) this.ctx.logger.warn("track cascade is missing a leg — its rows will be left behind", { meta: {