@camstack/addon-post-analysis 1.2.110 → 1.2.116

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { $ as deriveRecordingMode, At as array, B as TimelapseRulePatchSchema, Bt as EventCategory, C as NcScheduleSchema, Ct as DeviceType, D as NcSystemEventKindSchema, Dt as nodePin, E as NcSnoozeSuppressedSchema, Et as isDeviceScopedCap, F as SCENE_DEFAULT_UNCOVERED_POLICY, Ft as object, G as alarmPanelCapability, H as TrackSourceSchema, I as SCENE_DIVERGED, It as partialRecord, J as audioModeOf, K as assertTimelapseCadences, L as SceneMonitorSchema, Lt as record, M as RetrainStatusSchema, Mt as discriminatedUnion, Nt as literal, O as NcTaxonomySchema, Ot as sleep, P as SCENE_DEFAULT_ANCHOR_THRESHOLD, Pt as number, Q as defineCustomActions, R as TIMELAPSE_DENSE_FLOOR_SEC, Rt as string, S as NcRuleTargetSchema, St as BaseAddon, T as NcSnoozeSchema, Tt as hydrateSchema, U as VISIT_MERGE_GAP_MS, V as TimelapseRuleSchema, W as addonWidgetsSourceCapability, X as cosineSimilarity$1, Y as buildEventKindDescriptor, Z as customAction, _ as NC_TAXONOMY, _t as systemEventFilterApplies, a as DETECTION_MACRO_CLASSES, at as isScheduleActive, b as NcRulePatchSchema, bt as zoneAnalyticsCapability, c as EVENT_KIND_BY_CAP, d as LabelAttributionSchema, dt as plateGalleryCapability, f as MACRO_LABELS, ft as readDeviceStateFrom, gt as subKindsOf, h as NC_DEFAULT_SNOOZE_MINUTES, ht as sceneMonitorCapability, it as isDetectionMacroClass, j as RECORDING_EXPORT_MAX_READ_BYTES, jt as boolean, k as OpsLogEntrySchema, kt as _enum, l as EVENT_PAD_MS, lt as pickClusterStepModels, m as NC_CONDITION_CATALOG, n as CLUSTER_MODEL_SCOPED_STEPS, nt as faceGalleryCapability, ot as kebabToCamel, p as NC_ALARM_SYSTEM_EVENT_KINDS, pt as readTimelapseGeneratedAt, q as audioMetricsCapability, r as DEFAULT_EVENT_COLOR, s as DeclaredDevices, st as notificationRulesCapability, t as BaseDevice, tt as encodeVectorBase64, u as FULL_IMAGE_BBOX, ut as pipelineAnalyticsCapability, v as NcConditionDescriptorSchema, vt as vectorDimFromBase64, w as NcSnoozeInputSchema, wt as createEvent, x as NcRuleSchema, xt as errMsg, y as NcRuleInputSchema, yt as videoclipsCapability, z as TimelapseRuleInputSchema, zt as unknown } from "../dist-BtpxOtGv.mjs";
1
+ import { $ as deriveRecordingMode, At as array, B as TimelapseRulePatchSchema, Bt as EventCategory, C as NcScheduleSchema, Ct as DeviceType, D as NcSystemEventKindSchema, Dt as nodePin, E as NcSnoozeSuppressedSchema, Et as isDeviceScopedCap, F as SCENE_DEFAULT_UNCOVERED_POLICY, Ft as object, G as alarmPanelCapability, H as TrackSourceSchema, I as SCENE_DIVERGED, It as partialRecord, J as audioModeOf, K as assertTimelapseCadences, L as SceneMonitorSchema, Lt as record, M as RetrainStatusSchema, Mt as discriminatedUnion, Nt as literal, O as NcTaxonomySchema, Ot as sleep, P as SCENE_DEFAULT_ANCHOR_THRESHOLD, Pt as number, Q as defineCustomActions, R as TIMELAPSE_DENSE_FLOOR_SEC, Rt as string, S as NcRuleTargetSchema, St as BaseAddon, T as NcSnoozeSchema, Tt as hydrateSchema, U as VISIT_MERGE_GAP_MS, V as TimelapseRuleSchema, W as addonWidgetsSourceCapability, X as cosineSimilarity$1, Y as buildEventKindDescriptor, Z as customAction, _ as NC_TAXONOMY, _t as systemEventFilterApplies, a as DETECTION_MACRO_CLASSES, at as isScheduleActive, b as NcRulePatchSchema, bt as zoneAnalyticsCapability, c as EVENT_KIND_BY_CAP, d as LabelAttributionSchema, dt as plateGalleryCapability, f as MACRO_LABELS, ft as readDeviceStateFrom, gt as subKindsOf, h as NC_DEFAULT_SNOOZE_MINUTES, ht as sceneMonitorCapability, it as isDetectionMacroClass, j as RECORDING_EXPORT_MAX_READ_BYTES, jt as boolean, k as OpsLogEntrySchema, kt as _enum, l as EVENT_PAD_MS, lt as pickClusterStepModels, m as NC_CONDITION_CATALOG, n as CLUSTER_MODEL_SCOPED_STEPS, nt as faceGalleryCapability, ot as kebabToCamel, p as NC_ALARM_SYSTEM_EVENT_KINDS, pt as readTimelapseGeneratedAt, q as audioMetricsCapability, r as DEFAULT_EVENT_COLOR, s as DeclaredDevices, st as notificationRulesCapability, t as BaseDevice, tt as encodeVectorBase64, u as FULL_IMAGE_BBOX, ut as pipelineAnalyticsCapability, v as NcConditionDescriptorSchema, vt as vectorDimFromBase64, w as NcSnoozeInputSchema, wt as createEvent, x as NcRuleSchema, xt as errMsg, y as NcRuleInputSchema, yt as videoclipsCapability, z as TimelapseRuleInputSchema, zt as unknown } from "../dist-B13sFjR2.mjs";
2
2
  import { t as __exportAll } from "../embedding-encoder/index.mjs";
3
3
  import * as fs from "node:fs";
4
4
  import { promises } from "node:fs";
@@ -5347,24 +5347,17 @@ function buildAlarmButtons(input) {
5347
5347
  *
5348
5348
  * ── TWO EXCLUSIVE MODES (operator decision 2026-08-14, D157) ───────────────
5349
5349
  *
5350
- * **LABEL mode** — the rule NAMES SOUNDS (`labels`). It confirms on the FIRST
5351
- * frame the classifier labels with one of them. No window, no percentage, no
5352
- * waiting.
5353
- *
5354
- * That is not a convenience: it is the only shape that can fire at all. The
5355
- * analyzer emits ~1 inference frame per second, but YAMNet puts a macro label
5356
- * on only one to three of them per episode — even through continuous crying.
5357
- * The maximum `hitPercent` ever measured on this installation's whole history
5358
- * was 40, under the shipped default of 60, so the previous windowed semantics
5359
- * meant a label rule could NEVER confirm. A percentage of frames is the wrong
5360
- * question to ask of a sparse classifier. The per-label confidence floor still
5361
- * applies — it is the analyzer's own `classificationMinScore`, applied per
5362
- * device before a label ever reaches this module.
5363
- *
5364
- * The rule's `throttle` cooldown is the ONLY brake in this mode. There is
5365
- * deliberately no re-arm timer here: a second brake nobody can see in the
5366
- * editor is how a rule ends up silently not firing, which is the failure this
5367
- * change exists to remove.
5350
+ * **LABEL mode** — the rule NAMES SOUNDS (`labels`). It confirms when
5351
+ * `confirmHits` labelled frames land inside `confirmWindowSec` (default 2
5352
+ * in 5 s). That is a COUNT of sparse frames, not a percentage of the
5353
+ * stream: YAMNet still labels only one to three frames per episode, so a
5354
+ * 60%-of-10s window can never fire, but two of those frames two seconds
5355
+ * apart can. A single-frame false positive (the live "Pianti" pattern)
5356
+ * never reaches 2. `confirmHits: 1` is the old first-frame behaviour.
5357
+ *
5358
+ * The per-label confidence floor still applies — it is the analyzer's own
5359
+ * `classificationMinScore`, applied per device before a label ever reaches
5360
+ * this module. The rule's `throttle` cooldown is the OTHER brake.
5368
5361
  *
5369
5362
  * **LEVEL mode** — the rule NAMES A LEVEL (`dbThreshold`). Unchanged, and the
5370
5363
  * window is the whole point: over `samplingSeconds`, at least `hitPercent`% of
@@ -5427,7 +5420,9 @@ function audioSpecFromCondition(condition) {
5427
5420
  const mode = audioModeOf(condition);
5428
5421
  if (mode === "label") return {
5429
5422
  mode: "label",
5430
- labels: [...new Set((condition.labels ?? []).map(normalizeAudioLabel))].sort()
5423
+ labels: [...new Set((condition.labels ?? []).map(normalizeAudioLabel))].sort(),
5424
+ confirmHits: condition.confirmHits ?? 2,
5425
+ confirmWindowSec: condition.confirmWindowSec ?? 5
5431
5426
  };
5432
5427
  const dbThreshold = condition.dbThreshold;
5433
5428
  if (mode === "level" && dbThreshold !== void 0) return {
@@ -5440,7 +5435,7 @@ function audioSpecFromCondition(condition) {
5440
5435
  }
5441
5436
  /** Stable, device-agnostic key of a spec — rules that agree share a window. */
5442
5437
  function audioSpecKey(spec) {
5443
- return spec.mode === "label" ? `label|${spec.labels.join(",")}` : `level|${String(spec.dbThreshold)}|h${String(spec.hitPercent)}|s${String(spec.samplingSeconds)}`;
5438
+ return spec.mode === "label" ? `label|${spec.labels.join(",")}|c${String(spec.confirmHits)}|w${String(spec.confirmWindowSec)}` : `level|${String(spec.dbThreshold)}|h${String(spec.hitPercent)}|s${String(spec.samplingSeconds)}`;
5444
5439
  }
5445
5440
  /**
5446
5441
  * The watched labels this sample carries, normalized and sorted. Empty = no
@@ -5465,6 +5460,8 @@ var AudioWatcher = class {
5465
5460
  watched = /* @__PURE__ */ new Map();
5466
5461
  /** LEVEL windows, keyed `${deviceId}|${specKey}`. */
5467
5462
  windows = /* @__PURE__ */ new Map();
5463
+ /** LABEL pending hits, keyed `${deviceId}|${specKey}` — timestamps only. */
5464
+ labelHits = /* @__PURE__ */ new Map();
5468
5465
  /**
5469
5466
  * Replace the watched spec set (rule-driven — recomputed on every rule
5470
5467
  * reload). Windows whose spec is no longer watched are DROPPED: a rule
@@ -5475,9 +5472,9 @@ var AudioWatcher = class {
5475
5472
  setWatchedSpecs(specs) {
5476
5473
  this.watched.clear();
5477
5474
  for (const spec of specs) this.watched.set(audioSpecKey(spec), spec);
5478
- for (const key of [...this.windows.keys()]) {
5475
+ for (const store of [this.windows, this.labelHits]) for (const key of [...store.keys()]) {
5479
5476
  const specKey = key.slice(key.indexOf("|") + 1);
5480
- if (!this.watched.has(specKey)) this.windows.delete(key);
5477
+ if (!this.watched.has(specKey)) store.delete(key);
5481
5478
  }
5482
5479
  }
5483
5480
  /** True when at least one spec is watched — the caller's cheap no-op gate. */
@@ -5493,7 +5490,7 @@ var AudioWatcher = class {
5493
5490
  if (this.watched.size === 0) return [];
5494
5491
  const confirmed = [];
5495
5492
  for (const [specKey, spec] of this.watched) {
5496
- const hit = spec.mode === "label" ? observeLabel(deviceId, spec, sample, now) : this.observeLevel(deviceId, specKey, spec, sample, now);
5493
+ const hit = spec.mode === "label" ? this.observeLabel(deviceId, specKey, spec, sample, now) : this.observeLevel(deviceId, specKey, spec, sample, now);
5497
5494
  if (hit !== null) confirmed.push(hit);
5498
5495
  }
5499
5496
  return confirmed;
@@ -5501,7 +5498,7 @@ var AudioWatcher = class {
5501
5498
  /** Drop every window of a camera (unbound, removed, audio switched off). */
5502
5499
  forgetDevice(deviceId) {
5503
5500
  const prefix = `${deviceId}|`;
5504
- for (const key of [...this.windows.keys()]) if (key.startsWith(prefix)) this.windows.delete(key);
5501
+ for (const store of [this.windows, this.labelHits]) for (const key of [...store.keys()]) if (key.startsWith(prefix)) store.delete(key);
5505
5502
  }
5506
5503
  observeLevel(deviceId, specKey, spec, sample, now) {
5507
5504
  const key = `${deviceId}|${specKey}`;
@@ -5550,23 +5547,34 @@ var AudioWatcher = class {
5550
5547
  spec
5551
5548
  };
5552
5549
  }
5550
+ /**
5551
+ * LABEL mode: confirm when `confirmHits` labelled frames land inside
5552
+ * `confirmWindowSec`. `confirmHits <= 1` is first-frame fire (stateless).
5553
+ * Confirming re-arms — the next episode needs a fresh count.
5554
+ */
5555
+ observeLabel(deviceId, specKey, spec, sample, now) {
5556
+ const matched = matchedAudioLabels(sample, spec.labels);
5557
+ if (matched.length === 0) return null;
5558
+ const hit = {
5559
+ deviceId,
5560
+ timestamp: now,
5561
+ labels: matched,
5562
+ ...sample.dbfs !== void 0 && Number.isFinite(sample.dbfs) ? { peakDbfs: sample.dbfs } : {},
5563
+ spec
5564
+ };
5565
+ if (spec.confirmHits <= 1) return hit;
5566
+ const key = `${deviceId}|${specKey}`;
5567
+ const cutoff = now - spec.confirmWindowSec * 1e3;
5568
+ const kept = (this.labelHits.get(key) ?? []).filter((at) => at > cutoff);
5569
+ kept.push(now);
5570
+ if (kept.length < spec.confirmHits) {
5571
+ this.labelHits.set(key, kept);
5572
+ return null;
5573
+ }
5574
+ this.labelHits.delete(key);
5575
+ return hit;
5576
+ }
5553
5577
  };
5554
- /**
5555
- * LABEL mode: confirm on the first frame carrying a watched label. Stateless
5556
- * by construction — nothing is remembered between frames, so there is no
5557
- * window to be not-full and no percentage to fall short of.
5558
- */
5559
- function observeLabel(deviceId, spec, sample, now) {
5560
- const matched = matchedAudioLabels(sample, spec.labels);
5561
- if (matched.length === 0) return null;
5562
- return {
5563
- deviceId,
5564
- timestamp: now,
5565
- labels: matched,
5566
- ...sample.dbfs !== void 0 && Number.isFinite(sample.dbfs) ? { peakDbfs: sample.dbfs } : {},
5567
- spec
5568
- };
5569
- }
5570
5578
  //#endregion
5571
5579
  //#region src/shared/llm-vision/prompt-hygiene.ts
5572
5580
  /**
@@ -7913,7 +7921,10 @@ function attachmentKindPreference(policy, ownerKind) {
7913
7921
  if (policy === "keyFrame") return ownerKind === "track" ? [
7914
7922
  "keyFrameSmall",
7915
7923
  "keyFrame",
7916
- "fullFrameBoxed"
7924
+ "fullFrameBoxed",
7925
+ "firstFrame",
7926
+ "lastFrame",
7927
+ "snapshot"
7917
7928
  ] : [
7918
7929
  "fullFrameBoxed",
7919
7930
  "fullFrame",
@@ -15121,22 +15132,29 @@ function selectSummaryTiles(candidates, opts) {
15121
15132
  };
15122
15133
  }
15123
15134
  /**
15135
+ * Ranked leftover after the quota pass — refill a mosaic that dropped
15136
+ * duplicate frames or missing crops without walking the same track twice.
15137
+ */
15138
+ function remainingSummaryCandidates(kept, taken, rank) {
15139
+ const takenIds = new Set(taken.map((c) => c.trackId));
15140
+ return kept.filter((c) => !takenIds.has(c.trackId)).toSorted(byRankDesc(rank));
15141
+ }
15142
+ /**
15124
15143
  * The media a tile is drawn from, best first.
15125
15144
  *
15126
- * `keyFrameSmall` heads the ladder because it is the CLEAN best-shot frame at
15127
- * 960 px — the whole scene, legible on a phone, cheap. `fullFrameBoxed` and
15128
- * `firstFrame` are deliberately absent: a drawn rectangle is a permanent lie in
15129
- * a digest tile, the operator asked for a mosaic that is ALWAYS CLEAN, and (for
15130
- * P2) a box burnt into a pixel is text inside an image — an injection channel
15131
- * and a source of hallucination. Boxes stay data.
15145
+ * For a group: `groupCrop` (union crop, the group's own blob) then
15146
+ * `nativeFrame` (pointer at the anchor's clean keyFrame). A digest of a
15147
+ * cohort may show the native still when the crop was not written; a
15148
+ * member's `keyFrame` listed as itself stays off this ladder so tracker
15149
+ * ID churn cannot refill a mosaic with eleven driveways. Boxed frames stay
15150
+ * absent: a drawn rectangle is a permanent lie in a digest tile.
15132
15151
  */
15133
15152
  var SUMMARY_MEDIA_LADDER = [
15134
- "keyFrameSmall",
15135
- "keyFrame",
15136
- "snapshot",
15137
- "fullFrame",
15153
+ "groupCrop",
15154
+ "nativeFrame",
15155
+ "thumbnail",
15138
15156
  "thumbnailSmall",
15139
- "thumbnail"
15157
+ "snapshot"
15140
15158
  ];
15141
15159
  /**
15142
15160
  * Pick ONE media row for a track — the cheap question first (index rows only),
@@ -15154,6 +15172,13 @@ function pickSummaryMedia(rows, aroundMs) {
15154
15172
  }
15155
15173
  return null;
15156
15174
  }
15175
+ /**
15176
+ * Same camera, same wall-clock second → same JPEG. Tracker ID churn
15177
+ * otherwise fills a mosaic with eleven copies of one driveway still.
15178
+ */
15179
+ function frameDedupeKey(deviceId, mediaTimestamp) {
15180
+ return `${deviceId}:${Math.floor(mediaTimestamp / 1e3)}`;
15181
+ }
15157
15182
  //#endregion
15158
15183
  //#region src/notification-center/summary/summary-window.ts
15159
15184
  /**
@@ -15212,7 +15237,7 @@ var SUMMARY_TRACK_QUERY_LIMIT = 2e3;
15212
15237
  function summaryMediaOwnerId(ruleId, windowEndMs) {
15213
15238
  return `summary-${ruleId}-${windowEndMs}`;
15214
15239
  }
15215
- var EMPTY_REPORT = {
15240
+ var EMPTY_REPORT$1 = {
15216
15241
  produced: 0,
15217
15242
  collecting: 0,
15218
15243
  barrier: 0,
@@ -15276,9 +15301,9 @@ var NcSummaryProducer = class {
15276
15301
  * must not open ten simultaneous render/publish sequences.
15277
15302
  */
15278
15303
  async tick() {
15279
- if (this.tickInFlight) return EMPTY_REPORT;
15304
+ if (this.tickInFlight) return EMPTY_REPORT$1;
15280
15305
  this.tickInFlight = true;
15281
- const report = { ...EMPTY_REPORT };
15306
+ const report = { ...EMPTY_REPORT$1 };
15282
15307
  try {
15283
15308
  const now = this.deps.now();
15284
15309
  const rules = this.deps.store.listEnabled();
@@ -15453,7 +15478,12 @@ var NcSummaryProducer = class {
15453
15478
  droppedStationary: filtered.droppedStationary
15454
15479
  };
15455
15480
  }
15456
- const loaded = await this.loadTiles(rule, selection.tiles);
15481
+ const loaded = await this.loadTiles({
15482
+ rule,
15483
+ preferred: selection.tiles,
15484
+ refill: remainingSummaryCandidates(filtered.kept, selection.tiles, rule.rank),
15485
+ maxTiles: rule.maxTiles
15486
+ });
15457
15487
  const detections = countDetections({ tracks: selection.tiles.map((t) => ({
15458
15488
  firstSeen: t.firstSeen,
15459
15489
  lastSeen: t.lastSeen,
@@ -15511,7 +15541,7 @@ var NcSummaryProducer = class {
15511
15541
  tiles: loaded.length,
15512
15542
  cameras: new Set(loaded.map((t) => t.candidate.deviceId)).size,
15513
15543
  camerasDropped: selection.camerasDropped,
15514
- droppedNoMedia: selection.tiles.length - loaded.length,
15544
+ droppedNoMedia: Math.max(0, rule.maxTiles - loaded.length),
15515
15545
  mosaicBytes: mosaic.bytes,
15516
15546
  mosaicPublished: mosaic.ref !== null,
15517
15547
  enqueued,
@@ -15575,10 +15605,18 @@ var NcSummaryProducer = class {
15575
15605
  }
15576
15606
  return all;
15577
15607
  }
15578
- /** One blob per tile — the cheap question (index rows) always first. */
15579
- async loadTiles(rule, tiles) {
15608
+ /** One blob per tile — the cheap question (index rows) always first.
15609
+ * Duplicate camera-seconds are skipped so tracker ID churn cannot paint
15610
+ * the same driveway eleven times; leftover ranked candidates refill. */
15611
+ async loadTiles(input) {
15580
15612
  const out = [];
15581
- for (const candidate of tiles) {
15613
+ const seenFrames = /* @__PURE__ */ new Set();
15614
+ const seenIds = /* @__PURE__ */ new Set();
15615
+ const queue = [...input.preferred, ...input.refill];
15616
+ for (const candidate of queue) {
15617
+ if (out.length >= input.maxTiles) break;
15618
+ if (seenIds.has(candidate.trackId)) continue;
15619
+ seenIds.add(candidate.trackId);
15582
15620
  const tags = { deviceId: candidate.deviceId };
15583
15621
  try {
15584
15622
  const rows = await this.deps.listMediaInfo(candidate.trackId);
@@ -15587,25 +15625,38 @@ var NcSummaryProducer = class {
15587
15625
  this.deps.logger.debug("summary tile dropped: the track carries no clean media", {
15588
15626
  tags,
15589
15627
  meta: {
15590
- ruleId: rule.id,
15628
+ ruleId: input.rule.id,
15591
15629
  trackId: candidate.trackId,
15592
15630
  mediaRows: rows.length
15593
15631
  }
15594
15632
  });
15595
15633
  continue;
15596
15634
  }
15635
+ const frameKey = frameDedupeKey(candidate.deviceId, picked.timestamp);
15636
+ if (seenFrames.has(frameKey)) {
15637
+ this.deps.logger.debug("summary tile dropped: same frame as an earlier tile", {
15638
+ tags,
15639
+ meta: {
15640
+ ruleId: input.rule.id,
15641
+ trackId: candidate.trackId,
15642
+ frameKey
15643
+ }
15644
+ });
15645
+ continue;
15646
+ }
15597
15647
  const jpeg = await this.deps.readMedia(picked.key);
15598
15648
  if (jpeg === null || jpeg.byteLength === 0) {
15599
15649
  this.deps.logger.debug("summary tile dropped: the media blob could not be read", {
15600
15650
  tags,
15601
15651
  meta: {
15602
- ruleId: rule.id,
15652
+ ruleId: input.rule.id,
15603
15653
  trackId: candidate.trackId,
15604
15654
  mediaKey: picked.key
15605
15655
  }
15606
15656
  });
15607
15657
  continue;
15608
15658
  }
15659
+ seenFrames.add(frameKey);
15609
15660
  out.push({
15610
15661
  candidate,
15611
15662
  jpeg
@@ -15614,7 +15665,7 @@ var NcSummaryProducer = class {
15614
15665
  this.deps.logger.warn("summary tile dropped: reading its media threw", {
15615
15666
  tags,
15616
15667
  meta: {
15617
- ruleId: rule.id,
15668
+ ruleId: input.rule.id,
15618
15669
  trackId: candidate.trackId,
15619
15670
  error: String(err)
15620
15671
  }
@@ -15989,7 +16040,7 @@ var NcSummaryRuleInputSchema = object({
15989
16040
  deviceIds: DeviceIdsField,
15990
16041
  window: NcSummaryWindowSchema,
15991
16042
  filters: NcSummaryFiltersSchema.optional(),
15992
- maxTiles: MaxTilesField.default(9),
16043
+ maxTiles: MaxTilesField.default(12),
15993
16044
  maxAiImages: MaxAiImagesField.default(6),
15994
16045
  rank: NcSummaryRankSchema.default("importance"),
15995
16046
  barrierSeconds: BarrierSecondsField.default(120),
@@ -18340,6 +18391,15 @@ var TIMELAPSE_LOOKBACK_MS = 8 * 864e5;
18340
18391
  function timelapseEligibilityFloor(rule, deviceId) {
18341
18392
  return Math.max(readTimelapseGeneratedAt(rule, deviceId), rule.createdAt);
18342
18393
  }
18394
+ var EMPTY_REPORT = {
18395
+ produced: 0,
18396
+ waiting: 0,
18397
+ alreadyDone: 0,
18398
+ beforeFloor: 0,
18399
+ failed: 0,
18400
+ idle: 0,
18401
+ skippedInFlight: 0
18402
+ };
18343
18403
  /** The in-flight map's key. One render per (rule, camera) pair by construction. */
18344
18404
  function renderFlightKey(ruleId, deviceId) {
18345
18405
  return `${ruleId}:${String(deviceId)}`;
@@ -18347,7 +18407,8 @@ function renderFlightKey(ruleId, deviceId) {
18347
18407
  var TimelapseScheduler = class {
18348
18408
  deps;
18349
18409
  timer = null;
18350
- tickInFlight = false;
18410
+ /** True only while this tick is scanning eligibility — not while ffmpeg runs. */
18411
+ scanning = false;
18351
18412
  inFlight = /* @__PURE__ */ new Map();
18352
18413
  settleMs;
18353
18414
  lookbackMs;
@@ -18394,25 +18455,29 @@ var TimelapseScheduler = class {
18394
18455
  this.timer = null;
18395
18456
  }
18396
18457
  }
18397
- /** One pass over every enabled rule × camera. Single-flight. */
18458
+ /**
18459
+ * One pass over every enabled rule × camera.
18460
+ *
18461
+ * Eligibility is scanned under a short `scanning` lock so two ticks do not
18462
+ * double-launch the same pair. Renders themselves are started together —
18463
+ * awaiting them one-by-one made camera B wait for camera A's ffmpeg
18464
+ * (live: 1441 until 08:39, 615 until 08:46, both windows closed at 07:00).
18465
+ * A later tick that meets a pair already in `inFlight` counts it and
18466
+ * moves on, so a 30-minute render does not make the producer go deaf.
18467
+ */
18398
18468
  async tick() {
18399
- if (this.tickInFlight) return {
18400
- produced: 0,
18401
- waiting: 0,
18402
- alreadyDone: 0,
18403
- beforeFloor: 0,
18404
- failed: 0,
18405
- idle: 0
18406
- };
18407
- this.tickInFlight = true;
18469
+ if (this.scanning) return { ...EMPTY_REPORT };
18470
+ this.scanning = true;
18408
18471
  const report = {
18409
18472
  produced: 0,
18410
18473
  waiting: 0,
18411
18474
  alreadyDone: 0,
18412
18475
  beforeFloor: 0,
18413
18476
  failed: 0,
18414
- idle: 0
18477
+ idle: 0,
18478
+ skippedInFlight: 0
18415
18479
  };
18480
+ const jobs = [];
18416
18481
  try {
18417
18482
  const now = this.deps.now();
18418
18483
  for (const rule of this.deps.store.listEnabled()) {
@@ -18447,13 +18512,20 @@ var TimelapseScheduler = class {
18447
18512
  });
18448
18513
  continue;
18449
18514
  }
18450
- if ((await this.produce(rule, deviceId, window, { stamp: true })).produced > 0) report.produced += 1;
18451
- else report.failed += 1;
18515
+ if (this.inFlight.has(renderFlightKey(rule.id, deviceId))) {
18516
+ report.skippedInFlight += 1;
18517
+ continue;
18518
+ }
18519
+ jobs.push(this.produce(rule, deviceId, window, { stamp: true }).then((ok) => {
18520
+ if (ok.produced > 0) report.produced += 1;
18521
+ else report.failed += 1;
18522
+ }));
18452
18523
  }
18453
18524
  }
18454
18525
  } finally {
18455
- this.tickInFlight = false;
18526
+ this.scanning = false;
18456
18527
  }
18528
+ await Promise.all(jobs);
18457
18529
  return report;
18458
18530
  }
18459
18531
  /**
@@ -27607,6 +27679,35 @@ function pairRiders(detections) {
27607
27679
  return pairs;
27608
27680
  }
27609
27681
  //#endregion
27682
+ //#region src/pipeline-analytics/pipeline/tracker/rescue-growth.ts
27683
+ /** How far the grown box's centroid may sit, as a multiple of the LARGER
27684
+ * diagonal. 1.35 covers the live 4285605a bird→dog jump (~75px / 59px). */
27685
+ var GROWTH_CENTROID_FACTOR = 1.35;
27686
+ function intersectionArea(a, b) {
27687
+ const x1 = Math.max(a.x, b.x);
27688
+ const y1 = Math.max(a.y, b.y);
27689
+ const x2 = Math.min(a.x + a.w, b.x + b.w);
27690
+ const y2 = Math.min(a.y + a.h, b.y + b.h);
27691
+ if (x2 <= x1 || y2 <= y1) return 0;
27692
+ return (x2 - x1) * (y2 - y1);
27693
+ }
27694
+ /** Same-class partial crop growing into (or collapsing from) a full-body box
27695
+ * with IoU 0. Used by rescue and spawn-dedup so we do not mint a second id.
27696
+ * A SMALL box nested inside a LARGE one is occlusion, not growth — those
27697
+ * stay two tracks (sort-tracker occlusion suite). */
27698
+ function growthAdjacentMatch(track, det) {
27699
+ const trackArea = track.w * track.h;
27700
+ const detArea = det.w * det.h;
27701
+ if (trackArea <= 0 || detArea <= 0) return false;
27702
+ const larger = Math.max(trackArea, detArea);
27703
+ const smaller = Math.min(trackArea, detArea);
27704
+ if (larger / smaller < 1.5) return false;
27705
+ if (intersectionArea(track, det) / smaller >= .5) return false;
27706
+ const tc = bboxCentroid(track);
27707
+ const dc = bboxCentroid(det);
27708
+ return Math.hypot(tc.x - dc.x, tc.y - dc.y) <= GROWTH_CENTROID_FACTOR * Math.max(Math.hypot(track.w, track.h), Math.hypot(det.w, det.h));
27709
+ }
27710
+ //#endregion
27610
27711
  //#region src/pipeline-analytics/pipeline/tracker/sort-tracker.ts
27611
27712
  var DEFAULT_TRACKER_CONFIG = {
27612
27713
  iouThreshold: .3,
@@ -27734,7 +27835,7 @@ var SortTracker = class SortTracker {
27734
27835
  * and earlier same-frame spawns).
27735
27836
  */
27736
27837
  isDuplicateSpawn(det, live) {
27737
- for (const t of live) if (this.dedupCompatible(t.class, det.class) && iou$2(det.bbox, t.bbox) >= this.config.dedupSpawnIou) return true;
27838
+ for (const t of live) if (this.dedupCompatible(t.class, det.class) && (iou$2(det.bbox, t.bbox) >= this.config.dedupSpawnIou || growthAdjacentMatch(t.bbox, det.bbox))) return true;
27738
27839
  if (this.config.personAnimalDedup && det.class === ANIMAL_CLASS && det.score < this.config.animalOverPersonMaxScore) {
27739
27840
  for (const t of live) if (t.class === PERSON_CLASS && iou$2(det.bbox, t.bbox) >= this.config.animalOverPersonIou) return true;
27740
27841
  }
@@ -27828,7 +27929,8 @@ var SortTracker = class SortTracker {
27828
27929
  const dc = bboxCentroid(det.bbox);
27829
27930
  const dist = Math.hypot(tc.x - dc.x, tc.y - dc.y);
27830
27931
  const diag = Math.hypot(track.bbox.w, track.bbox.h);
27831
- return dist <= this.config.rescueCentroidFactor * diag;
27932
+ if (dist <= this.config.rescueCentroidFactor * diag) return true;
27933
+ return growthAdjacentMatch(track.bbox, det.bbox);
27832
27934
  }
27833
27935
  /** Fold a matched detection's class + score into a track's lifetime vote
27834
27936
  * tally. A non-positive score still counts as an infinitesimal vote so a
@@ -28153,6 +28255,7 @@ var StateAnalyzer = class {
28153
28255
  enteredAt: timestamp,
28154
28256
  stationarySince: void 0,
28155
28257
  totalDistancePx: 0,
28258
+ startPosition: center,
28156
28259
  lastPosition: center,
28157
28260
  frameCount: 1
28158
28261
  };
@@ -28172,8 +28275,9 @@ var StateAnalyzer = class {
28172
28275
  else if (velocity <= this.config.velocityThreshold) {
28173
28276
  if (!ts.stationarySince) ts.stationarySince = timestamp;
28174
28277
  const stationaryDurationSec = (timestamp - ts.stationarySince) / 1e3;
28278
+ const neverMoved = Math.hypot(ts.lastPosition.x - ts.startPosition.x, ts.lastPosition.y - ts.startPosition.y) <= this.config.velocityThreshold;
28175
28279
  if (stationaryDurationSec >= this.config.loiteringThresholdSec) state = "loitering";
28176
- else if (stationaryDurationSec >= this.config.stationaryThresholdSec) state = "stationary";
28280
+ else if (neverMoved || stationaryDurationSec >= this.config.stationaryThresholdSec) state = "stationary";
28177
28281
  else state = "moving";
28178
28282
  } else {
28179
28283
  ts.stationarySince = void 0;
@@ -29006,6 +29110,22 @@ function promoteSemanticBestFrame(deps, input) {
29006
29110
  };
29007
29111
  }
29008
29112
  //#endregion
29113
+ //#region src/pipeline-analytics/pipeline/semantic-detail-bbox.ts
29114
+ /** Which box the semantic best-shot should crop: a plate/face DETAIL box
29115
+ * when we have one, otherwise the subject box. */
29116
+ function bboxForSemanticPromotion(className, detailBbox, subjectBbox) {
29117
+ if ((className === "plate" || className === "face") && detailBbox !== void 0 && detailBbox.w > 0 && detailBbox.h > 0) return detailBbox;
29118
+ return subjectBbox;
29119
+ }
29120
+ //#endregion
29121
+ //#region src/pipeline-analytics/pipeline/first-frame-retry-bbox.ts
29122
+ /** Crop box for a late firstFrame retry: the BIRTH geometry, not the
29123
+ * later frozen/coasted box (track 1b659d56). */
29124
+ function firstFrameRetryBbox(current, birth) {
29125
+ if (birth !== void 0 && birth.w > 0 && birth.h > 0) return { ...birth };
29126
+ return { ...current };
29127
+ }
29128
+ //#endregion
29009
29129
  //#region src/pipeline-analytics/store/zone-geometry.ts
29010
29130
  /**
29011
29131
  * Normalized min/max envelope over every position's bbox. Returns `null`
@@ -29220,6 +29340,7 @@ var EMPTY_CASCADE_TALLY = {
29220
29340
  faces: 0,
29221
29341
  plates: 0,
29222
29342
  embeddings: 0,
29343
+ groups: 0,
29223
29344
  bytesReclaimed: 0
29224
29345
  };
29225
29346
  /** Fold a leaf store's receipts into a running tally. */
@@ -29263,6 +29384,13 @@ function addLeafCounts(tally, counts) {
29263
29384
  bytesReclaimed
29264
29385
  };
29265
29386
  break;
29387
+ case "groups":
29388
+ next = {
29389
+ ...next,
29390
+ groups: next.groups + c.rows,
29391
+ bytesReclaimed
29392
+ };
29393
+ break;
29266
29394
  }
29267
29395
  }
29268
29396
  return next;
@@ -29276,6 +29404,7 @@ function mergeCascadeTallies(a, b) {
29276
29404
  faces: a.faces + b.faces,
29277
29405
  plates: a.plates + b.plates,
29278
29406
  embeddings: a.embeddings + b.embeddings,
29407
+ groups: a.groups + b.groups,
29279
29408
  bytesReclaimed: a.bytesReclaimed + b.bytesReclaimed
29280
29409
  };
29281
29410
  }
@@ -29324,6 +29453,7 @@ function buildAnalyticsCascadeRegistry(stores) {
29324
29453
  if (stores.faceStore) registry.push(stores.faceStore);
29325
29454
  if (stores.plateStore) registry.push(stores.plateStore);
29326
29455
  if (stores.objectEmbeddingStore) registry.push(stores.objectEmbeddingStore);
29456
+ if (stores.groupStore) registry.push(stores.groupStore);
29327
29457
  return registry;
29328
29458
  }
29329
29459
  /**
@@ -31034,7 +31164,8 @@ var OWNER_KINDS = [
31034
31164
  "identity",
31035
31165
  "plate",
31036
31166
  "vehicle",
31037
- "scene"
31167
+ "scene",
31168
+ "group"
31038
31169
  ];
31039
31170
  /**
31040
31171
  * Narrow an arbitrary string to an `OwnerKind`.
@@ -31783,6 +31914,10 @@ var MediaStore = class {
31783
31914
  async deleteForTracks(trackIds) {
31784
31915
  return this.deleteForOwner("track", trackIds);
31785
31916
  }
31917
+ /** Delete the group's OWN crop. The native frame is a pointer and is not here. */
31918
+ async deleteByGroups(groupIds) {
31919
+ return this.deleteForOwnerCounted("group", groupIds);
31920
+ }
31786
31921
  /**
31787
31922
  * `TrackScopedStore` contract for the retention cascade: delete all media
31788
31923
  * owned by the given tracks — the tracks' OWN media (`ownerKind:'track'`) plus
@@ -39836,7 +39971,9 @@ function resolvePackageDropSettings(raw) {
39836
39971
  function planPeriodicMedia(input) {
39837
39972
  const appendSnapshot = input.dueSnapshot;
39838
39973
  const lastFrameInterval = input.lastFrameIntervalMs ?? input.intervalMs;
39839
- const rollingLastFrame = input.saveThumbnails && Math.floor(input.now / lastFrameInterval) > Math.floor(input.lastFrameAt / lastFrameInterval) && !appendSnapshot;
39974
+ const lastFrameElapsed = input.saveThumbnails && Math.floor(input.now / lastFrameInterval) > Math.floor(input.lastFrameAt / lastFrameInterval);
39975
+ const firstFrameLanded = input.firstFrameLanded ?? true;
39976
+ const rollingLastFrame = lastFrameElapsed && !appendSnapshot && firstFrameLanded;
39840
39977
  const thumbnailLanded = input.thumbnailLanded ?? true;
39841
39978
  return {
39842
39979
  appendSnapshot,
@@ -40069,6 +40206,1404 @@ var DeferredBirthRegistry = class {
40069
40206
  this.byDevice.delete(deviceKey);
40070
40207
  }
40071
40208
  };
40209
+ var AnalyticsGroupRecordSchema = object({
40210
+ id: string(),
40211
+ deviceId: number().int(),
40212
+ openedAt: number().int(),
40213
+ closedAt: number().int(),
40214
+ timestamp: number().int(),
40215
+ memberCount: number().int(),
40216
+ memberTrackIds: array(string()).readonly(),
40217
+ className: string(),
40218
+ classes: array(string()).readonly(),
40219
+ /** Relative event-media path, or null when the group has no picture yet. */
40220
+ mediaUrl: string().nullable(),
40221
+ singleton: boolean()
40222
+ });
40223
+ var AnalyticsGroupMemberSchema = object({
40224
+ trackId: string(),
40225
+ deviceId: number().int(),
40226
+ className: string(),
40227
+ firstSeen: number().int(),
40228
+ lastSeen: number().int(),
40229
+ mediaUrl: string().nullable()
40230
+ });
40231
+ var AnalyticsGroupDetailSchema = AnalyticsGroupRecordSchema.extend({ members: array(AnalyticsGroupMemberSchema).readonly() });
40232
+ var groupActions = defineCustomActions({
40233
+ "analytics.listGroups": customAction(object({
40234
+ deviceId: number().int().optional(),
40235
+ sinceMs: number().int().optional(),
40236
+ untilMs: number().int().optional(),
40237
+ limit: number().int().min(1).max(100).optional(),
40238
+ offset: number().int().min(0).optional()
40239
+ }), object({
40240
+ groups: array(AnalyticsGroupRecordSchema).readonly(),
40241
+ hasMore: boolean()
40242
+ })),
40243
+ "analytics.getGroup": customAction(object({ groupId: string().min(1) }), object({ group: AnalyticsGroupDetailSchema.nullable() }))
40244
+ });
40245
+ //#endregion
40246
+ //#region src/pipeline-analytics/group/group-query.ts
40247
+ /**
40248
+ * Stamp a relative event-media URL.
40249
+ *
40250
+ * Media keys contain `:` and the data-plane serves them by key. Track ids are
40251
+ * bare and fall through to the existing event/track thumbnail resolver. Never
40252
+ * percent-encode: the handler does not URL-decode the path.
40253
+ */
40254
+ function stampEventMediaUrl(baseUrl, id) {
40255
+ if (baseUrl === null || baseUrl.length === 0) return null;
40256
+ if (id === null || id === void 0 || id.length === 0) return null;
40257
+ return `${baseUrl}/${id}`;
40258
+ }
40259
+ /** Union of every class on the members; `className` is the first, stable order. */
40260
+ function aggregateGroupClasses(tracks) {
40261
+ const seen = /* @__PURE__ */ new Set();
40262
+ const classes = [];
40263
+ for (const track of tracks) for (const name of [track.className, ...track.classes ?? []]) {
40264
+ if (name.length === 0 || seen.has(name)) continue;
40265
+ seen.add(name);
40266
+ classes.push(name);
40267
+ }
40268
+ return {
40269
+ className: classes[0] ?? "unknown",
40270
+ classes
40271
+ };
40272
+ }
40273
+ function toGroupRecord(input) {
40274
+ const { className, classes } = aggregateGroupClasses(input.tracks);
40275
+ const mediaId = input.group.mediaKey ?? input.group.nativeKey ?? (input.group.memberCount === 1 ? input.memberTrackIds[0] : null);
40276
+ return {
40277
+ id: input.group.id,
40278
+ deviceId: input.group.deviceId,
40279
+ openedAt: input.group.openedAt,
40280
+ closedAt: input.group.closedAt,
40281
+ timestamp: input.group.timestamp,
40282
+ memberCount: input.memberTrackIds.length > 0 ? input.memberTrackIds.length : input.group.memberCount,
40283
+ memberTrackIds: [...input.memberTrackIds],
40284
+ className,
40285
+ classes: [...classes],
40286
+ mediaUrl: stampEventMediaUrl(input.eventMediaBaseUrl, mediaId),
40287
+ singleton: input.memberTrackIds.length <= 1
40288
+ };
40289
+ }
40290
+ function toGroupMember(member, track, eventMediaBaseUrl) {
40291
+ return {
40292
+ trackId: member.trackId,
40293
+ deviceId: member.deviceId,
40294
+ className: track?.className ?? "unknown",
40295
+ firstSeen: track?.firstSeen ?? member.timestamp,
40296
+ lastSeen: track?.lastSeen ?? member.timestamp,
40297
+ mediaUrl: stampEventMediaUrl(eventMediaBaseUrl, member.trackId)
40298
+ };
40299
+ }
40300
+ function trackToLite(track) {
40301
+ return {
40302
+ trackId: track.trackId,
40303
+ className: track.className,
40304
+ classes: track.classes,
40305
+ firstSeen: track.firstSeen,
40306
+ lastSeen: track.lastSeen
40307
+ };
40308
+ }
40309
+ async function listGroupRecords(ports, input) {
40310
+ const page = await ports.groupStore.listRecent(input);
40311
+ const groups = [];
40312
+ for (const group of page.groups) groups.push(await buildGroupRecord(ports, group));
40313
+ return {
40314
+ groups,
40315
+ hasMore: page.hasMore
40316
+ };
40317
+ }
40318
+ async function getGroupDetail(ports, groupId) {
40319
+ const group = await ports.groupStore.get(groupId);
40320
+ if (group === null) return null;
40321
+ const members = await ports.groupStore.listMembers(groupId);
40322
+ const lites = [];
40323
+ const rows = [];
40324
+ for (const member of members) {
40325
+ const track = await ports.loadTrack(member.trackId);
40326
+ const lite = track === null ? null : trackToLite(track);
40327
+ if (lite !== null) lites.push(lite);
40328
+ rows.push(toGroupMember(member, lite, ports.eventMediaBaseUrl()));
40329
+ }
40330
+ return {
40331
+ ...toGroupRecord({
40332
+ group,
40333
+ memberTrackIds: members.map((m) => m.trackId),
40334
+ tracks: lites,
40335
+ eventMediaBaseUrl: ports.eventMediaBaseUrl()
40336
+ }),
40337
+ members: rows
40338
+ };
40339
+ }
40340
+ async function buildGroupRecord(ports, group) {
40341
+ const members = await ports.groupStore.listMembers(group.id);
40342
+ const lites = [];
40343
+ for (const member of members) {
40344
+ const track = await ports.loadTrack(member.trackId);
40345
+ if (track !== null) lites.push(trackToLite(track));
40346
+ }
40347
+ return toGroupRecord({
40348
+ group,
40349
+ memberTrackIds: members.map((m) => m.trackId),
40350
+ tracks: lites,
40351
+ eventMediaBaseUrl: ports.eventMediaBaseUrl()
40352
+ });
40353
+ }
40354
+ /**
40355
+ * Gallery and digest read the same ladder: groupCrop, then the native-frame
40356
+ * pointer, then member subject crops. A multi with no crop still has a
40357
+ * picture if the sealer stored `nativeKey`.
40358
+ */
40359
+ function digestMediaRows(input) {
40360
+ const owned = [];
40361
+ if (input.mediaKey !== null && input.mediaKey.length > 0) owned.push({
40362
+ key: input.mediaKey,
40363
+ kind: "groupCrop",
40364
+ timestamp: input.timestamp
40365
+ });
40366
+ if (input.nativeKey !== null && input.nativeKey.length > 0) owned.push({
40367
+ key: input.nativeKey,
40368
+ kind: "nativeFrame",
40369
+ timestamp: input.timestamp
40370
+ });
40371
+ if (owned.length > 0) return owned;
40372
+ return input.memberRows;
40373
+ }
40374
+ var MATCH_WINDOW_MS = 1e3;
40375
+ var MOVE_EPS = .35;
40376
+ /** Live-assigner window: same predicate, recent positions only. */
40377
+ var LIVE_WINDOW_MS = 3e3;
40378
+ /** Registry stationary is out; audio has no bbox and is never a member or a singleton. */
40379
+ function isEligibleMember(track) {
40380
+ if (track.closedReason === "stationary-promotion") return false;
40381
+ if (track.className === "audio") return false;
40382
+ return true;
40383
+ }
40384
+ function diag(bbox) {
40385
+ return Math.hypot(bbox.w, bbox.h);
40386
+ }
40387
+ function nearestPosition(positions, t) {
40388
+ let best;
40389
+ let bestDt = 1001;
40390
+ for (const p of positions) {
40391
+ const dt = Math.abs(p.timestamp - t);
40392
+ if (dt <= 1e3 && dt < bestDt) {
40393
+ best = p;
40394
+ bestDt = dt;
40395
+ }
40396
+ }
40397
+ return best;
40398
+ }
40399
+ function meanDiag(positions) {
40400
+ if (positions.length === 0) return 0;
40401
+ let sum = 0;
40402
+ for (const p of positions) sum += diag(p.bbox);
40403
+ return sum / positions.length;
40404
+ }
40405
+ function vecLen(v) {
40406
+ return Math.hypot(v.x, v.y);
40407
+ }
40408
+ function cosine(a, b) {
40409
+ const la = vecLen(a);
40410
+ const lb = vecLen(b);
40411
+ if (la === 0 || lb === 0) return 1;
40412
+ return (a.x * b.x + a.y * b.y) / (la * lb);
40413
+ }
40414
+ function positionsInWindow(track, start, end) {
40415
+ return track.positions.filter((p) => p.timestamp >= start && p.timestamp <= end);
40416
+ }
40417
+ function windowDisplacement(positions) {
40418
+ const first = positions[0];
40419
+ const last = positions[positions.length - 1];
40420
+ if (!first || !last) return {
40421
+ x: 0,
40422
+ y: 0
40423
+ };
40424
+ const a = bboxCentroid(first.bbox);
40425
+ const b = bboxCentroid(last.bbox);
40426
+ return {
40427
+ x: b.x - a.x,
40428
+ y: b.y - a.y
40429
+ };
40430
+ }
40431
+ function windowRelativeMotion(positions) {
40432
+ const mean = meanDiag(positions);
40433
+ if (mean <= 0) return 0;
40434
+ return vecLen(windowDisplacement(positions)) / mean;
40435
+ }
40436
+ /**
40437
+ * Same cohesion + crossing veto as {@link pairTogether}, over `[now − 3 s, now]`.
40438
+ * Displacement/cosine use the window, not the whole track.
40439
+ */
40440
+ function pairTogetherNow(a, b, now) {
40441
+ const start = now - LIVE_WINDOW_MS;
40442
+ const overlapStart = Math.max(a.firstSeen, b.firstSeen, start);
40443
+ const overlapEnd = Math.min(a.lastSeen, b.lastSeen, now);
40444
+ if (overlapEnd < overlapStart) return false;
40445
+ let joint = 0;
40446
+ let close = 0;
40447
+ for (let t = overlapStart; t <= overlapEnd; t += 500) {
40448
+ const pa = nearestPosition(a.positions, t);
40449
+ const pb = nearestPosition(b.positions, t);
40450
+ if (!pa || !pb) continue;
40451
+ joint++;
40452
+ const ca = bboxCentroid(pa.bbox);
40453
+ const cb = bboxCentroid(pb.bbox);
40454
+ const gap = Math.hypot(ca.x - cb.x, ca.y - cb.y);
40455
+ const scale = (diag(pa.bbox) + diag(pb.bbox)) / 2;
40456
+ if ((scale > 0 ? gap / scale : Number.POSITIVE_INFINITY) <= 1.5) close++;
40457
+ }
40458
+ if (joint === 0) return false;
40459
+ if (close / joint < .5) return false;
40460
+ const wa = positionsInWindow(a, start, now);
40461
+ const wb = positionsInWindow(b, start, now);
40462
+ const aMoves = windowRelativeMotion(wa) >= MOVE_EPS;
40463
+ const bMoves = windowRelativeMotion(wb) >= MOVE_EPS;
40464
+ if (aMoves && bMoves && cosine(windowDisplacement(wa), windowDisplacement(wb)) < .3) return false;
40465
+ return true;
40466
+ }
40467
+ var GroupLiveAssigner = class {
40468
+ nextId;
40469
+ snapshots = /* @__PURE__ */ new Map();
40470
+ groups = /* @__PURE__ */ new Map();
40471
+ membership = /* @__PURE__ */ new Map();
40472
+ /** `${trackId}>${groupId}` → first instant the join predicate held. */
40473
+ joinSince = /* @__PURE__ */ new Map();
40474
+ /** trackId → first instant they failed pair against every remaining member. */
40475
+ leaveSince = /* @__PURE__ */ new Map();
40476
+ constructor(nextId = () => randomUUID()) {
40477
+ this.nextId = nextId;
40478
+ }
40479
+ observe(track, now) {
40480
+ this.snapshots.set(track.trackId, track);
40481
+ if (!isEligibleMember(track)) {
40482
+ this.removeFromGroup(track.trackId);
40483
+ return;
40484
+ }
40485
+ if (!this.membership.has(track.trackId)) this.birthSingleton(track, now);
40486
+ this.considerLeave(track, now);
40487
+ const groupId = this.membership.get(track.trackId);
40488
+ const group = groupId !== void 0 ? this.groups.get(groupId) : void 0;
40489
+ if (group) for (const id of [...group.members]) {
40490
+ if (id === track.trackId) continue;
40491
+ const snap = this.snapshots.get(id);
40492
+ if (snap) this.considerLeave(snap, now);
40493
+ }
40494
+ this.considerJoin(track, now);
40495
+ }
40496
+ groupOf(trackId) {
40497
+ return this.membership.get(trackId);
40498
+ }
40499
+ snapshot(trackId) {
40500
+ return this.snapshots.get(trackId);
40501
+ }
40502
+ liveGroups() {
40503
+ return [...this.groups.values()].map((g) => this.toLive(g));
40504
+ }
40505
+ idleGroups(now, idleMs) {
40506
+ return this.liveGroups().filter((g) => now >= g.lastActiveAt + idleMs);
40507
+ }
40508
+ drop(ids) {
40509
+ for (const id of ids) {
40510
+ const g = this.groups.get(id);
40511
+ if (!g) continue;
40512
+ for (const member of g.members) this.membership.delete(member);
40513
+ this.groups.delete(id);
40514
+ }
40515
+ }
40516
+ birthSingleton(track, now) {
40517
+ const id = this.nextId();
40518
+ this.groups.set(id, {
40519
+ id,
40520
+ deviceId: track.deviceId,
40521
+ openedAt: now,
40522
+ members: new Set([track.trackId])
40523
+ });
40524
+ this.membership.set(track.trackId, id);
40525
+ }
40526
+ considerLeave(track, now) {
40527
+ const groupId = this.membership.get(track.trackId);
40528
+ if (groupId === void 0) return;
40529
+ const group = this.groups.get(groupId);
40530
+ if (!group || group.members.size < 2) {
40531
+ this.leaveSince.delete(track.trackId);
40532
+ return;
40533
+ }
40534
+ if ([...group.members].filter((id) => id !== track.trackId).some((id) => {
40535
+ const other = this.snapshots.get(id);
40536
+ return other !== void 0 && pairTogetherNow(track, other, now);
40537
+ })) {
40538
+ this.leaveSince.delete(track.trackId);
40539
+ return;
40540
+ }
40541
+ const since = this.leaveSince.get(track.trackId) ?? now;
40542
+ this.leaveSince.set(track.trackId, since);
40543
+ if (now - since < 3e3) return;
40544
+ this.peel(track, now);
40545
+ }
40546
+ peel(track, now) {
40547
+ this.removeFromGroup(track.trackId);
40548
+ this.leaveSince.delete(track.trackId);
40549
+ this.birthSingleton(track, now);
40550
+ }
40551
+ considerJoin(track, now) {
40552
+ const mineId = this.membership.get(track.trackId);
40553
+ if (mineId === void 0) return;
40554
+ const mine = this.groups.get(mineId);
40555
+ if (!mine || mine.members.size !== 1) {
40556
+ this.clearJoinClocks(track.trackId);
40557
+ return;
40558
+ }
40559
+ let best;
40560
+ for (const group of this.groups.values()) {
40561
+ if (group.id === mineId || group.deviceId !== track.deviceId) continue;
40562
+ let gap = Number.POSITIVE_INFINITY;
40563
+ let together = false;
40564
+ for (const memberId of group.members) {
40565
+ const other = this.snapshots.get(memberId);
40566
+ if (!other) continue;
40567
+ if (pairTogetherNow(track, other, now)) {
40568
+ together = true;
40569
+ gap = Math.min(gap, centroidGap(track, other, now));
40570
+ }
40571
+ }
40572
+ if (!together) continue;
40573
+ if (best === void 0 || gap < best.gap) best = {
40574
+ groupId: group.id,
40575
+ gap
40576
+ };
40577
+ }
40578
+ if (best === void 0) {
40579
+ this.clearJoinClocks(track.trackId);
40580
+ return;
40581
+ }
40582
+ const key = `${track.trackId}>${best.groupId}`;
40583
+ for (const existing of [...this.joinSince.keys()]) if (existing.startsWith(`${track.trackId}>`) && existing !== key) this.joinSince.delete(existing);
40584
+ const since = this.joinSince.get(key) ?? now;
40585
+ this.joinSince.set(key, since);
40586
+ if (now - since < 1500) return;
40587
+ this.absorb(track.trackId, mineId, best.groupId);
40588
+ }
40589
+ absorb(trackId, fromId, intoId) {
40590
+ const into = this.groups.get(intoId);
40591
+ if (!into) return;
40592
+ into.members.add(trackId);
40593
+ this.membership.set(trackId, intoId);
40594
+ this.groups.delete(fromId);
40595
+ this.clearJoinClocks(trackId);
40596
+ this.leaveSince.delete(trackId);
40597
+ }
40598
+ removeFromGroup(trackId) {
40599
+ const groupId = this.membership.get(trackId);
40600
+ this.membership.delete(trackId);
40601
+ this.clearJoinClocks(trackId);
40602
+ this.leaveSince.delete(trackId);
40603
+ if (groupId === void 0) return;
40604
+ const group = this.groups.get(groupId);
40605
+ if (!group) return;
40606
+ group.members.delete(trackId);
40607
+ if (group.members.size === 0) this.groups.delete(groupId);
40608
+ }
40609
+ clearJoinClocks(trackId) {
40610
+ for (const key of [...this.joinSince.keys()]) if (key.startsWith(`${trackId}>`)) this.joinSince.delete(key);
40611
+ }
40612
+ toLive(group) {
40613
+ let lastActiveAt = group.openedAt;
40614
+ for (const id of group.members) {
40615
+ const snap = this.snapshots.get(id);
40616
+ if (snap && snap.lastSeen > lastActiveAt) lastActiveAt = snap.lastSeen;
40617
+ }
40618
+ return {
40619
+ id: group.id,
40620
+ deviceId: group.deviceId,
40621
+ memberTrackIds: [...group.members],
40622
+ openedAt: group.openedAt,
40623
+ lastActiveAt
40624
+ };
40625
+ }
40626
+ };
40627
+ function centroidGap(a, b, now) {
40628
+ const pa = a.positions.reduce((best, p) => Math.abs(p.timestamp - now) < Math.abs(best.timestamp - now) ? p : best, a.positions[0]);
40629
+ const pb = b.positions.reduce((best, p) => Math.abs(p.timestamp - now) < Math.abs(best.timestamp - now) ? p : best, b.positions[0]);
40630
+ if (!pa || !pb) return Number.POSITIVE_INFINITY;
40631
+ const ca = bboxCentroid(pa.bbox);
40632
+ const cb = bboxCentroid(pb.bbox);
40633
+ return Math.hypot(ca.x - cb.x, ca.y - cb.y);
40634
+ }
40635
+ //#endregion
40636
+ //#region src/pipeline-analytics/group/group-session.ts
40637
+ /** Same idle as the notification burst (`NC_GROUP_DEFAULT_IDLE_SEC`). */
40638
+ var GROUP_SESSION_IDLE_MS = 3e4;
40639
+ //#endregion
40640
+ //#region src/pipeline-analytics/group/group-live-buffer.ts
40641
+ function toGroupableTrack(track) {
40642
+ return {
40643
+ trackId: track.trackId,
40644
+ deviceId: track.deviceId,
40645
+ className: track.className,
40646
+ firstSeen: track.firstSeen,
40647
+ lastSeen: track.lastSeen,
40648
+ positions: track.positions.map((p) => ({
40649
+ ...p,
40650
+ bbox: { ...p.bbox }
40651
+ })),
40652
+ ...track.closedReason !== void 0 ? { closedReason: track.closedReason } : {}
40653
+ };
40654
+ }
40655
+ var GroupLiveBuffer = class {
40656
+ assigner;
40657
+ primed = /* @__PURE__ */ new Set();
40658
+ constructor(nextId) {
40659
+ this.assigner = new GroupLiveAssigner(nextId);
40660
+ }
40661
+ observe(track, now) {
40662
+ this.assigner.observe(track, now);
40663
+ }
40664
+ groupOf(trackId) {
40665
+ return this.assigner.groupOf(trackId);
40666
+ }
40667
+ snapshot(trackId) {
40668
+ return this.assigner.snapshot(trackId);
40669
+ }
40670
+ liveGroups() {
40671
+ return this.assigner.liveGroups();
40672
+ }
40673
+ idleGroups(now, idleMs = GROUP_SESSION_IDLE_MS) {
40674
+ return this.assigner.idleGroups(now, idleMs);
40675
+ }
40676
+ drop(ids) {
40677
+ this.assigner.drop(ids);
40678
+ }
40679
+ isPrimed(deviceId) {
40680
+ return this.primed.has(deviceId);
40681
+ }
40682
+ markPrimed(deviceId) {
40683
+ this.primed.add(deviceId);
40684
+ }
40685
+ };
40686
+ //#endregion
40687
+ //#region src/pipeline-analytics/group/group-crop.ts
40688
+ /** Always containSubject: true. Never the 0.5 delivered threshold. */
40689
+ function groupWideCentralSquareLayout(unionBbox, frame) {
40690
+ return wideCentralSquareLayout(unionBbox, frame, { containSubject: true });
40691
+ }
40692
+ async function cropGroupFromRgb(rgb, frameWidth, frameHeight, unionBbox) {
40693
+ const layout = groupWideCentralSquareLayout(unionBbox, {
40694
+ W: frameWidth,
40695
+ H: frameHeight
40696
+ });
40697
+ const { crop: slab } = await extractCrop(rgb, frameWidth, frameHeight, layout.fetch);
40698
+ return composeWideCentralSquareThumbnail(slab, layout);
40699
+ }
40700
+ //#endregion
40701
+ //#region src/pipeline-analytics/group/group-instant.ts
40702
+ /**
40703
+ * Chosen instant + union bbox for a group, at close, over stored positions[]
40704
+ * (`docs/design/2026-08-21-group-entity-and-group-media.md` §4.1).
40705
+ *
40706
+ * Tier order — the first that separates two candidates decides:
40707
+ * 1. completeness (members with a position within ±1 s)
40708
+ * 2. square containment of the union (D212)
40709
+ * 3. semantic (a member's confirmed recognition)
40710
+ * 4. edge-clear (union touches no frame border)
40711
+ * 5. effective score = mean(confidence) × containment × sizeFactor
40712
+ *
40713
+ * Retrospective. No new capture path.
40714
+ */
40715
+ var GROUP_MEDIA_MATCH_MS = 2e3;
40716
+ function nearest(positions, t, windowMs) {
40717
+ let best;
40718
+ let bestDt = windowMs + 1;
40719
+ for (const p of positions) {
40720
+ const dt = Math.abs(p.timestamp - t);
40721
+ if (dt <= windowMs && dt < bestDt) {
40722
+ best = p;
40723
+ bestDt = dt;
40724
+ }
40725
+ }
40726
+ return best;
40727
+ }
40728
+ function unionOf(boxes) {
40729
+ if (boxes.length === 0) return null;
40730
+ let x0 = Infinity;
40731
+ let y0 = Infinity;
40732
+ let x1 = -Infinity;
40733
+ let y1 = -Infinity;
40734
+ for (const b of boxes) {
40735
+ x0 = Math.min(x0, b.x);
40736
+ y0 = Math.min(y0, b.y);
40737
+ x1 = Math.max(x1, b.x + b.w);
40738
+ y1 = Math.max(y1, b.y + b.h);
40739
+ }
40740
+ return {
40741
+ x: x0,
40742
+ y: y0,
40743
+ w: x1 - x0,
40744
+ h: y1 - y0
40745
+ };
40746
+ }
40747
+ function touchesBorder(box, frame, tol = 1) {
40748
+ return box.x <= tol || box.y <= tol || box.x + box.w >= frame.width - tol || box.y + box.h >= frame.height - tol;
40749
+ }
40750
+ function scoreInstant(members, t, frame) {
40751
+ const boxes = [];
40752
+ let semantic = false;
40753
+ let confSum = 0;
40754
+ let confN = 0;
40755
+ for (const m of members) {
40756
+ const p = nearest(m.positions, t, MATCH_WINDOW_MS);
40757
+ if (!p) continue;
40758
+ boxes.push(p.bbox);
40759
+ if (m.semantic === true) semantic = true;
40760
+ if (m.confidence !== void 0) {
40761
+ confSum += m.confidence;
40762
+ confN += 1;
40763
+ }
40764
+ }
40765
+ const union = unionOf(boxes);
40766
+ if (!union) return null;
40767
+ const square = centralSquareContainmentFraction(union, {
40768
+ W: frame.width,
40769
+ H: frame.height
40770
+ });
40771
+ const containment = bboxContainment(union, frame.width, frame.height);
40772
+ const size = bboxSizeScore(union, frame.width, frame.height);
40773
+ const meanConf = confN > 0 ? confSum / confN : 1;
40774
+ return {
40775
+ timestamp: t,
40776
+ union,
40777
+ visibleCount: boxes.length,
40778
+ square,
40779
+ semantic,
40780
+ edgeClear: !touchesBorder(union, frame),
40781
+ effective: meanConf * containment * size
40782
+ };
40783
+ }
40784
+ function better(a, b) {
40785
+ if (a.visibleCount !== b.visibleCount) return a.visibleCount > b.visibleCount ? a : b;
40786
+ if (a.square !== b.square) return a.square > b.square ? a : b;
40787
+ if (a.semantic !== b.semantic) return a.semantic ? a : b;
40788
+ if (a.edgeClear !== b.edgeClear) return a.edgeClear ? a : b;
40789
+ if (a.effective !== b.effective) return a.effective > b.effective ? a : b;
40790
+ return a.timestamp <= b.timestamp ? a : b;
40791
+ }
40792
+ function chooseGroupInstant(members, frame) {
40793
+ if (members.length === 0) return null;
40794
+ const candidates = /* @__PURE__ */ new Set();
40795
+ for (const m of members) for (const p of m.positions) candidates.add(p.timestamp);
40796
+ let best = null;
40797
+ for (const t of candidates) {
40798
+ const scored = scoreInstant(members, t, frame);
40799
+ if (!scored) continue;
40800
+ best = best === null ? scored : better(scored, best);
40801
+ }
40802
+ if (!best) return null;
40803
+ const w = Math.max(frame.width, 1);
40804
+ const h = Math.max(frame.height, 1);
40805
+ return {
40806
+ timestamp: best.timestamp,
40807
+ union: best.union,
40808
+ ux: best.union.x / w,
40809
+ uy: best.union.y / h,
40810
+ uw: best.union.w / w,
40811
+ uh: best.union.h / h,
40812
+ visibleCount: best.visibleCount
40813
+ };
40814
+ }
40815
+ /** Longest-lived member that owns a clean full frame; ties break on trackId. */
40816
+ function chooseAnchorTrackId(members, ownsCleanFrame) {
40817
+ const eligible = members.filter((m) => ownsCleanFrame(m.trackId));
40818
+ if (eligible.length === 0) return null;
40819
+ return eligible.toSorted((a, b) => {
40820
+ const life = b.lastSeen - b.firstSeen - (a.lastSeen - a.firstSeen);
40821
+ return life !== 0 ? life : a.trackId.localeCompare(b.trackId);
40822
+ })[0].trackId;
40823
+ }
40824
+ /**
40825
+ * The RGB source for a group crop is a full native frame. Thumbnails are
40826
+ * already single-subject crops and cannot substitute for union-bbox geometry.
40827
+ *
40828
+ * Prefer a keyFrame within {@link GROUP_MEDIA_MATCH_MS} of the chosen instant;
40829
+ * if none, take the nearest keyFrame anyway — dropping the crop because the
40830
+ * best-shot was 3 s off the instant is how a recognised person left the
40831
+ * digest with no crop.
40832
+ */
40833
+ function pickCleanPixelRow(rows, instantMs) {
40834
+ const ranked = (maxDt) => {
40835
+ const candidates = [];
40836
+ for (const row of rows) {
40837
+ const rank = row.kind === "keyFrame" ? 0 : row.kind === "keyFrameSmall" ? 1 : -1;
40838
+ if (rank < 0) continue;
40839
+ const dt = Math.abs(row.timestamp - instantMs);
40840
+ if (maxDt !== null && dt > maxDt) continue;
40841
+ candidates.push({
40842
+ key: row.key,
40843
+ dt,
40844
+ rank
40845
+ });
40846
+ }
40847
+ return candidates.toSorted((a, b) => a.rank - b.rank || a.dt - b.dt)[0];
40848
+ };
40849
+ const near = ranked(GROUP_MEDIA_MATCH_MS);
40850
+ if (near !== void 0) return {
40851
+ key: near.key,
40852
+ usedFallback: false
40853
+ };
40854
+ const any = ranked(null);
40855
+ if (any === void 0) return null;
40856
+ return {
40857
+ key: any.key,
40858
+ usedFallback: true
40859
+ };
40860
+ }
40861
+ //#endregion
40862
+ //#region src/pipeline-analytics/group/group-live-replay.ts
40863
+ function sliceTrackAt(track, now) {
40864
+ const positions = track.positions.filter((p) => p.timestamp <= now);
40865
+ const last = positions[positions.length - 1];
40866
+ return {
40867
+ ...track,
40868
+ lastSeen: last?.timestamp ?? track.firstSeen,
40869
+ positions
40870
+ };
40871
+ }
40872
+ function replayInto(tracks, observer) {
40873
+ const events = [];
40874
+ const byId = /* @__PURE__ */ new Map();
40875
+ for (const track of tracks) {
40876
+ byId.set(track.trackId, track);
40877
+ if (track.positions.length === 0) {
40878
+ events.push({
40879
+ t: track.lastSeen,
40880
+ trackId: track.trackId
40881
+ });
40882
+ continue;
40883
+ }
40884
+ for (const p of track.positions) events.push({
40885
+ t: p.timestamp,
40886
+ trackId: track.trackId
40887
+ });
40888
+ }
40889
+ events.sort((a, b) => a.t - b.t || a.trackId.localeCompare(b.trackId));
40890
+ let lastKey = "";
40891
+ for (const e of events) {
40892
+ const key = `${e.trackId}@${e.t}`;
40893
+ if (key === lastKey) continue;
40894
+ lastKey = key;
40895
+ const track = byId.get(e.trackId);
40896
+ if (!track) continue;
40897
+ observer.observe(sliceTrackAt(track, e.t), e.t);
40898
+ }
40899
+ }
40900
+ function replayLive(tracks, assigner = new GroupLiveAssigner()) {
40901
+ replayInto(tracks, assigner);
40902
+ return assigner;
40903
+ }
40904
+ //#endregion
40905
+ //#region src/pipeline-analytics/group/group-sealer.ts
40906
+ var DEFAULT_FRAME = {
40907
+ width: 640,
40908
+ height: 360
40909
+ };
40910
+ var QUERY_LIMIT = 2e3;
40911
+ /** Crash recover: replay this camera only, on its next activity. */
40912
+ var GROUP_CRASH_LOOKBACK_MS = 2 * 6e4;
40913
+ function toInstant(t) {
40914
+ return {
40915
+ trackId: t.trackId,
40916
+ deviceId: t.deviceId,
40917
+ className: t.className,
40918
+ firstSeen: t.firstSeen,
40919
+ lastSeen: t.lastSeen,
40920
+ positions: t.positions,
40921
+ ...t.closedReason !== void 0 ? { closedReason: t.closedReason } : {},
40922
+ semantic: t.hasFace === true || t.hasPlate === true,
40923
+ confidence: t.importance
40924
+ };
40925
+ }
40926
+ function toSealer(track) {
40927
+ return {
40928
+ trackId: track.trackId,
40929
+ deviceId: track.deviceId,
40930
+ className: track.className,
40931
+ firstSeen: track.firstSeen,
40932
+ lastSeen: track.lastSeen,
40933
+ positions: track.positions,
40934
+ ...track.closedReason !== void 0 ? { closedReason: track.closedReason } : {}
40935
+ };
40936
+ }
40937
+ var GroupSealer = class {
40938
+ deps;
40939
+ constructor(deps) {
40940
+ this.deps = deps;
40941
+ }
40942
+ async flushIdle(buffer, now = this.deps.now()) {
40943
+ let written = 0;
40944
+ for (const group of buffer.idleGroups(now, GROUP_SESSION_IDLE_MS)) if (await this.persistLiveGroup(group, buffer)) {
40945
+ buffer.drop([group.id]);
40946
+ written += 1;
40947
+ }
40948
+ return written;
40949
+ }
40950
+ /**
40951
+ * First activity on camera D after a crash: replay 2 minutes of that camera
40952
+ * into the live buffer, persist whoever is already idle, keep the rest in RAM.
40953
+ */
40954
+ async recoverCamera(deviceId, buffer) {
40955
+ if (buffer.isPrimed(deviceId)) return 0;
40956
+ const now = this.deps.now();
40957
+ try {
40958
+ replayInto(await this.deps.trackStore.queryHistorical({
40959
+ deviceId,
40960
+ since: now - GROUP_CRASH_LOOKBACK_MS,
40961
+ until: now,
40962
+ limit: QUERY_LIMIT,
40963
+ projection: "full"
40964
+ }), buffer);
40965
+ } catch (err) {
40966
+ this.deps.logger.warn("group sealer: lookback failed", {
40967
+ tags: { deviceId },
40968
+ meta: { error: String(err) }
40969
+ });
40970
+ } finally {
40971
+ buffer.markPrimed(deviceId);
40972
+ }
40973
+ return this.flushIdle(buffer, now);
40974
+ }
40975
+ /**
40976
+ * Seal ONE camera over a caller-owned window via live replay.
40977
+ *
40978
+ * This is how a digest recovers groups without a boot scan: the producer
40979
+ * already knows the window. Walking every camera for 48h at initialize()
40980
+ * is what wedged hub-main.
40981
+ */
40982
+ async sealWindow(deviceId, since, until) {
40983
+ try {
40984
+ const closed = await this.deps.trackStore.queryHistorical({
40985
+ deviceId,
40986
+ since,
40987
+ until,
40988
+ limit: QUERY_LIMIT,
40989
+ projection: "full"
40990
+ });
40991
+ if (closed.length === 0) return 0;
40992
+ const already = new Set((await this.deps.groupStore.membersOfTracks(closed.map((t) => t.trackId))).map((m) => m.trackId));
40993
+ const pending = closed.filter((t) => !already.has(t.trackId));
40994
+ if (pending.length === 0) return 0;
40995
+ const assigner = replayLive(pending);
40996
+ const now = this.deps.now();
40997
+ const byId = new Map(pending.map((t) => [t.trackId, t]));
40998
+ let written = 0;
40999
+ for (const group of assigner.idleGroups(now, GROUP_SESSION_IDLE_MS)) {
41000
+ const members = group.memberTrackIds.map((id) => byId.get(id)).filter((t) => t !== void 0);
41001
+ if (members.length === 0) continue;
41002
+ try {
41003
+ await this.persistPartition(members, group.openedAt);
41004
+ written += 1;
41005
+ } catch (err) {
41006
+ this.deps.logger.warn("group sealer: persist failed", {
41007
+ tags: { deviceId },
41008
+ meta: {
41009
+ members: members.map((m) => m.trackId),
41010
+ error: String(err)
41011
+ }
41012
+ });
41013
+ }
41014
+ }
41015
+ return written;
41016
+ } catch (err) {
41017
+ this.deps.logger.warn("group sealer: device failed", {
41018
+ tags: { deviceId },
41019
+ meta: { error: String(err) }
41020
+ });
41021
+ return 0;
41022
+ }
41023
+ }
41024
+ async persistLiveGroup(group, buffer) {
41025
+ const deviceId = group.deviceId;
41026
+ const snaps = group.memberTrackIds.map((id) => buffer.snapshot(id)).filter((t) => t !== void 0);
41027
+ if (snaps.length === 0) return true;
41028
+ const already = new Set((await this.deps.groupStore.membersOfTracks(snaps.map((t) => t.trackId))).map((m) => m.trackId));
41029
+ const pending = snaps.filter((t) => !already.has(t.trackId)).map(toSealer);
41030
+ if (pending.length === 0) return true;
41031
+ try {
41032
+ await this.persistPartition(pending, group.openedAt);
41033
+ return true;
41034
+ } catch (err) {
41035
+ this.deps.logger.warn("group sealer: persist failed", {
41036
+ tags: { deviceId },
41037
+ meta: {
41038
+ members: pending.map((m) => m.trackId),
41039
+ error: String(err)
41040
+ }
41041
+ });
41042
+ return false;
41043
+ }
41044
+ }
41045
+ async persistPartition(members, openedAt) {
41046
+ const deviceId = members[0].deviceId;
41047
+ const frame = this.deps.frameDims(deviceId) ?? DEFAULT_FRAME;
41048
+ const instantMembers = members.map(toInstant);
41049
+ const instant = chooseGroupInstant(instantMembers, frame);
41050
+ const closedAt = Math.max(...members.map((m) => m.lastSeen));
41051
+ const timestamp = instant?.timestamp ?? closedAt;
41052
+ const mediaInfos = await Promise.all(members.map(async (m) => ({
41053
+ trackId: m.trackId,
41054
+ rows: await this.deps.mediaStore.listInfoByOwner("track", m.trackId)
41055
+ })));
41056
+ const ownsClean = (trackId) => {
41057
+ return mediaInfos.find((m) => m.trackId === trackId)?.rows.some((r) => r.kind === "keyFrame" || r.kind === "keyFrameSmall") ?? false;
41058
+ };
41059
+ const anchorTrackId = members.length >= 2 ? chooseAnchorTrackId(instantMembers, ownsClean) : null;
41060
+ let nativeKey = null;
41061
+ if (anchorTrackId !== null) {
41062
+ const rows = mediaInfos.find((m) => m.trackId === anchorTrackId)?.rows ?? [];
41063
+ nativeKey = (rows.find((r) => r.kind === "keyFrame") ?? rows.find((r) => r.kind === "keyFrameSmall"))?.key ?? null;
41064
+ }
41065
+ const draft = await this.deps.groupStore.insert({
41066
+ deviceId,
41067
+ timestamp,
41068
+ openedAt,
41069
+ closedAt,
41070
+ members: members.map((m) => ({
41071
+ trackId: m.trackId,
41072
+ firstSeen: m.firstSeen
41073
+ })),
41074
+ anchorTrackId,
41075
+ ux: instant?.ux ?? 0,
41076
+ uy: instant?.uy ?? 0,
41077
+ uw: instant?.uw ?? 0,
41078
+ uh: instant?.uh ?? 0,
41079
+ frameWidth: frame.width,
41080
+ frameHeight: frame.height,
41081
+ mediaKey: null,
41082
+ nativeKey
41083
+ });
41084
+ if (members.length < 2 || instant === null) return;
41085
+ const source = await this.pickCleanPixels(mediaInfos, timestamp, deviceId);
41086
+ if (source === null) {
41087
+ this.deps.logger.info("group sealer: no clean pixels for crop", {
41088
+ tags: { deviceId },
41089
+ meta: {
41090
+ groupId: draft.id,
41091
+ members: members.map((m) => m.trackId)
41092
+ }
41093
+ });
41094
+ return;
41095
+ }
41096
+ try {
41097
+ const raw = await sharp(source.jpeg).removeAlpha().raw().toBuffer({ resolveWithObject: true });
41098
+ if (raw.info.channels !== 3) return;
41099
+ const union = {
41100
+ x: instant.ux * raw.info.width,
41101
+ y: instant.uy * raw.info.height,
41102
+ w: instant.uw * raw.info.width,
41103
+ h: instant.uh * raw.info.height
41104
+ };
41105
+ const crop = await cropGroupFromRgb(raw.data, raw.info.width, raw.info.height, union);
41106
+ const mediaKey = await this.deps.mediaStore.put({
41107
+ deviceId,
41108
+ ownerKind: "group",
41109
+ ownerId: draft.id,
41110
+ kind: "thumbnail",
41111
+ timestamp,
41112
+ data: crop
41113
+ });
41114
+ await this.deps.groupStore.setMediaKey(draft.id, mediaKey);
41115
+ } catch (err) {
41116
+ this.deps.logger.warn("group sealer: crop failed", {
41117
+ tags: { deviceId },
41118
+ meta: {
41119
+ groupId: draft.id,
41120
+ error: String(err)
41121
+ }
41122
+ });
41123
+ }
41124
+ }
41125
+ async pickCleanPixels(mediaInfos, instantMs, deviceId) {
41126
+ const chosen = pickCleanPixelRow(mediaInfos.flatMap((info) => info.rows), instantMs);
41127
+ if (chosen === null) return null;
41128
+ if (chosen.usedFallback) this.deps.logger.info("group sealer: crop pixels not within 2s of instant, using nearest keyFrame", {
41129
+ tags: { deviceId },
41130
+ meta: {
41131
+ instantMs,
41132
+ mediaKey: chosen.key
41133
+ }
41134
+ });
41135
+ const file = await this.deps.mediaStore.getByKey(chosen.key);
41136
+ if (file === null) return null;
41137
+ return { jpeg: Buffer.from(file.base64, "base64") };
41138
+ }
41139
+ };
41140
+ //#endregion
41141
+ //#region src/pipeline-analytics/group/group-store.ts
41142
+ /**
41143
+ * The group ENTITY store — a co-moving partition of one session, with one
41144
+ * picture of its own (`docs/design/2026-08-21-group-entity-and-group-media.md`).
41145
+ *
41146
+ * Two tables, two questions:
41147
+ *
41148
+ * - `pipeline-analytics:track-groups` is the group row. It has N members and
41149
+ * no `trackId` column; it is owned by its members (owner-indirection).
41150
+ * - `pipeline-analytics:track-group-members` is one row per member. The column
41151
+ * is named exactly `trackId` so the classification derives `track-owned`.
41152
+ *
41153
+ * `anchorTrackId` and `nativeKey` are the only fields mutable after birth:
41154
+ * they are nulled when the anchor member is deleted alone. Everything else is
41155
+ * written once. Re-anchoring is deliberately not done.
41156
+ *
41157
+ * No age clock. A group dies when its last member does; a group with one live
41158
+ * member does not, and it never keeps a dead link.
41159
+ */
41160
+ /**
41161
+ * @durable class=ledger owner=pipeline-analytics
41162
+ * write="one row per co-moving group, sealed at idle — never a live row.
41163
+ * Membership is the RAM members at persist, not a session partition. mediaKey is
41164
+ * the group's own crop; nativeKey is a POINTER to the anchor member's
41165
+ * keyFrame, nulled if that member is deleted alone. anchorTrackId and
41166
+ * nativeKey are the only fields rewritten after birth."
41167
+ * retention="NO age clock (D61). Rows leave with their last member through
41168
+ * TrackGroupStore.deleteByTracks: memberships first; zero survivors
41169
+ * deletes the group's media then the row; survivors plus a lost anchor
41170
+ * null the pointer. The orphan audit verifies the same via a second
41171
+ * resolver (groupId → any member still present)."
41172
+ */
41173
+ var TRACK_GROUPS_COLLECTION = "pipeline-analytics:track-groups";
41174
+ /**
41175
+ * @durable class=ledger owner=pipeline-analytics
41176
+ * write="one row per member at group birth — id is '<groupId>:<trackId>'.
41177
+ * timestamp is the member's firstSeen so the orphan audit has a clock
41178
+ * column. Never rewritten."
41179
+ * retention="TRACK-OWNED and clockless (D61): the column is named exactly
41180
+ * trackId, so classification derives ownership and the cascade deletes
41181
+ * the row with its track. A group with surviving members stays."
41182
+ */
41183
+ var TRACK_GROUP_MEMBERS_COLLECTION = "pipeline-analytics:track-group-members";
41184
+ var TRACK_GROUPS_COLUMNS = [
41185
+ {
41186
+ name: "id",
41187
+ type: "TEXT",
41188
+ primaryKey: true,
41189
+ notNull: true
41190
+ },
41191
+ {
41192
+ name: "deviceId",
41193
+ type: "INTEGER",
41194
+ notNull: true
41195
+ },
41196
+ {
41197
+ name: "timestamp",
41198
+ type: "INTEGER",
41199
+ notNull: true
41200
+ },
41201
+ {
41202
+ name: "openedAt",
41203
+ type: "INTEGER",
41204
+ notNull: true
41205
+ },
41206
+ {
41207
+ name: "closedAt",
41208
+ type: "INTEGER",
41209
+ notNull: true
41210
+ },
41211
+ {
41212
+ name: "memberCount",
41213
+ type: "INTEGER",
41214
+ notNull: true
41215
+ },
41216
+ {
41217
+ name: "anchorTrackId",
41218
+ type: "TEXT"
41219
+ },
41220
+ {
41221
+ name: "ux",
41222
+ type: "REAL",
41223
+ notNull: true
41224
+ },
41225
+ {
41226
+ name: "uy",
41227
+ type: "REAL",
41228
+ notNull: true
41229
+ },
41230
+ {
41231
+ name: "uw",
41232
+ type: "REAL",
41233
+ notNull: true
41234
+ },
41235
+ {
41236
+ name: "uh",
41237
+ type: "REAL",
41238
+ notNull: true
41239
+ },
41240
+ {
41241
+ name: "frameWidth",
41242
+ type: "INTEGER",
41243
+ notNull: true
41244
+ },
41245
+ {
41246
+ name: "frameHeight",
41247
+ type: "INTEGER",
41248
+ notNull: true
41249
+ },
41250
+ {
41251
+ name: "mediaKey",
41252
+ type: "TEXT"
41253
+ },
41254
+ {
41255
+ name: "nativeKey",
41256
+ type: "TEXT"
41257
+ },
41258
+ {
41259
+ name: "sealed",
41260
+ type: "BOOLEAN",
41261
+ notNull: true
41262
+ }
41263
+ ];
41264
+ var TRACK_GROUP_MEMBERS_COLUMNS = [
41265
+ {
41266
+ name: "id",
41267
+ type: "TEXT",
41268
+ primaryKey: true,
41269
+ notNull: true
41270
+ },
41271
+ {
41272
+ name: "groupId",
41273
+ type: "TEXT",
41274
+ notNull: true
41275
+ },
41276
+ {
41277
+ name: "trackId",
41278
+ type: "TEXT",
41279
+ notNull: true
41280
+ },
41281
+ {
41282
+ name: "deviceId",
41283
+ type: "INTEGER",
41284
+ notNull: true
41285
+ },
41286
+ {
41287
+ name: "timestamp",
41288
+ type: "INTEGER",
41289
+ notNull: true
41290
+ }
41291
+ ];
41292
+ var TRACK_GROUPS_INDEXES = [{
41293
+ name: "idx_track_groups_device_closed",
41294
+ columns: ["deviceId", "closedAt"]
41295
+ }, {
41296
+ name: "idx_track_groups_device_opened",
41297
+ columns: ["deviceId", "openedAt"]
41298
+ }];
41299
+ var TRACK_GROUP_MEMBERS_INDEXES = [{
41300
+ name: "idx_track_group_members_track",
41301
+ columns: ["trackId"]
41302
+ }, {
41303
+ name: "idx_track_group_members_group",
41304
+ columns: ["groupId"]
41305
+ }];
41306
+ function newGroupId() {
41307
+ return `grp-${randomUUID()}`;
41308
+ }
41309
+ function memberRowId(groupId, trackId) {
41310
+ return `${groupId}:${trackId}`;
41311
+ }
41312
+ var TrackGroupStore = class {
41313
+ store;
41314
+ logger;
41315
+ media;
41316
+ constructor(deps) {
41317
+ this.store = deps.store;
41318
+ this.logger = deps.logger;
41319
+ this.media = deps.media ?? null;
41320
+ }
41321
+ static async declare(store) {
41322
+ await Promise.all([store.declareCollection.mutate({
41323
+ collection: TRACK_GROUPS_COLLECTION,
41324
+ columns: [...TRACK_GROUPS_COLUMNS],
41325
+ indexes: [...TRACK_GROUPS_INDEXES]
41326
+ }), store.declareCollection.mutate({
41327
+ collection: TRACK_GROUP_MEMBERS_COLLECTION,
41328
+ columns: [...TRACK_GROUP_MEMBERS_COLUMNS],
41329
+ indexes: [...TRACK_GROUP_MEMBERS_INDEXES]
41330
+ })]);
41331
+ }
41332
+ async insert(input) {
41333
+ const id = newGroupId();
41334
+ const group = {
41335
+ id,
41336
+ deviceId: input.deviceId,
41337
+ timestamp: input.timestamp,
41338
+ openedAt: input.openedAt,
41339
+ closedAt: input.closedAt,
41340
+ memberCount: input.members.length,
41341
+ anchorTrackId: input.anchorTrackId,
41342
+ ux: input.ux,
41343
+ uy: input.uy,
41344
+ uw: input.uw,
41345
+ uh: input.uh,
41346
+ frameWidth: input.frameWidth,
41347
+ frameHeight: input.frameHeight,
41348
+ mediaKey: input.mediaKey,
41349
+ nativeKey: input.nativeKey,
41350
+ sealed: true
41351
+ };
41352
+ await this.store.set.mutate({
41353
+ collection: TRACK_GROUPS_COLLECTION,
41354
+ key: id,
41355
+ value: this.toRow(group)
41356
+ });
41357
+ for (const member of input.members) await this.store.set.mutate({
41358
+ collection: TRACK_GROUP_MEMBERS_COLLECTION,
41359
+ key: memberRowId(id, member.trackId),
41360
+ value: {
41361
+ groupId: id,
41362
+ trackId: member.trackId,
41363
+ deviceId: input.deviceId,
41364
+ timestamp: member.firstSeen
41365
+ }
41366
+ });
41367
+ return group;
41368
+ }
41369
+ /** The crop is written after birth; this is the only post-birth write besides the pointer null. */
41370
+ async setMediaKey(groupId, mediaKey) {
41371
+ const current = await this.get(groupId);
41372
+ if (current === null) return;
41373
+ await this.store.set.mutate({
41374
+ collection: TRACK_GROUPS_COLLECTION,
41375
+ key: groupId,
41376
+ value: this.toRow({
41377
+ ...current,
41378
+ mediaKey
41379
+ })
41380
+ });
41381
+ }
41382
+ async get(groupId) {
41383
+ const raw = await this.store.get.query({
41384
+ collection: TRACK_GROUPS_COLLECTION,
41385
+ key: groupId
41386
+ });
41387
+ if (!raw) return null;
41388
+ return this.normalizeGroup({
41389
+ id: groupId,
41390
+ data: raw
41391
+ });
41392
+ }
41393
+ async listMembers(groupId) {
41394
+ return (await this.store.query.query({
41395
+ collection: TRACK_GROUP_MEMBERS_COLLECTION,
41396
+ filter: { where: { groupId } }
41397
+ })).map((r) => this.normalizeMember({
41398
+ id: r.id,
41399
+ data: r.data
41400
+ }));
41401
+ }
41402
+ async membersOfTracks(trackIds) {
41403
+ const out = [];
41404
+ for (const trackId of trackIds) {
41405
+ const rows = await this.store.query.query({
41406
+ collection: TRACK_GROUP_MEMBERS_COLLECTION,
41407
+ filter: { where: { trackId } }
41408
+ });
41409
+ for (const r of rows) out.push(this.normalizeMember({
41410
+ id: r.id,
41411
+ data: r.data
41412
+ }));
41413
+ }
41414
+ return out;
41415
+ }
41416
+ /**
41417
+ * Newest-closed page for the Library Groups tab.
41418
+ *
41419
+ * Time filter is overlap (`closedAt >= since` and `openedAt <= until`),
41420
+ * same as {@link listOverlapping}. Pagination is applied AFTER the window
41421
+ * so a day cursor is honest rather than a raw-row offset that skips live
41422
+ * groups on a busy camera.
41423
+ */
41424
+ async listRecent(input) {
41425
+ const limit = Math.min(Math.max(input.limit ?? 40, 1), 100);
41426
+ const offset = Math.max(input.offset ?? 0, 0);
41427
+ const slice = (await this.store.query.query({
41428
+ collection: TRACK_GROUPS_COLLECTION,
41429
+ filter: {
41430
+ ...input.deviceId !== void 0 ? { where: { deviceId: input.deviceId } } : {},
41431
+ orderBy: {
41432
+ field: "closedAt",
41433
+ direction: "desc"
41434
+ },
41435
+ limit: 2e3
41436
+ }
41437
+ })).map((r) => this.normalizeGroup({
41438
+ id: r.id,
41439
+ data: r.data
41440
+ })).filter((g) => {
41441
+ if (input.sinceMs !== void 0 && g.closedAt < input.sinceMs) return false;
41442
+ if (input.untilMs !== void 0 && g.openedAt > input.untilMs) return false;
41443
+ return true;
41444
+ }).slice(offset, offset + limit + 1);
41445
+ return {
41446
+ groups: slice.slice(0, limit),
41447
+ hasMore: slice.length > limit
41448
+ };
41449
+ }
41450
+ /** Groups whose life overlaps `[since, until]` on one camera. */
41451
+ async listOverlapping(input) {
41452
+ return (await this.store.query.query({
41453
+ collection: TRACK_GROUPS_COLLECTION,
41454
+ filter: {
41455
+ where: { deviceId: input.deviceId },
41456
+ orderBy: {
41457
+ field: "closedAt",
41458
+ direction: "desc"
41459
+ },
41460
+ limit: input.limit ?? 2e3
41461
+ }
41462
+ })).map((r) => this.normalizeGroup({
41463
+ id: r.id,
41464
+ data: r.data
41465
+ })).filter((g) => g.closedAt >= input.sinceMs && g.openedAt <= input.untilMs);
41466
+ }
41467
+ async existingGroupIds(ids) {
41468
+ const live = /* @__PURE__ */ new Set();
41469
+ for (const id of ids) if (await this.store.get.query({
41470
+ collection: "pipeline-analytics:track-groups",
41471
+ key: id
41472
+ })) live.add(id);
41473
+ return live;
41474
+ }
41475
+ /** Group ids that still have at least one membership row. */
41476
+ async groupsWithMembers(ids) {
41477
+ const live = /* @__PURE__ */ new Set();
41478
+ for (const groupId of ids) if ((await this.store.query.query({
41479
+ collection: "pipeline-analytics:track-group-members",
41480
+ filter: {
41481
+ where: { groupId },
41482
+ limit: 1
41483
+ }
41484
+ })).length > 0) live.add(groupId);
41485
+ return live;
41486
+ }
41487
+ /**
41488
+ * Cascade leaf: delete memberships for the batch; a group with zero survivors
41489
+ * dies (media then row); a group that lost only its anchor nulls the pointer.
41490
+ */
41491
+ async deleteByTracks(trackIds) {
41492
+ const batch = new Set(trackIds);
41493
+ let memberRows = 0;
41494
+ let groupRows = 0;
41495
+ let mediaRows = 0;
41496
+ let mediaBytes = 0;
41497
+ const affected = /* @__PURE__ */ new Set();
41498
+ try {
41499
+ const memberships = await this.membersOfTracks(trackIds);
41500
+ for (const member of memberships) try {
41501
+ await this.store.delete.mutate({
41502
+ collection: TRACK_GROUP_MEMBERS_COLLECTION,
41503
+ key: member.id
41504
+ });
41505
+ memberRows += 1;
41506
+ affected.add(member.groupId);
41507
+ } catch (err) {
41508
+ this.logger.warn("TrackGroupStore.deleteByTracks member delete failed", { meta: {
41509
+ memberId: member.id,
41510
+ error: String(err)
41511
+ } });
41512
+ }
41513
+ for (const groupId of affected) try {
41514
+ if ((await this.listMembers(groupId)).length === 0) {
41515
+ if (this.media) {
41516
+ const gone = await this.media.deleteByGroups([groupId]);
41517
+ mediaRows += gone.rows;
41518
+ mediaBytes += gone.bytes;
41519
+ }
41520
+ await this.store.delete.mutate({
41521
+ collection: TRACK_GROUPS_COLLECTION,
41522
+ key: groupId
41523
+ });
41524
+ groupRows += 1;
41525
+ continue;
41526
+ }
41527
+ const group = await this.get(groupId);
41528
+ if (group?.anchorTrackId && batch.has(group.anchorTrackId)) await this.store.set.mutate({
41529
+ collection: TRACK_GROUPS_COLLECTION,
41530
+ key: groupId,
41531
+ value: this.toRow({
41532
+ ...group,
41533
+ anchorTrackId: null,
41534
+ nativeKey: null
41535
+ })
41536
+ });
41537
+ } catch (err) {
41538
+ this.logger.warn("TrackGroupStore.deleteByTracks group update failed", { meta: {
41539
+ groupId,
41540
+ error: String(err)
41541
+ } });
41542
+ }
41543
+ } catch (err) {
41544
+ this.logger.warn("TrackGroupStore.deleteByTracks failed", { meta: { error: String(err) } });
41545
+ }
41546
+ return [{
41547
+ family: "groups",
41548
+ rows: memberRows + groupRows,
41549
+ bytes: 0
41550
+ }, {
41551
+ family: "media",
41552
+ rows: mediaRows,
41553
+ bytes: mediaBytes
41554
+ }];
41555
+ }
41556
+ toRow(group) {
41557
+ return {
41558
+ deviceId: group.deviceId,
41559
+ timestamp: group.timestamp,
41560
+ openedAt: group.openedAt,
41561
+ closedAt: group.closedAt,
41562
+ memberCount: group.memberCount,
41563
+ anchorTrackId: group.anchorTrackId,
41564
+ ux: group.ux,
41565
+ uy: group.uy,
41566
+ uw: group.uw,
41567
+ uh: group.uh,
41568
+ frameWidth: group.frameWidth,
41569
+ frameHeight: group.frameHeight,
41570
+ mediaKey: group.mediaKey,
41571
+ nativeKey: group.nativeKey,
41572
+ sealed: group.sealed
41573
+ };
41574
+ }
41575
+ normalizeGroup(r) {
41576
+ const data = r.data;
41577
+ return {
41578
+ id: r.id,
41579
+ deviceId: Number(data["deviceId"]),
41580
+ timestamp: Number(data["timestamp"]),
41581
+ openedAt: Number(data["openedAt"]),
41582
+ closedAt: Number(data["closedAt"]),
41583
+ memberCount: Number(data["memberCount"]),
41584
+ anchorTrackId: typeof data["anchorTrackId"] === "string" ? data["anchorTrackId"] : null,
41585
+ ux: Number(data["ux"]),
41586
+ uy: Number(data["uy"]),
41587
+ uw: Number(data["uw"]),
41588
+ uh: Number(data["uh"]),
41589
+ frameWidth: Number(data["frameWidth"]),
41590
+ frameHeight: Number(data["frameHeight"]),
41591
+ mediaKey: typeof data["mediaKey"] === "string" ? data["mediaKey"] : null,
41592
+ nativeKey: typeof data["nativeKey"] === "string" ? data["nativeKey"] : null,
41593
+ sealed: Boolean(data["sealed"])
41594
+ };
41595
+ }
41596
+ normalizeMember(r) {
41597
+ const data = r.data;
41598
+ return {
41599
+ id: r.id,
41600
+ groupId: String(data["groupId"]),
41601
+ trackId: String(data["trackId"]),
41602
+ deviceId: Number(data["deviceId"]),
41603
+ timestamp: Number(data["timestamp"])
41604
+ };
41605
+ }
41606
+ };
40072
41607
  //#endregion
40073
41608
  //#region src/pipeline-analytics/pipeline/dropout-skip.ts
40074
41609
  function shouldSkipDropoutFrame(activeTrackCount, detectionCount, consecutiveSkips, cfg) {
@@ -46686,6 +48221,15 @@ function isAfterCursor(row, cursor) {
46686
48221
  if (row.lastSeen < cursor.lastSeen) return true;
46687
48222
  return row.lastSeen === cursor.lastSeen && row.id < cursor.trackId;
46688
48223
  }
48224
+ function netDisplacementPx(positions) {
48225
+ const first = positions[0];
48226
+ const last = positions[positions.length - 1];
48227
+ if (!first || !last) return 0;
48228
+ return Math.hypot(last.x - first.x, last.y - first.y);
48229
+ }
48230
+ function isShortMotionlessTrack(lifeMs, movedPx) {
48231
+ return lifeMs <= 25e3 && movedPx <= 8;
48232
+ }
46689
48233
  //#endregion
46690
48234
  //#region src/pipeline-analytics/store/track-store.ts
46691
48235
  /**
@@ -47268,6 +48812,7 @@ var TrackStore = class {
47268
48812
  for (const [trackId, t] of this.active) {
47269
48813
  if (nowMs - t.lastSeen < this.config.ttlMs) continue;
47270
48814
  t.active = false;
48815
+ if (t.positions.length > 0 && isShortMotionlessTrack(t.lastSeen - t.firstSeen, netDisplacementPx(t.positions))) t.closedReason = "stationary-promotion";
47271
48816
  const record = cloneTrack(t);
47272
48817
  try {
47273
48818
  await this.persistCompleted(record);
@@ -48017,10 +49562,21 @@ var TrackStore = class {
48017
49562
  * in-memory dedup (the query cap has no DISTINCT); best-effort.
48018
49563
  */
48019
49564
  async listDeviceIds() {
49565
+ return this.collectDeviceIds(0);
49566
+ }
49567
+ /**
49568
+ * Distinct deviceIds with a persisted track whose `lastSeen` is at least
49569
+ * `since`. The group sealer's 10-minute window. Walking from epoch 0 is how
49570
+ * `sealRecent` wedged hub-main every TTL sweep.
49571
+ */
49572
+ async listDeviceIdsSince(since) {
49573
+ return this.collectDeviceIds(Math.max(0, Math.floor(since)));
49574
+ }
49575
+ async collectDeviceIds(fromLastSeen) {
48020
49576
  const seenDevices = /* @__PURE__ */ new Set();
48021
49577
  const seenIds = /* @__PURE__ */ new Set();
48022
49578
  const PAGE = 500;
48023
- let cursor = 0;
49579
+ let cursor = fromLastSeen;
48024
49580
  try {
48025
49581
  for (;;) {
48026
49582
  const rows = await this.store.query.query({
@@ -48816,12 +50372,24 @@ var ANALYTICS_COLLECTIONS = [
48816
50372
  collection: PLATES_COLLECTION,
48817
50373
  classification: derived(PLATE_COLUMNS)
48818
50374
  },
50375
+ {
50376
+ collection: TRACK_GROUP_MEMBERS_COLLECTION,
50377
+ classification: derived(TRACK_GROUP_MEMBERS_COLUMNS)
50378
+ },
48819
50379
  {
48820
50380
  collection: MEDIA_COLLECTION,
48821
50381
  classification: {
48822
50382
  kind: "owner-indirection",
48823
50383
  derivation: "explicit",
48824
- why: "(ownerKind, ownerId) means a different entity per kind — a trackId for 'track', a PREFIXED trackId for 'face'/'plate', an eventId for 'event', and an enrolled gallery row for 'identity'/'vehicle' that retention must never touch. The mapping is `resolveMediaOwner` in orphan-audit.ts."
50384
+ why: "(ownerKind, ownerId) means a different entity per kind — a trackId for 'track', a PREFIXED trackId for 'face'/'plate', an eventId for 'event', a groupId for 'group', and an enrolled gallery row for 'identity'/'vehicle' that retention must never touch. The mapping is `resolveMediaOwner` in orphan-audit.ts."
50385
+ }
50386
+ },
50387
+ {
50388
+ collection: TRACK_GROUPS_COLLECTION,
50389
+ classification: {
50390
+ kind: "owner-indirection",
50391
+ derivation: "explicit",
50392
+ why: "a group has N members and no trackId column; it is owned by its membership rows. The mapping is `resolveGroupOwner` in orphan-audit.ts (groupId → any member still present). Do not classify never-orphaned — that would make the last-member guarantee unverifiable."
48825
50393
  }
48826
50394
  },
48827
50395
  {
@@ -48933,7 +50501,7 @@ var ANALYTICS_COLLECTIONS = [
48933
50501
  collection: SCENE_LATCH_COLLECTION,
48934
50502
  classification: {
48935
50503
  kind: "never-orphaned",
48936
- why: "it IS owned — by the scene set — but the owner-cleanup is self-contained: `pruneExcept(the scene set)` runs on every scene reload, so a latch whose scene is gone is collected there and never by retention. Deliberately NOT `owner-indirection`: that kind puts a collection into `auditableCollections()`, and the orphan audit resolves exactly one indirection, that of `pipeline-analytics:media`."
50504
+ why: "it IS owned — by the scene set — but the owner-cleanup is self-contained: `pruneExcept(the scene set)` runs on every scene reload, so a latch whose scene is gone is collected there and never by retention. Deliberately NOT `owner-indirection`: that kind puts a collection into `auditableCollections()`, and the orphan audit resolves only the indirections it has a resolver for (`pipeline-analytics:media` and `pipeline-analytics:track-groups`)."
48937
50505
  }
48938
50506
  },
48939
50507
  {
@@ -49021,6 +50589,10 @@ function resolveMediaOwner(row) {
49021
50589
  kind: "unresolvable",
49022
50590
  why: `plate-owned media whose ownerId does not carry the '${PLATE_MEDIA_OWNER_PREFIX}' prefix`
49023
50591
  };
50592
+ case "group": return {
50593
+ kind: "group",
50594
+ groupId: ownerId
50595
+ };
49024
50596
  default: return {
49025
50597
  kind: "unresolvable",
49026
50598
  why: `unknown ownerKind '${kindRaw}'`
@@ -49032,6 +50604,13 @@ function resolveMediaOwner(row) {
49032
50604
  * is derived the same way the classification is — an enrolled row is out of
49033
50605
  * reach regardless of what happened to the track that produced it.
49034
50606
  */
50607
+ /** A group row lives iff any membership row still names it. */
50608
+ function resolveGroupOwner(row) {
50609
+ return {
50610
+ kind: "group-members",
50611
+ groupId: row.key
50612
+ };
50613
+ }
49035
50614
  function resolveDeclaredTrackOwner(row, enrolmentExempt) {
49036
50615
  if (enrolmentExempt && row.data["assigned"] === true) return {
49037
50616
  kind: "exempt",
@@ -49051,15 +50630,28 @@ function buildOrphanScopes(collections = auditableCollections()) {
49051
50630
  const scopes = [];
49052
50631
  for (const entry of collections) {
49053
50632
  if (entry.classification.kind === "owner-indirection") {
49054
- if (entry.collection !== "pipeline-analytics:media") continue;
49055
- scopes.push({
49056
- id: entry.collection,
49057
- collection: entry.collection,
49058
- resolve: resolveMediaOwner,
49059
- timestampField: "timestamp",
49060
- sizeField: "sizeBytes",
49061
- deviceIdField: "deviceId"
49062
- });
50633
+ if (entry.collection === "pipeline-analytics:media") {
50634
+ scopes.push({
50635
+ id: entry.collection,
50636
+ collection: entry.collection,
50637
+ resolve: resolveMediaOwner,
50638
+ timestampField: "timestamp",
50639
+ sizeField: "sizeBytes",
50640
+ deviceIdField: "deviceId"
50641
+ });
50642
+ continue;
50643
+ }
50644
+ if (entry.collection === "pipeline-analytics:track-groups") {
50645
+ scopes.push({
50646
+ id: entry.collection,
50647
+ collection: entry.collection,
50648
+ resolve: resolveGroupOwner,
50649
+ timestampField: "timestamp",
50650
+ sizeField: null,
50651
+ deviceIdField: "deviceId"
50652
+ });
50653
+ continue;
50654
+ }
49063
50655
  continue;
49064
50656
  }
49065
50657
  if (entry.classification.kind !== "track-owned") continue;
@@ -49091,11 +50683,17 @@ var DEFAULT_ORPHAN_LIMITS = {
49091
50683
  function questionsFor(refs) {
49092
50684
  const trackIds = /* @__PURE__ */ new Set();
49093
50685
  const eventIds = /* @__PURE__ */ new Set();
50686
+ const groupIds = /* @__PURE__ */ new Set();
50687
+ const groupMemberIds = /* @__PURE__ */ new Set();
49094
50688
  for (const ref of refs) if (ref.kind === "track") trackIds.add(ref.trackId);
49095
50689
  else if (ref.kind === "event") eventIds.add(ref.eventId);
50690
+ else if (ref.kind === "group") groupIds.add(ref.groupId);
50691
+ else if (ref.kind === "group-members") groupMemberIds.add(ref.groupId);
49096
50692
  return {
49097
50693
  trackIds: [...trackIds],
49098
- eventIds: [...eventIds]
50694
+ eventIds: [...eventIds],
50695
+ groupIds: [...groupIds],
50696
+ groupMemberIds: [...groupMemberIds]
49099
50697
  };
49100
50698
  }
49101
50699
  /**
@@ -49127,12 +50725,16 @@ async function auditScope(scope, deps, limits, cursor) {
49127
50725
  break;
49128
50726
  }
49129
50727
  const refs = rows.map((row) => scope.resolve(row));
49130
- const { trackIds, eventIds } = questionsFor(refs);
50728
+ const { trackIds, eventIds, groupIds, groupMemberIds } = questionsFor(refs);
49131
50729
  let liveTracks;
49132
50730
  let liveEvents;
50731
+ let liveGroups;
50732
+ let liveGroupsWithMembers;
49133
50733
  try {
49134
50734
  liveTracks = trackIds.length > 0 ? await deps.liveTrackIds(trackIds) : /* @__PURE__ */ new Set();
49135
50735
  liveEvents = eventIds.length > 0 ? await deps.liveEventIds(eventIds) : /* @__PURE__ */ new Set();
50736
+ liveGroups = groupIds.length > 0 && deps.liveGroupIds ? await deps.liveGroupIds(groupIds) : /* @__PURE__ */ new Set();
50737
+ liveGroupsWithMembers = groupMemberIds.length > 0 && deps.liveGroupsWithMembers ? await deps.liveGroupsWithMembers(groupMemberIds) : /* @__PURE__ */ new Set();
49136
50738
  } catch (err) {
49137
50739
  deps.logger.warn("orphan audit: liveness probe failed — scope abandoned for this run", { meta: {
49138
50740
  collection: scope.collection,
@@ -49156,7 +50758,7 @@ async function auditScope(scope, deps, limits, cursor) {
49156
50758
  tally.unresolvable += 1;
49157
50759
  continue;
49158
50760
  }
49159
- if (ref.kind === "track" ? liveTracks.has(ref.trackId) : liveEvents.has(ref.eventId)) continue;
50761
+ if (ref.kind === "track" ? liveTracks.has(ref.trackId) : ref.kind === "event" ? liveEvents.has(ref.eventId) : ref.kind === "group" ? liveGroups.has(ref.groupId) : liveGroupsWithMembers.has(ref.groupId)) continue;
49160
50762
  const ts = num(row.data, scope.timestampField);
49161
50763
  if (ts !== null && ts > graceFloor) {
49162
50764
  tally.withinGrace += 1;
@@ -51248,6 +52850,13 @@ function squareSafeCropRegionNormalized(bbox, frame, padding) {
51248
52850
  };
51249
52851
  }
51250
52852
  //#endregion
52853
+ //#region src/pipeline-analytics/pipeline/usable-bbox.ts
52854
+ /** A box that can legally drive a crop. The leaving-event fallback
52855
+ * `{0,0,0,0}` must never become a face/plate JPEG. */
52856
+ function isUsableBbox(bbox) {
52857
+ return bbox.w > 0 && bbox.h > 0;
52858
+ }
52859
+ //#endregion
51251
52860
  //#region src/pipeline-analytics/services/event-media-dispatcher.ts
51252
52861
  /**
51253
52862
  * Long-side cap (px) for the boxed timeline tiles — `firstFrame`, `snapshot`,
@@ -51826,26 +53435,29 @@ var EventMediaDispatcher = class {
51826
53435
  }
51827
53436
  }
51828
53437
  async writeEventMedia(deviceId, frameHandle, frameData, fw, fh, ev, cropPadding) {
51829
- if (ev.childCrops) for (const child of ev.childCrops) try {
51830
- const childCropData = await this.cropSubjectRegion(deviceId, frameHandle, frameData, fw, fh, child.bbox, cropPadding, `${ev.eventId}:${child.kind}`);
51831
- if (!childCropData) continue;
51832
- await this.deps.mediaStore.put({
51833
- deviceId,
51834
- ownerKind: "event",
51835
- ownerId: ev.eventId,
51836
- kind: child.kind,
51837
- timestamp: ev.timestamp,
51838
- data: childCropData
51839
- });
51840
- } catch (err) {
51841
- this.deps.logger.warn(`event media: ${child.kind} failed`, {
51842
- tags: { deviceId },
51843
- meta: {
53438
+ if (ev.childCrops) for (const child of ev.childCrops) {
53439
+ if (!isUsableBbox(child.bbox)) continue;
53440
+ try {
53441
+ const childCropData = await this.cropSubjectRegion(deviceId, frameHandle, frameData, fw, fh, child.bbox, cropPadding, `${ev.eventId}:${child.kind}`);
53442
+ if (!childCropData) continue;
53443
+ await this.deps.mediaStore.put({
51844
53444
  deviceId,
51845
- eventId: ev.eventId,
51846
- error: err instanceof Error ? err.message : String(err)
51847
- }
51848
- });
53445
+ ownerKind: "event",
53446
+ ownerId: ev.eventId,
53447
+ kind: child.kind,
53448
+ timestamp: ev.timestamp,
53449
+ data: childCropData
53450
+ });
53451
+ } catch (err) {
53452
+ this.deps.logger.warn(`event media: ${child.kind} failed`, {
53453
+ tags: { deviceId },
53454
+ meta: {
53455
+ deviceId,
53456
+ eventId: ev.eventId,
53457
+ error: err instanceof Error ? err.message : String(err)
53458
+ }
53459
+ });
53460
+ }
51849
53461
  }
51850
53462
  }
51851
53463
  async writeTrackFrame(deviceId, frameHandle, frameData, fw, fh, tf) {
@@ -55106,7 +56718,8 @@ var customActions = {
55106
56718
  ...orphanAuditActions,
55107
56719
  ...viewerSettingsActions,
55108
56720
  ...photoEnrollActions,
55109
- ...faceRescoreActions
56721
+ ...faceRescoreActions,
56722
+ ...groupActions
55110
56723
  };
55111
56724
  /**
55112
56725
  * Assist threshold when the caller does not pin one. Packages have their own
@@ -55285,11 +56898,6 @@ function decodeEmbeddingBase64(base64) {
55285
56898
  const view = new Float32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));
55286
56899
  return Array.from(view);
55287
56900
  }
55288
- /** A track shorter than this is a candidate phantom, not a subject that came
55289
- * and went. Brackets the observed plant tracks (3.8-19.0 s). */
55290
- var SHORT_TRACK_MAX_MS = 25e3;
55291
- /** Total displacement under this is "did not move at all" (observed: 0-2.5 px). */
55292
- var MOTIONLESS_MAX_PX = 8;
55293
56901
  /** Grid the spawn point is quantised to, so respawns whose boxes never repeat
55294
56902
  * to the pixel still land in one cell. */
55295
56903
  var PHANTOM_CELL_PX = 32;
@@ -55353,6 +56961,11 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
55353
56961
  faceStore = null;
55354
56962
  faceRecognizer = null;
55355
56963
  plateStore = null;
56964
+ groupStore = null;
56965
+ groupSealer = null;
56966
+ groupLive = null;
56967
+ /** Serialise first-activity lookback per camera, then observe in order. */
56968
+ groupObserveTail = /* @__PURE__ */ new Map();
55356
56969
  plateRecognizer = null;
55357
56970
  objectEmbeddingStore = null;
55358
56971
  /** Live state of the detached embedding rebuild — see `rebuildObjectEmbeddings`. */
@@ -56170,7 +57783,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
56170
57783
  ...orphanAuditActions,
56171
57784
  ...viewerSettingsActions,
56172
57785
  ...photoEnrollActions,
56173
- ...faceRescoreActions
57786
+ ...faceRescoreActions,
57787
+ ...groupActions
56174
57788
  },
56175
57789
  actionHandlers: {
56176
57790
  ...ncHandlers,
@@ -56178,6 +57792,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
56178
57792
  ...this.buildDebugActionHandlers(),
56179
57793
  ...this.buildPhotoEnrollActionHandlers(),
56180
57794
  ...this.buildFaceRescoreActionHandlers(),
57795
+ ...this.buildGroupActionHandlers(),
56181
57796
  ...this.viewerSettingsSnapshots ? makeViewerSettingsActionHandlers(this.viewerSettingsSnapshots) : {}
56182
57797
  }
56183
57798
  } : {}
@@ -56380,6 +57995,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
56380
57995
  SceneStore.declare(api.settingsStore),
56381
57996
  NotificationCenter.declare(api.settingsStore),
56382
57997
  ViewerSettingsSnapshotStore.declare(api.settingsStore),
57998
+ TrackGroupStore.declare(api.settingsStore),
56383
57999
  declareMigrationMarkers(api.settingsStore)
56384
58000
  ]);
56385
58001
  this.viewerSettingsSnapshots = new ViewerSettingsSnapshotStore(api.settingsStore);
@@ -56664,6 +58280,31 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
56664
58280
  logger: logger.child("PlateStore")
56665
58281
  });
56666
58282
  this.plateStore = plateStore;
58283
+ const vehicleStore = new VehicleStore({
58284
+ store: api.settingsStore,
58285
+ logger: logger.child("VehicleStore")
58286
+ });
58287
+ const groupStore = new TrackGroupStore({
58288
+ store: api.settingsStore,
58289
+ logger: logger.child("TrackGroupStore"),
58290
+ media: mediaStore
58291
+ });
58292
+ this.groupStore = groupStore;
58293
+ this.groupLive = new GroupLiveBuffer();
58294
+ this.groupSealer = new GroupSealer({
58295
+ trackStore,
58296
+ groupStore,
58297
+ mediaStore,
58298
+ logger: logger.child("GroupSealer"),
58299
+ frameDims: (deviceId) => {
58300
+ const dims = this.lastFrameDimsByDevice.get(deviceId);
58301
+ return dims ? {
58302
+ width: dims.w,
58303
+ height: dims.h
58304
+ } : void 0;
58305
+ },
58306
+ now: () => Date.now()
58307
+ });
56667
58308
  return {
56668
58309
  trackStore,
56669
58310
  mediaStore,
@@ -56671,10 +58312,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
56671
58312
  identityStore,
56672
58313
  faceStore,
56673
58314
  plateStore,
56674
- vehicleStore: new VehicleStore({
56675
- store: api.settingsStore,
56676
- logger: logger.child("VehicleStore")
56677
- })
58315
+ vehicleStore,
58316
+ groupStore
56678
58317
  };
56679
58318
  }
56680
58319
  /** Face + plate recognizers, the durable keyFrame-derived plate crop and the
@@ -57108,29 +58747,71 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
57108
58747
  buildSummaryPorts(stores) {
57109
58748
  return {
57110
58749
  listTracks: async ({ deviceId, sinceMs, untilMs, limit }) => {
57111
- const tracks = await stores.trackStore.queryHistorical({
58750
+ await this.groupSealer?.sealWindow(deviceId, sinceMs, untilMs);
58751
+ const groups = await stores.groupStore.listOverlapping({
58752
+ deviceId,
58753
+ sinceMs,
58754
+ untilMs,
58755
+ limit
58756
+ });
58757
+ const windowTracks = await stores.trackStore.queryHistorical({
57112
58758
  deviceId,
57113
58759
  since: sinceMs,
57114
58760
  until: untilMs,
57115
58761
  projection: "slim",
57116
- limit
58762
+ limit: Math.max(limit, 2e3)
57117
58763
  });
58764
+ const trackById = new Map(windowTracks.map((t) => [t.trackId, t]));
57118
58765
  const { minHits } = await this.resolveDeviceDetectionSensitivitySettings(deviceId);
57119
- return tracks.map((t) => ({
57120
- trackId: t.trackId,
57121
- deviceId: t.deviceId,
57122
- className: t.className,
57123
- firstSeen: t.firstSeen,
57124
- lastSeen: t.lastSeen,
57125
- ...t.importance !== void 0 ? { importance: t.importance } : {},
57126
- zones: [...t.zonesVisited],
57127
- ...t.classes !== void 0 ? { classes: [...t.classes] } : {},
57128
- ...t.closedReason === "stationary-promotion" ? { stationary: true } : {},
57129
- ...t.observations !== void 0 ? { confirmed: t.observations >= minHits } : {}
57130
- }));
58766
+ const out = [];
58767
+ for (const group of groups) {
58768
+ const members = await stores.groupStore.listMembers(group.id);
58769
+ const tracks = members.map((m) => trackById.get(m.trackId)).filter((t) => t !== void 0);
58770
+ if (tracks.length === 0) continue;
58771
+ const classes = [...new Set(tracks.flatMap((t) => [t.className, ...t.classes ?? []]))];
58772
+ const zones = [...new Set(tracks.flatMap((t) => [...t.zonesVisited]))];
58773
+ const importance = Math.max(0, ...tracks.map((t) => t.importance ?? 0));
58774
+ const primary = tracks.reduce((best, t) => (t.importance ?? 0) > (best.importance ?? 0) ? t : best);
58775
+ out.push({
58776
+ trackId: group.id,
58777
+ groupId: group.id,
58778
+ memberTrackIds: members.map((m) => m.trackId),
58779
+ deviceId,
58780
+ className: primary.className,
58781
+ firstSeen: group.openedAt,
58782
+ lastSeen: group.closedAt,
58783
+ importance,
58784
+ zones,
58785
+ classes,
58786
+ ...tracks.every((t) => t.closedReason === "stationary-promotion") ? { stationary: true } : {},
58787
+ confirmed: tracks.some((t) => t.observations !== void 0 && t.observations >= minHits)
58788
+ });
58789
+ }
58790
+ return out;
57131
58791
  },
57132
- listMediaInfo: async (trackId) => {
57133
- return (await stores.mediaStore.listInfoByOwner("track", trackId)).map((r) => ({
58792
+ listMediaInfo: async (id) => {
58793
+ const group = await stores.groupStore.get(id);
58794
+ if (group) {
58795
+ if (group.mediaKey || group.nativeKey) return digestMediaRows({
58796
+ mediaKey: group.mediaKey,
58797
+ nativeKey: group.nativeKey,
58798
+ timestamp: group.timestamp,
58799
+ memberRows: []
58800
+ });
58801
+ const members = await stores.groupStore.listMembers(group.id);
58802
+ const memberRows = (await Promise.all(members.map((m) => stores.mediaStore.listInfoByOwner("track", m.trackId)))).flatMap((rows) => rows.map((r) => ({
58803
+ key: r.key,
58804
+ kind: r.kind,
58805
+ timestamp: r.timestamp
58806
+ })));
58807
+ return digestMediaRows({
58808
+ mediaKey: group.mediaKey,
58809
+ nativeKey: group.nativeKey,
58810
+ timestamp: group.timestamp,
58811
+ memberRows
58812
+ });
58813
+ }
58814
+ return (await stores.mediaStore.listInfoByOwner("track", id)).map((r) => ({
57134
58815
  key: r.key,
57135
58816
  kind: r.kind,
57136
58817
  timestamp: r.timestamp
@@ -58375,6 +60056,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
58375
60056
  state: t.state
58376
60057
  });
58377
60058
  positionsCountById.set(t.trackId, upserted.positions.length);
60059
+ this.enqueueLiveGroup(upserted, result.timestamp);
58378
60060
  }
58379
60061
  if (result.rawTrackedDetections.length > 0) {
58380
60062
  const { minHits } = await this.resolveDeviceDetectionSensitivitySettings(deviceId);
@@ -59154,8 +60836,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
59154
60836
  const first = track.positions?.[0];
59155
60837
  if (!first) return;
59156
60838
  const lifeMs = track.lastSeen - track.firstSeen;
59157
- const moved = track.totalDistance ?? 0;
59158
- if (!(lifeMs <= SHORT_TRACK_MAX_MS && moved <= MOTIONLESS_MAX_PX)) {
60839
+ const moved = netDisplacementPx(track.positions ?? []);
60840
+ if (!isShortMotionlessTrack(lifeMs, moved)) {
59159
60841
  this.phantomCells?.noteMovingClose({
59160
60842
  deviceId: track.deviceId,
59161
60843
  className: track.className,
@@ -59298,7 +60980,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
59298
60980
  deviceId,
59299
60981
  trackId,
59300
60982
  timestamp: frame.timestamp,
59301
- bbox: { ...frame.bbox },
60983
+ bbox: { ...bboxForSemanticPromotion(d.className, d.bbox, frame.bbox) },
59302
60984
  frameWidth: frame.frameWidth,
59303
60985
  frameHeight: frame.frameHeight,
59304
60986
  kind: d.className === "plate" ? "plate" : "classification",
@@ -59731,7 +61413,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
59731
61413
  retries.push({
59732
61414
  trackId: t.trackId,
59733
61415
  timestamp,
59734
- bbox: { ...t.bbox },
61416
+ bbox: firstFrameRetryBbox(t.bbox, this.trackStore?.peekActive(t.trackId)?.positions[0]?.bbox),
59735
61417
  ...patchDisplayLabel(t.labelPatch) !== void 0 ? { label: patchDisplayLabel(t.labelPatch) } : {}
59736
61418
  });
59737
61419
  }
@@ -59788,7 +61470,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
59788
61470
  lastFrameAt: this.residents.lastFrameAt(t.trackId) ?? 0,
59789
61471
  now: timestamp,
59790
61472
  intervalMs: media.snapshotIntervalMs,
59791
- lastFrameIntervalMs: Math.min(LAST_FRAME_INTERVAL_MS, media.snapshotIntervalMs)
61473
+ lastFrameIntervalMs: Math.min(LAST_FRAME_INTERVAL_MS, media.snapshotIntervalMs),
61474
+ firstFrameLanded: this.residents.isFirstFrameLanded(t.trackId)
59792
61475
  });
59793
61476
  const rollingLastFrame = plan.rollingLastFrame && !this.captureScheduler.isKeyActive(captureCoalesceKey(deviceId, "lastFrame", t.trackId));
59794
61477
  if (plan.appendSnapshot) this.trackStore.markSnapshotPending(t.trackId, timestamp, t.bbox);
@@ -60297,6 +61980,28 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
60297
61980
  async sweepExpiredTracks() {
60298
61981
  if (this.shuttingDown || !this.trackStore) return;
60299
61982
  await this.trackCloser.sweep();
61983
+ if (this.isPostProcessingNode && this.groupLive) this.groupSealer?.flushIdle(this.groupLive);
61984
+ }
61985
+ /** Hub-only: assign this sighting in RAM. First activity on a camera
61986
+ * replays 2 minutes of that camera (crash recover), then observe is sync. */
61987
+ enqueueLiveGroup(track, now) {
61988
+ if (!this.isPostProcessingNode || !this.groupLive) return;
61989
+ const deviceId = track.deviceId;
61990
+ const snapshot = toGroupableTrack(track);
61991
+ if (this.groupLive.isPrimed(deviceId)) {
61992
+ this.groupLive.observe(snapshot, now);
61993
+ return;
61994
+ }
61995
+ const next = (this.groupObserveTail.get(deviceId) ?? Promise.resolve()).then(async () => {
61996
+ if (this.groupLive && this.groupSealer && !this.groupLive.isPrimed(deviceId)) await this.groupSealer.recoverCamera(deviceId, this.groupLive);
61997
+ this.groupLive?.observe(snapshot, now);
61998
+ }).catch((err) => {
61999
+ this.ctx.logger.warn("group live observe failed", {
62000
+ tags: { deviceId },
62001
+ meta: { error: String(err) }
62002
+ });
62003
+ });
62004
+ this.groupObserveTail.set(deviceId, next);
60300
62005
  }
60301
62006
  /** Unified retention (Phase 1): earliest retained footage per device — the
60302
62007
  * follow-recordings cutoff source. Cached 10 min: availability answers in
@@ -60941,6 +62646,16 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
60941
62646
  };
60942
62647
  if (plausible) this.stationaryRegistry?.promote(entry);
60943
62648
  processor.dropTrack(track.trackId);
62649
+ const peek = this.trackStore?.peekActive(track.trackId);
62650
+ if (peek) this.enqueueLiveGroup(toGroupableTrack({
62651
+ trackId: track.trackId,
62652
+ deviceId,
62653
+ className: track.className,
62654
+ firstSeen: peek.firstSeen,
62655
+ lastSeen: timestamp,
62656
+ positions: peek.positions,
62657
+ closedReason: "stationary-promotion"
62658
+ }), timestamp);
60944
62659
  this.trackStore?.closeActive(track.trackId, "stationary-promotion").catch((err) => {
60945
62660
  this.ctx.logger.warn("stationary promotion: closing the source track failed — its media, events and vector are now unowned", {
60946
62661
  tags: { deviceId },
@@ -61575,6 +63290,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
61575
63290
  },
61576
63291
  liveTrackIds: (ids) => trackStore.existingTrackIds(ids),
61577
63292
  liveEventIds: (ids) => eventStore.existingEventIds(ids),
63293
+ liveGroupIds: (ids) => this.groupStore?.existingGroupIds(ids) ?? Promise.resolve(/* @__PURE__ */ new Set()),
63294
+ liveGroupsWithMembers: (ids) => this.groupStore?.groupsWithMembers(ids) ?? Promise.resolve(/* @__PURE__ */ new Set()),
61578
63295
  reclaim: async (collection, key) => {
61579
63296
  if (collection === "pipeline-analytics:media") {
61580
63297
  await mediaStore.deleteByKey(key);
@@ -62298,6 +64015,36 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
62298
64015
  buildFaceRescoreActionHandlers() {
62299
64016
  return { "face.rescoreTrack": (input) => this.rescoreFaceForTrack(input) };
62300
64017
  }
64018
+ buildGroupActionHandlers() {
64019
+ const ports = () => {
64020
+ const groupStore = this.groupStore;
64021
+ if (groupStore === null) return null;
64022
+ return {
64023
+ groupStore,
64024
+ loadTrack: async (trackId) => {
64025
+ const tracks = this.trackStore;
64026
+ if (!tracks) return null;
64027
+ return tracks.getActiveByTrack(trackId) ?? await tracks.getPersistedByTrackId(trackId);
64028
+ },
64029
+ eventMediaBaseUrl: () => this.eventMediaBaseUrl
64030
+ };
64031
+ };
64032
+ return {
64033
+ "analytics.listGroups": async (input) => {
64034
+ const ready = ports();
64035
+ if (ready === null) return {
64036
+ groups: [],
64037
+ hasMore: false
64038
+ };
64039
+ return listGroupRecords(ready, input);
64040
+ },
64041
+ "analytics.getGroup": async (input) => {
64042
+ const ready = ports();
64043
+ if (ready === null) return { group: null };
64044
+ return { group: await getGroupDetail(ready, input.groupId) };
64045
+ }
64046
+ };
64047
+ }
62301
64048
  buildPhotoEnrollActionHandlers() {
62302
64049
  return {
62303
64050
  "photo.analyze": async (input) => {
@@ -62528,7 +64275,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
62528
64275
  mediaStore,
62529
64276
  faceStore: this.faceStore,
62530
64277
  plateStore: this.plateStore,
62531
- objectEmbeddingStore: this.objectEmbeddingStore
64278
+ objectEmbeddingStore: this.objectEmbeddingStore,
64279
+ groupStore: this.groupStore
62532
64280
  }),
62533
64281
  trackStore
62534
64282
  };
@@ -62677,7 +64425,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
62677
64425
  media: 0,
62678
64426
  faces: 0,
62679
64427
  plates: 0,
62680
- embeddings: 0
64428
+ embeddings: 0,
64429
+ groups: 0
62681
64430
  };
62682
64431
  this.lastPruneBytes = 0;
62683
64432
  const cascade = this.buildTrackCascadeRegistry();
@@ -62737,7 +64486,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
62737
64486
  media: tally.media,
62738
64487
  faces: tally.faces,
62739
64488
  plates: tally.plates,
62740
- embeddings: tally.embeddings
64489
+ embeddings: tally.embeddings,
64490
+ groups: tally.groups
62741
64491
  };
62742
64492
  }
62743
64493
  /**