@camstack/addon-post-analysis 1.2.110 → 1.2.115

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",
@@ -15123,14 +15134,13 @@ function selectSummaryTiles(candidates, opts) {
15123
15134
  /**
15124
15135
  * The media a tile is drawn from, best first.
15125
15136
  *
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.
15137
+ * `groupCrop` heads the ladder: it is the group's own clean wide-central-square
15138
+ * over the union bbox, and a drawn rectangle is a permanent lie in a digest
15139
+ * tile. Singletons have no groupCrop and fall through to the member ladder.
15140
+ * `fullFrameBoxed` and `firstFrame` stay absent for the same reason.
15132
15141
  */
15133
15142
  var SUMMARY_MEDIA_LADDER = [
15143
+ "groupCrop",
15134
15144
  "keyFrameSmall",
15135
15145
  "keyFrame",
15136
15146
  "snapshot",
@@ -15212,7 +15222,7 @@ var SUMMARY_TRACK_QUERY_LIMIT = 2e3;
15212
15222
  function summaryMediaOwnerId(ruleId, windowEndMs) {
15213
15223
  return `summary-${ruleId}-${windowEndMs}`;
15214
15224
  }
15215
- var EMPTY_REPORT = {
15225
+ var EMPTY_REPORT$1 = {
15216
15226
  produced: 0,
15217
15227
  collecting: 0,
15218
15228
  barrier: 0,
@@ -15276,9 +15286,9 @@ var NcSummaryProducer = class {
15276
15286
  * must not open ten simultaneous render/publish sequences.
15277
15287
  */
15278
15288
  async tick() {
15279
- if (this.tickInFlight) return EMPTY_REPORT;
15289
+ if (this.tickInFlight) return EMPTY_REPORT$1;
15280
15290
  this.tickInFlight = true;
15281
- const report = { ...EMPTY_REPORT };
15291
+ const report = { ...EMPTY_REPORT$1 };
15282
15292
  try {
15283
15293
  const now = this.deps.now();
15284
15294
  const rules = this.deps.store.listEnabled();
@@ -15989,7 +15999,7 @@ var NcSummaryRuleInputSchema = object({
15989
15999
  deviceIds: DeviceIdsField,
15990
16000
  window: NcSummaryWindowSchema,
15991
16001
  filters: NcSummaryFiltersSchema.optional(),
15992
- maxTiles: MaxTilesField.default(9),
16002
+ maxTiles: MaxTilesField.default(12),
15993
16003
  maxAiImages: MaxAiImagesField.default(6),
15994
16004
  rank: NcSummaryRankSchema.default("importance"),
15995
16005
  barrierSeconds: BarrierSecondsField.default(120),
@@ -18340,6 +18350,15 @@ var TIMELAPSE_LOOKBACK_MS = 8 * 864e5;
18340
18350
  function timelapseEligibilityFloor(rule, deviceId) {
18341
18351
  return Math.max(readTimelapseGeneratedAt(rule, deviceId), rule.createdAt);
18342
18352
  }
18353
+ var EMPTY_REPORT = {
18354
+ produced: 0,
18355
+ waiting: 0,
18356
+ alreadyDone: 0,
18357
+ beforeFloor: 0,
18358
+ failed: 0,
18359
+ idle: 0,
18360
+ skippedInFlight: 0
18361
+ };
18343
18362
  /** The in-flight map's key. One render per (rule, camera) pair by construction. */
18344
18363
  function renderFlightKey(ruleId, deviceId) {
18345
18364
  return `${ruleId}:${String(deviceId)}`;
@@ -18347,7 +18366,8 @@ function renderFlightKey(ruleId, deviceId) {
18347
18366
  var TimelapseScheduler = class {
18348
18367
  deps;
18349
18368
  timer = null;
18350
- tickInFlight = false;
18369
+ /** True only while this tick is scanning eligibility — not while ffmpeg runs. */
18370
+ scanning = false;
18351
18371
  inFlight = /* @__PURE__ */ new Map();
18352
18372
  settleMs;
18353
18373
  lookbackMs;
@@ -18394,25 +18414,29 @@ var TimelapseScheduler = class {
18394
18414
  this.timer = null;
18395
18415
  }
18396
18416
  }
18397
- /** One pass over every enabled rule × camera. Single-flight. */
18417
+ /**
18418
+ * One pass over every enabled rule × camera.
18419
+ *
18420
+ * Eligibility is scanned under a short `scanning` lock so two ticks do not
18421
+ * double-launch the same pair. Renders themselves are started together —
18422
+ * awaiting them one-by-one made camera B wait for camera A's ffmpeg
18423
+ * (live: 1441 until 08:39, 615 until 08:46, both windows closed at 07:00).
18424
+ * A later tick that meets a pair already in `inFlight` counts it and
18425
+ * moves on, so a 30-minute render does not make the producer go deaf.
18426
+ */
18398
18427
  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;
18428
+ if (this.scanning) return { ...EMPTY_REPORT };
18429
+ this.scanning = true;
18408
18430
  const report = {
18409
18431
  produced: 0,
18410
18432
  waiting: 0,
18411
18433
  alreadyDone: 0,
18412
18434
  beforeFloor: 0,
18413
18435
  failed: 0,
18414
- idle: 0
18436
+ idle: 0,
18437
+ skippedInFlight: 0
18415
18438
  };
18439
+ const jobs = [];
18416
18440
  try {
18417
18441
  const now = this.deps.now();
18418
18442
  for (const rule of this.deps.store.listEnabled()) {
@@ -18447,13 +18471,20 @@ var TimelapseScheduler = class {
18447
18471
  });
18448
18472
  continue;
18449
18473
  }
18450
- if ((await this.produce(rule, deviceId, window, { stamp: true })).produced > 0) report.produced += 1;
18451
- else report.failed += 1;
18474
+ if (this.inFlight.has(renderFlightKey(rule.id, deviceId))) {
18475
+ report.skippedInFlight += 1;
18476
+ continue;
18477
+ }
18478
+ jobs.push(this.produce(rule, deviceId, window, { stamp: true }).then((ok) => {
18479
+ if (ok.produced > 0) report.produced += 1;
18480
+ else report.failed += 1;
18481
+ }));
18452
18482
  }
18453
18483
  }
18454
18484
  } finally {
18455
- this.tickInFlight = false;
18485
+ this.scanning = false;
18456
18486
  }
18487
+ await Promise.all(jobs);
18457
18488
  return report;
18458
18489
  }
18459
18490
  /**
@@ -27607,6 +27638,35 @@ function pairRiders(detections) {
27607
27638
  return pairs;
27608
27639
  }
27609
27640
  //#endregion
27641
+ //#region src/pipeline-analytics/pipeline/tracker/rescue-growth.ts
27642
+ /** How far the grown box's centroid may sit, as a multiple of the LARGER
27643
+ * diagonal. 1.35 covers the live 4285605a bird→dog jump (~75px / 59px). */
27644
+ var GROWTH_CENTROID_FACTOR = 1.35;
27645
+ function intersectionArea(a, b) {
27646
+ const x1 = Math.max(a.x, b.x);
27647
+ const y1 = Math.max(a.y, b.y);
27648
+ const x2 = Math.min(a.x + a.w, b.x + b.w);
27649
+ const y2 = Math.min(a.y + a.h, b.y + b.h);
27650
+ if (x2 <= x1 || y2 <= y1) return 0;
27651
+ return (x2 - x1) * (y2 - y1);
27652
+ }
27653
+ /** Same-class partial crop growing into (or collapsing from) a full-body box
27654
+ * with IoU 0. Used by rescue and spawn-dedup so we do not mint a second id.
27655
+ * A SMALL box nested inside a LARGE one is occlusion, not growth — those
27656
+ * stay two tracks (sort-tracker occlusion suite). */
27657
+ function growthAdjacentMatch(track, det) {
27658
+ const trackArea = track.w * track.h;
27659
+ const detArea = det.w * det.h;
27660
+ if (trackArea <= 0 || detArea <= 0) return false;
27661
+ const larger = Math.max(trackArea, detArea);
27662
+ const smaller = Math.min(trackArea, detArea);
27663
+ if (larger / smaller < 1.5) return false;
27664
+ if (intersectionArea(track, det) / smaller >= .5) return false;
27665
+ const tc = bboxCentroid(track);
27666
+ const dc = bboxCentroid(det);
27667
+ 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));
27668
+ }
27669
+ //#endregion
27610
27670
  //#region src/pipeline-analytics/pipeline/tracker/sort-tracker.ts
27611
27671
  var DEFAULT_TRACKER_CONFIG = {
27612
27672
  iouThreshold: .3,
@@ -27734,7 +27794,7 @@ var SortTracker = class SortTracker {
27734
27794
  * and earlier same-frame spawns).
27735
27795
  */
27736
27796
  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;
27797
+ 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
27798
  if (this.config.personAnimalDedup && det.class === ANIMAL_CLASS && det.score < this.config.animalOverPersonMaxScore) {
27739
27799
  for (const t of live) if (t.class === PERSON_CLASS && iou$2(det.bbox, t.bbox) >= this.config.animalOverPersonIou) return true;
27740
27800
  }
@@ -27828,7 +27888,8 @@ var SortTracker = class SortTracker {
27828
27888
  const dc = bboxCentroid(det.bbox);
27829
27889
  const dist = Math.hypot(tc.x - dc.x, tc.y - dc.y);
27830
27890
  const diag = Math.hypot(track.bbox.w, track.bbox.h);
27831
- return dist <= this.config.rescueCentroidFactor * diag;
27891
+ if (dist <= this.config.rescueCentroidFactor * diag) return true;
27892
+ return growthAdjacentMatch(track.bbox, det.bbox);
27832
27893
  }
27833
27894
  /** Fold a matched detection's class + score into a track's lifetime vote
27834
27895
  * tally. A non-positive score still counts as an infinitesimal vote so a
@@ -28153,6 +28214,7 @@ var StateAnalyzer = class {
28153
28214
  enteredAt: timestamp,
28154
28215
  stationarySince: void 0,
28155
28216
  totalDistancePx: 0,
28217
+ startPosition: center,
28156
28218
  lastPosition: center,
28157
28219
  frameCount: 1
28158
28220
  };
@@ -28172,8 +28234,9 @@ var StateAnalyzer = class {
28172
28234
  else if (velocity <= this.config.velocityThreshold) {
28173
28235
  if (!ts.stationarySince) ts.stationarySince = timestamp;
28174
28236
  const stationaryDurationSec = (timestamp - ts.stationarySince) / 1e3;
28237
+ const neverMoved = Math.hypot(ts.lastPosition.x - ts.startPosition.x, ts.lastPosition.y - ts.startPosition.y) <= this.config.velocityThreshold;
28175
28238
  if (stationaryDurationSec >= this.config.loiteringThresholdSec) state = "loitering";
28176
- else if (stationaryDurationSec >= this.config.stationaryThresholdSec) state = "stationary";
28239
+ else if (neverMoved || stationaryDurationSec >= this.config.stationaryThresholdSec) state = "stationary";
28177
28240
  else state = "moving";
28178
28241
  } else {
28179
28242
  ts.stationarySince = void 0;
@@ -29006,6 +29069,22 @@ function promoteSemanticBestFrame(deps, input) {
29006
29069
  };
29007
29070
  }
29008
29071
  //#endregion
29072
+ //#region src/pipeline-analytics/pipeline/semantic-detail-bbox.ts
29073
+ /** Which box the semantic best-shot should crop: a plate/face DETAIL box
29074
+ * when we have one, otherwise the subject box. */
29075
+ function bboxForSemanticPromotion(className, detailBbox, subjectBbox) {
29076
+ if ((className === "plate" || className === "face") && detailBbox !== void 0 && detailBbox.w > 0 && detailBbox.h > 0) return detailBbox;
29077
+ return subjectBbox;
29078
+ }
29079
+ //#endregion
29080
+ //#region src/pipeline-analytics/pipeline/first-frame-retry-bbox.ts
29081
+ /** Crop box for a late firstFrame retry: the BIRTH geometry, not the
29082
+ * later frozen/coasted box (track 1b659d56). */
29083
+ function firstFrameRetryBbox(current, birth) {
29084
+ if (birth !== void 0 && birth.w > 0 && birth.h > 0) return { ...birth };
29085
+ return { ...current };
29086
+ }
29087
+ //#endregion
29009
29088
  //#region src/pipeline-analytics/store/zone-geometry.ts
29010
29089
  /**
29011
29090
  * Normalized min/max envelope over every position's bbox. Returns `null`
@@ -29220,6 +29299,7 @@ var EMPTY_CASCADE_TALLY = {
29220
29299
  faces: 0,
29221
29300
  plates: 0,
29222
29301
  embeddings: 0,
29302
+ groups: 0,
29223
29303
  bytesReclaimed: 0
29224
29304
  };
29225
29305
  /** Fold a leaf store's receipts into a running tally. */
@@ -29263,6 +29343,13 @@ function addLeafCounts(tally, counts) {
29263
29343
  bytesReclaimed
29264
29344
  };
29265
29345
  break;
29346
+ case "groups":
29347
+ next = {
29348
+ ...next,
29349
+ groups: next.groups + c.rows,
29350
+ bytesReclaimed
29351
+ };
29352
+ break;
29266
29353
  }
29267
29354
  }
29268
29355
  return next;
@@ -29276,6 +29363,7 @@ function mergeCascadeTallies(a, b) {
29276
29363
  faces: a.faces + b.faces,
29277
29364
  plates: a.plates + b.plates,
29278
29365
  embeddings: a.embeddings + b.embeddings,
29366
+ groups: a.groups + b.groups,
29279
29367
  bytesReclaimed: a.bytesReclaimed + b.bytesReclaimed
29280
29368
  };
29281
29369
  }
@@ -29324,6 +29412,7 @@ function buildAnalyticsCascadeRegistry(stores) {
29324
29412
  if (stores.faceStore) registry.push(stores.faceStore);
29325
29413
  if (stores.plateStore) registry.push(stores.plateStore);
29326
29414
  if (stores.objectEmbeddingStore) registry.push(stores.objectEmbeddingStore);
29415
+ if (stores.groupStore) registry.push(stores.groupStore);
29327
29416
  return registry;
29328
29417
  }
29329
29418
  /**
@@ -31034,7 +31123,8 @@ var OWNER_KINDS = [
31034
31123
  "identity",
31035
31124
  "plate",
31036
31125
  "vehicle",
31037
- "scene"
31126
+ "scene",
31127
+ "group"
31038
31128
  ];
31039
31129
  /**
31040
31130
  * Narrow an arbitrary string to an `OwnerKind`.
@@ -31783,6 +31873,10 @@ var MediaStore = class {
31783
31873
  async deleteForTracks(trackIds) {
31784
31874
  return this.deleteForOwner("track", trackIds);
31785
31875
  }
31876
+ /** Delete the group's OWN crop. The native frame is a pointer and is not here. */
31877
+ async deleteByGroups(groupIds) {
31878
+ return this.deleteForOwnerCounted("group", groupIds);
31879
+ }
31786
31880
  /**
31787
31881
  * `TrackScopedStore` contract for the retention cascade: delete all media
31788
31882
  * owned by the given tracks — the tracks' OWN media (`ownerKind:'track'`) plus
@@ -39836,7 +39930,9 @@ function resolvePackageDropSettings(raw) {
39836
39930
  function planPeriodicMedia(input) {
39837
39931
  const appendSnapshot = input.dueSnapshot;
39838
39932
  const lastFrameInterval = input.lastFrameIntervalMs ?? input.intervalMs;
39839
- const rollingLastFrame = input.saveThumbnails && Math.floor(input.now / lastFrameInterval) > Math.floor(input.lastFrameAt / lastFrameInterval) && !appendSnapshot;
39933
+ const lastFrameElapsed = input.saveThumbnails && Math.floor(input.now / lastFrameInterval) > Math.floor(input.lastFrameAt / lastFrameInterval);
39934
+ const firstFrameLanded = input.firstFrameLanded ?? true;
39935
+ const rollingLastFrame = lastFrameElapsed && !appendSnapshot && firstFrameLanded;
39840
39936
  const thumbnailLanded = input.thumbnailLanded ?? true;
39841
39937
  return {
39842
39938
  appendSnapshot,
@@ -40069,6 +40165,1170 @@ var DeferredBirthRegistry = class {
40069
40165
  this.byDevice.delete(deviceKey);
40070
40166
  }
40071
40167
  };
40168
+ var AnalyticsGroupRecordSchema = object({
40169
+ id: string(),
40170
+ deviceId: number().int(),
40171
+ openedAt: number().int(),
40172
+ closedAt: number().int(),
40173
+ timestamp: number().int(),
40174
+ memberCount: number().int(),
40175
+ memberTrackIds: array(string()).readonly(),
40176
+ className: string(),
40177
+ classes: array(string()).readonly(),
40178
+ /** Relative event-media path, or null when the group has no picture yet. */
40179
+ mediaUrl: string().nullable(),
40180
+ singleton: boolean()
40181
+ });
40182
+ var AnalyticsGroupMemberSchema = object({
40183
+ trackId: string(),
40184
+ deviceId: number().int(),
40185
+ className: string(),
40186
+ firstSeen: number().int(),
40187
+ lastSeen: number().int(),
40188
+ mediaUrl: string().nullable()
40189
+ });
40190
+ var AnalyticsGroupDetailSchema = AnalyticsGroupRecordSchema.extend({ members: array(AnalyticsGroupMemberSchema).readonly() });
40191
+ var groupActions = defineCustomActions({
40192
+ "analytics.listGroups": customAction(object({
40193
+ deviceId: number().int().optional(),
40194
+ sinceMs: number().int().optional(),
40195
+ untilMs: number().int().optional(),
40196
+ limit: number().int().min(1).max(100).optional(),
40197
+ offset: number().int().min(0).optional()
40198
+ }), object({
40199
+ groups: array(AnalyticsGroupRecordSchema).readonly(),
40200
+ hasMore: boolean()
40201
+ })),
40202
+ "analytics.getGroup": customAction(object({ groupId: string().min(1) }), object({ group: AnalyticsGroupDetailSchema.nullable() }))
40203
+ });
40204
+ //#endregion
40205
+ //#region src/pipeline-analytics/group/group-query.ts
40206
+ /**
40207
+ * Stamp a relative event-media URL.
40208
+ *
40209
+ * Media keys contain `:` and the data-plane serves them by key. Track ids are
40210
+ * bare and fall through to the existing event/track thumbnail resolver. Never
40211
+ * percent-encode: the handler does not URL-decode the path.
40212
+ */
40213
+ function stampEventMediaUrl(baseUrl, id) {
40214
+ if (baseUrl === null || baseUrl.length === 0) return null;
40215
+ if (id === null || id === void 0 || id.length === 0) return null;
40216
+ return `${baseUrl}/${id}`;
40217
+ }
40218
+ /** Union of every class on the members; `className` is the first, stable order. */
40219
+ function aggregateGroupClasses(tracks) {
40220
+ const seen = /* @__PURE__ */ new Set();
40221
+ const classes = [];
40222
+ for (const track of tracks) for (const name of [track.className, ...track.classes ?? []]) {
40223
+ if (name.length === 0 || seen.has(name)) continue;
40224
+ seen.add(name);
40225
+ classes.push(name);
40226
+ }
40227
+ return {
40228
+ className: classes[0] ?? "unknown",
40229
+ classes
40230
+ };
40231
+ }
40232
+ function toGroupRecord(input) {
40233
+ const { className, classes } = aggregateGroupClasses(input.tracks);
40234
+ const mediaId = input.group.mediaKey ?? (input.group.memberCount === 1 ? input.memberTrackIds[0] : null);
40235
+ return {
40236
+ id: input.group.id,
40237
+ deviceId: input.group.deviceId,
40238
+ openedAt: input.group.openedAt,
40239
+ closedAt: input.group.closedAt,
40240
+ timestamp: input.group.timestamp,
40241
+ memberCount: input.memberTrackIds.length > 0 ? input.memberTrackIds.length : input.group.memberCount,
40242
+ memberTrackIds: [...input.memberTrackIds],
40243
+ className,
40244
+ classes: [...classes],
40245
+ mediaUrl: stampEventMediaUrl(input.eventMediaBaseUrl, mediaId),
40246
+ singleton: input.memberTrackIds.length <= 1
40247
+ };
40248
+ }
40249
+ function toGroupMember(member, track, eventMediaBaseUrl) {
40250
+ return {
40251
+ trackId: member.trackId,
40252
+ deviceId: member.deviceId,
40253
+ className: track?.className ?? "unknown",
40254
+ firstSeen: track?.firstSeen ?? member.timestamp,
40255
+ lastSeen: track?.lastSeen ?? member.timestamp,
40256
+ mediaUrl: stampEventMediaUrl(eventMediaBaseUrl, member.trackId)
40257
+ };
40258
+ }
40259
+ function trackToLite(track) {
40260
+ return {
40261
+ trackId: track.trackId,
40262
+ className: track.className,
40263
+ classes: track.classes,
40264
+ firstSeen: track.firstSeen,
40265
+ lastSeen: track.lastSeen
40266
+ };
40267
+ }
40268
+ async function listGroupRecords(ports, input) {
40269
+ const page = await ports.groupStore.listRecent(input);
40270
+ const groups = [];
40271
+ for (const group of page.groups) groups.push(await buildGroupRecord(ports, group));
40272
+ return {
40273
+ groups,
40274
+ hasMore: page.hasMore
40275
+ };
40276
+ }
40277
+ async function getGroupDetail(ports, groupId) {
40278
+ const group = await ports.groupStore.get(groupId);
40279
+ if (group === null) return null;
40280
+ const members = await ports.groupStore.listMembers(groupId);
40281
+ const lites = [];
40282
+ const rows = [];
40283
+ for (const member of members) {
40284
+ const track = await ports.loadTrack(member.trackId);
40285
+ const lite = track === null ? null : trackToLite(track);
40286
+ if (lite !== null) lites.push(lite);
40287
+ rows.push(toGroupMember(member, lite, ports.eventMediaBaseUrl()));
40288
+ }
40289
+ return {
40290
+ ...toGroupRecord({
40291
+ group,
40292
+ memberTrackIds: members.map((m) => m.trackId),
40293
+ tracks: lites,
40294
+ eventMediaBaseUrl: ports.eventMediaBaseUrl()
40295
+ }),
40296
+ members: rows
40297
+ };
40298
+ }
40299
+ async function buildGroupRecord(ports, group) {
40300
+ const members = await ports.groupStore.listMembers(group.id);
40301
+ const lites = [];
40302
+ for (const member of members) {
40303
+ const track = await ports.loadTrack(member.trackId);
40304
+ if (track !== null) lites.push(trackToLite(track));
40305
+ }
40306
+ return toGroupRecord({
40307
+ group,
40308
+ memberTrackIds: members.map((m) => m.trackId),
40309
+ tracks: lites,
40310
+ eventMediaBaseUrl: ports.eventMediaBaseUrl()
40311
+ });
40312
+ }
40313
+ //#endregion
40314
+ //#region src/pipeline-analytics/group/group-crop.ts
40315
+ /** Always containSubject: true. Never the 0.5 delivered threshold. */
40316
+ function groupWideCentralSquareLayout(unionBbox, frame) {
40317
+ return wideCentralSquareLayout(unionBbox, frame, { containSubject: true });
40318
+ }
40319
+ async function cropGroupFromRgb(rgb, frameWidth, frameHeight, unionBbox) {
40320
+ const layout = groupWideCentralSquareLayout(unionBbox, {
40321
+ W: frameWidth,
40322
+ H: frameHeight
40323
+ });
40324
+ const { crop: slab } = await extractCrop(rgb, frameWidth, frameHeight, layout.fetch);
40325
+ return composeWideCentralSquareThumbnail(slab, layout);
40326
+ }
40327
+ var MATCH_WINDOW_MS = 1e3;
40328
+ var MOVE_EPS = .35;
40329
+ /** Registry stationary is out; audio has no bbox and is never a member or a singleton. */
40330
+ function isEligibleMember(track) {
40331
+ if (track.closedReason === "stationary-promotion") return false;
40332
+ if (track.className === "audio") return false;
40333
+ return true;
40334
+ }
40335
+ function temporallyOverlap(a, b, slackMs) {
40336
+ return a.firstSeen <= b.lastSeen + slackMs && b.firstSeen <= a.lastSeen + slackMs;
40337
+ }
40338
+ /** Connected components of temporal overlap (+ slack) per camera. */
40339
+ function sessionsOf(tracks) {
40340
+ const byDevice = /* @__PURE__ */ new Map();
40341
+ for (const t of tracks) {
40342
+ const list = byDevice.get(t.deviceId);
40343
+ if (list) list.push(t);
40344
+ else byDevice.set(t.deviceId, [t]);
40345
+ }
40346
+ const sessions = [];
40347
+ for (const deviceTracks of byDevice.values()) {
40348
+ const n = deviceTracks.length;
40349
+ const parent = deviceTracks.map((_, i) => i);
40350
+ const find = (i) => {
40351
+ let cur = i;
40352
+ while (parent[cur] !== cur) cur = parent[cur];
40353
+ let walk = i;
40354
+ while (parent[walk] !== cur) {
40355
+ const next = parent[walk];
40356
+ parent[walk] = cur;
40357
+ walk = next;
40358
+ }
40359
+ return cur;
40360
+ };
40361
+ const union = (i, j) => {
40362
+ const ri = find(i);
40363
+ const rj = find(j);
40364
+ if (ri !== rj) parent[ri] = rj;
40365
+ };
40366
+ for (let i = 0; i < n; i++) for (let j = i + 1; j < n; j++) if (temporallyOverlap(deviceTracks[i], deviceTracks[j], 3e3)) union(i, j);
40367
+ const buckets = /* @__PURE__ */ new Map();
40368
+ for (let i = 0; i < n; i++) {
40369
+ const root = find(i);
40370
+ const bucket = buckets.get(root);
40371
+ if (bucket) bucket.push(deviceTracks[i]);
40372
+ else buckets.set(root, [deviceTracks[i]]);
40373
+ }
40374
+ sessions.push(...buckets.values());
40375
+ }
40376
+ return sessions;
40377
+ }
40378
+ function diag(bbox) {
40379
+ return Math.hypot(bbox.w, bbox.h);
40380
+ }
40381
+ function nearestPosition(positions, t) {
40382
+ let best;
40383
+ let bestDt = 1001;
40384
+ for (const p of positions) {
40385
+ const dt = Math.abs(p.timestamp - t);
40386
+ if (dt <= 1e3 && dt < bestDt) {
40387
+ best = p;
40388
+ bestDt = dt;
40389
+ }
40390
+ }
40391
+ return best;
40392
+ }
40393
+ function meanDiag(positions) {
40394
+ if (positions.length === 0) return 0;
40395
+ let sum = 0;
40396
+ for (const p of positions) sum += diag(p.bbox);
40397
+ return sum / positions.length;
40398
+ }
40399
+ function netDisplacement(track) {
40400
+ const first = track.positions[0];
40401
+ const last = track.positions[track.positions.length - 1];
40402
+ if (!first || !last) return {
40403
+ x: 0,
40404
+ y: 0
40405
+ };
40406
+ const a = bboxCentroid(first.bbox);
40407
+ const b = bboxCentroid(last.bbox);
40408
+ return {
40409
+ x: b.x - a.x,
40410
+ y: b.y - a.y
40411
+ };
40412
+ }
40413
+ function vecLen(v) {
40414
+ return Math.hypot(v.x, v.y);
40415
+ }
40416
+ function cosine(a, b) {
40417
+ const la = vecLen(a);
40418
+ const lb = vecLen(b);
40419
+ if (la === 0 || lb === 0) return 1;
40420
+ return (a.x * b.x + a.y * b.y) / (la * lb);
40421
+ }
40422
+ function relativeMotion(track) {
40423
+ const mean = meanDiag(track.positions);
40424
+ if (mean <= 0) return 0;
40425
+ return vecLen(netDisplacement(track)) / mean;
40426
+ }
40427
+ /** A ~ B iff cohesion ≥ 0.5 AND NOT (both move AND cos < 0.3). */
40428
+ function pairTogether(a, b) {
40429
+ const overlapStart = Math.max(a.firstSeen, b.firstSeen);
40430
+ const overlapEnd = Math.min(a.lastSeen, b.lastSeen);
40431
+ if (overlapEnd < overlapStart) return false;
40432
+ let joint = 0;
40433
+ let close = 0;
40434
+ for (let t = overlapStart; t <= overlapEnd; t += 500) {
40435
+ const pa = nearestPosition(a.positions, t);
40436
+ const pb = nearestPosition(b.positions, t);
40437
+ if (!pa || !pb) continue;
40438
+ joint++;
40439
+ const ca = bboxCentroid(pa.bbox);
40440
+ const cb = bboxCentroid(pb.bbox);
40441
+ const gap = Math.hypot(ca.x - cb.x, ca.y - cb.y);
40442
+ const scale = (diag(pa.bbox) + diag(pb.bbox)) / 2;
40443
+ if ((scale > 0 ? gap / scale : Number.POSITIVE_INFINITY) <= 1.5) close++;
40444
+ }
40445
+ if (joint === 0) return false;
40446
+ if (close / joint < .5) return false;
40447
+ const aMoves = relativeMotion(a) >= MOVE_EPS;
40448
+ const bMoves = relativeMotion(b) >= MOVE_EPS;
40449
+ if (aMoves && bMoves && cosine(netDisplacement(a), netDisplacement(b)) < .3) return false;
40450
+ return true;
40451
+ }
40452
+ function clusterGroups(tracks) {
40453
+ const eligible = tracks.filter(isEligibleMember);
40454
+ const out = [];
40455
+ for (const session of sessionsOf(eligible)) {
40456
+ const pairable = [];
40457
+ for (const t of session) if (t.positions.length < 2) out.push({
40458
+ deviceId: t.deviceId,
40459
+ memberTrackIds: [t.trackId]
40460
+ });
40461
+ else pairable.push(t);
40462
+ if (pairable.length === 0) continue;
40463
+ const parent = pairable.map((_, i) => i);
40464
+ const find = (i) => {
40465
+ let cur = i;
40466
+ while (parent[cur] !== cur) cur = parent[cur];
40467
+ let walk = i;
40468
+ while (parent[walk] !== cur) {
40469
+ const next = parent[walk];
40470
+ parent[walk] = cur;
40471
+ walk = next;
40472
+ }
40473
+ return cur;
40474
+ };
40475
+ const union = (i, j) => {
40476
+ const ri = find(i);
40477
+ const rj = find(j);
40478
+ if (ri !== rj) parent[ri] = rj;
40479
+ };
40480
+ for (let i = 0; i < pairable.length; i++) for (let j = i + 1; j < pairable.length; j++) if (pairTogether(pairable[i], pairable[j])) union(i, j);
40481
+ const buckets = /* @__PURE__ */ new Map();
40482
+ for (let i = 0; i < pairable.length; i++) {
40483
+ const root = find(i);
40484
+ const bucket = buckets.get(root);
40485
+ const id = pairable[i].trackId;
40486
+ if (bucket) bucket.push(id);
40487
+ else buckets.set(root, [id]);
40488
+ }
40489
+ const deviceId = pairable[0].deviceId;
40490
+ for (const memberTrackIds of buckets.values()) out.push({
40491
+ deviceId,
40492
+ memberTrackIds
40493
+ });
40494
+ }
40495
+ return out;
40496
+ }
40497
+ //#endregion
40498
+ //#region src/pipeline-analytics/group/group-instant.ts
40499
+ /**
40500
+ * Chosen instant + union bbox for a group, at close, over stored positions[]
40501
+ * (`docs/design/2026-08-21-group-entity-and-group-media.md` §4.1).
40502
+ *
40503
+ * Tier order — the first that separates two candidates decides:
40504
+ * 1. completeness (members with a position within ±1 s)
40505
+ * 2. square containment of the union (D212)
40506
+ * 3. semantic (a member's confirmed recognition)
40507
+ * 4. edge-clear (union touches no frame border)
40508
+ * 5. effective score = mean(confidence) × containment × sizeFactor
40509
+ *
40510
+ * Retrospective. No new capture path.
40511
+ */
40512
+ function nearest(positions, t, windowMs) {
40513
+ let best;
40514
+ let bestDt = windowMs + 1;
40515
+ for (const p of positions) {
40516
+ const dt = Math.abs(p.timestamp - t);
40517
+ if (dt <= windowMs && dt < bestDt) {
40518
+ best = p;
40519
+ bestDt = dt;
40520
+ }
40521
+ }
40522
+ return best;
40523
+ }
40524
+ function unionOf(boxes) {
40525
+ if (boxes.length === 0) return null;
40526
+ let x0 = Infinity;
40527
+ let y0 = Infinity;
40528
+ let x1 = -Infinity;
40529
+ let y1 = -Infinity;
40530
+ for (const b of boxes) {
40531
+ x0 = Math.min(x0, b.x);
40532
+ y0 = Math.min(y0, b.y);
40533
+ x1 = Math.max(x1, b.x + b.w);
40534
+ y1 = Math.max(y1, b.y + b.h);
40535
+ }
40536
+ return {
40537
+ x: x0,
40538
+ y: y0,
40539
+ w: x1 - x0,
40540
+ h: y1 - y0
40541
+ };
40542
+ }
40543
+ function touchesBorder(box, frame, tol = 1) {
40544
+ return box.x <= tol || box.y <= tol || box.x + box.w >= frame.width - tol || box.y + box.h >= frame.height - tol;
40545
+ }
40546
+ function scoreInstant(members, t, frame) {
40547
+ const boxes = [];
40548
+ let semantic = false;
40549
+ let confSum = 0;
40550
+ let confN = 0;
40551
+ for (const m of members) {
40552
+ const p = nearest(m.positions, t, MATCH_WINDOW_MS);
40553
+ if (!p) continue;
40554
+ boxes.push(p.bbox);
40555
+ if (m.semantic === true) semantic = true;
40556
+ if (m.confidence !== void 0) {
40557
+ confSum += m.confidence;
40558
+ confN += 1;
40559
+ }
40560
+ }
40561
+ const union = unionOf(boxes);
40562
+ if (!union) return null;
40563
+ const square = centralSquareContainmentFraction(union, {
40564
+ W: frame.width,
40565
+ H: frame.height
40566
+ });
40567
+ const containment = bboxContainment(union, frame.width, frame.height);
40568
+ const size = bboxSizeScore(union, frame.width, frame.height);
40569
+ const meanConf = confN > 0 ? confSum / confN : 1;
40570
+ return {
40571
+ timestamp: t,
40572
+ union,
40573
+ visibleCount: boxes.length,
40574
+ square,
40575
+ semantic,
40576
+ edgeClear: !touchesBorder(union, frame),
40577
+ effective: meanConf * containment * size
40578
+ };
40579
+ }
40580
+ function better(a, b) {
40581
+ if (a.visibleCount !== b.visibleCount) return a.visibleCount > b.visibleCount ? a : b;
40582
+ if (a.square !== b.square) return a.square > b.square ? a : b;
40583
+ if (a.semantic !== b.semantic) return a.semantic ? a : b;
40584
+ if (a.edgeClear !== b.edgeClear) return a.edgeClear ? a : b;
40585
+ if (a.effective !== b.effective) return a.effective > b.effective ? a : b;
40586
+ return a.timestamp <= b.timestamp ? a : b;
40587
+ }
40588
+ function chooseGroupInstant(members, frame) {
40589
+ if (members.length === 0) return null;
40590
+ const candidates = /* @__PURE__ */ new Set();
40591
+ for (const m of members) for (const p of m.positions) candidates.add(p.timestamp);
40592
+ let best = null;
40593
+ for (const t of candidates) {
40594
+ const scored = scoreInstant(members, t, frame);
40595
+ if (!scored) continue;
40596
+ best = best === null ? scored : better(scored, best);
40597
+ }
40598
+ if (!best) return null;
40599
+ const w = Math.max(frame.width, 1);
40600
+ const h = Math.max(frame.height, 1);
40601
+ return {
40602
+ timestamp: best.timestamp,
40603
+ union: best.union,
40604
+ ux: best.union.x / w,
40605
+ uy: best.union.y / h,
40606
+ uw: best.union.w / w,
40607
+ uh: best.union.h / h,
40608
+ visibleCount: best.visibleCount
40609
+ };
40610
+ }
40611
+ /** Longest-lived member that owns a clean full frame; ties break on trackId. */
40612
+ function chooseAnchorTrackId(members, ownsCleanFrame) {
40613
+ const eligible = members.filter((m) => ownsCleanFrame(m.trackId));
40614
+ if (eligible.length === 0) return null;
40615
+ return eligible.toSorted((a, b) => {
40616
+ const life = b.lastSeen - b.firstSeen - (a.lastSeen - a.firstSeen);
40617
+ return life !== 0 ? life : a.trackId.localeCompare(b.trackId);
40618
+ })[0].trackId;
40619
+ }
40620
+ //#endregion
40621
+ //#region src/pipeline-analytics/group/group-session.ts
40622
+ /**
40623
+ * Session close → sealed groups. A group is born here, never live.
40624
+ *
40625
+ * Session = temporal chain + 3 s slack, per camera. Close = every overlapping
40626
+ * track is persisted AND the newest lastSeen is older than the idle cutoff
40627
+ * (the Phase-1 burst's 30 s, reused — not a second clock).
40628
+ *
40629
+ * Merge and split cannot occur: connected components of a closed session is a
40630
+ * partition. Say it out loud so a later live-group attempt does not reach
40631
+ * for them here.
40632
+ */
40633
+ /** Same idle as the notification burst (`NC_GROUP_DEFAULT_IDLE_SEC`). */
40634
+ var GROUP_SESSION_IDLE_MS = 3e4;
40635
+ function overlaps(a, b) {
40636
+ return a.firstSeen <= b.lastSeen + 3e3 && b.firstSeen <= a.lastSeen + 3e3;
40637
+ }
40638
+ function sessionSpan(tracks) {
40639
+ return {
40640
+ firstSeen: Math.min(...tracks.map((t) => t.firstSeen)),
40641
+ lastSeen: Math.max(...tracks.map((t) => t.lastSeen))
40642
+ };
40643
+ }
40644
+ /**
40645
+ * Sessions whose members are all in `closed` and whose newest lastSeen is
40646
+ * older than `idleMs`. An overlapping ACTIVE track keeps the session open.
40647
+ */
40648
+ function sessionsReadyToSeal(closed, active, now, idleMs = GROUP_SESSION_IDLE_MS) {
40649
+ const byDevice = /* @__PURE__ */ new Map();
40650
+ for (const t of closed) {
40651
+ const list = byDevice.get(t.deviceId);
40652
+ if (list) list.push(t);
40653
+ else byDevice.set(t.deviceId, [t]);
40654
+ }
40655
+ const ready = [];
40656
+ for (const [deviceId, tracks] of byDevice) {
40657
+ const deviceActive = active.filter((a) => a.deviceId === deviceId);
40658
+ for (const session of sessionsOf(tracks)) {
40659
+ const span = sessionSpan(session);
40660
+ if (now < span.lastSeen + idleMs) continue;
40661
+ if (deviceActive.some((a) => overlaps(span, a))) continue;
40662
+ ready.push(session);
40663
+ }
40664
+ }
40665
+ return ready;
40666
+ }
40667
+ function partitionsForSealedSessions(closed, active, now, idleMs = GROUP_SESSION_IDLE_MS) {
40668
+ return clusterGroups(sessionsReadyToSeal(closed, active, now, idleMs).flat());
40669
+ }
40670
+ //#endregion
40671
+ //#region src/pipeline-analytics/group/group-sealer.ts
40672
+ var DEFAULT_FRAME = {
40673
+ width: 640,
40674
+ height: 360
40675
+ };
40676
+ var SEAL_LOOKBACK_MS = 10 * 6e4;
40677
+ var BACKFILL_LOOKBACK_MS = 2880 * 6e4;
40678
+ var QUERY_LIMIT = 2e3;
40679
+ function toInstant(t) {
40680
+ return {
40681
+ trackId: t.trackId,
40682
+ deviceId: t.deviceId,
40683
+ className: t.className,
40684
+ firstSeen: t.firstSeen,
40685
+ lastSeen: t.lastSeen,
40686
+ positions: t.positions,
40687
+ ...t.closedReason !== void 0 ? { closedReason: t.closedReason } : {},
40688
+ semantic: t.hasFace === true || t.hasPlate === true,
40689
+ confidence: t.importance
40690
+ };
40691
+ }
40692
+ var GroupSealer = class {
40693
+ deps;
40694
+ constructor(deps) {
40695
+ this.deps = deps;
40696
+ }
40697
+ async sealRecent() {
40698
+ const now = this.deps.now();
40699
+ return this.sealSince(now - SEAL_LOOKBACK_MS, now);
40700
+ }
40701
+ async backfill() {
40702
+ const now = this.deps.now();
40703
+ return this.sealSince(now - BACKFILL_LOOKBACK_MS, now);
40704
+ }
40705
+ async sealSince(since, now) {
40706
+ let written = 0;
40707
+ let devices;
40708
+ try {
40709
+ devices = await this.deps.trackStore.listDeviceIds();
40710
+ } catch (err) {
40711
+ this.deps.logger.warn("group sealer: listDeviceIds failed", { meta: { error: String(err) } });
40712
+ return 0;
40713
+ }
40714
+ for (const deviceId of devices) try {
40715
+ written += await this.sealDevice(deviceId, since, now);
40716
+ } catch (err) {
40717
+ this.deps.logger.warn("group sealer: device failed", {
40718
+ tags: { deviceId },
40719
+ meta: { error: String(err) }
40720
+ });
40721
+ }
40722
+ return written;
40723
+ }
40724
+ async sealDevice(deviceId, since, now) {
40725
+ const closed = await this.deps.trackStore.queryHistorical({
40726
+ deviceId,
40727
+ since,
40728
+ until: now,
40729
+ limit: QUERY_LIMIT,
40730
+ projection: "full"
40731
+ });
40732
+ if (closed.length === 0) return 0;
40733
+ const already = new Set((await this.deps.groupStore.membersOfTracks(closed.map((t) => t.trackId))).map((m) => m.trackId));
40734
+ const pending = closed.filter((t) => !already.has(t.trackId));
40735
+ if (pending.length === 0) return 0;
40736
+ const active = this.deps.trackStore.getActive(deviceId).map((t) => ({
40737
+ deviceId,
40738
+ firstSeen: t.firstSeen,
40739
+ lastSeen: t.lastSeen
40740
+ }));
40741
+ const partitions = partitionsForSealedSessions(pending.map(toInstant), active, now, GROUP_SESSION_IDLE_MS);
40742
+ if (partitions.length === 0) {
40743
+ if (pending.filter((t) => t.closedReason !== "stationary-promotion" && t.className !== "audio").length === 0 && pending.length > 0) this.deps.logger.info("group session produced no groups", {
40744
+ tags: { deviceId },
40745
+ meta: {
40746
+ dropped: pending.length,
40747
+ reason: "no-eligible-members"
40748
+ }
40749
+ });
40750
+ return 0;
40751
+ }
40752
+ const byId = new Map(pending.map((t) => [t.trackId, t]));
40753
+ let written = 0;
40754
+ for (const part of partitions) {
40755
+ const members = part.memberTrackIds.map((id) => byId.get(id)).filter((t) => t !== void 0);
40756
+ if (members.length === 0) continue;
40757
+ if (members.every((m) => already.has(m.trackId))) continue;
40758
+ try {
40759
+ await this.persistPartition(members);
40760
+ written += 1;
40761
+ } catch (err) {
40762
+ this.deps.logger.warn("group sealer: persist failed", {
40763
+ tags: { deviceId },
40764
+ meta: {
40765
+ members: members.map((m) => m.trackId),
40766
+ error: String(err)
40767
+ }
40768
+ });
40769
+ }
40770
+ }
40771
+ return written;
40772
+ }
40773
+ async persistPartition(members) {
40774
+ const deviceId = members[0].deviceId;
40775
+ const frame = this.deps.frameDims(deviceId) ?? DEFAULT_FRAME;
40776
+ const instantMembers = members.map(toInstant);
40777
+ const instant = chooseGroupInstant(instantMembers, frame);
40778
+ const openedAt = Math.min(...members.map((m) => m.firstSeen));
40779
+ const closedAt = Math.max(...members.map((m) => m.lastSeen));
40780
+ const timestamp = instant?.timestamp ?? closedAt;
40781
+ const mediaInfos = await Promise.all(members.map(async (m) => ({
40782
+ trackId: m.trackId,
40783
+ rows: await this.deps.mediaStore.listInfoByOwner("track", m.trackId)
40784
+ })));
40785
+ const ownsClean = (trackId) => {
40786
+ return mediaInfos.find((m) => m.trackId === trackId)?.rows.some((r) => r.kind === "keyFrame" || r.kind === "keyFrameSmall") ?? false;
40787
+ };
40788
+ const anchorTrackId = members.length >= 2 ? chooseAnchorTrackId(instantMembers, ownsClean) : null;
40789
+ let nativeKey = null;
40790
+ if (anchorTrackId !== null) {
40791
+ const rows = mediaInfos.find((m) => m.trackId === anchorTrackId)?.rows ?? [];
40792
+ nativeKey = (rows.find((r) => r.kind === "keyFrame") ?? rows.find((r) => r.kind === "keyFrameSmall"))?.key ?? null;
40793
+ }
40794
+ const draft = await this.deps.groupStore.insert({
40795
+ deviceId,
40796
+ timestamp,
40797
+ openedAt,
40798
+ closedAt,
40799
+ members: members.map((m) => ({
40800
+ trackId: m.trackId,
40801
+ firstSeen: m.firstSeen
40802
+ })),
40803
+ anchorTrackId,
40804
+ ux: instant?.ux ?? 0,
40805
+ uy: instant?.uy ?? 0,
40806
+ uw: instant?.uw ?? 0,
40807
+ uh: instant?.uh ?? 0,
40808
+ frameWidth: frame.width,
40809
+ frameHeight: frame.height,
40810
+ mediaKey: null,
40811
+ nativeKey
40812
+ });
40813
+ if (members.length < 2 || instant === null) return;
40814
+ const source = await this.pickCleanPixels(mediaInfos, timestamp);
40815
+ if (source === null) return;
40816
+ try {
40817
+ const raw = await sharp(source.jpeg).removeAlpha().raw().toBuffer({ resolveWithObject: true });
40818
+ if (raw.info.channels !== 3) return;
40819
+ const union = {
40820
+ x: instant.ux * raw.info.width,
40821
+ y: instant.uy * raw.info.height,
40822
+ w: instant.uw * raw.info.width,
40823
+ h: instant.uh * raw.info.height
40824
+ };
40825
+ const crop = await cropGroupFromRgb(raw.data, raw.info.width, raw.info.height, union);
40826
+ const mediaKey = await this.deps.mediaStore.put({
40827
+ deviceId,
40828
+ ownerKind: "group",
40829
+ ownerId: draft.id,
40830
+ kind: "thumbnail",
40831
+ timestamp,
40832
+ data: crop
40833
+ });
40834
+ await this.deps.groupStore.setMediaKey(draft.id, mediaKey);
40835
+ } catch (err) {
40836
+ this.deps.logger.warn("group sealer: crop failed", {
40837
+ tags: { deviceId },
40838
+ meta: {
40839
+ groupId: draft.id,
40840
+ error: String(err)
40841
+ }
40842
+ });
40843
+ }
40844
+ }
40845
+ async pickCleanPixels(mediaInfos, instantMs) {
40846
+ const candidates = [];
40847
+ for (const info of mediaInfos) for (const row of info.rows) {
40848
+ const rank = row.kind === "keyFrame" ? 0 : row.kind === "keyFrameSmall" ? 1 : -1;
40849
+ if (rank < 0) continue;
40850
+ const dt = Math.abs(row.timestamp - instantMs);
40851
+ if (dt > 2e3) continue;
40852
+ candidates.push({
40853
+ key: row.key,
40854
+ dt,
40855
+ rank
40856
+ });
40857
+ }
40858
+ const chosen = candidates.toSorted((a, b) => a.rank - b.rank || a.dt - b.dt)[0];
40859
+ if (chosen === void 0) return null;
40860
+ const file = await this.deps.mediaStore.getByKey(chosen.key);
40861
+ if (file === null) return null;
40862
+ return { jpeg: Buffer.from(file.base64, "base64") };
40863
+ }
40864
+ };
40865
+ //#endregion
40866
+ //#region src/pipeline-analytics/group/group-store.ts
40867
+ /**
40868
+ * The group ENTITY store — a co-moving partition of one session, with one
40869
+ * picture of its own (`docs/design/2026-08-21-group-entity-and-group-media.md`).
40870
+ *
40871
+ * Two tables, two questions:
40872
+ *
40873
+ * - `pipeline-analytics:track-groups` is the group row. It has N members and
40874
+ * no `trackId` column; it is owned by its members (owner-indirection).
40875
+ * - `pipeline-analytics:track-group-members` is one row per member. The column
40876
+ * is named exactly `trackId` so the classification derives `track-owned`.
40877
+ *
40878
+ * `anchorTrackId` and `nativeKey` are the only fields mutable after birth:
40879
+ * they are nulled when the anchor member is deleted alone. Everything else is
40880
+ * written once. Re-anchoring is deliberately not done.
40881
+ *
40882
+ * No age clock. A group dies when its last member does; a group with one live
40883
+ * member does not, and it never keeps a dead link.
40884
+ */
40885
+ /**
40886
+ * @durable class=ledger owner=pipeline-analytics
40887
+ * write="one row per co-moving group at session close — sealed, never live.
40888
+ * Membership is a partition of one camera's eligible tracks. mediaKey is
40889
+ * the group's own crop; nativeKey is a POINTER to the anchor member's
40890
+ * keyFrame, nulled if that member is deleted alone. anchorTrackId and
40891
+ * nativeKey are the only fields rewritten after birth."
40892
+ * retention="NO age clock (D61). Rows leave with their last member through
40893
+ * TrackGroupStore.deleteByTracks: memberships first; zero survivors
40894
+ * deletes the group's media then the row; survivors plus a lost anchor
40895
+ * null the pointer. The orphan audit verifies the same via a second
40896
+ * resolver (groupId → any member still present)."
40897
+ */
40898
+ var TRACK_GROUPS_COLLECTION = "pipeline-analytics:track-groups";
40899
+ /**
40900
+ * @durable class=ledger owner=pipeline-analytics
40901
+ * write="one row per member at group birth — id is '<groupId>:<trackId>'.
40902
+ * timestamp is the member's firstSeen so the orphan audit has a clock
40903
+ * column. Never rewritten."
40904
+ * retention="TRACK-OWNED and clockless (D61): the column is named exactly
40905
+ * trackId, so classification derives ownership and the cascade deletes
40906
+ * the row with its track. A group with surviving members stays."
40907
+ */
40908
+ var TRACK_GROUP_MEMBERS_COLLECTION = "pipeline-analytics:track-group-members";
40909
+ var TRACK_GROUPS_COLUMNS = [
40910
+ {
40911
+ name: "id",
40912
+ type: "TEXT",
40913
+ primaryKey: true,
40914
+ notNull: true
40915
+ },
40916
+ {
40917
+ name: "deviceId",
40918
+ type: "INTEGER",
40919
+ notNull: true
40920
+ },
40921
+ {
40922
+ name: "timestamp",
40923
+ type: "INTEGER",
40924
+ notNull: true
40925
+ },
40926
+ {
40927
+ name: "openedAt",
40928
+ type: "INTEGER",
40929
+ notNull: true
40930
+ },
40931
+ {
40932
+ name: "closedAt",
40933
+ type: "INTEGER",
40934
+ notNull: true
40935
+ },
40936
+ {
40937
+ name: "memberCount",
40938
+ type: "INTEGER",
40939
+ notNull: true
40940
+ },
40941
+ {
40942
+ name: "anchorTrackId",
40943
+ type: "TEXT"
40944
+ },
40945
+ {
40946
+ name: "ux",
40947
+ type: "REAL",
40948
+ notNull: true
40949
+ },
40950
+ {
40951
+ name: "uy",
40952
+ type: "REAL",
40953
+ notNull: true
40954
+ },
40955
+ {
40956
+ name: "uw",
40957
+ type: "REAL",
40958
+ notNull: true
40959
+ },
40960
+ {
40961
+ name: "uh",
40962
+ type: "REAL",
40963
+ notNull: true
40964
+ },
40965
+ {
40966
+ name: "frameWidth",
40967
+ type: "INTEGER",
40968
+ notNull: true
40969
+ },
40970
+ {
40971
+ name: "frameHeight",
40972
+ type: "INTEGER",
40973
+ notNull: true
40974
+ },
40975
+ {
40976
+ name: "mediaKey",
40977
+ type: "TEXT"
40978
+ },
40979
+ {
40980
+ name: "nativeKey",
40981
+ type: "TEXT"
40982
+ },
40983
+ {
40984
+ name: "sealed",
40985
+ type: "BOOLEAN",
40986
+ notNull: true
40987
+ }
40988
+ ];
40989
+ var TRACK_GROUP_MEMBERS_COLUMNS = [
40990
+ {
40991
+ name: "id",
40992
+ type: "TEXT",
40993
+ primaryKey: true,
40994
+ notNull: true
40995
+ },
40996
+ {
40997
+ name: "groupId",
40998
+ type: "TEXT",
40999
+ notNull: true
41000
+ },
41001
+ {
41002
+ name: "trackId",
41003
+ type: "TEXT",
41004
+ notNull: true
41005
+ },
41006
+ {
41007
+ name: "deviceId",
41008
+ type: "INTEGER",
41009
+ notNull: true
41010
+ },
41011
+ {
41012
+ name: "timestamp",
41013
+ type: "INTEGER",
41014
+ notNull: true
41015
+ }
41016
+ ];
41017
+ var TRACK_GROUPS_INDEXES = [{
41018
+ name: "idx_track_groups_device_closed",
41019
+ columns: ["deviceId", "closedAt"]
41020
+ }, {
41021
+ name: "idx_track_groups_device_opened",
41022
+ columns: ["deviceId", "openedAt"]
41023
+ }];
41024
+ var TRACK_GROUP_MEMBERS_INDEXES = [{
41025
+ name: "idx_track_group_members_track",
41026
+ columns: ["trackId"]
41027
+ }, {
41028
+ name: "idx_track_group_members_group",
41029
+ columns: ["groupId"]
41030
+ }];
41031
+ function newGroupId() {
41032
+ return `grp-${randomUUID()}`;
41033
+ }
41034
+ function memberRowId(groupId, trackId) {
41035
+ return `${groupId}:${trackId}`;
41036
+ }
41037
+ var TrackGroupStore = class {
41038
+ store;
41039
+ logger;
41040
+ media;
41041
+ constructor(deps) {
41042
+ this.store = deps.store;
41043
+ this.logger = deps.logger;
41044
+ this.media = deps.media ?? null;
41045
+ }
41046
+ static async declare(store) {
41047
+ await Promise.all([store.declareCollection.mutate({
41048
+ collection: TRACK_GROUPS_COLLECTION,
41049
+ columns: [...TRACK_GROUPS_COLUMNS],
41050
+ indexes: [...TRACK_GROUPS_INDEXES]
41051
+ }), store.declareCollection.mutate({
41052
+ collection: TRACK_GROUP_MEMBERS_COLLECTION,
41053
+ columns: [...TRACK_GROUP_MEMBERS_COLUMNS],
41054
+ indexes: [...TRACK_GROUP_MEMBERS_INDEXES]
41055
+ })]);
41056
+ }
41057
+ async insert(input) {
41058
+ const id = newGroupId();
41059
+ const group = {
41060
+ id,
41061
+ deviceId: input.deviceId,
41062
+ timestamp: input.timestamp,
41063
+ openedAt: input.openedAt,
41064
+ closedAt: input.closedAt,
41065
+ memberCount: input.members.length,
41066
+ anchorTrackId: input.anchorTrackId,
41067
+ ux: input.ux,
41068
+ uy: input.uy,
41069
+ uw: input.uw,
41070
+ uh: input.uh,
41071
+ frameWidth: input.frameWidth,
41072
+ frameHeight: input.frameHeight,
41073
+ mediaKey: input.mediaKey,
41074
+ nativeKey: input.nativeKey,
41075
+ sealed: true
41076
+ };
41077
+ await this.store.set.mutate({
41078
+ collection: TRACK_GROUPS_COLLECTION,
41079
+ key: id,
41080
+ value: this.toRow(group)
41081
+ });
41082
+ for (const member of input.members) await this.store.set.mutate({
41083
+ collection: TRACK_GROUP_MEMBERS_COLLECTION,
41084
+ key: memberRowId(id, member.trackId),
41085
+ value: {
41086
+ groupId: id,
41087
+ trackId: member.trackId,
41088
+ deviceId: input.deviceId,
41089
+ timestamp: member.firstSeen
41090
+ }
41091
+ });
41092
+ return group;
41093
+ }
41094
+ /** The crop is written after birth; this is the only post-birth write besides the pointer null. */
41095
+ async setMediaKey(groupId, mediaKey) {
41096
+ const current = await this.get(groupId);
41097
+ if (current === null) return;
41098
+ await this.store.set.mutate({
41099
+ collection: TRACK_GROUPS_COLLECTION,
41100
+ key: groupId,
41101
+ value: this.toRow({
41102
+ ...current,
41103
+ mediaKey
41104
+ })
41105
+ });
41106
+ }
41107
+ async get(groupId) {
41108
+ const raw = await this.store.get.query({
41109
+ collection: TRACK_GROUPS_COLLECTION,
41110
+ key: groupId
41111
+ });
41112
+ if (!raw) return null;
41113
+ return this.normalizeGroup({
41114
+ id: groupId,
41115
+ data: raw
41116
+ });
41117
+ }
41118
+ async listMembers(groupId) {
41119
+ return (await this.store.query.query({
41120
+ collection: TRACK_GROUP_MEMBERS_COLLECTION,
41121
+ filter: { where: { groupId } }
41122
+ })).map((r) => this.normalizeMember({
41123
+ id: r.id,
41124
+ data: r.data
41125
+ }));
41126
+ }
41127
+ async membersOfTracks(trackIds) {
41128
+ const out = [];
41129
+ for (const trackId of trackIds) {
41130
+ const rows = await this.store.query.query({
41131
+ collection: TRACK_GROUP_MEMBERS_COLLECTION,
41132
+ filter: { where: { trackId } }
41133
+ });
41134
+ for (const r of rows) out.push(this.normalizeMember({
41135
+ id: r.id,
41136
+ data: r.data
41137
+ }));
41138
+ }
41139
+ return out;
41140
+ }
41141
+ /**
41142
+ * Newest-closed page for the Library Groups tab.
41143
+ *
41144
+ * Time filter is overlap (`closedAt >= since` and `openedAt <= until`),
41145
+ * same as {@link listOverlapping}. Pagination is applied AFTER the window
41146
+ * so a day cursor is honest rather than a raw-row offset that skips live
41147
+ * groups on a busy camera.
41148
+ */
41149
+ async listRecent(input) {
41150
+ const limit = Math.min(Math.max(input.limit ?? 40, 1), 100);
41151
+ const offset = Math.max(input.offset ?? 0, 0);
41152
+ const slice = (await this.store.query.query({
41153
+ collection: TRACK_GROUPS_COLLECTION,
41154
+ filter: {
41155
+ ...input.deviceId !== void 0 ? { where: { deviceId: input.deviceId } } : {},
41156
+ orderBy: {
41157
+ field: "closedAt",
41158
+ direction: "desc"
41159
+ },
41160
+ limit: 2e3
41161
+ }
41162
+ })).map((r) => this.normalizeGroup({
41163
+ id: r.id,
41164
+ data: r.data
41165
+ })).filter((g) => {
41166
+ if (input.sinceMs !== void 0 && g.closedAt < input.sinceMs) return false;
41167
+ if (input.untilMs !== void 0 && g.openedAt > input.untilMs) return false;
41168
+ return true;
41169
+ }).slice(offset, offset + limit + 1);
41170
+ return {
41171
+ groups: slice.slice(0, limit),
41172
+ hasMore: slice.length > limit
41173
+ };
41174
+ }
41175
+ /** Groups whose life overlaps `[since, until]` on one camera. */
41176
+ async listOverlapping(input) {
41177
+ return (await this.store.query.query({
41178
+ collection: TRACK_GROUPS_COLLECTION,
41179
+ filter: {
41180
+ where: { deviceId: input.deviceId },
41181
+ orderBy: {
41182
+ field: "closedAt",
41183
+ direction: "desc"
41184
+ },
41185
+ limit: input.limit ?? 2e3
41186
+ }
41187
+ })).map((r) => this.normalizeGroup({
41188
+ id: r.id,
41189
+ data: r.data
41190
+ })).filter((g) => g.closedAt >= input.sinceMs && g.openedAt <= input.untilMs);
41191
+ }
41192
+ async existingGroupIds(ids) {
41193
+ const live = /* @__PURE__ */ new Set();
41194
+ for (const id of ids) if (await this.store.get.query({
41195
+ collection: "pipeline-analytics:track-groups",
41196
+ key: id
41197
+ })) live.add(id);
41198
+ return live;
41199
+ }
41200
+ /** Group ids that still have at least one membership row. */
41201
+ async groupsWithMembers(ids) {
41202
+ const live = /* @__PURE__ */ new Set();
41203
+ for (const groupId of ids) if ((await this.store.query.query({
41204
+ collection: "pipeline-analytics:track-group-members",
41205
+ filter: {
41206
+ where: { groupId },
41207
+ limit: 1
41208
+ }
41209
+ })).length > 0) live.add(groupId);
41210
+ return live;
41211
+ }
41212
+ /**
41213
+ * Cascade leaf: delete memberships for the batch; a group with zero survivors
41214
+ * dies (media then row); a group that lost only its anchor nulls the pointer.
41215
+ */
41216
+ async deleteByTracks(trackIds) {
41217
+ const batch = new Set(trackIds);
41218
+ let memberRows = 0;
41219
+ let groupRows = 0;
41220
+ let mediaRows = 0;
41221
+ let mediaBytes = 0;
41222
+ const affected = /* @__PURE__ */ new Set();
41223
+ try {
41224
+ const memberships = await this.membersOfTracks(trackIds);
41225
+ for (const member of memberships) try {
41226
+ await this.store.delete.mutate({
41227
+ collection: TRACK_GROUP_MEMBERS_COLLECTION,
41228
+ key: member.id
41229
+ });
41230
+ memberRows += 1;
41231
+ affected.add(member.groupId);
41232
+ } catch (err) {
41233
+ this.logger.warn("TrackGroupStore.deleteByTracks member delete failed", { meta: {
41234
+ memberId: member.id,
41235
+ error: String(err)
41236
+ } });
41237
+ }
41238
+ for (const groupId of affected) try {
41239
+ if ((await this.listMembers(groupId)).length === 0) {
41240
+ if (this.media) {
41241
+ const gone = await this.media.deleteByGroups([groupId]);
41242
+ mediaRows += gone.rows;
41243
+ mediaBytes += gone.bytes;
41244
+ }
41245
+ await this.store.delete.mutate({
41246
+ collection: TRACK_GROUPS_COLLECTION,
41247
+ key: groupId
41248
+ });
41249
+ groupRows += 1;
41250
+ continue;
41251
+ }
41252
+ const group = await this.get(groupId);
41253
+ if (group?.anchorTrackId && batch.has(group.anchorTrackId)) await this.store.set.mutate({
41254
+ collection: TRACK_GROUPS_COLLECTION,
41255
+ key: groupId,
41256
+ value: this.toRow({
41257
+ ...group,
41258
+ anchorTrackId: null,
41259
+ nativeKey: null
41260
+ })
41261
+ });
41262
+ } catch (err) {
41263
+ this.logger.warn("TrackGroupStore.deleteByTracks group update failed", { meta: {
41264
+ groupId,
41265
+ error: String(err)
41266
+ } });
41267
+ }
41268
+ } catch (err) {
41269
+ this.logger.warn("TrackGroupStore.deleteByTracks failed", { meta: { error: String(err) } });
41270
+ }
41271
+ return [{
41272
+ family: "groups",
41273
+ rows: memberRows + groupRows,
41274
+ bytes: 0
41275
+ }, {
41276
+ family: "media",
41277
+ rows: mediaRows,
41278
+ bytes: mediaBytes
41279
+ }];
41280
+ }
41281
+ toRow(group) {
41282
+ return {
41283
+ deviceId: group.deviceId,
41284
+ timestamp: group.timestamp,
41285
+ openedAt: group.openedAt,
41286
+ closedAt: group.closedAt,
41287
+ memberCount: group.memberCount,
41288
+ anchorTrackId: group.anchorTrackId,
41289
+ ux: group.ux,
41290
+ uy: group.uy,
41291
+ uw: group.uw,
41292
+ uh: group.uh,
41293
+ frameWidth: group.frameWidth,
41294
+ frameHeight: group.frameHeight,
41295
+ mediaKey: group.mediaKey,
41296
+ nativeKey: group.nativeKey,
41297
+ sealed: group.sealed
41298
+ };
41299
+ }
41300
+ normalizeGroup(r) {
41301
+ const data = r.data;
41302
+ return {
41303
+ id: r.id,
41304
+ deviceId: Number(data["deviceId"]),
41305
+ timestamp: Number(data["timestamp"]),
41306
+ openedAt: Number(data["openedAt"]),
41307
+ closedAt: Number(data["closedAt"]),
41308
+ memberCount: Number(data["memberCount"]),
41309
+ anchorTrackId: typeof data["anchorTrackId"] === "string" ? data["anchorTrackId"] : null,
41310
+ ux: Number(data["ux"]),
41311
+ uy: Number(data["uy"]),
41312
+ uw: Number(data["uw"]),
41313
+ uh: Number(data["uh"]),
41314
+ frameWidth: Number(data["frameWidth"]),
41315
+ frameHeight: Number(data["frameHeight"]),
41316
+ mediaKey: typeof data["mediaKey"] === "string" ? data["mediaKey"] : null,
41317
+ nativeKey: typeof data["nativeKey"] === "string" ? data["nativeKey"] : null,
41318
+ sealed: Boolean(data["sealed"])
41319
+ };
41320
+ }
41321
+ normalizeMember(r) {
41322
+ const data = r.data;
41323
+ return {
41324
+ id: r.id,
41325
+ groupId: String(data["groupId"]),
41326
+ trackId: String(data["trackId"]),
41327
+ deviceId: Number(data["deviceId"]),
41328
+ timestamp: Number(data["timestamp"])
41329
+ };
41330
+ }
41331
+ };
40072
41332
  //#endregion
40073
41333
  //#region src/pipeline-analytics/pipeline/dropout-skip.ts
40074
41334
  function shouldSkipDropoutFrame(activeTrackCount, detectionCount, consecutiveSkips, cfg) {
@@ -46686,6 +47946,15 @@ function isAfterCursor(row, cursor) {
46686
47946
  if (row.lastSeen < cursor.lastSeen) return true;
46687
47947
  return row.lastSeen === cursor.lastSeen && row.id < cursor.trackId;
46688
47948
  }
47949
+ function netDisplacementPx(positions) {
47950
+ const first = positions[0];
47951
+ const last = positions[positions.length - 1];
47952
+ if (!first || !last) return 0;
47953
+ return Math.hypot(last.x - first.x, last.y - first.y);
47954
+ }
47955
+ function isShortMotionlessTrack(lifeMs, movedPx) {
47956
+ return lifeMs <= 25e3 && movedPx <= 8;
47957
+ }
46689
47958
  //#endregion
46690
47959
  //#region src/pipeline-analytics/store/track-store.ts
46691
47960
  /**
@@ -47268,6 +48537,7 @@ var TrackStore = class {
47268
48537
  for (const [trackId, t] of this.active) {
47269
48538
  if (nowMs - t.lastSeen < this.config.ttlMs) continue;
47270
48539
  t.active = false;
48540
+ if (t.positions.length > 0 && isShortMotionlessTrack(t.lastSeen - t.firstSeen, netDisplacementPx(t.positions))) t.closedReason = "stationary-promotion";
47271
48541
  const record = cloneTrack(t);
47272
48542
  try {
47273
48543
  await this.persistCompleted(record);
@@ -48816,12 +50086,24 @@ var ANALYTICS_COLLECTIONS = [
48816
50086
  collection: PLATES_COLLECTION,
48817
50087
  classification: derived(PLATE_COLUMNS)
48818
50088
  },
50089
+ {
50090
+ collection: TRACK_GROUP_MEMBERS_COLLECTION,
50091
+ classification: derived(TRACK_GROUP_MEMBERS_COLUMNS)
50092
+ },
48819
50093
  {
48820
50094
  collection: MEDIA_COLLECTION,
48821
50095
  classification: {
48822
50096
  kind: "owner-indirection",
48823
50097
  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."
50098
+ 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."
50099
+ }
50100
+ },
50101
+ {
50102
+ collection: TRACK_GROUPS_COLLECTION,
50103
+ classification: {
50104
+ kind: "owner-indirection",
50105
+ derivation: "explicit",
50106
+ 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
50107
  }
48826
50108
  },
48827
50109
  {
@@ -48933,7 +50215,7 @@ var ANALYTICS_COLLECTIONS = [
48933
50215
  collection: SCENE_LATCH_COLLECTION,
48934
50216
  classification: {
48935
50217
  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`."
50218
+ 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
50219
  }
48938
50220
  },
48939
50221
  {
@@ -49021,6 +50303,10 @@ function resolveMediaOwner(row) {
49021
50303
  kind: "unresolvable",
49022
50304
  why: `plate-owned media whose ownerId does not carry the '${PLATE_MEDIA_OWNER_PREFIX}' prefix`
49023
50305
  };
50306
+ case "group": return {
50307
+ kind: "group",
50308
+ groupId: ownerId
50309
+ };
49024
50310
  default: return {
49025
50311
  kind: "unresolvable",
49026
50312
  why: `unknown ownerKind '${kindRaw}'`
@@ -49032,6 +50318,13 @@ function resolveMediaOwner(row) {
49032
50318
  * is derived the same way the classification is — an enrolled row is out of
49033
50319
  * reach regardless of what happened to the track that produced it.
49034
50320
  */
50321
+ /** A group row lives iff any membership row still names it. */
50322
+ function resolveGroupOwner(row) {
50323
+ return {
50324
+ kind: "group-members",
50325
+ groupId: row.key
50326
+ };
50327
+ }
49035
50328
  function resolveDeclaredTrackOwner(row, enrolmentExempt) {
49036
50329
  if (enrolmentExempt && row.data["assigned"] === true) return {
49037
50330
  kind: "exempt",
@@ -49051,15 +50344,28 @@ function buildOrphanScopes(collections = auditableCollections()) {
49051
50344
  const scopes = [];
49052
50345
  for (const entry of collections) {
49053
50346
  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
- });
50347
+ if (entry.collection === "pipeline-analytics:media") {
50348
+ scopes.push({
50349
+ id: entry.collection,
50350
+ collection: entry.collection,
50351
+ resolve: resolveMediaOwner,
50352
+ timestampField: "timestamp",
50353
+ sizeField: "sizeBytes",
50354
+ deviceIdField: "deviceId"
50355
+ });
50356
+ continue;
50357
+ }
50358
+ if (entry.collection === "pipeline-analytics:track-groups") {
50359
+ scopes.push({
50360
+ id: entry.collection,
50361
+ collection: entry.collection,
50362
+ resolve: resolveGroupOwner,
50363
+ timestampField: "timestamp",
50364
+ sizeField: null,
50365
+ deviceIdField: "deviceId"
50366
+ });
50367
+ continue;
50368
+ }
49063
50369
  continue;
49064
50370
  }
49065
50371
  if (entry.classification.kind !== "track-owned") continue;
@@ -49091,11 +50397,17 @@ var DEFAULT_ORPHAN_LIMITS = {
49091
50397
  function questionsFor(refs) {
49092
50398
  const trackIds = /* @__PURE__ */ new Set();
49093
50399
  const eventIds = /* @__PURE__ */ new Set();
50400
+ const groupIds = /* @__PURE__ */ new Set();
50401
+ const groupMemberIds = /* @__PURE__ */ new Set();
49094
50402
  for (const ref of refs) if (ref.kind === "track") trackIds.add(ref.trackId);
49095
50403
  else if (ref.kind === "event") eventIds.add(ref.eventId);
50404
+ else if (ref.kind === "group") groupIds.add(ref.groupId);
50405
+ else if (ref.kind === "group-members") groupMemberIds.add(ref.groupId);
49096
50406
  return {
49097
50407
  trackIds: [...trackIds],
49098
- eventIds: [...eventIds]
50408
+ eventIds: [...eventIds],
50409
+ groupIds: [...groupIds],
50410
+ groupMemberIds: [...groupMemberIds]
49099
50411
  };
49100
50412
  }
49101
50413
  /**
@@ -49127,12 +50439,16 @@ async function auditScope(scope, deps, limits, cursor) {
49127
50439
  break;
49128
50440
  }
49129
50441
  const refs = rows.map((row) => scope.resolve(row));
49130
- const { trackIds, eventIds } = questionsFor(refs);
50442
+ const { trackIds, eventIds, groupIds, groupMemberIds } = questionsFor(refs);
49131
50443
  let liveTracks;
49132
50444
  let liveEvents;
50445
+ let liveGroups;
50446
+ let liveGroupsWithMembers;
49133
50447
  try {
49134
50448
  liveTracks = trackIds.length > 0 ? await deps.liveTrackIds(trackIds) : /* @__PURE__ */ new Set();
49135
50449
  liveEvents = eventIds.length > 0 ? await deps.liveEventIds(eventIds) : /* @__PURE__ */ new Set();
50450
+ liveGroups = groupIds.length > 0 && deps.liveGroupIds ? await deps.liveGroupIds(groupIds) : /* @__PURE__ */ new Set();
50451
+ liveGroupsWithMembers = groupMemberIds.length > 0 && deps.liveGroupsWithMembers ? await deps.liveGroupsWithMembers(groupMemberIds) : /* @__PURE__ */ new Set();
49136
50452
  } catch (err) {
49137
50453
  deps.logger.warn("orphan audit: liveness probe failed — scope abandoned for this run", { meta: {
49138
50454
  collection: scope.collection,
@@ -49156,7 +50472,7 @@ async function auditScope(scope, deps, limits, cursor) {
49156
50472
  tally.unresolvable += 1;
49157
50473
  continue;
49158
50474
  }
49159
- if (ref.kind === "track" ? liveTracks.has(ref.trackId) : liveEvents.has(ref.eventId)) continue;
50475
+ 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
50476
  const ts = num(row.data, scope.timestampField);
49161
50477
  if (ts !== null && ts > graceFloor) {
49162
50478
  tally.withinGrace += 1;
@@ -51248,6 +52564,13 @@ function squareSafeCropRegionNormalized(bbox, frame, padding) {
51248
52564
  };
51249
52565
  }
51250
52566
  //#endregion
52567
+ //#region src/pipeline-analytics/pipeline/usable-bbox.ts
52568
+ /** A box that can legally drive a crop. The leaving-event fallback
52569
+ * `{0,0,0,0}` must never become a face/plate JPEG. */
52570
+ function isUsableBbox(bbox) {
52571
+ return bbox.w > 0 && bbox.h > 0;
52572
+ }
52573
+ //#endregion
51251
52574
  //#region src/pipeline-analytics/services/event-media-dispatcher.ts
51252
52575
  /**
51253
52576
  * Long-side cap (px) for the boxed timeline tiles — `firstFrame`, `snapshot`,
@@ -51826,26 +53149,29 @@ var EventMediaDispatcher = class {
51826
53149
  }
51827
53150
  }
51828
53151
  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: {
53152
+ if (ev.childCrops) for (const child of ev.childCrops) {
53153
+ if (!isUsableBbox(child.bbox)) continue;
53154
+ try {
53155
+ const childCropData = await this.cropSubjectRegion(deviceId, frameHandle, frameData, fw, fh, child.bbox, cropPadding, `${ev.eventId}:${child.kind}`);
53156
+ if (!childCropData) continue;
53157
+ await this.deps.mediaStore.put({
51844
53158
  deviceId,
51845
- eventId: ev.eventId,
51846
- error: err instanceof Error ? err.message : String(err)
51847
- }
51848
- });
53159
+ ownerKind: "event",
53160
+ ownerId: ev.eventId,
53161
+ kind: child.kind,
53162
+ timestamp: ev.timestamp,
53163
+ data: childCropData
53164
+ });
53165
+ } catch (err) {
53166
+ this.deps.logger.warn(`event media: ${child.kind} failed`, {
53167
+ tags: { deviceId },
53168
+ meta: {
53169
+ deviceId,
53170
+ eventId: ev.eventId,
53171
+ error: err instanceof Error ? err.message : String(err)
53172
+ }
53173
+ });
53174
+ }
51849
53175
  }
51850
53176
  }
51851
53177
  async writeTrackFrame(deviceId, frameHandle, frameData, fw, fh, tf) {
@@ -55106,7 +56432,8 @@ var customActions = {
55106
56432
  ...orphanAuditActions,
55107
56433
  ...viewerSettingsActions,
55108
56434
  ...photoEnrollActions,
55109
- ...faceRescoreActions
56435
+ ...faceRescoreActions,
56436
+ ...groupActions
55110
56437
  };
55111
56438
  /**
55112
56439
  * Assist threshold when the caller does not pin one. Packages have their own
@@ -55285,11 +56612,6 @@ function decodeEmbeddingBase64(base64) {
55285
56612
  const view = new Float32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));
55286
56613
  return Array.from(view);
55287
56614
  }
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
56615
  /** Grid the spawn point is quantised to, so respawns whose boxes never repeat
55294
56616
  * to the pixel still land in one cell. */
55295
56617
  var PHANTOM_CELL_PX = 32;
@@ -55353,6 +56675,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
55353
56675
  faceStore = null;
55354
56676
  faceRecognizer = null;
55355
56677
  plateStore = null;
56678
+ groupStore = null;
56679
+ groupSealer = null;
55356
56680
  plateRecognizer = null;
55357
56681
  objectEmbeddingStore = null;
55358
56682
  /** Live state of the detached embedding rebuild — see `rebuildObjectEmbeddings`. */
@@ -56170,7 +57494,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
56170
57494
  ...orphanAuditActions,
56171
57495
  ...viewerSettingsActions,
56172
57496
  ...photoEnrollActions,
56173
- ...faceRescoreActions
57497
+ ...faceRescoreActions,
57498
+ ...groupActions
56174
57499
  },
56175
57500
  actionHandlers: {
56176
57501
  ...ncHandlers,
@@ -56178,6 +57503,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
56178
57503
  ...this.buildDebugActionHandlers(),
56179
57504
  ...this.buildPhotoEnrollActionHandlers(),
56180
57505
  ...this.buildFaceRescoreActionHandlers(),
57506
+ ...this.buildGroupActionHandlers(),
56181
57507
  ...this.viewerSettingsSnapshots ? makeViewerSettingsActionHandlers(this.viewerSettingsSnapshots) : {}
56182
57508
  }
56183
57509
  } : {}
@@ -56380,6 +57706,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
56380
57706
  SceneStore.declare(api.settingsStore),
56381
57707
  NotificationCenter.declare(api.settingsStore),
56382
57708
  ViewerSettingsSnapshotStore.declare(api.settingsStore),
57709
+ TrackGroupStore.declare(api.settingsStore),
56383
57710
  declareMigrationMarkers(api.settingsStore)
56384
57711
  ]);
56385
57712
  this.viewerSettingsSnapshots = new ViewerSettingsSnapshotStore(api.settingsStore);
@@ -56664,6 +57991,33 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
56664
57991
  logger: logger.child("PlateStore")
56665
57992
  });
56666
57993
  this.plateStore = plateStore;
57994
+ const vehicleStore = new VehicleStore({
57995
+ store: api.settingsStore,
57996
+ logger: logger.child("VehicleStore")
57997
+ });
57998
+ const groupStore = new TrackGroupStore({
57999
+ store: api.settingsStore,
58000
+ logger: logger.child("TrackGroupStore"),
58001
+ media: mediaStore
58002
+ });
58003
+ this.groupStore = groupStore;
58004
+ this.groupSealer = new GroupSealer({
58005
+ trackStore,
58006
+ groupStore,
58007
+ mediaStore,
58008
+ logger: logger.child("GroupSealer"),
58009
+ frameDims: (deviceId) => {
58010
+ const dims = this.lastFrameDimsByDevice.get(deviceId);
58011
+ return dims ? {
58012
+ width: dims.w,
58013
+ height: dims.h
58014
+ } : void 0;
58015
+ },
58016
+ now: () => Date.now()
58017
+ });
58018
+ this.groupSealer.backfill().catch((err) => {
58019
+ logger.warn("group sealer backfill failed", { meta: { error: errMsg(err) } });
58020
+ });
56667
58021
  return {
56668
58022
  trackStore,
56669
58023
  mediaStore,
@@ -56671,10 +58025,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
56671
58025
  identityStore,
56672
58026
  faceStore,
56673
58027
  plateStore,
56674
- vehicleStore: new VehicleStore({
56675
- store: api.settingsStore,
56676
- logger: logger.child("VehicleStore")
56677
- })
58028
+ vehicleStore,
58029
+ groupStore
56678
58030
  };
56679
58031
  }
56680
58032
  /** Face + plate recognizers, the durable keyFrame-derived plate crop and the
@@ -57108,29 +58460,65 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
57108
58460
  buildSummaryPorts(stores) {
57109
58461
  return {
57110
58462
  listTracks: async ({ deviceId, sinceMs, untilMs, limit }) => {
57111
- const tracks = await stores.trackStore.queryHistorical({
58463
+ const groups = await stores.groupStore.listOverlapping({
58464
+ deviceId,
58465
+ sinceMs,
58466
+ untilMs,
58467
+ limit
58468
+ });
58469
+ const windowTracks = await stores.trackStore.queryHistorical({
57112
58470
  deviceId,
57113
58471
  since: sinceMs,
57114
58472
  until: untilMs,
57115
58473
  projection: "slim",
57116
- limit
58474
+ limit: Math.max(limit, 2e3)
57117
58475
  });
58476
+ const trackById = new Map(windowTracks.map((t) => [t.trackId, t]));
57118
58477
  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
- }));
58478
+ const out = [];
58479
+ for (const group of groups) {
58480
+ const members = await stores.groupStore.listMembers(group.id);
58481
+ const tracks = members.map((m) => trackById.get(m.trackId)).filter((t) => t !== void 0);
58482
+ if (tracks.length === 0) continue;
58483
+ const classes = [...new Set(tracks.flatMap((t) => [t.className, ...t.classes ?? []]))];
58484
+ const zones = [...new Set(tracks.flatMap((t) => [...t.zonesVisited]))];
58485
+ const importance = Math.max(0, ...tracks.map((t) => t.importance ?? 0));
58486
+ const primary = tracks.reduce((best, t) => (t.importance ?? 0) > (best.importance ?? 0) ? t : best);
58487
+ out.push({
58488
+ trackId: group.id,
58489
+ groupId: group.id,
58490
+ memberTrackIds: members.map((m) => m.trackId),
58491
+ deviceId,
58492
+ className: primary.className,
58493
+ firstSeen: group.openedAt,
58494
+ lastSeen: group.closedAt,
58495
+ importance,
58496
+ zones,
58497
+ classes,
58498
+ ...tracks.every((t) => t.closedReason === "stationary-promotion") ? { stationary: true } : {},
58499
+ confirmed: tracks.some((t) => t.observations !== void 0 && t.observations >= minHits)
58500
+ });
58501
+ }
58502
+ return out;
57131
58503
  },
57132
- listMediaInfo: async (trackId) => {
57133
- return (await stores.mediaStore.listInfoByOwner("track", trackId)).map((r) => ({
58504
+ listMediaInfo: async (id) => {
58505
+ const group = await stores.groupStore.get(id);
58506
+ if (group?.mediaKey) return [{
58507
+ key: group.mediaKey,
58508
+ kind: "groupCrop",
58509
+ timestamp: group.timestamp
58510
+ }];
58511
+ if (group && group.memberCount === 1) {
58512
+ const memberId = (await stores.groupStore.listMembers(group.id))[0]?.trackId;
58513
+ if (!memberId) return [];
58514
+ return (await stores.mediaStore.listInfoByOwner("track", memberId)).map((r) => ({
58515
+ key: r.key,
58516
+ kind: r.kind,
58517
+ timestamp: r.timestamp
58518
+ }));
58519
+ }
58520
+ if (group) return [];
58521
+ return (await stores.mediaStore.listInfoByOwner("track", id)).map((r) => ({
57134
58522
  key: r.key,
57135
58523
  kind: r.kind,
57136
58524
  timestamp: r.timestamp
@@ -59154,8 +60542,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
59154
60542
  const first = track.positions?.[0];
59155
60543
  if (!first) return;
59156
60544
  const lifeMs = track.lastSeen - track.firstSeen;
59157
- const moved = track.totalDistance ?? 0;
59158
- if (!(lifeMs <= SHORT_TRACK_MAX_MS && moved <= MOTIONLESS_MAX_PX)) {
60545
+ const moved = netDisplacementPx(track.positions ?? []);
60546
+ if (!isShortMotionlessTrack(lifeMs, moved)) {
59159
60547
  this.phantomCells?.noteMovingClose({
59160
60548
  deviceId: track.deviceId,
59161
60549
  className: track.className,
@@ -59298,7 +60686,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
59298
60686
  deviceId,
59299
60687
  trackId,
59300
60688
  timestamp: frame.timestamp,
59301
- bbox: { ...frame.bbox },
60689
+ bbox: { ...bboxForSemanticPromotion(d.className, d.bbox, frame.bbox) },
59302
60690
  frameWidth: frame.frameWidth,
59303
60691
  frameHeight: frame.frameHeight,
59304
60692
  kind: d.className === "plate" ? "plate" : "classification",
@@ -59731,7 +61119,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
59731
61119
  retries.push({
59732
61120
  trackId: t.trackId,
59733
61121
  timestamp,
59734
- bbox: { ...t.bbox },
61122
+ bbox: firstFrameRetryBbox(t.bbox, this.trackStore?.peekActive(t.trackId)?.positions[0]?.bbox),
59735
61123
  ...patchDisplayLabel(t.labelPatch) !== void 0 ? { label: patchDisplayLabel(t.labelPatch) } : {}
59736
61124
  });
59737
61125
  }
@@ -59788,7 +61176,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
59788
61176
  lastFrameAt: this.residents.lastFrameAt(t.trackId) ?? 0,
59789
61177
  now: timestamp,
59790
61178
  intervalMs: media.snapshotIntervalMs,
59791
- lastFrameIntervalMs: Math.min(LAST_FRAME_INTERVAL_MS, media.snapshotIntervalMs)
61179
+ lastFrameIntervalMs: Math.min(LAST_FRAME_INTERVAL_MS, media.snapshotIntervalMs),
61180
+ firstFrameLanded: this.residents.isFirstFrameLanded(t.trackId)
59792
61181
  });
59793
61182
  const rollingLastFrame = plan.rollingLastFrame && !this.captureScheduler.isKeyActive(captureCoalesceKey(deviceId, "lastFrame", t.trackId));
59794
61183
  if (plan.appendSnapshot) this.trackStore.markSnapshotPending(t.trackId, timestamp, t.bbox);
@@ -60297,6 +61686,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
60297
61686
  async sweepExpiredTracks() {
60298
61687
  if (this.shuttingDown || !this.trackStore) return;
60299
61688
  await this.trackCloser.sweep();
61689
+ this.groupSealer?.sealRecent();
60300
61690
  }
60301
61691
  /** Unified retention (Phase 1): earliest retained footage per device — the
60302
61692
  * follow-recordings cutoff source. Cached 10 min: availability answers in
@@ -61575,6 +62965,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
61575
62965
  },
61576
62966
  liveTrackIds: (ids) => trackStore.existingTrackIds(ids),
61577
62967
  liveEventIds: (ids) => eventStore.existingEventIds(ids),
62968
+ liveGroupIds: (ids) => this.groupStore?.existingGroupIds(ids) ?? Promise.resolve(/* @__PURE__ */ new Set()),
62969
+ liveGroupsWithMembers: (ids) => this.groupStore?.groupsWithMembers(ids) ?? Promise.resolve(/* @__PURE__ */ new Set()),
61578
62970
  reclaim: async (collection, key) => {
61579
62971
  if (collection === "pipeline-analytics:media") {
61580
62972
  await mediaStore.deleteByKey(key);
@@ -62298,6 +63690,36 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
62298
63690
  buildFaceRescoreActionHandlers() {
62299
63691
  return { "face.rescoreTrack": (input) => this.rescoreFaceForTrack(input) };
62300
63692
  }
63693
+ buildGroupActionHandlers() {
63694
+ const ports = () => {
63695
+ const groupStore = this.groupStore;
63696
+ if (groupStore === null) return null;
63697
+ return {
63698
+ groupStore,
63699
+ loadTrack: async (trackId) => {
63700
+ const tracks = this.trackStore;
63701
+ if (!tracks) return null;
63702
+ return tracks.getActiveByTrack(trackId) ?? await tracks.getPersistedByTrackId(trackId);
63703
+ },
63704
+ eventMediaBaseUrl: () => this.eventMediaBaseUrl
63705
+ };
63706
+ };
63707
+ return {
63708
+ "analytics.listGroups": async (input) => {
63709
+ const ready = ports();
63710
+ if (ready === null) return {
63711
+ groups: [],
63712
+ hasMore: false
63713
+ };
63714
+ return listGroupRecords(ready, input);
63715
+ },
63716
+ "analytics.getGroup": async (input) => {
63717
+ const ready = ports();
63718
+ if (ready === null) return { group: null };
63719
+ return { group: await getGroupDetail(ready, input.groupId) };
63720
+ }
63721
+ };
63722
+ }
62301
63723
  buildPhotoEnrollActionHandlers() {
62302
63724
  return {
62303
63725
  "photo.analyze": async (input) => {
@@ -62528,7 +63950,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
62528
63950
  mediaStore,
62529
63951
  faceStore: this.faceStore,
62530
63952
  plateStore: this.plateStore,
62531
- objectEmbeddingStore: this.objectEmbeddingStore
63953
+ objectEmbeddingStore: this.objectEmbeddingStore,
63954
+ groupStore: this.groupStore
62532
63955
  }),
62533
63956
  trackStore
62534
63957
  };
@@ -62677,7 +64100,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
62677
64100
  media: 0,
62678
64101
  faces: 0,
62679
64102
  plates: 0,
62680
- embeddings: 0
64103
+ embeddings: 0,
64104
+ groups: 0
62681
64105
  };
62682
64106
  this.lastPruneBytes = 0;
62683
64107
  const cascade = this.buildTrackCascadeRegistry();
@@ -62737,7 +64161,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
62737
64161
  media: tally.media,
62738
64162
  faces: tally.faces,
62739
64163
  plates: tally.plates,
62740
- embeddings: tally.embeddings
64164
+ embeddings: tally.embeddings,
64165
+ groups: tally.groups
62741
64166
  };
62742
64167
  }
62743
64168
  /**