@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.
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-RzJseCJP.js");
5
+ const require_dist = require("../dist-C0uPGtBv.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_dist.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -3029,8 +3029,8 @@ async function raceTimeout(call, timeoutMs) {
3029
3029
  * detail the encoder can see.
3030
3030
  */
3031
3031
  async function downscaleJpeg(bytes, maxPx) {
3032
- const { default: sharp$15 } = await import("sharp");
3033
- const out = await sharp$15(Buffer.from(bytes)).resize({
3032
+ const { default: sharp$16 } = await import("sharp");
3033
+ const out = await sharp$16(Buffer.from(bytes)).resize({
3034
3034
  width: maxPx,
3035
3035
  height: maxPx,
3036
3036
  fit: "inside",
@@ -5354,24 +5354,17 @@ function buildAlarmButtons(input) {
5354
5354
  *
5355
5355
  * ── TWO EXCLUSIVE MODES (operator decision 2026-08-14, D157) ───────────────
5356
5356
  *
5357
- * **LABEL mode** — the rule NAMES SOUNDS (`labels`). It confirms on the FIRST
5358
- * frame the classifier labels with one of them. No window, no percentage, no
5359
- * waiting.
5360
- *
5361
- * That is not a convenience: it is the only shape that can fire at all. The
5362
- * analyzer emits ~1 inference frame per second, but YAMNet puts a macro label
5363
- * on only one to three of them per episode — even through continuous crying.
5364
- * The maximum `hitPercent` ever measured on this installation's whole history
5365
- * was 40, under the shipped default of 60, so the previous windowed semantics
5366
- * meant a label rule could NEVER confirm. A percentage of frames is the wrong
5367
- * question to ask of a sparse classifier. The per-label confidence floor still
5368
- * applies — it is the analyzer's own `classificationMinScore`, applied per
5369
- * device before a label ever reaches this module.
5370
- *
5371
- * The rule's `throttle` cooldown is the ONLY brake in this mode. There is
5372
- * deliberately no re-arm timer here: a second brake nobody can see in the
5373
- * editor is how a rule ends up silently not firing, which is the failure this
5374
- * change exists to remove.
5357
+ * **LABEL mode** — the rule NAMES SOUNDS (`labels`). It confirms when
5358
+ * `confirmHits` labelled frames land inside `confirmWindowSec` (default 2
5359
+ * in 5 s). That is a COUNT of sparse frames, not a percentage of the
5360
+ * stream: YAMNet still labels only one to three frames per episode, so a
5361
+ * 60%-of-10s window can never fire, but two of those frames two seconds
5362
+ * apart can. A single-frame false positive (the live "Pianti" pattern)
5363
+ * never reaches 2. `confirmHits: 1` is the old first-frame behaviour.
5364
+ *
5365
+ * The per-label confidence floor still applies — it is the analyzer's own
5366
+ * `classificationMinScore`, applied per device before a label ever reaches
5367
+ * this module. The rule's `throttle` cooldown is the OTHER brake.
5375
5368
  *
5376
5369
  * **LEVEL mode** — the rule NAMES A LEVEL (`dbThreshold`). Unchanged, and the
5377
5370
  * window is the whole point: over `samplingSeconds`, at least `hitPercent`% of
@@ -5434,7 +5427,9 @@ function audioSpecFromCondition(condition) {
5434
5427
  const mode = require_dist.audioModeOf(condition);
5435
5428
  if (mode === "label") return {
5436
5429
  mode: "label",
5437
- labels: [...new Set((condition.labels ?? []).map(normalizeAudioLabel))].sort()
5430
+ labels: [...new Set((condition.labels ?? []).map(normalizeAudioLabel))].sort(),
5431
+ confirmHits: condition.confirmHits ?? 2,
5432
+ confirmWindowSec: condition.confirmWindowSec ?? 5
5438
5433
  };
5439
5434
  const dbThreshold = condition.dbThreshold;
5440
5435
  if (mode === "level" && dbThreshold !== void 0) return {
@@ -5447,7 +5442,7 @@ function audioSpecFromCondition(condition) {
5447
5442
  }
5448
5443
  /** Stable, device-agnostic key of a spec — rules that agree share a window. */
5449
5444
  function audioSpecKey(spec) {
5450
- return spec.mode === "label" ? `label|${spec.labels.join(",")}` : `level|${String(spec.dbThreshold)}|h${String(spec.hitPercent)}|s${String(spec.samplingSeconds)}`;
5445
+ 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)}`;
5451
5446
  }
5452
5447
  /**
5453
5448
  * The watched labels this sample carries, normalized and sorted. Empty = no
@@ -5472,6 +5467,8 @@ var AudioWatcher = class {
5472
5467
  watched = /* @__PURE__ */ new Map();
5473
5468
  /** LEVEL windows, keyed `${deviceId}|${specKey}`. */
5474
5469
  windows = /* @__PURE__ */ new Map();
5470
+ /** LABEL pending hits, keyed `${deviceId}|${specKey}` — timestamps only. */
5471
+ labelHits = /* @__PURE__ */ new Map();
5475
5472
  /**
5476
5473
  * Replace the watched spec set (rule-driven — recomputed on every rule
5477
5474
  * reload). Windows whose spec is no longer watched are DROPPED: a rule
@@ -5482,9 +5479,9 @@ var AudioWatcher = class {
5482
5479
  setWatchedSpecs(specs) {
5483
5480
  this.watched.clear();
5484
5481
  for (const spec of specs) this.watched.set(audioSpecKey(spec), spec);
5485
- for (const key of [...this.windows.keys()]) {
5482
+ for (const store of [this.windows, this.labelHits]) for (const key of [...store.keys()]) {
5486
5483
  const specKey = key.slice(key.indexOf("|") + 1);
5487
- if (!this.watched.has(specKey)) this.windows.delete(key);
5484
+ if (!this.watched.has(specKey)) store.delete(key);
5488
5485
  }
5489
5486
  }
5490
5487
  /** True when at least one spec is watched — the caller's cheap no-op gate. */
@@ -5500,7 +5497,7 @@ var AudioWatcher = class {
5500
5497
  if (this.watched.size === 0) return [];
5501
5498
  const confirmed = [];
5502
5499
  for (const [specKey, spec] of this.watched) {
5503
- const hit = spec.mode === "label" ? observeLabel(deviceId, spec, sample, now) : this.observeLevel(deviceId, specKey, spec, sample, now);
5500
+ const hit = spec.mode === "label" ? this.observeLabel(deviceId, specKey, spec, sample, now) : this.observeLevel(deviceId, specKey, spec, sample, now);
5504
5501
  if (hit !== null) confirmed.push(hit);
5505
5502
  }
5506
5503
  return confirmed;
@@ -5508,7 +5505,7 @@ var AudioWatcher = class {
5508
5505
  /** Drop every window of a camera (unbound, removed, audio switched off). */
5509
5506
  forgetDevice(deviceId) {
5510
5507
  const prefix = `${deviceId}|`;
5511
- for (const key of [...this.windows.keys()]) if (key.startsWith(prefix)) this.windows.delete(key);
5508
+ for (const store of [this.windows, this.labelHits]) for (const key of [...store.keys()]) if (key.startsWith(prefix)) store.delete(key);
5512
5509
  }
5513
5510
  observeLevel(deviceId, specKey, spec, sample, now) {
5514
5511
  const key = `${deviceId}|${specKey}`;
@@ -5557,23 +5554,34 @@ var AudioWatcher = class {
5557
5554
  spec
5558
5555
  };
5559
5556
  }
5557
+ /**
5558
+ * LABEL mode: confirm when `confirmHits` labelled frames land inside
5559
+ * `confirmWindowSec`. `confirmHits <= 1` is first-frame fire (stateless).
5560
+ * Confirming re-arms — the next episode needs a fresh count.
5561
+ */
5562
+ observeLabel(deviceId, specKey, spec, sample, now) {
5563
+ const matched = matchedAudioLabels(sample, spec.labels);
5564
+ if (matched.length === 0) return null;
5565
+ const hit = {
5566
+ deviceId,
5567
+ timestamp: now,
5568
+ labels: matched,
5569
+ ...sample.dbfs !== void 0 && Number.isFinite(sample.dbfs) ? { peakDbfs: sample.dbfs } : {},
5570
+ spec
5571
+ };
5572
+ if (spec.confirmHits <= 1) return hit;
5573
+ const key = `${deviceId}|${specKey}`;
5574
+ const cutoff = now - spec.confirmWindowSec * 1e3;
5575
+ const kept = (this.labelHits.get(key) ?? []).filter((at) => at > cutoff);
5576
+ kept.push(now);
5577
+ if (kept.length < spec.confirmHits) {
5578
+ this.labelHits.set(key, kept);
5579
+ return null;
5580
+ }
5581
+ this.labelHits.delete(key);
5582
+ return hit;
5583
+ }
5560
5584
  };
5561
- /**
5562
- * LABEL mode: confirm on the first frame carrying a watched label. Stateless
5563
- * by construction — nothing is remembered between frames, so there is no
5564
- * window to be not-full and no percentage to fall short of.
5565
- */
5566
- function observeLabel(deviceId, spec, sample, now) {
5567
- const matched = matchedAudioLabels(sample, spec.labels);
5568
- if (matched.length === 0) return null;
5569
- return {
5570
- deviceId,
5571
- timestamp: now,
5572
- labels: matched,
5573
- ...sample.dbfs !== void 0 && Number.isFinite(sample.dbfs) ? { peakDbfs: sample.dbfs } : {},
5574
- spec
5575
- };
5576
- }
5577
5585
  //#endregion
5578
5586
  //#region src/shared/llm-vision/prompt-hygiene.ts
5579
5587
  /**
@@ -7920,7 +7928,10 @@ function attachmentKindPreference(policy, ownerKind) {
7920
7928
  if (policy === "keyFrame") return ownerKind === "track" ? [
7921
7929
  "keyFrameSmall",
7922
7930
  "keyFrame",
7923
- "fullFrameBoxed"
7931
+ "fullFrameBoxed",
7932
+ "firstFrame",
7933
+ "lastFrame",
7934
+ "snapshot"
7924
7935
  ] : [
7925
7936
  "fullFrameBoxed",
7926
7937
  "fullFrame",
@@ -9455,8 +9466,8 @@ var NcDispatcher = class {
9455
9466
  try {
9456
9467
  const points = (await this.deps.getZonePolygons?.(deviceId, zoneIds) ?? []).flat();
9457
9468
  if (points.length === 0) return null;
9458
- const { default: sharp$14 } = await import("sharp");
9459
- const img = sharp$14(Buffer.from(jpeg));
9469
+ const { default: sharp$15 } = await import("sharp");
9470
+ const img = sharp$15(Buffer.from(jpeg));
9460
9471
  const meta = await img.metadata();
9461
9472
  const W = meta.width ?? 0;
9462
9473
  const H = meta.height ?? 0;
@@ -15130,14 +15141,13 @@ function selectSummaryTiles(candidates, opts) {
15130
15141
  /**
15131
15142
  * The media a tile is drawn from, best first.
15132
15143
  *
15133
- * `keyFrameSmall` heads the ladder because it is the CLEAN best-shot frame at
15134
- * 960 px — the whole scene, legible on a phone, cheap. `fullFrameBoxed` and
15135
- * `firstFrame` are deliberately absent: a drawn rectangle is a permanent lie in
15136
- * a digest tile, the operator asked for a mosaic that is ALWAYS CLEAN, and (for
15137
- * P2) a box burnt into a pixel is text inside an image — an injection channel
15138
- * and a source of hallucination. Boxes stay data.
15144
+ * `groupCrop` heads the ladder: it is the group's own clean wide-central-square
15145
+ * over the union bbox, and a drawn rectangle is a permanent lie in a digest
15146
+ * tile. Singletons have no groupCrop and fall through to the member ladder.
15147
+ * `fullFrameBoxed` and `firstFrame` stay absent for the same reason.
15139
15148
  */
15140
15149
  var SUMMARY_MEDIA_LADDER = [
15150
+ "groupCrop",
15141
15151
  "keyFrameSmall",
15142
15152
  "keyFrame",
15143
15153
  "snapshot",
@@ -15219,7 +15229,7 @@ var SUMMARY_TRACK_QUERY_LIMIT = 2e3;
15219
15229
  function summaryMediaOwnerId(ruleId, windowEndMs) {
15220
15230
  return `summary-${ruleId}-${windowEndMs}`;
15221
15231
  }
15222
- var EMPTY_REPORT = {
15232
+ var EMPTY_REPORT$1 = {
15223
15233
  produced: 0,
15224
15234
  collecting: 0,
15225
15235
  barrier: 0,
@@ -15283,9 +15293,9 @@ var NcSummaryProducer = class {
15283
15293
  * must not open ten simultaneous render/publish sequences.
15284
15294
  */
15285
15295
  async tick() {
15286
- if (this.tickInFlight) return EMPTY_REPORT;
15296
+ if (this.tickInFlight) return EMPTY_REPORT$1;
15287
15297
  this.tickInFlight = true;
15288
- const report = { ...EMPTY_REPORT };
15298
+ const report = { ...EMPTY_REPORT$1 };
15289
15299
  try {
15290
15300
  const now = this.deps.now();
15291
15301
  const rules = this.deps.store.listEnabled();
@@ -15996,7 +16006,7 @@ var NcSummaryRuleInputSchema = require_dist.object({
15996
16006
  deviceIds: DeviceIdsField,
15997
16007
  window: NcSummaryWindowSchema,
15998
16008
  filters: NcSummaryFiltersSchema.optional(),
15999
- maxTiles: MaxTilesField.default(9),
16009
+ maxTiles: MaxTilesField.default(12),
16000
16010
  maxAiImages: MaxAiImagesField.default(6),
16001
16011
  rank: NcSummaryRankSchema.default("importance"),
16002
16012
  barrierSeconds: BarrierSecondsField.default(120),
@@ -16714,8 +16724,8 @@ async function deriveSyntheticMedia(jpeg, bbox, timestamp) {
16714
16724
  ];
16715
16725
  if (bbox === void 0) return files;
16716
16726
  try {
16717
- const { default: sharp$13 } = await import("sharp");
16718
- const meta = await sharp$13(Buffer.from(jpeg)).metadata();
16727
+ const { default: sharp$14 } = await import("sharp");
16728
+ const meta = await sharp$14(Buffer.from(jpeg)).metadata();
16719
16729
  const W = meta.width ?? 0;
16720
16730
  const H = meta.height ?? 0;
16721
16731
  if (W === 0 || H === 0) return files;
@@ -16723,7 +16733,7 @@ async function deriveSyntheticMedia(jpeg, bbox, timestamp) {
16723
16733
  const top = Math.max(0, Math.min(H - 1, Math.floor(bbox.y * H)));
16724
16734
  const width = Math.max(1, Math.min(W - left, Math.ceil(bbox.w * W)));
16725
16735
  const height = Math.max(1, Math.min(H - top, Math.ceil(bbox.h * H)));
16726
- const cropBuf = await sharp$13(Buffer.from(jpeg)).extract({
16736
+ const cropBuf = await sharp$14(Buffer.from(jpeg)).extract({
16727
16737
  left,
16728
16738
  top,
16729
16739
  width,
@@ -16745,7 +16755,7 @@ async function deriveSyntheticMedia(jpeg, bbox, timestamp) {
16745
16755
  });
16746
16756
  const stroke = Math.max(2, Math.round(Math.min(W, H) / 240));
16747
16757
  const overlay = Buffer.from(`<svg width="${W}" height="${H}"><rect x="${left}" y="${top}" width="${width}" height="${height}" fill="none" stroke="#22d3ee" stroke-width="${stroke}"/></svg>`);
16748
- const boxedBuf = await sharp$13(Buffer.from(jpeg)).composite([{
16758
+ const boxedBuf = await sharp$14(Buffer.from(jpeg)).composite([{
16749
16759
  input: overlay,
16750
16760
  top: 0,
16751
16761
  left: 0
@@ -18362,6 +18372,15 @@ var TIMELAPSE_LOOKBACK_MS = 8 * 864e5;
18362
18372
  function timelapseEligibilityFloor(rule, deviceId) {
18363
18373
  return Math.max(require_dist.readTimelapseGeneratedAt(rule, deviceId), rule.createdAt);
18364
18374
  }
18375
+ var EMPTY_REPORT = {
18376
+ produced: 0,
18377
+ waiting: 0,
18378
+ alreadyDone: 0,
18379
+ beforeFloor: 0,
18380
+ failed: 0,
18381
+ idle: 0,
18382
+ skippedInFlight: 0
18383
+ };
18365
18384
  /** The in-flight map's key. One render per (rule, camera) pair by construction. */
18366
18385
  function renderFlightKey(ruleId, deviceId) {
18367
18386
  return `${ruleId}:${String(deviceId)}`;
@@ -18369,7 +18388,8 @@ function renderFlightKey(ruleId, deviceId) {
18369
18388
  var TimelapseScheduler = class {
18370
18389
  deps;
18371
18390
  timer = null;
18372
- tickInFlight = false;
18391
+ /** True only while this tick is scanning eligibility — not while ffmpeg runs. */
18392
+ scanning = false;
18373
18393
  inFlight = /* @__PURE__ */ new Map();
18374
18394
  settleMs;
18375
18395
  lookbackMs;
@@ -18416,25 +18436,29 @@ var TimelapseScheduler = class {
18416
18436
  this.timer = null;
18417
18437
  }
18418
18438
  }
18419
- /** One pass over every enabled rule × camera. Single-flight. */
18439
+ /**
18440
+ * One pass over every enabled rule × camera.
18441
+ *
18442
+ * Eligibility is scanned under a short `scanning` lock so two ticks do not
18443
+ * double-launch the same pair. Renders themselves are started together —
18444
+ * awaiting them one-by-one made camera B wait for camera A's ffmpeg
18445
+ * (live: 1441 until 08:39, 615 until 08:46, both windows closed at 07:00).
18446
+ * A later tick that meets a pair already in `inFlight` counts it and
18447
+ * moves on, so a 30-minute render does not make the producer go deaf.
18448
+ */
18420
18449
  async tick() {
18421
- if (this.tickInFlight) return {
18422
- produced: 0,
18423
- waiting: 0,
18424
- alreadyDone: 0,
18425
- beforeFloor: 0,
18426
- failed: 0,
18427
- idle: 0
18428
- };
18429
- this.tickInFlight = true;
18450
+ if (this.scanning) return { ...EMPTY_REPORT };
18451
+ this.scanning = true;
18430
18452
  const report = {
18431
18453
  produced: 0,
18432
18454
  waiting: 0,
18433
18455
  alreadyDone: 0,
18434
18456
  beforeFloor: 0,
18435
18457
  failed: 0,
18436
- idle: 0
18458
+ idle: 0,
18459
+ skippedInFlight: 0
18437
18460
  };
18461
+ const jobs = [];
18438
18462
  try {
18439
18463
  const now = this.deps.now();
18440
18464
  for (const rule of this.deps.store.listEnabled()) {
@@ -18469,13 +18493,20 @@ var TimelapseScheduler = class {
18469
18493
  });
18470
18494
  continue;
18471
18495
  }
18472
- if ((await this.produce(rule, deviceId, window, { stamp: true })).produced > 0) report.produced += 1;
18473
- else report.failed += 1;
18496
+ if (this.inFlight.has(renderFlightKey(rule.id, deviceId))) {
18497
+ report.skippedInFlight += 1;
18498
+ continue;
18499
+ }
18500
+ jobs.push(this.produce(rule, deviceId, window, { stamp: true }).then((ok) => {
18501
+ if (ok.produced > 0) report.produced += 1;
18502
+ else report.failed += 1;
18503
+ }));
18474
18504
  }
18475
18505
  }
18476
18506
  } finally {
18477
- this.tickInFlight = false;
18507
+ this.scanning = false;
18478
18508
  }
18509
+ await Promise.all(jobs);
18479
18510
  return report;
18480
18511
  }
18481
18512
  /**
@@ -27631,6 +27662,35 @@ function pairRiders(detections) {
27631
27662
  return pairs;
27632
27663
  }
27633
27664
  //#endregion
27665
+ //#region src/pipeline-analytics/pipeline/tracker/rescue-growth.ts
27666
+ /** How far the grown box's centroid may sit, as a multiple of the LARGER
27667
+ * diagonal. 1.35 covers the live 4285605a bird→dog jump (~75px / 59px). */
27668
+ var GROWTH_CENTROID_FACTOR = 1.35;
27669
+ function intersectionArea(a, b) {
27670
+ const x1 = Math.max(a.x, b.x);
27671
+ const y1 = Math.max(a.y, b.y);
27672
+ const x2 = Math.min(a.x + a.w, b.x + b.w);
27673
+ const y2 = Math.min(a.y + a.h, b.y + b.h);
27674
+ if (x2 <= x1 || y2 <= y1) return 0;
27675
+ return (x2 - x1) * (y2 - y1);
27676
+ }
27677
+ /** Same-class partial crop growing into (or collapsing from) a full-body box
27678
+ * with IoU 0. Used by rescue and spawn-dedup so we do not mint a second id.
27679
+ * A SMALL box nested inside a LARGE one is occlusion, not growth — those
27680
+ * stay two tracks (sort-tracker occlusion suite). */
27681
+ function growthAdjacentMatch(track, det) {
27682
+ const trackArea = track.w * track.h;
27683
+ const detArea = det.w * det.h;
27684
+ if (trackArea <= 0 || detArea <= 0) return false;
27685
+ const larger = Math.max(trackArea, detArea);
27686
+ const smaller = Math.min(trackArea, detArea);
27687
+ if (larger / smaller < 1.5) return false;
27688
+ if (intersectionArea(track, det) / smaller >= .5) return false;
27689
+ const tc = bboxCentroid(track);
27690
+ const dc = bboxCentroid(det);
27691
+ 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));
27692
+ }
27693
+ //#endregion
27634
27694
  //#region src/pipeline-analytics/pipeline/tracker/sort-tracker.ts
27635
27695
  var DEFAULT_TRACKER_CONFIG = {
27636
27696
  iouThreshold: .3,
@@ -27758,7 +27818,7 @@ var SortTracker = class SortTracker {
27758
27818
  * and earlier same-frame spawns).
27759
27819
  */
27760
27820
  isDuplicateSpawn(det, live) {
27761
- for (const t of live) if (this.dedupCompatible(t.class, det.class) && iou$2(det.bbox, t.bbox) >= this.config.dedupSpawnIou) return true;
27821
+ 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;
27762
27822
  if (this.config.personAnimalDedup && det.class === ANIMAL_CLASS && det.score < this.config.animalOverPersonMaxScore) {
27763
27823
  for (const t of live) if (t.class === PERSON_CLASS && iou$2(det.bbox, t.bbox) >= this.config.animalOverPersonIou) return true;
27764
27824
  }
@@ -27852,7 +27912,8 @@ var SortTracker = class SortTracker {
27852
27912
  const dc = bboxCentroid(det.bbox);
27853
27913
  const dist = Math.hypot(tc.x - dc.x, tc.y - dc.y);
27854
27914
  const diag = Math.hypot(track.bbox.w, track.bbox.h);
27855
- return dist <= this.config.rescueCentroidFactor * diag;
27915
+ if (dist <= this.config.rescueCentroidFactor * diag) return true;
27916
+ return growthAdjacentMatch(track.bbox, det.bbox);
27856
27917
  }
27857
27918
  /** Fold a matched detection's class + score into a track's lifetime vote
27858
27919
  * tally. A non-positive score still counts as an infinitesimal vote so a
@@ -28177,6 +28238,7 @@ var StateAnalyzer = class {
28177
28238
  enteredAt: timestamp,
28178
28239
  stationarySince: void 0,
28179
28240
  totalDistancePx: 0,
28241
+ startPosition: center,
28180
28242
  lastPosition: center,
28181
28243
  frameCount: 1
28182
28244
  };
@@ -28196,8 +28258,9 @@ var StateAnalyzer = class {
28196
28258
  else if (velocity <= this.config.velocityThreshold) {
28197
28259
  if (!ts.stationarySince) ts.stationarySince = timestamp;
28198
28260
  const stationaryDurationSec = (timestamp - ts.stationarySince) / 1e3;
28261
+ const neverMoved = Math.hypot(ts.lastPosition.x - ts.startPosition.x, ts.lastPosition.y - ts.startPosition.y) <= this.config.velocityThreshold;
28199
28262
  if (stationaryDurationSec >= this.config.loiteringThresholdSec) state = "loitering";
28200
- else if (stationaryDurationSec >= this.config.stationaryThresholdSec) state = "stationary";
28263
+ else if (neverMoved || stationaryDurationSec >= this.config.stationaryThresholdSec) state = "stationary";
28201
28264
  else state = "moving";
28202
28265
  } else {
28203
28266
  ts.stationarySince = void 0;
@@ -29030,6 +29093,22 @@ function promoteSemanticBestFrame(deps, input) {
29030
29093
  };
29031
29094
  }
29032
29095
  //#endregion
29096
+ //#region src/pipeline-analytics/pipeline/semantic-detail-bbox.ts
29097
+ /** Which box the semantic best-shot should crop: a plate/face DETAIL box
29098
+ * when we have one, otherwise the subject box. */
29099
+ function bboxForSemanticPromotion(className, detailBbox, subjectBbox) {
29100
+ if ((className === "plate" || className === "face") && detailBbox !== void 0 && detailBbox.w > 0 && detailBbox.h > 0) return detailBbox;
29101
+ return subjectBbox;
29102
+ }
29103
+ //#endregion
29104
+ //#region src/pipeline-analytics/pipeline/first-frame-retry-bbox.ts
29105
+ /** Crop box for a late firstFrame retry: the BIRTH geometry, not the
29106
+ * later frozen/coasted box (track 1b659d56). */
29107
+ function firstFrameRetryBbox(current, birth) {
29108
+ if (birth !== void 0 && birth.w > 0 && birth.h > 0) return { ...birth };
29109
+ return { ...current };
29110
+ }
29111
+ //#endregion
29033
29112
  //#region src/pipeline-analytics/store/zone-geometry.ts
29034
29113
  /**
29035
29114
  * Normalized min/max envelope over every position's bbox. Returns `null`
@@ -29244,6 +29323,7 @@ var EMPTY_CASCADE_TALLY = {
29244
29323
  faces: 0,
29245
29324
  plates: 0,
29246
29325
  embeddings: 0,
29326
+ groups: 0,
29247
29327
  bytesReclaimed: 0
29248
29328
  };
29249
29329
  /** Fold a leaf store's receipts into a running tally. */
@@ -29287,6 +29367,13 @@ function addLeafCounts(tally, counts) {
29287
29367
  bytesReclaimed
29288
29368
  };
29289
29369
  break;
29370
+ case "groups":
29371
+ next = {
29372
+ ...next,
29373
+ groups: next.groups + c.rows,
29374
+ bytesReclaimed
29375
+ };
29376
+ break;
29290
29377
  }
29291
29378
  }
29292
29379
  return next;
@@ -29300,6 +29387,7 @@ function mergeCascadeTallies(a, b) {
29300
29387
  faces: a.faces + b.faces,
29301
29388
  plates: a.plates + b.plates,
29302
29389
  embeddings: a.embeddings + b.embeddings,
29390
+ groups: a.groups + b.groups,
29303
29391
  bytesReclaimed: a.bytesReclaimed + b.bytesReclaimed
29304
29392
  };
29305
29393
  }
@@ -29348,6 +29436,7 @@ function buildAnalyticsCascadeRegistry(stores) {
29348
29436
  if (stores.faceStore) registry.push(stores.faceStore);
29349
29437
  if (stores.plateStore) registry.push(stores.plateStore);
29350
29438
  if (stores.objectEmbeddingStore) registry.push(stores.objectEmbeddingStore);
29439
+ if (stores.groupStore) registry.push(stores.groupStore);
29351
29440
  return registry;
29352
29441
  }
29353
29442
  /**
@@ -31058,7 +31147,8 @@ var OWNER_KINDS = [
31058
31147
  "identity",
31059
31148
  "plate",
31060
31149
  "vehicle",
31061
- "scene"
31150
+ "scene",
31151
+ "group"
31062
31152
  ];
31063
31153
  /**
31064
31154
  * Narrow an arbitrary string to an `OwnerKind`.
@@ -31807,6 +31897,10 @@ var MediaStore = class {
31807
31897
  async deleteForTracks(trackIds) {
31808
31898
  return this.deleteForOwner("track", trackIds);
31809
31899
  }
31900
+ /** Delete the group's OWN crop. The native frame is a pointer and is not here. */
31901
+ async deleteByGroups(groupIds) {
31902
+ return this.deleteForOwnerCounted("group", groupIds);
31903
+ }
31810
31904
  /**
31811
31905
  * `TrackScopedStore` contract for the retention cascade: delete all media
31812
31906
  * owned by the given tracks — the tracks' OWN media (`ownerKind:'track'`) plus
@@ -39898,7 +39992,9 @@ function resolvePackageDropSettings(raw) {
39898
39992
  function planPeriodicMedia(input) {
39899
39993
  const appendSnapshot = input.dueSnapshot;
39900
39994
  const lastFrameInterval = input.lastFrameIntervalMs ?? input.intervalMs;
39901
- const rollingLastFrame = input.saveThumbnails && Math.floor(input.now / lastFrameInterval) > Math.floor(input.lastFrameAt / lastFrameInterval) && !appendSnapshot;
39995
+ const lastFrameElapsed = input.saveThumbnails && Math.floor(input.now / lastFrameInterval) > Math.floor(input.lastFrameAt / lastFrameInterval);
39996
+ const firstFrameLanded = input.firstFrameLanded ?? true;
39997
+ const rollingLastFrame = lastFrameElapsed && !appendSnapshot && firstFrameLanded;
39902
39998
  const thumbnailLanded = input.thumbnailLanded ?? true;
39903
39999
  return {
39904
40000
  appendSnapshot,
@@ -40131,6 +40227,1171 @@ var DeferredBirthRegistry = class {
40131
40227
  this.byDevice.delete(deviceKey);
40132
40228
  }
40133
40229
  };
40230
+ var AnalyticsGroupRecordSchema = require_dist.object({
40231
+ id: require_dist.string(),
40232
+ deviceId: require_dist.number().int(),
40233
+ openedAt: require_dist.number().int(),
40234
+ closedAt: require_dist.number().int(),
40235
+ timestamp: require_dist.number().int(),
40236
+ memberCount: require_dist.number().int(),
40237
+ memberTrackIds: require_dist.array(require_dist.string()).readonly(),
40238
+ className: require_dist.string(),
40239
+ classes: require_dist.array(require_dist.string()).readonly(),
40240
+ /** Relative event-media path, or null when the group has no picture yet. */
40241
+ mediaUrl: require_dist.string().nullable(),
40242
+ singleton: require_dist.boolean()
40243
+ });
40244
+ var AnalyticsGroupMemberSchema = require_dist.object({
40245
+ trackId: require_dist.string(),
40246
+ deviceId: require_dist.number().int(),
40247
+ className: require_dist.string(),
40248
+ firstSeen: require_dist.number().int(),
40249
+ lastSeen: require_dist.number().int(),
40250
+ mediaUrl: require_dist.string().nullable()
40251
+ });
40252
+ var AnalyticsGroupDetailSchema = AnalyticsGroupRecordSchema.extend({ members: require_dist.array(AnalyticsGroupMemberSchema).readonly() });
40253
+ var ListGroupsInputSchema = require_dist.object({
40254
+ deviceId: require_dist.number().int().optional(),
40255
+ sinceMs: require_dist.number().int().optional(),
40256
+ untilMs: require_dist.number().int().optional(),
40257
+ limit: require_dist.number().int().min(1).max(100).optional(),
40258
+ offset: require_dist.number().int().min(0).optional()
40259
+ });
40260
+ var groupActions = require_dist.defineCustomActions({
40261
+ "analytics.listGroups": require_dist.customAction(ListGroupsInputSchema, require_dist.object({
40262
+ groups: require_dist.array(AnalyticsGroupRecordSchema).readonly(),
40263
+ hasMore: require_dist.boolean()
40264
+ })),
40265
+ "analytics.getGroup": require_dist.customAction(require_dist.object({ groupId: require_dist.string().min(1) }), require_dist.object({ group: AnalyticsGroupDetailSchema.nullable() }))
40266
+ });
40267
+ //#endregion
40268
+ //#region src/pipeline-analytics/group/group-query.ts
40269
+ /**
40270
+ * Stamp a relative event-media URL.
40271
+ *
40272
+ * Media keys contain `:` and the data-plane serves them by key. Track ids are
40273
+ * bare and fall through to the existing event/track thumbnail resolver. Never
40274
+ * percent-encode: the handler does not URL-decode the path.
40275
+ */
40276
+ function stampEventMediaUrl(baseUrl, id) {
40277
+ if (baseUrl === null || baseUrl.length === 0) return null;
40278
+ if (id === null || id === void 0 || id.length === 0) return null;
40279
+ return `${baseUrl}/${id}`;
40280
+ }
40281
+ /** Union of every class on the members; `className` is the first, stable order. */
40282
+ function aggregateGroupClasses(tracks) {
40283
+ const seen = /* @__PURE__ */ new Set();
40284
+ const classes = [];
40285
+ for (const track of tracks) for (const name of [track.className, ...track.classes ?? []]) {
40286
+ if (name.length === 0 || seen.has(name)) continue;
40287
+ seen.add(name);
40288
+ classes.push(name);
40289
+ }
40290
+ return {
40291
+ className: classes[0] ?? "unknown",
40292
+ classes
40293
+ };
40294
+ }
40295
+ function toGroupRecord(input) {
40296
+ const { className, classes } = aggregateGroupClasses(input.tracks);
40297
+ const mediaId = input.group.mediaKey ?? (input.group.memberCount === 1 ? input.memberTrackIds[0] : null);
40298
+ return {
40299
+ id: input.group.id,
40300
+ deviceId: input.group.deviceId,
40301
+ openedAt: input.group.openedAt,
40302
+ closedAt: input.group.closedAt,
40303
+ timestamp: input.group.timestamp,
40304
+ memberCount: input.memberTrackIds.length > 0 ? input.memberTrackIds.length : input.group.memberCount,
40305
+ memberTrackIds: [...input.memberTrackIds],
40306
+ className,
40307
+ classes: [...classes],
40308
+ mediaUrl: stampEventMediaUrl(input.eventMediaBaseUrl, mediaId),
40309
+ singleton: input.memberTrackIds.length <= 1
40310
+ };
40311
+ }
40312
+ function toGroupMember(member, track, eventMediaBaseUrl) {
40313
+ return {
40314
+ trackId: member.trackId,
40315
+ deviceId: member.deviceId,
40316
+ className: track?.className ?? "unknown",
40317
+ firstSeen: track?.firstSeen ?? member.timestamp,
40318
+ lastSeen: track?.lastSeen ?? member.timestamp,
40319
+ mediaUrl: stampEventMediaUrl(eventMediaBaseUrl, member.trackId)
40320
+ };
40321
+ }
40322
+ function trackToLite(track) {
40323
+ return {
40324
+ trackId: track.trackId,
40325
+ className: track.className,
40326
+ classes: track.classes,
40327
+ firstSeen: track.firstSeen,
40328
+ lastSeen: track.lastSeen
40329
+ };
40330
+ }
40331
+ async function listGroupRecords(ports, input) {
40332
+ const page = await ports.groupStore.listRecent(input);
40333
+ const groups = [];
40334
+ for (const group of page.groups) groups.push(await buildGroupRecord(ports, group));
40335
+ return {
40336
+ groups,
40337
+ hasMore: page.hasMore
40338
+ };
40339
+ }
40340
+ async function getGroupDetail(ports, groupId) {
40341
+ const group = await ports.groupStore.get(groupId);
40342
+ if (group === null) return null;
40343
+ const members = await ports.groupStore.listMembers(groupId);
40344
+ const lites = [];
40345
+ const rows = [];
40346
+ for (const member of members) {
40347
+ const track = await ports.loadTrack(member.trackId);
40348
+ const lite = track === null ? null : trackToLite(track);
40349
+ if (lite !== null) lites.push(lite);
40350
+ rows.push(toGroupMember(member, lite, ports.eventMediaBaseUrl()));
40351
+ }
40352
+ return {
40353
+ ...toGroupRecord({
40354
+ group,
40355
+ memberTrackIds: members.map((m) => m.trackId),
40356
+ tracks: lites,
40357
+ eventMediaBaseUrl: ports.eventMediaBaseUrl()
40358
+ }),
40359
+ members: rows
40360
+ };
40361
+ }
40362
+ async function buildGroupRecord(ports, group) {
40363
+ const members = await ports.groupStore.listMembers(group.id);
40364
+ const lites = [];
40365
+ for (const member of members) {
40366
+ const track = await ports.loadTrack(member.trackId);
40367
+ if (track !== null) lites.push(trackToLite(track));
40368
+ }
40369
+ return toGroupRecord({
40370
+ group,
40371
+ memberTrackIds: members.map((m) => m.trackId),
40372
+ tracks: lites,
40373
+ eventMediaBaseUrl: ports.eventMediaBaseUrl()
40374
+ });
40375
+ }
40376
+ //#endregion
40377
+ //#region src/pipeline-analytics/group/group-crop.ts
40378
+ /** Always containSubject: true. Never the 0.5 delivered threshold. */
40379
+ function groupWideCentralSquareLayout(unionBbox, frame) {
40380
+ return wideCentralSquareLayout(unionBbox, frame, { containSubject: true });
40381
+ }
40382
+ async function cropGroupFromRgb(rgb, frameWidth, frameHeight, unionBbox) {
40383
+ const layout = groupWideCentralSquareLayout(unionBbox, {
40384
+ W: frameWidth,
40385
+ H: frameHeight
40386
+ });
40387
+ const { crop: slab } = await extractCrop(rgb, frameWidth, frameHeight, layout.fetch);
40388
+ return composeWideCentralSquareThumbnail(slab, layout);
40389
+ }
40390
+ var MATCH_WINDOW_MS = 1e3;
40391
+ var MOVE_EPS = .35;
40392
+ /** Registry stationary is out; audio has no bbox and is never a member or a singleton. */
40393
+ function isEligibleMember(track) {
40394
+ if (track.closedReason === "stationary-promotion") return false;
40395
+ if (track.className === "audio") return false;
40396
+ return true;
40397
+ }
40398
+ function temporallyOverlap(a, b, slackMs) {
40399
+ return a.firstSeen <= b.lastSeen + slackMs && b.firstSeen <= a.lastSeen + slackMs;
40400
+ }
40401
+ /** Connected components of temporal overlap (+ slack) per camera. */
40402
+ function sessionsOf(tracks) {
40403
+ const byDevice = /* @__PURE__ */ new Map();
40404
+ for (const t of tracks) {
40405
+ const list = byDevice.get(t.deviceId);
40406
+ if (list) list.push(t);
40407
+ else byDevice.set(t.deviceId, [t]);
40408
+ }
40409
+ const sessions = [];
40410
+ for (const deviceTracks of byDevice.values()) {
40411
+ const n = deviceTracks.length;
40412
+ const parent = deviceTracks.map((_, i) => i);
40413
+ const find = (i) => {
40414
+ let cur = i;
40415
+ while (parent[cur] !== cur) cur = parent[cur];
40416
+ let walk = i;
40417
+ while (parent[walk] !== cur) {
40418
+ const next = parent[walk];
40419
+ parent[walk] = cur;
40420
+ walk = next;
40421
+ }
40422
+ return cur;
40423
+ };
40424
+ const union = (i, j) => {
40425
+ const ri = find(i);
40426
+ const rj = find(j);
40427
+ if (ri !== rj) parent[ri] = rj;
40428
+ };
40429
+ 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);
40430
+ const buckets = /* @__PURE__ */ new Map();
40431
+ for (let i = 0; i < n; i++) {
40432
+ const root = find(i);
40433
+ const bucket = buckets.get(root);
40434
+ if (bucket) bucket.push(deviceTracks[i]);
40435
+ else buckets.set(root, [deviceTracks[i]]);
40436
+ }
40437
+ sessions.push(...buckets.values());
40438
+ }
40439
+ return sessions;
40440
+ }
40441
+ function diag(bbox) {
40442
+ return Math.hypot(bbox.w, bbox.h);
40443
+ }
40444
+ function nearestPosition(positions, t) {
40445
+ let best;
40446
+ let bestDt = 1001;
40447
+ for (const p of positions) {
40448
+ const dt = Math.abs(p.timestamp - t);
40449
+ if (dt <= 1e3 && dt < bestDt) {
40450
+ best = p;
40451
+ bestDt = dt;
40452
+ }
40453
+ }
40454
+ return best;
40455
+ }
40456
+ function meanDiag(positions) {
40457
+ if (positions.length === 0) return 0;
40458
+ let sum = 0;
40459
+ for (const p of positions) sum += diag(p.bbox);
40460
+ return sum / positions.length;
40461
+ }
40462
+ function netDisplacement(track) {
40463
+ const first = track.positions[0];
40464
+ const last = track.positions[track.positions.length - 1];
40465
+ if (!first || !last) return {
40466
+ x: 0,
40467
+ y: 0
40468
+ };
40469
+ const a = bboxCentroid(first.bbox);
40470
+ const b = bboxCentroid(last.bbox);
40471
+ return {
40472
+ x: b.x - a.x,
40473
+ y: b.y - a.y
40474
+ };
40475
+ }
40476
+ function vecLen(v) {
40477
+ return Math.hypot(v.x, v.y);
40478
+ }
40479
+ function cosine(a, b) {
40480
+ const la = vecLen(a);
40481
+ const lb = vecLen(b);
40482
+ if (la === 0 || lb === 0) return 1;
40483
+ return (a.x * b.x + a.y * b.y) / (la * lb);
40484
+ }
40485
+ function relativeMotion(track) {
40486
+ const mean = meanDiag(track.positions);
40487
+ if (mean <= 0) return 0;
40488
+ return vecLen(netDisplacement(track)) / mean;
40489
+ }
40490
+ /** A ~ B iff cohesion ≥ 0.5 AND NOT (both move AND cos < 0.3). */
40491
+ function pairTogether(a, b) {
40492
+ const overlapStart = Math.max(a.firstSeen, b.firstSeen);
40493
+ const overlapEnd = Math.min(a.lastSeen, b.lastSeen);
40494
+ if (overlapEnd < overlapStart) return false;
40495
+ let joint = 0;
40496
+ let close = 0;
40497
+ for (let t = overlapStart; t <= overlapEnd; t += 500) {
40498
+ const pa = nearestPosition(a.positions, t);
40499
+ const pb = nearestPosition(b.positions, t);
40500
+ if (!pa || !pb) continue;
40501
+ joint++;
40502
+ const ca = bboxCentroid(pa.bbox);
40503
+ const cb = bboxCentroid(pb.bbox);
40504
+ const gap = Math.hypot(ca.x - cb.x, ca.y - cb.y);
40505
+ const scale = (diag(pa.bbox) + diag(pb.bbox)) / 2;
40506
+ if ((scale > 0 ? gap / scale : Number.POSITIVE_INFINITY) <= 1.5) close++;
40507
+ }
40508
+ if (joint === 0) return false;
40509
+ if (close / joint < .5) return false;
40510
+ const aMoves = relativeMotion(a) >= MOVE_EPS;
40511
+ const bMoves = relativeMotion(b) >= MOVE_EPS;
40512
+ if (aMoves && bMoves && cosine(netDisplacement(a), netDisplacement(b)) < .3) return false;
40513
+ return true;
40514
+ }
40515
+ function clusterGroups(tracks) {
40516
+ const eligible = tracks.filter(isEligibleMember);
40517
+ const out = [];
40518
+ for (const session of sessionsOf(eligible)) {
40519
+ const pairable = [];
40520
+ for (const t of session) if (t.positions.length < 2) out.push({
40521
+ deviceId: t.deviceId,
40522
+ memberTrackIds: [t.trackId]
40523
+ });
40524
+ else pairable.push(t);
40525
+ if (pairable.length === 0) continue;
40526
+ const parent = pairable.map((_, i) => i);
40527
+ const find = (i) => {
40528
+ let cur = i;
40529
+ while (parent[cur] !== cur) cur = parent[cur];
40530
+ let walk = i;
40531
+ while (parent[walk] !== cur) {
40532
+ const next = parent[walk];
40533
+ parent[walk] = cur;
40534
+ walk = next;
40535
+ }
40536
+ return cur;
40537
+ };
40538
+ const union = (i, j) => {
40539
+ const ri = find(i);
40540
+ const rj = find(j);
40541
+ if (ri !== rj) parent[ri] = rj;
40542
+ };
40543
+ 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);
40544
+ const buckets = /* @__PURE__ */ new Map();
40545
+ for (let i = 0; i < pairable.length; i++) {
40546
+ const root = find(i);
40547
+ const bucket = buckets.get(root);
40548
+ const id = pairable[i].trackId;
40549
+ if (bucket) bucket.push(id);
40550
+ else buckets.set(root, [id]);
40551
+ }
40552
+ const deviceId = pairable[0].deviceId;
40553
+ for (const memberTrackIds of buckets.values()) out.push({
40554
+ deviceId,
40555
+ memberTrackIds
40556
+ });
40557
+ }
40558
+ return out;
40559
+ }
40560
+ //#endregion
40561
+ //#region src/pipeline-analytics/group/group-instant.ts
40562
+ /**
40563
+ * Chosen instant + union bbox for a group, at close, over stored positions[]
40564
+ * (`docs/design/2026-08-21-group-entity-and-group-media.md` §4.1).
40565
+ *
40566
+ * Tier order — the first that separates two candidates decides:
40567
+ * 1. completeness (members with a position within ±1 s)
40568
+ * 2. square containment of the union (D212)
40569
+ * 3. semantic (a member's confirmed recognition)
40570
+ * 4. edge-clear (union touches no frame border)
40571
+ * 5. effective score = mean(confidence) × containment × sizeFactor
40572
+ *
40573
+ * Retrospective. No new capture path.
40574
+ */
40575
+ function nearest(positions, t, windowMs) {
40576
+ let best;
40577
+ let bestDt = windowMs + 1;
40578
+ for (const p of positions) {
40579
+ const dt = Math.abs(p.timestamp - t);
40580
+ if (dt <= windowMs && dt < bestDt) {
40581
+ best = p;
40582
+ bestDt = dt;
40583
+ }
40584
+ }
40585
+ return best;
40586
+ }
40587
+ function unionOf(boxes) {
40588
+ if (boxes.length === 0) return null;
40589
+ let x0 = Infinity;
40590
+ let y0 = Infinity;
40591
+ let x1 = -Infinity;
40592
+ let y1 = -Infinity;
40593
+ for (const b of boxes) {
40594
+ x0 = Math.min(x0, b.x);
40595
+ y0 = Math.min(y0, b.y);
40596
+ x1 = Math.max(x1, b.x + b.w);
40597
+ y1 = Math.max(y1, b.y + b.h);
40598
+ }
40599
+ return {
40600
+ x: x0,
40601
+ y: y0,
40602
+ w: x1 - x0,
40603
+ h: y1 - y0
40604
+ };
40605
+ }
40606
+ function touchesBorder(box, frame, tol = 1) {
40607
+ return box.x <= tol || box.y <= tol || box.x + box.w >= frame.width - tol || box.y + box.h >= frame.height - tol;
40608
+ }
40609
+ function scoreInstant(members, t, frame) {
40610
+ const boxes = [];
40611
+ let semantic = false;
40612
+ let confSum = 0;
40613
+ let confN = 0;
40614
+ for (const m of members) {
40615
+ const p = nearest(m.positions, t, MATCH_WINDOW_MS);
40616
+ if (!p) continue;
40617
+ boxes.push(p.bbox);
40618
+ if (m.semantic === true) semantic = true;
40619
+ if (m.confidence !== void 0) {
40620
+ confSum += m.confidence;
40621
+ confN += 1;
40622
+ }
40623
+ }
40624
+ const union = unionOf(boxes);
40625
+ if (!union) return null;
40626
+ const square = centralSquareContainmentFraction(union, {
40627
+ W: frame.width,
40628
+ H: frame.height
40629
+ });
40630
+ const containment = bboxContainment(union, frame.width, frame.height);
40631
+ const size = bboxSizeScore(union, frame.width, frame.height);
40632
+ const meanConf = confN > 0 ? confSum / confN : 1;
40633
+ return {
40634
+ timestamp: t,
40635
+ union,
40636
+ visibleCount: boxes.length,
40637
+ square,
40638
+ semantic,
40639
+ edgeClear: !touchesBorder(union, frame),
40640
+ effective: meanConf * containment * size
40641
+ };
40642
+ }
40643
+ function better(a, b) {
40644
+ if (a.visibleCount !== b.visibleCount) return a.visibleCount > b.visibleCount ? a : b;
40645
+ if (a.square !== b.square) return a.square > b.square ? a : b;
40646
+ if (a.semantic !== b.semantic) return a.semantic ? a : b;
40647
+ if (a.edgeClear !== b.edgeClear) return a.edgeClear ? a : b;
40648
+ if (a.effective !== b.effective) return a.effective > b.effective ? a : b;
40649
+ return a.timestamp <= b.timestamp ? a : b;
40650
+ }
40651
+ function chooseGroupInstant(members, frame) {
40652
+ if (members.length === 0) return null;
40653
+ const candidates = /* @__PURE__ */ new Set();
40654
+ for (const m of members) for (const p of m.positions) candidates.add(p.timestamp);
40655
+ let best = null;
40656
+ for (const t of candidates) {
40657
+ const scored = scoreInstant(members, t, frame);
40658
+ if (!scored) continue;
40659
+ best = best === null ? scored : better(scored, best);
40660
+ }
40661
+ if (!best) return null;
40662
+ const w = Math.max(frame.width, 1);
40663
+ const h = Math.max(frame.height, 1);
40664
+ return {
40665
+ timestamp: best.timestamp,
40666
+ union: best.union,
40667
+ ux: best.union.x / w,
40668
+ uy: best.union.y / h,
40669
+ uw: best.union.w / w,
40670
+ uh: best.union.h / h,
40671
+ visibleCount: best.visibleCount
40672
+ };
40673
+ }
40674
+ /** Longest-lived member that owns a clean full frame; ties break on trackId. */
40675
+ function chooseAnchorTrackId(members, ownsCleanFrame) {
40676
+ const eligible = members.filter((m) => ownsCleanFrame(m.trackId));
40677
+ if (eligible.length === 0) return null;
40678
+ return eligible.toSorted((a, b) => {
40679
+ const life = b.lastSeen - b.firstSeen - (a.lastSeen - a.firstSeen);
40680
+ return life !== 0 ? life : a.trackId.localeCompare(b.trackId);
40681
+ })[0].trackId;
40682
+ }
40683
+ //#endregion
40684
+ //#region src/pipeline-analytics/group/group-session.ts
40685
+ /**
40686
+ * Session close → sealed groups. A group is born here, never live.
40687
+ *
40688
+ * Session = temporal chain + 3 s slack, per camera. Close = every overlapping
40689
+ * track is persisted AND the newest lastSeen is older than the idle cutoff
40690
+ * (the Phase-1 burst's 30 s, reused — not a second clock).
40691
+ *
40692
+ * Merge and split cannot occur: connected components of a closed session is a
40693
+ * partition. Say it out loud so a later live-group attempt does not reach
40694
+ * for them here.
40695
+ */
40696
+ /** Same idle as the notification burst (`NC_GROUP_DEFAULT_IDLE_SEC`). */
40697
+ var GROUP_SESSION_IDLE_MS = 3e4;
40698
+ function overlaps(a, b) {
40699
+ return a.firstSeen <= b.lastSeen + 3e3 && b.firstSeen <= a.lastSeen + 3e3;
40700
+ }
40701
+ function sessionSpan(tracks) {
40702
+ return {
40703
+ firstSeen: Math.min(...tracks.map((t) => t.firstSeen)),
40704
+ lastSeen: Math.max(...tracks.map((t) => t.lastSeen))
40705
+ };
40706
+ }
40707
+ /**
40708
+ * Sessions whose members are all in `closed` and whose newest lastSeen is
40709
+ * older than `idleMs`. An overlapping ACTIVE track keeps the session open.
40710
+ */
40711
+ function sessionsReadyToSeal(closed, active, now, idleMs = GROUP_SESSION_IDLE_MS) {
40712
+ const byDevice = /* @__PURE__ */ new Map();
40713
+ for (const t of closed) {
40714
+ const list = byDevice.get(t.deviceId);
40715
+ if (list) list.push(t);
40716
+ else byDevice.set(t.deviceId, [t]);
40717
+ }
40718
+ const ready = [];
40719
+ for (const [deviceId, tracks] of byDevice) {
40720
+ const deviceActive = active.filter((a) => a.deviceId === deviceId);
40721
+ for (const session of sessionsOf(tracks)) {
40722
+ const span = sessionSpan(session);
40723
+ if (now < span.lastSeen + idleMs) continue;
40724
+ if (deviceActive.some((a) => overlaps(span, a))) continue;
40725
+ ready.push(session);
40726
+ }
40727
+ }
40728
+ return ready;
40729
+ }
40730
+ function partitionsForSealedSessions(closed, active, now, idleMs = GROUP_SESSION_IDLE_MS) {
40731
+ return clusterGroups(sessionsReadyToSeal(closed, active, now, idleMs).flat());
40732
+ }
40733
+ //#endregion
40734
+ //#region src/pipeline-analytics/group/group-sealer.ts
40735
+ var DEFAULT_FRAME = {
40736
+ width: 640,
40737
+ height: 360
40738
+ };
40739
+ var SEAL_LOOKBACK_MS = 10 * 6e4;
40740
+ var BACKFILL_LOOKBACK_MS = 2880 * 6e4;
40741
+ var QUERY_LIMIT = 2e3;
40742
+ function toInstant(t) {
40743
+ return {
40744
+ trackId: t.trackId,
40745
+ deviceId: t.deviceId,
40746
+ className: t.className,
40747
+ firstSeen: t.firstSeen,
40748
+ lastSeen: t.lastSeen,
40749
+ positions: t.positions,
40750
+ ...t.closedReason !== void 0 ? { closedReason: t.closedReason } : {},
40751
+ semantic: t.hasFace === true || t.hasPlate === true,
40752
+ confidence: t.importance
40753
+ };
40754
+ }
40755
+ var GroupSealer = class {
40756
+ deps;
40757
+ constructor(deps) {
40758
+ this.deps = deps;
40759
+ }
40760
+ async sealRecent() {
40761
+ const now = this.deps.now();
40762
+ return this.sealSince(now - SEAL_LOOKBACK_MS, now);
40763
+ }
40764
+ async backfill() {
40765
+ const now = this.deps.now();
40766
+ return this.sealSince(now - BACKFILL_LOOKBACK_MS, now);
40767
+ }
40768
+ async sealSince(since, now) {
40769
+ let written = 0;
40770
+ let devices;
40771
+ try {
40772
+ devices = await this.deps.trackStore.listDeviceIds();
40773
+ } catch (err) {
40774
+ this.deps.logger.warn("group sealer: listDeviceIds failed", { meta: { error: String(err) } });
40775
+ return 0;
40776
+ }
40777
+ for (const deviceId of devices) try {
40778
+ written += await this.sealDevice(deviceId, since, now);
40779
+ } catch (err) {
40780
+ this.deps.logger.warn("group sealer: device failed", {
40781
+ tags: { deviceId },
40782
+ meta: { error: String(err) }
40783
+ });
40784
+ }
40785
+ return written;
40786
+ }
40787
+ async sealDevice(deviceId, since, now) {
40788
+ const closed = await this.deps.trackStore.queryHistorical({
40789
+ deviceId,
40790
+ since,
40791
+ until: now,
40792
+ limit: QUERY_LIMIT,
40793
+ projection: "full"
40794
+ });
40795
+ if (closed.length === 0) return 0;
40796
+ const already = new Set((await this.deps.groupStore.membersOfTracks(closed.map((t) => t.trackId))).map((m) => m.trackId));
40797
+ const pending = closed.filter((t) => !already.has(t.trackId));
40798
+ if (pending.length === 0) return 0;
40799
+ const active = this.deps.trackStore.getActive(deviceId).map((t) => ({
40800
+ deviceId,
40801
+ firstSeen: t.firstSeen,
40802
+ lastSeen: t.lastSeen
40803
+ }));
40804
+ const partitions = partitionsForSealedSessions(pending.map(toInstant), active, now, GROUP_SESSION_IDLE_MS);
40805
+ if (partitions.length === 0) {
40806
+ 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", {
40807
+ tags: { deviceId },
40808
+ meta: {
40809
+ dropped: pending.length,
40810
+ reason: "no-eligible-members"
40811
+ }
40812
+ });
40813
+ return 0;
40814
+ }
40815
+ const byId = new Map(pending.map((t) => [t.trackId, t]));
40816
+ let written = 0;
40817
+ for (const part of partitions) {
40818
+ const members = part.memberTrackIds.map((id) => byId.get(id)).filter((t) => t !== void 0);
40819
+ if (members.length === 0) continue;
40820
+ if (members.every((m) => already.has(m.trackId))) continue;
40821
+ try {
40822
+ await this.persistPartition(members);
40823
+ written += 1;
40824
+ } catch (err) {
40825
+ this.deps.logger.warn("group sealer: persist failed", {
40826
+ tags: { deviceId },
40827
+ meta: {
40828
+ members: members.map((m) => m.trackId),
40829
+ error: String(err)
40830
+ }
40831
+ });
40832
+ }
40833
+ }
40834
+ return written;
40835
+ }
40836
+ async persistPartition(members) {
40837
+ const deviceId = members[0].deviceId;
40838
+ const frame = this.deps.frameDims(deviceId) ?? DEFAULT_FRAME;
40839
+ const instantMembers = members.map(toInstant);
40840
+ const instant = chooseGroupInstant(instantMembers, frame);
40841
+ const openedAt = Math.min(...members.map((m) => m.firstSeen));
40842
+ const closedAt = Math.max(...members.map((m) => m.lastSeen));
40843
+ const timestamp = instant?.timestamp ?? closedAt;
40844
+ const mediaInfos = await Promise.all(members.map(async (m) => ({
40845
+ trackId: m.trackId,
40846
+ rows: await this.deps.mediaStore.listInfoByOwner("track", m.trackId)
40847
+ })));
40848
+ const ownsClean = (trackId) => {
40849
+ return mediaInfos.find((m) => m.trackId === trackId)?.rows.some((r) => r.kind === "keyFrame" || r.kind === "keyFrameSmall") ?? false;
40850
+ };
40851
+ const anchorTrackId = members.length >= 2 ? chooseAnchorTrackId(instantMembers, ownsClean) : null;
40852
+ let nativeKey = null;
40853
+ if (anchorTrackId !== null) {
40854
+ const rows = mediaInfos.find((m) => m.trackId === anchorTrackId)?.rows ?? [];
40855
+ nativeKey = (rows.find((r) => r.kind === "keyFrame") ?? rows.find((r) => r.kind === "keyFrameSmall"))?.key ?? null;
40856
+ }
40857
+ const draft = await this.deps.groupStore.insert({
40858
+ deviceId,
40859
+ timestamp,
40860
+ openedAt,
40861
+ closedAt,
40862
+ members: members.map((m) => ({
40863
+ trackId: m.trackId,
40864
+ firstSeen: m.firstSeen
40865
+ })),
40866
+ anchorTrackId,
40867
+ ux: instant?.ux ?? 0,
40868
+ uy: instant?.uy ?? 0,
40869
+ uw: instant?.uw ?? 0,
40870
+ uh: instant?.uh ?? 0,
40871
+ frameWidth: frame.width,
40872
+ frameHeight: frame.height,
40873
+ mediaKey: null,
40874
+ nativeKey
40875
+ });
40876
+ if (members.length < 2 || instant === null) return;
40877
+ const source = await this.pickCleanPixels(mediaInfos, timestamp);
40878
+ if (source === null) return;
40879
+ try {
40880
+ const raw = await (0, sharp.default)(source.jpeg).removeAlpha().raw().toBuffer({ resolveWithObject: true });
40881
+ if (raw.info.channels !== 3) return;
40882
+ const union = {
40883
+ x: instant.ux * raw.info.width,
40884
+ y: instant.uy * raw.info.height,
40885
+ w: instant.uw * raw.info.width,
40886
+ h: instant.uh * raw.info.height
40887
+ };
40888
+ const crop = await cropGroupFromRgb(raw.data, raw.info.width, raw.info.height, union);
40889
+ const mediaKey = await this.deps.mediaStore.put({
40890
+ deviceId,
40891
+ ownerKind: "group",
40892
+ ownerId: draft.id,
40893
+ kind: "thumbnail",
40894
+ timestamp,
40895
+ data: crop
40896
+ });
40897
+ await this.deps.groupStore.setMediaKey(draft.id, mediaKey);
40898
+ } catch (err) {
40899
+ this.deps.logger.warn("group sealer: crop failed", {
40900
+ tags: { deviceId },
40901
+ meta: {
40902
+ groupId: draft.id,
40903
+ error: String(err)
40904
+ }
40905
+ });
40906
+ }
40907
+ }
40908
+ async pickCleanPixels(mediaInfos, instantMs) {
40909
+ const candidates = [];
40910
+ for (const info of mediaInfos) for (const row of info.rows) {
40911
+ const rank = row.kind === "keyFrame" ? 0 : row.kind === "keyFrameSmall" ? 1 : -1;
40912
+ if (rank < 0) continue;
40913
+ const dt = Math.abs(row.timestamp - instantMs);
40914
+ if (dt > 2e3) continue;
40915
+ candidates.push({
40916
+ key: row.key,
40917
+ dt,
40918
+ rank
40919
+ });
40920
+ }
40921
+ const chosen = candidates.toSorted((a, b) => a.rank - b.rank || a.dt - b.dt)[0];
40922
+ if (chosen === void 0) return null;
40923
+ const file = await this.deps.mediaStore.getByKey(chosen.key);
40924
+ if (file === null) return null;
40925
+ return { jpeg: Buffer.from(file.base64, "base64") };
40926
+ }
40927
+ };
40928
+ //#endregion
40929
+ //#region src/pipeline-analytics/group/group-store.ts
40930
+ /**
40931
+ * The group ENTITY store — a co-moving partition of one session, with one
40932
+ * picture of its own (`docs/design/2026-08-21-group-entity-and-group-media.md`).
40933
+ *
40934
+ * Two tables, two questions:
40935
+ *
40936
+ * - `pipeline-analytics:track-groups` is the group row. It has N members and
40937
+ * no `trackId` column; it is owned by its members (owner-indirection).
40938
+ * - `pipeline-analytics:track-group-members` is one row per member. The column
40939
+ * is named exactly `trackId` so the classification derives `track-owned`.
40940
+ *
40941
+ * `anchorTrackId` and `nativeKey` are the only fields mutable after birth:
40942
+ * they are nulled when the anchor member is deleted alone. Everything else is
40943
+ * written once. Re-anchoring is deliberately not done.
40944
+ *
40945
+ * No age clock. A group dies when its last member does; a group with one live
40946
+ * member does not, and it never keeps a dead link.
40947
+ */
40948
+ /**
40949
+ * @durable class=ledger owner=pipeline-analytics
40950
+ * write="one row per co-moving group at session close — sealed, never live.
40951
+ * Membership is a partition of one camera's eligible tracks. mediaKey is
40952
+ * the group's own crop; nativeKey is a POINTER to the anchor member's
40953
+ * keyFrame, nulled if that member is deleted alone. anchorTrackId and
40954
+ * nativeKey are the only fields rewritten after birth."
40955
+ * retention="NO age clock (D61). Rows leave with their last member through
40956
+ * TrackGroupStore.deleteByTracks: memberships first; zero survivors
40957
+ * deletes the group's media then the row; survivors plus a lost anchor
40958
+ * null the pointer. The orphan audit verifies the same via a second
40959
+ * resolver (groupId → any member still present)."
40960
+ */
40961
+ var TRACK_GROUPS_COLLECTION = "pipeline-analytics:track-groups";
40962
+ /**
40963
+ * @durable class=ledger owner=pipeline-analytics
40964
+ * write="one row per member at group birth — id is '<groupId>:<trackId>'.
40965
+ * timestamp is the member's firstSeen so the orphan audit has a clock
40966
+ * column. Never rewritten."
40967
+ * retention="TRACK-OWNED and clockless (D61): the column is named exactly
40968
+ * trackId, so classification derives ownership and the cascade deletes
40969
+ * the row with its track. A group with surviving members stays."
40970
+ */
40971
+ var TRACK_GROUP_MEMBERS_COLLECTION = "pipeline-analytics:track-group-members";
40972
+ var TRACK_GROUPS_COLUMNS = [
40973
+ {
40974
+ name: "id",
40975
+ type: "TEXT",
40976
+ primaryKey: true,
40977
+ notNull: true
40978
+ },
40979
+ {
40980
+ name: "deviceId",
40981
+ type: "INTEGER",
40982
+ notNull: true
40983
+ },
40984
+ {
40985
+ name: "timestamp",
40986
+ type: "INTEGER",
40987
+ notNull: true
40988
+ },
40989
+ {
40990
+ name: "openedAt",
40991
+ type: "INTEGER",
40992
+ notNull: true
40993
+ },
40994
+ {
40995
+ name: "closedAt",
40996
+ type: "INTEGER",
40997
+ notNull: true
40998
+ },
40999
+ {
41000
+ name: "memberCount",
41001
+ type: "INTEGER",
41002
+ notNull: true
41003
+ },
41004
+ {
41005
+ name: "anchorTrackId",
41006
+ type: "TEXT"
41007
+ },
41008
+ {
41009
+ name: "ux",
41010
+ type: "REAL",
41011
+ notNull: true
41012
+ },
41013
+ {
41014
+ name: "uy",
41015
+ type: "REAL",
41016
+ notNull: true
41017
+ },
41018
+ {
41019
+ name: "uw",
41020
+ type: "REAL",
41021
+ notNull: true
41022
+ },
41023
+ {
41024
+ name: "uh",
41025
+ type: "REAL",
41026
+ notNull: true
41027
+ },
41028
+ {
41029
+ name: "frameWidth",
41030
+ type: "INTEGER",
41031
+ notNull: true
41032
+ },
41033
+ {
41034
+ name: "frameHeight",
41035
+ type: "INTEGER",
41036
+ notNull: true
41037
+ },
41038
+ {
41039
+ name: "mediaKey",
41040
+ type: "TEXT"
41041
+ },
41042
+ {
41043
+ name: "nativeKey",
41044
+ type: "TEXT"
41045
+ },
41046
+ {
41047
+ name: "sealed",
41048
+ type: "BOOLEAN",
41049
+ notNull: true
41050
+ }
41051
+ ];
41052
+ var TRACK_GROUP_MEMBERS_COLUMNS = [
41053
+ {
41054
+ name: "id",
41055
+ type: "TEXT",
41056
+ primaryKey: true,
41057
+ notNull: true
41058
+ },
41059
+ {
41060
+ name: "groupId",
41061
+ type: "TEXT",
41062
+ notNull: true
41063
+ },
41064
+ {
41065
+ name: "trackId",
41066
+ type: "TEXT",
41067
+ notNull: true
41068
+ },
41069
+ {
41070
+ name: "deviceId",
41071
+ type: "INTEGER",
41072
+ notNull: true
41073
+ },
41074
+ {
41075
+ name: "timestamp",
41076
+ type: "INTEGER",
41077
+ notNull: true
41078
+ }
41079
+ ];
41080
+ var TRACK_GROUPS_INDEXES = [{
41081
+ name: "idx_track_groups_device_closed",
41082
+ columns: ["deviceId", "closedAt"]
41083
+ }, {
41084
+ name: "idx_track_groups_device_opened",
41085
+ columns: ["deviceId", "openedAt"]
41086
+ }];
41087
+ var TRACK_GROUP_MEMBERS_INDEXES = [{
41088
+ name: "idx_track_group_members_track",
41089
+ columns: ["trackId"]
41090
+ }, {
41091
+ name: "idx_track_group_members_group",
41092
+ columns: ["groupId"]
41093
+ }];
41094
+ function newGroupId() {
41095
+ return `grp-${(0, node_crypto.randomUUID)()}`;
41096
+ }
41097
+ function memberRowId(groupId, trackId) {
41098
+ return `${groupId}:${trackId}`;
41099
+ }
41100
+ var TrackGroupStore = class {
41101
+ store;
41102
+ logger;
41103
+ media;
41104
+ constructor(deps) {
41105
+ this.store = deps.store;
41106
+ this.logger = deps.logger;
41107
+ this.media = deps.media ?? null;
41108
+ }
41109
+ static async declare(store) {
41110
+ await Promise.all([store.declareCollection.mutate({
41111
+ collection: TRACK_GROUPS_COLLECTION,
41112
+ columns: [...TRACK_GROUPS_COLUMNS],
41113
+ indexes: [...TRACK_GROUPS_INDEXES]
41114
+ }), store.declareCollection.mutate({
41115
+ collection: TRACK_GROUP_MEMBERS_COLLECTION,
41116
+ columns: [...TRACK_GROUP_MEMBERS_COLUMNS],
41117
+ indexes: [...TRACK_GROUP_MEMBERS_INDEXES]
41118
+ })]);
41119
+ }
41120
+ async insert(input) {
41121
+ const id = newGroupId();
41122
+ const group = {
41123
+ id,
41124
+ deviceId: input.deviceId,
41125
+ timestamp: input.timestamp,
41126
+ openedAt: input.openedAt,
41127
+ closedAt: input.closedAt,
41128
+ memberCount: input.members.length,
41129
+ anchorTrackId: input.anchorTrackId,
41130
+ ux: input.ux,
41131
+ uy: input.uy,
41132
+ uw: input.uw,
41133
+ uh: input.uh,
41134
+ frameWidth: input.frameWidth,
41135
+ frameHeight: input.frameHeight,
41136
+ mediaKey: input.mediaKey,
41137
+ nativeKey: input.nativeKey,
41138
+ sealed: true
41139
+ };
41140
+ await this.store.set.mutate({
41141
+ collection: TRACK_GROUPS_COLLECTION,
41142
+ key: id,
41143
+ value: this.toRow(group)
41144
+ });
41145
+ for (const member of input.members) await this.store.set.mutate({
41146
+ collection: TRACK_GROUP_MEMBERS_COLLECTION,
41147
+ key: memberRowId(id, member.trackId),
41148
+ value: {
41149
+ groupId: id,
41150
+ trackId: member.trackId,
41151
+ deviceId: input.deviceId,
41152
+ timestamp: member.firstSeen
41153
+ }
41154
+ });
41155
+ return group;
41156
+ }
41157
+ /** The crop is written after birth; this is the only post-birth write besides the pointer null. */
41158
+ async setMediaKey(groupId, mediaKey) {
41159
+ const current = await this.get(groupId);
41160
+ if (current === null) return;
41161
+ await this.store.set.mutate({
41162
+ collection: TRACK_GROUPS_COLLECTION,
41163
+ key: groupId,
41164
+ value: this.toRow({
41165
+ ...current,
41166
+ mediaKey
41167
+ })
41168
+ });
41169
+ }
41170
+ async get(groupId) {
41171
+ const raw = await this.store.get.query({
41172
+ collection: TRACK_GROUPS_COLLECTION,
41173
+ key: groupId
41174
+ });
41175
+ if (!raw) return null;
41176
+ return this.normalizeGroup({
41177
+ id: groupId,
41178
+ data: raw
41179
+ });
41180
+ }
41181
+ async listMembers(groupId) {
41182
+ return (await this.store.query.query({
41183
+ collection: TRACK_GROUP_MEMBERS_COLLECTION,
41184
+ filter: { where: { groupId } }
41185
+ })).map((r) => this.normalizeMember({
41186
+ id: r.id,
41187
+ data: r.data
41188
+ }));
41189
+ }
41190
+ async membersOfTracks(trackIds) {
41191
+ const out = [];
41192
+ for (const trackId of trackIds) {
41193
+ const rows = await this.store.query.query({
41194
+ collection: TRACK_GROUP_MEMBERS_COLLECTION,
41195
+ filter: { where: { trackId } }
41196
+ });
41197
+ for (const r of rows) out.push(this.normalizeMember({
41198
+ id: r.id,
41199
+ data: r.data
41200
+ }));
41201
+ }
41202
+ return out;
41203
+ }
41204
+ /**
41205
+ * Newest-closed page for the Library Groups tab.
41206
+ *
41207
+ * Time filter is overlap (`closedAt >= since` and `openedAt <= until`),
41208
+ * same as {@link listOverlapping}. Pagination is applied AFTER the window
41209
+ * so a day cursor is honest rather than a raw-row offset that skips live
41210
+ * groups on a busy camera.
41211
+ */
41212
+ async listRecent(input) {
41213
+ const limit = Math.min(Math.max(input.limit ?? 40, 1), 100);
41214
+ const offset = Math.max(input.offset ?? 0, 0);
41215
+ const slice = (await this.store.query.query({
41216
+ collection: TRACK_GROUPS_COLLECTION,
41217
+ filter: {
41218
+ ...input.deviceId !== void 0 ? { where: { deviceId: input.deviceId } } : {},
41219
+ orderBy: {
41220
+ field: "closedAt",
41221
+ direction: "desc"
41222
+ },
41223
+ limit: 2e3
41224
+ }
41225
+ })).map((r) => this.normalizeGroup({
41226
+ id: r.id,
41227
+ data: r.data
41228
+ })).filter((g) => {
41229
+ if (input.sinceMs !== void 0 && g.closedAt < input.sinceMs) return false;
41230
+ if (input.untilMs !== void 0 && g.openedAt > input.untilMs) return false;
41231
+ return true;
41232
+ }).slice(offset, offset + limit + 1);
41233
+ return {
41234
+ groups: slice.slice(0, limit),
41235
+ hasMore: slice.length > limit
41236
+ };
41237
+ }
41238
+ /** Groups whose life overlaps `[since, until]` on one camera. */
41239
+ async listOverlapping(input) {
41240
+ return (await this.store.query.query({
41241
+ collection: TRACK_GROUPS_COLLECTION,
41242
+ filter: {
41243
+ where: { deviceId: input.deviceId },
41244
+ orderBy: {
41245
+ field: "closedAt",
41246
+ direction: "desc"
41247
+ },
41248
+ limit: input.limit ?? 2e3
41249
+ }
41250
+ })).map((r) => this.normalizeGroup({
41251
+ id: r.id,
41252
+ data: r.data
41253
+ })).filter((g) => g.closedAt >= input.sinceMs && g.openedAt <= input.untilMs);
41254
+ }
41255
+ async existingGroupIds(ids) {
41256
+ const live = /* @__PURE__ */ new Set();
41257
+ for (const id of ids) if (await this.store.get.query({
41258
+ collection: "pipeline-analytics:track-groups",
41259
+ key: id
41260
+ })) live.add(id);
41261
+ return live;
41262
+ }
41263
+ /** Group ids that still have at least one membership row. */
41264
+ async groupsWithMembers(ids) {
41265
+ const live = /* @__PURE__ */ new Set();
41266
+ for (const groupId of ids) if ((await this.store.query.query({
41267
+ collection: "pipeline-analytics:track-group-members",
41268
+ filter: {
41269
+ where: { groupId },
41270
+ limit: 1
41271
+ }
41272
+ })).length > 0) live.add(groupId);
41273
+ return live;
41274
+ }
41275
+ /**
41276
+ * Cascade leaf: delete memberships for the batch; a group with zero survivors
41277
+ * dies (media then row); a group that lost only its anchor nulls the pointer.
41278
+ */
41279
+ async deleteByTracks(trackIds) {
41280
+ const batch = new Set(trackIds);
41281
+ let memberRows = 0;
41282
+ let groupRows = 0;
41283
+ let mediaRows = 0;
41284
+ let mediaBytes = 0;
41285
+ const affected = /* @__PURE__ */ new Set();
41286
+ try {
41287
+ const memberships = await this.membersOfTracks(trackIds);
41288
+ for (const member of memberships) try {
41289
+ await this.store.delete.mutate({
41290
+ collection: TRACK_GROUP_MEMBERS_COLLECTION,
41291
+ key: member.id
41292
+ });
41293
+ memberRows += 1;
41294
+ affected.add(member.groupId);
41295
+ } catch (err) {
41296
+ this.logger.warn("TrackGroupStore.deleteByTracks member delete failed", { meta: {
41297
+ memberId: member.id,
41298
+ error: String(err)
41299
+ } });
41300
+ }
41301
+ for (const groupId of affected) try {
41302
+ if ((await this.listMembers(groupId)).length === 0) {
41303
+ if (this.media) {
41304
+ const gone = await this.media.deleteByGroups([groupId]);
41305
+ mediaRows += gone.rows;
41306
+ mediaBytes += gone.bytes;
41307
+ }
41308
+ await this.store.delete.mutate({
41309
+ collection: TRACK_GROUPS_COLLECTION,
41310
+ key: groupId
41311
+ });
41312
+ groupRows += 1;
41313
+ continue;
41314
+ }
41315
+ const group = await this.get(groupId);
41316
+ if (group?.anchorTrackId && batch.has(group.anchorTrackId)) await this.store.set.mutate({
41317
+ collection: TRACK_GROUPS_COLLECTION,
41318
+ key: groupId,
41319
+ value: this.toRow({
41320
+ ...group,
41321
+ anchorTrackId: null,
41322
+ nativeKey: null
41323
+ })
41324
+ });
41325
+ } catch (err) {
41326
+ this.logger.warn("TrackGroupStore.deleteByTracks group update failed", { meta: {
41327
+ groupId,
41328
+ error: String(err)
41329
+ } });
41330
+ }
41331
+ } catch (err) {
41332
+ this.logger.warn("TrackGroupStore.deleteByTracks failed", { meta: { error: String(err) } });
41333
+ }
41334
+ return [{
41335
+ family: "groups",
41336
+ rows: memberRows + groupRows,
41337
+ bytes: 0
41338
+ }, {
41339
+ family: "media",
41340
+ rows: mediaRows,
41341
+ bytes: mediaBytes
41342
+ }];
41343
+ }
41344
+ toRow(group) {
41345
+ return {
41346
+ deviceId: group.deviceId,
41347
+ timestamp: group.timestamp,
41348
+ openedAt: group.openedAt,
41349
+ closedAt: group.closedAt,
41350
+ memberCount: group.memberCount,
41351
+ anchorTrackId: group.anchorTrackId,
41352
+ ux: group.ux,
41353
+ uy: group.uy,
41354
+ uw: group.uw,
41355
+ uh: group.uh,
41356
+ frameWidth: group.frameWidth,
41357
+ frameHeight: group.frameHeight,
41358
+ mediaKey: group.mediaKey,
41359
+ nativeKey: group.nativeKey,
41360
+ sealed: group.sealed
41361
+ };
41362
+ }
41363
+ normalizeGroup(r) {
41364
+ const data = r.data;
41365
+ return {
41366
+ id: r.id,
41367
+ deviceId: Number(data["deviceId"]),
41368
+ timestamp: Number(data["timestamp"]),
41369
+ openedAt: Number(data["openedAt"]),
41370
+ closedAt: Number(data["closedAt"]),
41371
+ memberCount: Number(data["memberCount"]),
41372
+ anchorTrackId: typeof data["anchorTrackId"] === "string" ? data["anchorTrackId"] : null,
41373
+ ux: Number(data["ux"]),
41374
+ uy: Number(data["uy"]),
41375
+ uw: Number(data["uw"]),
41376
+ uh: Number(data["uh"]),
41377
+ frameWidth: Number(data["frameWidth"]),
41378
+ frameHeight: Number(data["frameHeight"]),
41379
+ mediaKey: typeof data["mediaKey"] === "string" ? data["mediaKey"] : null,
41380
+ nativeKey: typeof data["nativeKey"] === "string" ? data["nativeKey"] : null,
41381
+ sealed: Boolean(data["sealed"])
41382
+ };
41383
+ }
41384
+ normalizeMember(r) {
41385
+ const data = r.data;
41386
+ return {
41387
+ id: r.id,
41388
+ groupId: String(data["groupId"]),
41389
+ trackId: String(data["trackId"]),
41390
+ deviceId: Number(data["deviceId"]),
41391
+ timestamp: Number(data["timestamp"])
41392
+ };
41393
+ }
41394
+ };
40134
41395
  //#endregion
40135
41396
  //#region src/pipeline-analytics/pipeline/dropout-skip.ts
40136
41397
  function shouldSkipDropoutFrame(activeTrackCount, detectionCount, consecutiveSkips, cfg) {
@@ -46748,6 +48009,15 @@ function isAfterCursor(row, cursor) {
46748
48009
  if (row.lastSeen < cursor.lastSeen) return true;
46749
48010
  return row.lastSeen === cursor.lastSeen && row.id < cursor.trackId;
46750
48011
  }
48012
+ function netDisplacementPx(positions) {
48013
+ const first = positions[0];
48014
+ const last = positions[positions.length - 1];
48015
+ if (!first || !last) return 0;
48016
+ return Math.hypot(last.x - first.x, last.y - first.y);
48017
+ }
48018
+ function isShortMotionlessTrack(lifeMs, movedPx) {
48019
+ return lifeMs <= 25e3 && movedPx <= 8;
48020
+ }
46751
48021
  //#endregion
46752
48022
  //#region src/pipeline-analytics/store/track-store.ts
46753
48023
  /**
@@ -47330,6 +48600,7 @@ var TrackStore = class {
47330
48600
  for (const [trackId, t] of this.active) {
47331
48601
  if (nowMs - t.lastSeen < this.config.ttlMs) continue;
47332
48602
  t.active = false;
48603
+ if (t.positions.length > 0 && isShortMotionlessTrack(t.lastSeen - t.firstSeen, netDisplacementPx(t.positions))) t.closedReason = "stationary-promotion";
47333
48604
  const record = cloneTrack(t);
47334
48605
  try {
47335
48606
  await this.persistCompleted(record);
@@ -48878,12 +50149,24 @@ var ANALYTICS_COLLECTIONS = [
48878
50149
  collection: PLATES_COLLECTION,
48879
50150
  classification: derived(PLATE_COLUMNS)
48880
50151
  },
50152
+ {
50153
+ collection: TRACK_GROUP_MEMBERS_COLLECTION,
50154
+ classification: derived(TRACK_GROUP_MEMBERS_COLUMNS)
50155
+ },
48881
50156
  {
48882
50157
  collection: MEDIA_COLLECTION,
48883
50158
  classification: {
48884
50159
  kind: "owner-indirection",
48885
50160
  derivation: "explicit",
48886
- 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."
50161
+ 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."
50162
+ }
50163
+ },
50164
+ {
50165
+ collection: TRACK_GROUPS_COLLECTION,
50166
+ classification: {
50167
+ kind: "owner-indirection",
50168
+ derivation: "explicit",
50169
+ 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."
48887
50170
  }
48888
50171
  },
48889
50172
  {
@@ -48995,7 +50278,7 @@ var ANALYTICS_COLLECTIONS = [
48995
50278
  collection: SCENE_LATCH_COLLECTION,
48996
50279
  classification: {
48997
50280
  kind: "never-orphaned",
48998
- 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`."
50281
+ 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`)."
48999
50282
  }
49000
50283
  },
49001
50284
  {
@@ -49083,6 +50366,10 @@ function resolveMediaOwner(row) {
49083
50366
  kind: "unresolvable",
49084
50367
  why: `plate-owned media whose ownerId does not carry the '${PLATE_MEDIA_OWNER_PREFIX}' prefix`
49085
50368
  };
50369
+ case "group": return {
50370
+ kind: "group",
50371
+ groupId: ownerId
50372
+ };
49086
50373
  default: return {
49087
50374
  kind: "unresolvable",
49088
50375
  why: `unknown ownerKind '${kindRaw}'`
@@ -49094,6 +50381,13 @@ function resolveMediaOwner(row) {
49094
50381
  * is derived the same way the classification is — an enrolled row is out of
49095
50382
  * reach regardless of what happened to the track that produced it.
49096
50383
  */
50384
+ /** A group row lives iff any membership row still names it. */
50385
+ function resolveGroupOwner(row) {
50386
+ return {
50387
+ kind: "group-members",
50388
+ groupId: row.key
50389
+ };
50390
+ }
49097
50391
  function resolveDeclaredTrackOwner(row, enrolmentExempt) {
49098
50392
  if (enrolmentExempt && row.data["assigned"] === true) return {
49099
50393
  kind: "exempt",
@@ -49113,15 +50407,28 @@ function buildOrphanScopes(collections = auditableCollections()) {
49113
50407
  const scopes = [];
49114
50408
  for (const entry of collections) {
49115
50409
  if (entry.classification.kind === "owner-indirection") {
49116
- if (entry.collection !== "pipeline-analytics:media") continue;
49117
- scopes.push({
49118
- id: entry.collection,
49119
- collection: entry.collection,
49120
- resolve: resolveMediaOwner,
49121
- timestampField: "timestamp",
49122
- sizeField: "sizeBytes",
49123
- deviceIdField: "deviceId"
49124
- });
50410
+ if (entry.collection === "pipeline-analytics:media") {
50411
+ scopes.push({
50412
+ id: entry.collection,
50413
+ collection: entry.collection,
50414
+ resolve: resolveMediaOwner,
50415
+ timestampField: "timestamp",
50416
+ sizeField: "sizeBytes",
50417
+ deviceIdField: "deviceId"
50418
+ });
50419
+ continue;
50420
+ }
50421
+ if (entry.collection === "pipeline-analytics:track-groups") {
50422
+ scopes.push({
50423
+ id: entry.collection,
50424
+ collection: entry.collection,
50425
+ resolve: resolveGroupOwner,
50426
+ timestampField: "timestamp",
50427
+ sizeField: null,
50428
+ deviceIdField: "deviceId"
50429
+ });
50430
+ continue;
50431
+ }
49125
50432
  continue;
49126
50433
  }
49127
50434
  if (entry.classification.kind !== "track-owned") continue;
@@ -49153,11 +50460,17 @@ var DEFAULT_ORPHAN_LIMITS = {
49153
50460
  function questionsFor(refs) {
49154
50461
  const trackIds = /* @__PURE__ */ new Set();
49155
50462
  const eventIds = /* @__PURE__ */ new Set();
50463
+ const groupIds = /* @__PURE__ */ new Set();
50464
+ const groupMemberIds = /* @__PURE__ */ new Set();
49156
50465
  for (const ref of refs) if (ref.kind === "track") trackIds.add(ref.trackId);
49157
50466
  else if (ref.kind === "event") eventIds.add(ref.eventId);
50467
+ else if (ref.kind === "group") groupIds.add(ref.groupId);
50468
+ else if (ref.kind === "group-members") groupMemberIds.add(ref.groupId);
49158
50469
  return {
49159
50470
  trackIds: [...trackIds],
49160
- eventIds: [...eventIds]
50471
+ eventIds: [...eventIds],
50472
+ groupIds: [...groupIds],
50473
+ groupMemberIds: [...groupMemberIds]
49161
50474
  };
49162
50475
  }
49163
50476
  /**
@@ -49189,12 +50502,16 @@ async function auditScope(scope, deps, limits, cursor) {
49189
50502
  break;
49190
50503
  }
49191
50504
  const refs = rows.map((row) => scope.resolve(row));
49192
- const { trackIds, eventIds } = questionsFor(refs);
50505
+ const { trackIds, eventIds, groupIds, groupMemberIds } = questionsFor(refs);
49193
50506
  let liveTracks;
49194
50507
  let liveEvents;
50508
+ let liveGroups;
50509
+ let liveGroupsWithMembers;
49195
50510
  try {
49196
50511
  liveTracks = trackIds.length > 0 ? await deps.liveTrackIds(trackIds) : /* @__PURE__ */ new Set();
49197
50512
  liveEvents = eventIds.length > 0 ? await deps.liveEventIds(eventIds) : /* @__PURE__ */ new Set();
50513
+ liveGroups = groupIds.length > 0 && deps.liveGroupIds ? await deps.liveGroupIds(groupIds) : /* @__PURE__ */ new Set();
50514
+ liveGroupsWithMembers = groupMemberIds.length > 0 && deps.liveGroupsWithMembers ? await deps.liveGroupsWithMembers(groupMemberIds) : /* @__PURE__ */ new Set();
49198
50515
  } catch (err) {
49199
50516
  deps.logger.warn("orphan audit: liveness probe failed — scope abandoned for this run", { meta: {
49200
50517
  collection: scope.collection,
@@ -49218,7 +50535,7 @@ async function auditScope(scope, deps, limits, cursor) {
49218
50535
  tally.unresolvable += 1;
49219
50536
  continue;
49220
50537
  }
49221
- if (ref.kind === "track" ? liveTracks.has(ref.trackId) : liveEvents.has(ref.eventId)) continue;
50538
+ 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;
49222
50539
  const ts = num(row.data, scope.timestampField);
49223
50540
  if (ts !== null && ts > graceFloor) {
49224
50541
  tally.withinGrace += 1;
@@ -51310,6 +52627,13 @@ function squareSafeCropRegionNormalized(bbox, frame, padding) {
51310
52627
  };
51311
52628
  }
51312
52629
  //#endregion
52630
+ //#region src/pipeline-analytics/pipeline/usable-bbox.ts
52631
+ /** A box that can legally drive a crop. The leaving-event fallback
52632
+ * `{0,0,0,0}` must never become a face/plate JPEG. */
52633
+ function isUsableBbox(bbox) {
52634
+ return bbox.w > 0 && bbox.h > 0;
52635
+ }
52636
+ //#endregion
51313
52637
  //#region src/pipeline-analytics/services/event-media-dispatcher.ts
51314
52638
  /**
51315
52639
  * Long-side cap (px) for the boxed timeline tiles — `firstFrame`, `snapshot`,
@@ -51888,26 +53212,29 @@ var EventMediaDispatcher = class {
51888
53212
  }
51889
53213
  }
51890
53214
  async writeEventMedia(deviceId, frameHandle, frameData, fw, fh, ev, cropPadding) {
51891
- if (ev.childCrops) for (const child of ev.childCrops) try {
51892
- const childCropData = await this.cropSubjectRegion(deviceId, frameHandle, frameData, fw, fh, child.bbox, cropPadding, `${ev.eventId}:${child.kind}`);
51893
- if (!childCropData) continue;
51894
- await this.deps.mediaStore.put({
51895
- deviceId,
51896
- ownerKind: "event",
51897
- ownerId: ev.eventId,
51898
- kind: child.kind,
51899
- timestamp: ev.timestamp,
51900
- data: childCropData
51901
- });
51902
- } catch (err) {
51903
- this.deps.logger.warn(`event media: ${child.kind} failed`, {
51904
- tags: { deviceId },
51905
- meta: {
53215
+ if (ev.childCrops) for (const child of ev.childCrops) {
53216
+ if (!isUsableBbox(child.bbox)) continue;
53217
+ try {
53218
+ const childCropData = await this.cropSubjectRegion(deviceId, frameHandle, frameData, fw, fh, child.bbox, cropPadding, `${ev.eventId}:${child.kind}`);
53219
+ if (!childCropData) continue;
53220
+ await this.deps.mediaStore.put({
51906
53221
  deviceId,
51907
- eventId: ev.eventId,
51908
- error: err instanceof Error ? err.message : String(err)
51909
- }
51910
- });
53222
+ ownerKind: "event",
53223
+ ownerId: ev.eventId,
53224
+ kind: child.kind,
53225
+ timestamp: ev.timestamp,
53226
+ data: childCropData
53227
+ });
53228
+ } catch (err) {
53229
+ this.deps.logger.warn(`event media: ${child.kind} failed`, {
53230
+ tags: { deviceId },
53231
+ meta: {
53232
+ deviceId,
53233
+ eventId: ev.eventId,
53234
+ error: err instanceof Error ? err.message : String(err)
53235
+ }
53236
+ });
53237
+ }
51911
53238
  }
51912
53239
  }
51913
53240
  async writeTrackFrame(deviceId, frameHandle, frameData, fw, fh, tf) {
@@ -55168,7 +56495,8 @@ var customActions = {
55168
56495
  ...orphanAuditActions,
55169
56496
  ...viewerSettingsActions,
55170
56497
  ...photoEnrollActions,
55171
- ...faceRescoreActions
56498
+ ...faceRescoreActions,
56499
+ ...groupActions
55172
56500
  };
55173
56501
  /**
55174
56502
  * Assist threshold when the caller does not pin one. Packages have their own
@@ -55347,11 +56675,6 @@ function decodeEmbeddingBase64(base64) {
55347
56675
  const view = new Float32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));
55348
56676
  return Array.from(view);
55349
56677
  }
55350
- /** A track shorter than this is a candidate phantom, not a subject that came
55351
- * and went. Brackets the observed plant tracks (3.8-19.0 s). */
55352
- var SHORT_TRACK_MAX_MS = 25e3;
55353
- /** Total displacement under this is "did not move at all" (observed: 0-2.5 px). */
55354
- var MOTIONLESS_MAX_PX = 8;
55355
56678
  /** Grid the spawn point is quantised to, so respawns whose boxes never repeat
55356
56679
  * to the pixel still land in one cell. */
55357
56680
  var PHANTOM_CELL_PX = 32;
@@ -55415,6 +56738,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
55415
56738
  faceStore = null;
55416
56739
  faceRecognizer = null;
55417
56740
  plateStore = null;
56741
+ groupStore = null;
56742
+ groupSealer = null;
55418
56743
  plateRecognizer = null;
55419
56744
  objectEmbeddingStore = null;
55420
56745
  /** Live state of the detached embedding rebuild — see `rebuildObjectEmbeddings`. */
@@ -56232,7 +57557,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
56232
57557
  ...orphanAuditActions,
56233
57558
  ...viewerSettingsActions,
56234
57559
  ...photoEnrollActions,
56235
- ...faceRescoreActions
57560
+ ...faceRescoreActions,
57561
+ ...groupActions
56236
57562
  },
56237
57563
  actionHandlers: {
56238
57564
  ...ncHandlers,
@@ -56240,6 +57566,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
56240
57566
  ...this.buildDebugActionHandlers(),
56241
57567
  ...this.buildPhotoEnrollActionHandlers(),
56242
57568
  ...this.buildFaceRescoreActionHandlers(),
57569
+ ...this.buildGroupActionHandlers(),
56243
57570
  ...this.viewerSettingsSnapshots ? makeViewerSettingsActionHandlers(this.viewerSettingsSnapshots) : {}
56244
57571
  }
56245
57572
  } : {}
@@ -56442,6 +57769,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
56442
57769
  SceneStore.declare(api.settingsStore),
56443
57770
  NotificationCenter.declare(api.settingsStore),
56444
57771
  ViewerSettingsSnapshotStore.declare(api.settingsStore),
57772
+ TrackGroupStore.declare(api.settingsStore),
56445
57773
  declareMigrationMarkers(api.settingsStore)
56446
57774
  ]);
56447
57775
  this.viewerSettingsSnapshots = new ViewerSettingsSnapshotStore(api.settingsStore);
@@ -56726,6 +58054,33 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
56726
58054
  logger: logger.child("PlateStore")
56727
58055
  });
56728
58056
  this.plateStore = plateStore;
58057
+ const vehicleStore = new VehicleStore({
58058
+ store: api.settingsStore,
58059
+ logger: logger.child("VehicleStore")
58060
+ });
58061
+ const groupStore = new TrackGroupStore({
58062
+ store: api.settingsStore,
58063
+ logger: logger.child("TrackGroupStore"),
58064
+ media: mediaStore
58065
+ });
58066
+ this.groupStore = groupStore;
58067
+ this.groupSealer = new GroupSealer({
58068
+ trackStore,
58069
+ groupStore,
58070
+ mediaStore,
58071
+ logger: logger.child("GroupSealer"),
58072
+ frameDims: (deviceId) => {
58073
+ const dims = this.lastFrameDimsByDevice.get(deviceId);
58074
+ return dims ? {
58075
+ width: dims.w,
58076
+ height: dims.h
58077
+ } : void 0;
58078
+ },
58079
+ now: () => Date.now()
58080
+ });
58081
+ this.groupSealer.backfill().catch((err) => {
58082
+ logger.warn("group sealer backfill failed", { meta: { error: require_dist.errMsg(err) } });
58083
+ });
56729
58084
  return {
56730
58085
  trackStore,
56731
58086
  mediaStore,
@@ -56733,10 +58088,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
56733
58088
  identityStore,
56734
58089
  faceStore,
56735
58090
  plateStore,
56736
- vehicleStore: new VehicleStore({
56737
- store: api.settingsStore,
56738
- logger: logger.child("VehicleStore")
56739
- })
58091
+ vehicleStore,
58092
+ groupStore
56740
58093
  };
56741
58094
  }
56742
58095
  /** Face + plate recognizers, the durable keyFrame-derived plate crop and the
@@ -57170,29 +58523,65 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
57170
58523
  buildSummaryPorts(stores) {
57171
58524
  return {
57172
58525
  listTracks: async ({ deviceId, sinceMs, untilMs, limit }) => {
57173
- const tracks = await stores.trackStore.queryHistorical({
58526
+ const groups = await stores.groupStore.listOverlapping({
58527
+ deviceId,
58528
+ sinceMs,
58529
+ untilMs,
58530
+ limit
58531
+ });
58532
+ const windowTracks = await stores.trackStore.queryHistorical({
57174
58533
  deviceId,
57175
58534
  since: sinceMs,
57176
58535
  until: untilMs,
57177
58536
  projection: "slim",
57178
- limit
58537
+ limit: Math.max(limit, 2e3)
57179
58538
  });
58539
+ const trackById = new Map(windowTracks.map((t) => [t.trackId, t]));
57180
58540
  const { minHits } = await this.resolveDeviceDetectionSensitivitySettings(deviceId);
57181
- return tracks.map((t) => ({
57182
- trackId: t.trackId,
57183
- deviceId: t.deviceId,
57184
- className: t.className,
57185
- firstSeen: t.firstSeen,
57186
- lastSeen: t.lastSeen,
57187
- ...t.importance !== void 0 ? { importance: t.importance } : {},
57188
- zones: [...t.zonesVisited],
57189
- ...t.classes !== void 0 ? { classes: [...t.classes] } : {},
57190
- ...t.closedReason === "stationary-promotion" ? { stationary: true } : {},
57191
- ...t.observations !== void 0 ? { confirmed: t.observations >= minHits } : {}
57192
- }));
58541
+ const out = [];
58542
+ for (const group of groups) {
58543
+ const members = await stores.groupStore.listMembers(group.id);
58544
+ const tracks = members.map((m) => trackById.get(m.trackId)).filter((t) => t !== void 0);
58545
+ if (tracks.length === 0) continue;
58546
+ const classes = [...new Set(tracks.flatMap((t) => [t.className, ...t.classes ?? []]))];
58547
+ const zones = [...new Set(tracks.flatMap((t) => [...t.zonesVisited]))];
58548
+ const importance = Math.max(0, ...tracks.map((t) => t.importance ?? 0));
58549
+ const primary = tracks.reduce((best, t) => (t.importance ?? 0) > (best.importance ?? 0) ? t : best);
58550
+ out.push({
58551
+ trackId: group.id,
58552
+ groupId: group.id,
58553
+ memberTrackIds: members.map((m) => m.trackId),
58554
+ deviceId,
58555
+ className: primary.className,
58556
+ firstSeen: group.openedAt,
58557
+ lastSeen: group.closedAt,
58558
+ importance,
58559
+ zones,
58560
+ classes,
58561
+ ...tracks.every((t) => t.closedReason === "stationary-promotion") ? { stationary: true } : {},
58562
+ confirmed: tracks.some((t) => t.observations !== void 0 && t.observations >= minHits)
58563
+ });
58564
+ }
58565
+ return out;
57193
58566
  },
57194
- listMediaInfo: async (trackId) => {
57195
- return (await stores.mediaStore.listInfoByOwner("track", trackId)).map((r) => ({
58567
+ listMediaInfo: async (id) => {
58568
+ const group = await stores.groupStore.get(id);
58569
+ if (group?.mediaKey) return [{
58570
+ key: group.mediaKey,
58571
+ kind: "groupCrop",
58572
+ timestamp: group.timestamp
58573
+ }];
58574
+ if (group && group.memberCount === 1) {
58575
+ const memberId = (await stores.groupStore.listMembers(group.id))[0]?.trackId;
58576
+ if (!memberId) return [];
58577
+ return (await stores.mediaStore.listInfoByOwner("track", memberId)).map((r) => ({
58578
+ key: r.key,
58579
+ kind: r.kind,
58580
+ timestamp: r.timestamp
58581
+ }));
58582
+ }
58583
+ if (group) return [];
58584
+ return (await stores.mediaStore.listInfoByOwner("track", id)).map((r) => ({
57196
58585
  key: r.key,
57197
58586
  kind: r.kind,
57198
58587
  timestamp: r.timestamp
@@ -59216,8 +60605,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
59216
60605
  const first = track.positions?.[0];
59217
60606
  if (!first) return;
59218
60607
  const lifeMs = track.lastSeen - track.firstSeen;
59219
- const moved = track.totalDistance ?? 0;
59220
- if (!(lifeMs <= SHORT_TRACK_MAX_MS && moved <= MOTIONLESS_MAX_PX)) {
60608
+ const moved = netDisplacementPx(track.positions ?? []);
60609
+ if (!isShortMotionlessTrack(lifeMs, moved)) {
59221
60610
  this.phantomCells?.noteMovingClose({
59222
60611
  deviceId: track.deviceId,
59223
60612
  className: track.className,
@@ -59360,7 +60749,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
59360
60749
  deviceId,
59361
60750
  trackId,
59362
60751
  timestamp: frame.timestamp,
59363
- bbox: { ...frame.bbox },
60752
+ bbox: { ...bboxForSemanticPromotion(d.className, d.bbox, frame.bbox) },
59364
60753
  frameWidth: frame.frameWidth,
59365
60754
  frameHeight: frame.frameHeight,
59366
60755
  kind: d.className === "plate" ? "plate" : "classification",
@@ -59793,7 +61182,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
59793
61182
  retries.push({
59794
61183
  trackId: t.trackId,
59795
61184
  timestamp,
59796
- bbox: { ...t.bbox },
61185
+ bbox: firstFrameRetryBbox(t.bbox, this.trackStore?.peekActive(t.trackId)?.positions[0]?.bbox),
59797
61186
  ...patchDisplayLabel(t.labelPatch) !== void 0 ? { label: patchDisplayLabel(t.labelPatch) } : {}
59798
61187
  });
59799
61188
  }
@@ -59850,7 +61239,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
59850
61239
  lastFrameAt: this.residents.lastFrameAt(t.trackId) ?? 0,
59851
61240
  now: timestamp,
59852
61241
  intervalMs: media.snapshotIntervalMs,
59853
- lastFrameIntervalMs: Math.min(LAST_FRAME_INTERVAL_MS, media.snapshotIntervalMs)
61242
+ lastFrameIntervalMs: Math.min(LAST_FRAME_INTERVAL_MS, media.snapshotIntervalMs),
61243
+ firstFrameLanded: this.residents.isFirstFrameLanded(t.trackId)
59854
61244
  });
59855
61245
  const rollingLastFrame = plan.rollingLastFrame && !this.captureScheduler.isKeyActive(captureCoalesceKey(deviceId, "lastFrame", t.trackId));
59856
61246
  if (plan.appendSnapshot) this.trackStore.markSnapshotPending(t.trackId, timestamp, t.bbox);
@@ -60359,6 +61749,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
60359
61749
  async sweepExpiredTracks() {
60360
61750
  if (this.shuttingDown || !this.trackStore) return;
60361
61751
  await this.trackCloser.sweep();
61752
+ this.groupSealer?.sealRecent();
60362
61753
  }
60363
61754
  /** Unified retention (Phase 1): earliest retained footage per device — the
60364
61755
  * follow-recordings cutoff source. Cached 10 min: availability answers in
@@ -61637,6 +63028,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
61637
63028
  },
61638
63029
  liveTrackIds: (ids) => trackStore.existingTrackIds(ids),
61639
63030
  liveEventIds: (ids) => eventStore.existingEventIds(ids),
63031
+ liveGroupIds: (ids) => this.groupStore?.existingGroupIds(ids) ?? Promise.resolve(/* @__PURE__ */ new Set()),
63032
+ liveGroupsWithMembers: (ids) => this.groupStore?.groupsWithMembers(ids) ?? Promise.resolve(/* @__PURE__ */ new Set()),
61640
63033
  reclaim: async (collection, key) => {
61641
63034
  if (collection === "pipeline-analytics:media") {
61642
63035
  await mediaStore.deleteByKey(key);
@@ -62360,6 +63753,36 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
62360
63753
  buildFaceRescoreActionHandlers() {
62361
63754
  return { "face.rescoreTrack": (input) => this.rescoreFaceForTrack(input) };
62362
63755
  }
63756
+ buildGroupActionHandlers() {
63757
+ const ports = () => {
63758
+ const groupStore = this.groupStore;
63759
+ if (groupStore === null) return null;
63760
+ return {
63761
+ groupStore,
63762
+ loadTrack: async (trackId) => {
63763
+ const tracks = this.trackStore;
63764
+ if (!tracks) return null;
63765
+ return tracks.getActiveByTrack(trackId) ?? await tracks.getPersistedByTrackId(trackId);
63766
+ },
63767
+ eventMediaBaseUrl: () => this.eventMediaBaseUrl
63768
+ };
63769
+ };
63770
+ return {
63771
+ "analytics.listGroups": async (input) => {
63772
+ const ready = ports();
63773
+ if (ready === null) return {
63774
+ groups: [],
63775
+ hasMore: false
63776
+ };
63777
+ return listGroupRecords(ready, input);
63778
+ },
63779
+ "analytics.getGroup": async (input) => {
63780
+ const ready = ports();
63781
+ if (ready === null) return { group: null };
63782
+ return { group: await getGroupDetail(ready, input.groupId) };
63783
+ }
63784
+ };
63785
+ }
62363
63786
  buildPhotoEnrollActionHandlers() {
62364
63787
  return {
62365
63788
  "photo.analyze": async (input) => {
@@ -62590,7 +64013,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
62590
64013
  mediaStore,
62591
64014
  faceStore: this.faceStore,
62592
64015
  plateStore: this.plateStore,
62593
- objectEmbeddingStore: this.objectEmbeddingStore
64016
+ objectEmbeddingStore: this.objectEmbeddingStore,
64017
+ groupStore: this.groupStore
62594
64018
  }),
62595
64019
  trackStore
62596
64020
  };
@@ -62739,7 +64163,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
62739
64163
  media: 0,
62740
64164
  faces: 0,
62741
64165
  plates: 0,
62742
- embeddings: 0
64166
+ embeddings: 0,
64167
+ groups: 0
62743
64168
  };
62744
64169
  this.lastPruneBytes = 0;
62745
64170
  const cascade = this.buildTrackCascadeRegistry();
@@ -62799,7 +64224,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
62799
64224
  media: tally.media,
62800
64225
  faces: tally.faces,
62801
64226
  plates: tally.plates,
62802
- embeddings: tally.embeddings
64227
+ embeddings: tally.embeddings,
64228
+ groups: tally.groups
62803
64229
  };
62804
64230
  }
62805
64231
  /**