@camstack/addon-post-analysis 1.2.110 → 1.2.116

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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;
@@ -15128,22 +15139,29 @@ function selectSummaryTiles(candidates, opts) {
15128
15139
  };
15129
15140
  }
15130
15141
  /**
15142
+ * Ranked leftover after the quota pass — refill a mosaic that dropped
15143
+ * duplicate frames or missing crops without walking the same track twice.
15144
+ */
15145
+ function remainingSummaryCandidates(kept, taken, rank) {
15146
+ const takenIds = new Set(taken.map((c) => c.trackId));
15147
+ return kept.filter((c) => !takenIds.has(c.trackId)).toSorted(byRankDesc(rank));
15148
+ }
15149
+ /**
15131
15150
  * The media a tile is drawn from, best first.
15132
15151
  *
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.
15152
+ * For a group: `groupCrop` (union crop, the group's own blob) then
15153
+ * `nativeFrame` (pointer at the anchor's clean keyFrame). A digest of a
15154
+ * cohort may show the native still when the crop was not written; a
15155
+ * member's `keyFrame` listed as itself stays off this ladder so tracker
15156
+ * ID churn cannot refill a mosaic with eleven driveways. Boxed frames stay
15157
+ * absent: a drawn rectangle is a permanent lie in a digest tile.
15139
15158
  */
15140
15159
  var SUMMARY_MEDIA_LADDER = [
15141
- "keyFrameSmall",
15142
- "keyFrame",
15143
- "snapshot",
15144
- "fullFrame",
15160
+ "groupCrop",
15161
+ "nativeFrame",
15162
+ "thumbnail",
15145
15163
  "thumbnailSmall",
15146
- "thumbnail"
15164
+ "snapshot"
15147
15165
  ];
15148
15166
  /**
15149
15167
  * Pick ONE media row for a track — the cheap question first (index rows only),
@@ -15161,6 +15179,13 @@ function pickSummaryMedia(rows, aroundMs) {
15161
15179
  }
15162
15180
  return null;
15163
15181
  }
15182
+ /**
15183
+ * Same camera, same wall-clock second → same JPEG. Tracker ID churn
15184
+ * otherwise fills a mosaic with eleven copies of one driveway still.
15185
+ */
15186
+ function frameDedupeKey(deviceId, mediaTimestamp) {
15187
+ return `${deviceId}:${Math.floor(mediaTimestamp / 1e3)}`;
15188
+ }
15164
15189
  //#endregion
15165
15190
  //#region src/notification-center/summary/summary-window.ts
15166
15191
  /**
@@ -15219,7 +15244,7 @@ var SUMMARY_TRACK_QUERY_LIMIT = 2e3;
15219
15244
  function summaryMediaOwnerId(ruleId, windowEndMs) {
15220
15245
  return `summary-${ruleId}-${windowEndMs}`;
15221
15246
  }
15222
- var EMPTY_REPORT = {
15247
+ var EMPTY_REPORT$1 = {
15223
15248
  produced: 0,
15224
15249
  collecting: 0,
15225
15250
  barrier: 0,
@@ -15283,9 +15308,9 @@ var NcSummaryProducer = class {
15283
15308
  * must not open ten simultaneous render/publish sequences.
15284
15309
  */
15285
15310
  async tick() {
15286
- if (this.tickInFlight) return EMPTY_REPORT;
15311
+ if (this.tickInFlight) return EMPTY_REPORT$1;
15287
15312
  this.tickInFlight = true;
15288
- const report = { ...EMPTY_REPORT };
15313
+ const report = { ...EMPTY_REPORT$1 };
15289
15314
  try {
15290
15315
  const now = this.deps.now();
15291
15316
  const rules = this.deps.store.listEnabled();
@@ -15460,7 +15485,12 @@ var NcSummaryProducer = class {
15460
15485
  droppedStationary: filtered.droppedStationary
15461
15486
  };
15462
15487
  }
15463
- const loaded = await this.loadTiles(rule, selection.tiles);
15488
+ const loaded = await this.loadTiles({
15489
+ rule,
15490
+ preferred: selection.tiles,
15491
+ refill: remainingSummaryCandidates(filtered.kept, selection.tiles, rule.rank),
15492
+ maxTiles: rule.maxTiles
15493
+ });
15464
15494
  const detections = countDetections({ tracks: selection.tiles.map((t) => ({
15465
15495
  firstSeen: t.firstSeen,
15466
15496
  lastSeen: t.lastSeen,
@@ -15518,7 +15548,7 @@ var NcSummaryProducer = class {
15518
15548
  tiles: loaded.length,
15519
15549
  cameras: new Set(loaded.map((t) => t.candidate.deviceId)).size,
15520
15550
  camerasDropped: selection.camerasDropped,
15521
- droppedNoMedia: selection.tiles.length - loaded.length,
15551
+ droppedNoMedia: Math.max(0, rule.maxTiles - loaded.length),
15522
15552
  mosaicBytes: mosaic.bytes,
15523
15553
  mosaicPublished: mosaic.ref !== null,
15524
15554
  enqueued,
@@ -15582,10 +15612,18 @@ var NcSummaryProducer = class {
15582
15612
  }
15583
15613
  return all;
15584
15614
  }
15585
- /** One blob per tile — the cheap question (index rows) always first. */
15586
- async loadTiles(rule, tiles) {
15615
+ /** One blob per tile — the cheap question (index rows) always first.
15616
+ * Duplicate camera-seconds are skipped so tracker ID churn cannot paint
15617
+ * the same driveway eleven times; leftover ranked candidates refill. */
15618
+ async loadTiles(input) {
15587
15619
  const out = [];
15588
- for (const candidate of tiles) {
15620
+ const seenFrames = /* @__PURE__ */ new Set();
15621
+ const seenIds = /* @__PURE__ */ new Set();
15622
+ const queue = [...input.preferred, ...input.refill];
15623
+ for (const candidate of queue) {
15624
+ if (out.length >= input.maxTiles) break;
15625
+ if (seenIds.has(candidate.trackId)) continue;
15626
+ seenIds.add(candidate.trackId);
15589
15627
  const tags = { deviceId: candidate.deviceId };
15590
15628
  try {
15591
15629
  const rows = await this.deps.listMediaInfo(candidate.trackId);
@@ -15594,25 +15632,38 @@ var NcSummaryProducer = class {
15594
15632
  this.deps.logger.debug("summary tile dropped: the track carries no clean media", {
15595
15633
  tags,
15596
15634
  meta: {
15597
- ruleId: rule.id,
15635
+ ruleId: input.rule.id,
15598
15636
  trackId: candidate.trackId,
15599
15637
  mediaRows: rows.length
15600
15638
  }
15601
15639
  });
15602
15640
  continue;
15603
15641
  }
15642
+ const frameKey = frameDedupeKey(candidate.deviceId, picked.timestamp);
15643
+ if (seenFrames.has(frameKey)) {
15644
+ this.deps.logger.debug("summary tile dropped: same frame as an earlier tile", {
15645
+ tags,
15646
+ meta: {
15647
+ ruleId: input.rule.id,
15648
+ trackId: candidate.trackId,
15649
+ frameKey
15650
+ }
15651
+ });
15652
+ continue;
15653
+ }
15604
15654
  const jpeg = await this.deps.readMedia(picked.key);
15605
15655
  if (jpeg === null || jpeg.byteLength === 0) {
15606
15656
  this.deps.logger.debug("summary tile dropped: the media blob could not be read", {
15607
15657
  tags,
15608
15658
  meta: {
15609
- ruleId: rule.id,
15659
+ ruleId: input.rule.id,
15610
15660
  trackId: candidate.trackId,
15611
15661
  mediaKey: picked.key
15612
15662
  }
15613
15663
  });
15614
15664
  continue;
15615
15665
  }
15666
+ seenFrames.add(frameKey);
15616
15667
  out.push({
15617
15668
  candidate,
15618
15669
  jpeg
@@ -15621,7 +15672,7 @@ var NcSummaryProducer = class {
15621
15672
  this.deps.logger.warn("summary tile dropped: reading its media threw", {
15622
15673
  tags,
15623
15674
  meta: {
15624
- ruleId: rule.id,
15675
+ ruleId: input.rule.id,
15625
15676
  trackId: candidate.trackId,
15626
15677
  error: String(err)
15627
15678
  }
@@ -15996,7 +16047,7 @@ var NcSummaryRuleInputSchema = require_dist.object({
15996
16047
  deviceIds: DeviceIdsField,
15997
16048
  window: NcSummaryWindowSchema,
15998
16049
  filters: NcSummaryFiltersSchema.optional(),
15999
- maxTiles: MaxTilesField.default(9),
16050
+ maxTiles: MaxTilesField.default(12),
16000
16051
  maxAiImages: MaxAiImagesField.default(6),
16001
16052
  rank: NcSummaryRankSchema.default("importance"),
16002
16053
  barrierSeconds: BarrierSecondsField.default(120),
@@ -16714,8 +16765,8 @@ async function deriveSyntheticMedia(jpeg, bbox, timestamp) {
16714
16765
  ];
16715
16766
  if (bbox === void 0) return files;
16716
16767
  try {
16717
- const { default: sharp$13 } = await import("sharp");
16718
- const meta = await sharp$13(Buffer.from(jpeg)).metadata();
16768
+ const { default: sharp$14 } = await import("sharp");
16769
+ const meta = await sharp$14(Buffer.from(jpeg)).metadata();
16719
16770
  const W = meta.width ?? 0;
16720
16771
  const H = meta.height ?? 0;
16721
16772
  if (W === 0 || H === 0) return files;
@@ -16723,7 +16774,7 @@ async function deriveSyntheticMedia(jpeg, bbox, timestamp) {
16723
16774
  const top = Math.max(0, Math.min(H - 1, Math.floor(bbox.y * H)));
16724
16775
  const width = Math.max(1, Math.min(W - left, Math.ceil(bbox.w * W)));
16725
16776
  const height = Math.max(1, Math.min(H - top, Math.ceil(bbox.h * H)));
16726
- const cropBuf = await sharp$13(Buffer.from(jpeg)).extract({
16777
+ const cropBuf = await sharp$14(Buffer.from(jpeg)).extract({
16727
16778
  left,
16728
16779
  top,
16729
16780
  width,
@@ -16745,7 +16796,7 @@ async function deriveSyntheticMedia(jpeg, bbox, timestamp) {
16745
16796
  });
16746
16797
  const stroke = Math.max(2, Math.round(Math.min(W, H) / 240));
16747
16798
  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([{
16799
+ const boxedBuf = await sharp$14(Buffer.from(jpeg)).composite([{
16749
16800
  input: overlay,
16750
16801
  top: 0,
16751
16802
  left: 0
@@ -18362,6 +18413,15 @@ var TIMELAPSE_LOOKBACK_MS = 8 * 864e5;
18362
18413
  function timelapseEligibilityFloor(rule, deviceId) {
18363
18414
  return Math.max(require_dist.readTimelapseGeneratedAt(rule, deviceId), rule.createdAt);
18364
18415
  }
18416
+ var EMPTY_REPORT = {
18417
+ produced: 0,
18418
+ waiting: 0,
18419
+ alreadyDone: 0,
18420
+ beforeFloor: 0,
18421
+ failed: 0,
18422
+ idle: 0,
18423
+ skippedInFlight: 0
18424
+ };
18365
18425
  /** The in-flight map's key. One render per (rule, camera) pair by construction. */
18366
18426
  function renderFlightKey(ruleId, deviceId) {
18367
18427
  return `${ruleId}:${String(deviceId)}`;
@@ -18369,7 +18429,8 @@ function renderFlightKey(ruleId, deviceId) {
18369
18429
  var TimelapseScheduler = class {
18370
18430
  deps;
18371
18431
  timer = null;
18372
- tickInFlight = false;
18432
+ /** True only while this tick is scanning eligibility — not while ffmpeg runs. */
18433
+ scanning = false;
18373
18434
  inFlight = /* @__PURE__ */ new Map();
18374
18435
  settleMs;
18375
18436
  lookbackMs;
@@ -18416,25 +18477,29 @@ var TimelapseScheduler = class {
18416
18477
  this.timer = null;
18417
18478
  }
18418
18479
  }
18419
- /** One pass over every enabled rule × camera. Single-flight. */
18480
+ /**
18481
+ * One pass over every enabled rule × camera.
18482
+ *
18483
+ * Eligibility is scanned under a short `scanning` lock so two ticks do not
18484
+ * double-launch the same pair. Renders themselves are started together —
18485
+ * awaiting them one-by-one made camera B wait for camera A's ffmpeg
18486
+ * (live: 1441 until 08:39, 615 until 08:46, both windows closed at 07:00).
18487
+ * A later tick that meets a pair already in `inFlight` counts it and
18488
+ * moves on, so a 30-minute render does not make the producer go deaf.
18489
+ */
18420
18490
  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;
18491
+ if (this.scanning) return { ...EMPTY_REPORT };
18492
+ this.scanning = true;
18430
18493
  const report = {
18431
18494
  produced: 0,
18432
18495
  waiting: 0,
18433
18496
  alreadyDone: 0,
18434
18497
  beforeFloor: 0,
18435
18498
  failed: 0,
18436
- idle: 0
18499
+ idle: 0,
18500
+ skippedInFlight: 0
18437
18501
  };
18502
+ const jobs = [];
18438
18503
  try {
18439
18504
  const now = this.deps.now();
18440
18505
  for (const rule of this.deps.store.listEnabled()) {
@@ -18469,13 +18534,20 @@ var TimelapseScheduler = class {
18469
18534
  });
18470
18535
  continue;
18471
18536
  }
18472
- if ((await this.produce(rule, deviceId, window, { stamp: true })).produced > 0) report.produced += 1;
18473
- else report.failed += 1;
18537
+ if (this.inFlight.has(renderFlightKey(rule.id, deviceId))) {
18538
+ report.skippedInFlight += 1;
18539
+ continue;
18540
+ }
18541
+ jobs.push(this.produce(rule, deviceId, window, { stamp: true }).then((ok) => {
18542
+ if (ok.produced > 0) report.produced += 1;
18543
+ else report.failed += 1;
18544
+ }));
18474
18545
  }
18475
18546
  }
18476
18547
  } finally {
18477
- this.tickInFlight = false;
18548
+ this.scanning = false;
18478
18549
  }
18550
+ await Promise.all(jobs);
18479
18551
  return report;
18480
18552
  }
18481
18553
  /**
@@ -27631,6 +27703,35 @@ function pairRiders(detections) {
27631
27703
  return pairs;
27632
27704
  }
27633
27705
  //#endregion
27706
+ //#region src/pipeline-analytics/pipeline/tracker/rescue-growth.ts
27707
+ /** How far the grown box's centroid may sit, as a multiple of the LARGER
27708
+ * diagonal. 1.35 covers the live 4285605a bird→dog jump (~75px / 59px). */
27709
+ var GROWTH_CENTROID_FACTOR = 1.35;
27710
+ function intersectionArea(a, b) {
27711
+ const x1 = Math.max(a.x, b.x);
27712
+ const y1 = Math.max(a.y, b.y);
27713
+ const x2 = Math.min(a.x + a.w, b.x + b.w);
27714
+ const y2 = Math.min(a.y + a.h, b.y + b.h);
27715
+ if (x2 <= x1 || y2 <= y1) return 0;
27716
+ return (x2 - x1) * (y2 - y1);
27717
+ }
27718
+ /** Same-class partial crop growing into (or collapsing from) a full-body box
27719
+ * with IoU 0. Used by rescue and spawn-dedup so we do not mint a second id.
27720
+ * A SMALL box nested inside a LARGE one is occlusion, not growth — those
27721
+ * stay two tracks (sort-tracker occlusion suite). */
27722
+ function growthAdjacentMatch(track, det) {
27723
+ const trackArea = track.w * track.h;
27724
+ const detArea = det.w * det.h;
27725
+ if (trackArea <= 0 || detArea <= 0) return false;
27726
+ const larger = Math.max(trackArea, detArea);
27727
+ const smaller = Math.min(trackArea, detArea);
27728
+ if (larger / smaller < 1.5) return false;
27729
+ if (intersectionArea(track, det) / smaller >= .5) return false;
27730
+ const tc = bboxCentroid(track);
27731
+ const dc = bboxCentroid(det);
27732
+ 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));
27733
+ }
27734
+ //#endregion
27634
27735
  //#region src/pipeline-analytics/pipeline/tracker/sort-tracker.ts
27635
27736
  var DEFAULT_TRACKER_CONFIG = {
27636
27737
  iouThreshold: .3,
@@ -27758,7 +27859,7 @@ var SortTracker = class SortTracker {
27758
27859
  * and earlier same-frame spawns).
27759
27860
  */
27760
27861
  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;
27862
+ 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
27863
  if (this.config.personAnimalDedup && det.class === ANIMAL_CLASS && det.score < this.config.animalOverPersonMaxScore) {
27763
27864
  for (const t of live) if (t.class === PERSON_CLASS && iou$2(det.bbox, t.bbox) >= this.config.animalOverPersonIou) return true;
27764
27865
  }
@@ -27852,7 +27953,8 @@ var SortTracker = class SortTracker {
27852
27953
  const dc = bboxCentroid(det.bbox);
27853
27954
  const dist = Math.hypot(tc.x - dc.x, tc.y - dc.y);
27854
27955
  const diag = Math.hypot(track.bbox.w, track.bbox.h);
27855
- return dist <= this.config.rescueCentroidFactor * diag;
27956
+ if (dist <= this.config.rescueCentroidFactor * diag) return true;
27957
+ return growthAdjacentMatch(track.bbox, det.bbox);
27856
27958
  }
27857
27959
  /** Fold a matched detection's class + score into a track's lifetime vote
27858
27960
  * tally. A non-positive score still counts as an infinitesimal vote so a
@@ -28177,6 +28279,7 @@ var StateAnalyzer = class {
28177
28279
  enteredAt: timestamp,
28178
28280
  stationarySince: void 0,
28179
28281
  totalDistancePx: 0,
28282
+ startPosition: center,
28180
28283
  lastPosition: center,
28181
28284
  frameCount: 1
28182
28285
  };
@@ -28196,8 +28299,9 @@ var StateAnalyzer = class {
28196
28299
  else if (velocity <= this.config.velocityThreshold) {
28197
28300
  if (!ts.stationarySince) ts.stationarySince = timestamp;
28198
28301
  const stationaryDurationSec = (timestamp - ts.stationarySince) / 1e3;
28302
+ const neverMoved = Math.hypot(ts.lastPosition.x - ts.startPosition.x, ts.lastPosition.y - ts.startPosition.y) <= this.config.velocityThreshold;
28199
28303
  if (stationaryDurationSec >= this.config.loiteringThresholdSec) state = "loitering";
28200
- else if (stationaryDurationSec >= this.config.stationaryThresholdSec) state = "stationary";
28304
+ else if (neverMoved || stationaryDurationSec >= this.config.stationaryThresholdSec) state = "stationary";
28201
28305
  else state = "moving";
28202
28306
  } else {
28203
28307
  ts.stationarySince = void 0;
@@ -29030,6 +29134,22 @@ function promoteSemanticBestFrame(deps, input) {
29030
29134
  };
29031
29135
  }
29032
29136
  //#endregion
29137
+ //#region src/pipeline-analytics/pipeline/semantic-detail-bbox.ts
29138
+ /** Which box the semantic best-shot should crop: a plate/face DETAIL box
29139
+ * when we have one, otherwise the subject box. */
29140
+ function bboxForSemanticPromotion(className, detailBbox, subjectBbox) {
29141
+ if ((className === "plate" || className === "face") && detailBbox !== void 0 && detailBbox.w > 0 && detailBbox.h > 0) return detailBbox;
29142
+ return subjectBbox;
29143
+ }
29144
+ //#endregion
29145
+ //#region src/pipeline-analytics/pipeline/first-frame-retry-bbox.ts
29146
+ /** Crop box for a late firstFrame retry: the BIRTH geometry, not the
29147
+ * later frozen/coasted box (track 1b659d56). */
29148
+ function firstFrameRetryBbox(current, birth) {
29149
+ if (birth !== void 0 && birth.w > 0 && birth.h > 0) return { ...birth };
29150
+ return { ...current };
29151
+ }
29152
+ //#endregion
29033
29153
  //#region src/pipeline-analytics/store/zone-geometry.ts
29034
29154
  /**
29035
29155
  * Normalized min/max envelope over every position's bbox. Returns `null`
@@ -29244,6 +29364,7 @@ var EMPTY_CASCADE_TALLY = {
29244
29364
  faces: 0,
29245
29365
  plates: 0,
29246
29366
  embeddings: 0,
29367
+ groups: 0,
29247
29368
  bytesReclaimed: 0
29248
29369
  };
29249
29370
  /** Fold a leaf store's receipts into a running tally. */
@@ -29287,6 +29408,13 @@ function addLeafCounts(tally, counts) {
29287
29408
  bytesReclaimed
29288
29409
  };
29289
29410
  break;
29411
+ case "groups":
29412
+ next = {
29413
+ ...next,
29414
+ groups: next.groups + c.rows,
29415
+ bytesReclaimed
29416
+ };
29417
+ break;
29290
29418
  }
29291
29419
  }
29292
29420
  return next;
@@ -29300,6 +29428,7 @@ function mergeCascadeTallies(a, b) {
29300
29428
  faces: a.faces + b.faces,
29301
29429
  plates: a.plates + b.plates,
29302
29430
  embeddings: a.embeddings + b.embeddings,
29431
+ groups: a.groups + b.groups,
29303
29432
  bytesReclaimed: a.bytesReclaimed + b.bytesReclaimed
29304
29433
  };
29305
29434
  }
@@ -29348,6 +29477,7 @@ function buildAnalyticsCascadeRegistry(stores) {
29348
29477
  if (stores.faceStore) registry.push(stores.faceStore);
29349
29478
  if (stores.plateStore) registry.push(stores.plateStore);
29350
29479
  if (stores.objectEmbeddingStore) registry.push(stores.objectEmbeddingStore);
29480
+ if (stores.groupStore) registry.push(stores.groupStore);
29351
29481
  return registry;
29352
29482
  }
29353
29483
  /**
@@ -31058,7 +31188,8 @@ var OWNER_KINDS = [
31058
31188
  "identity",
31059
31189
  "plate",
31060
31190
  "vehicle",
31061
- "scene"
31191
+ "scene",
31192
+ "group"
31062
31193
  ];
31063
31194
  /**
31064
31195
  * Narrow an arbitrary string to an `OwnerKind`.
@@ -31807,6 +31938,10 @@ var MediaStore = class {
31807
31938
  async deleteForTracks(trackIds) {
31808
31939
  return this.deleteForOwner("track", trackIds);
31809
31940
  }
31941
+ /** Delete the group's OWN crop. The native frame is a pointer and is not here. */
31942
+ async deleteByGroups(groupIds) {
31943
+ return this.deleteForOwnerCounted("group", groupIds);
31944
+ }
31810
31945
  /**
31811
31946
  * `TrackScopedStore` contract for the retention cascade: delete all media
31812
31947
  * owned by the given tracks — the tracks' OWN media (`ownerKind:'track'`) plus
@@ -39898,7 +40033,9 @@ function resolvePackageDropSettings(raw) {
39898
40033
  function planPeriodicMedia(input) {
39899
40034
  const appendSnapshot = input.dueSnapshot;
39900
40035
  const lastFrameInterval = input.lastFrameIntervalMs ?? input.intervalMs;
39901
- const rollingLastFrame = input.saveThumbnails && Math.floor(input.now / lastFrameInterval) > Math.floor(input.lastFrameAt / lastFrameInterval) && !appendSnapshot;
40036
+ const lastFrameElapsed = input.saveThumbnails && Math.floor(input.now / lastFrameInterval) > Math.floor(input.lastFrameAt / lastFrameInterval);
40037
+ const firstFrameLanded = input.firstFrameLanded ?? true;
40038
+ const rollingLastFrame = lastFrameElapsed && !appendSnapshot && firstFrameLanded;
39902
40039
  const thumbnailLanded = input.thumbnailLanded ?? true;
39903
40040
  return {
39904
40041
  appendSnapshot,
@@ -40131,6 +40268,1405 @@ var DeferredBirthRegistry = class {
40131
40268
  this.byDevice.delete(deviceKey);
40132
40269
  }
40133
40270
  };
40271
+ var AnalyticsGroupRecordSchema = require_dist.object({
40272
+ id: require_dist.string(),
40273
+ deviceId: require_dist.number().int(),
40274
+ openedAt: require_dist.number().int(),
40275
+ closedAt: require_dist.number().int(),
40276
+ timestamp: require_dist.number().int(),
40277
+ memberCount: require_dist.number().int(),
40278
+ memberTrackIds: require_dist.array(require_dist.string()).readonly(),
40279
+ className: require_dist.string(),
40280
+ classes: require_dist.array(require_dist.string()).readonly(),
40281
+ /** Relative event-media path, or null when the group has no picture yet. */
40282
+ mediaUrl: require_dist.string().nullable(),
40283
+ singleton: require_dist.boolean()
40284
+ });
40285
+ var AnalyticsGroupMemberSchema = require_dist.object({
40286
+ trackId: require_dist.string(),
40287
+ deviceId: require_dist.number().int(),
40288
+ className: require_dist.string(),
40289
+ firstSeen: require_dist.number().int(),
40290
+ lastSeen: require_dist.number().int(),
40291
+ mediaUrl: require_dist.string().nullable()
40292
+ });
40293
+ var AnalyticsGroupDetailSchema = AnalyticsGroupRecordSchema.extend({ members: require_dist.array(AnalyticsGroupMemberSchema).readonly() });
40294
+ var ListGroupsInputSchema = require_dist.object({
40295
+ deviceId: require_dist.number().int().optional(),
40296
+ sinceMs: require_dist.number().int().optional(),
40297
+ untilMs: require_dist.number().int().optional(),
40298
+ limit: require_dist.number().int().min(1).max(100).optional(),
40299
+ offset: require_dist.number().int().min(0).optional()
40300
+ });
40301
+ var groupActions = require_dist.defineCustomActions({
40302
+ "analytics.listGroups": require_dist.customAction(ListGroupsInputSchema, require_dist.object({
40303
+ groups: require_dist.array(AnalyticsGroupRecordSchema).readonly(),
40304
+ hasMore: require_dist.boolean()
40305
+ })),
40306
+ "analytics.getGroup": require_dist.customAction(require_dist.object({ groupId: require_dist.string().min(1) }), require_dist.object({ group: AnalyticsGroupDetailSchema.nullable() }))
40307
+ });
40308
+ //#endregion
40309
+ //#region src/pipeline-analytics/group/group-query.ts
40310
+ /**
40311
+ * Stamp a relative event-media URL.
40312
+ *
40313
+ * Media keys contain `:` and the data-plane serves them by key. Track ids are
40314
+ * bare and fall through to the existing event/track thumbnail resolver. Never
40315
+ * percent-encode: the handler does not URL-decode the path.
40316
+ */
40317
+ function stampEventMediaUrl(baseUrl, id) {
40318
+ if (baseUrl === null || baseUrl.length === 0) return null;
40319
+ if (id === null || id === void 0 || id.length === 0) return null;
40320
+ return `${baseUrl}/${id}`;
40321
+ }
40322
+ /** Union of every class on the members; `className` is the first, stable order. */
40323
+ function aggregateGroupClasses(tracks) {
40324
+ const seen = /* @__PURE__ */ new Set();
40325
+ const classes = [];
40326
+ for (const track of tracks) for (const name of [track.className, ...track.classes ?? []]) {
40327
+ if (name.length === 0 || seen.has(name)) continue;
40328
+ seen.add(name);
40329
+ classes.push(name);
40330
+ }
40331
+ return {
40332
+ className: classes[0] ?? "unknown",
40333
+ classes
40334
+ };
40335
+ }
40336
+ function toGroupRecord(input) {
40337
+ const { className, classes } = aggregateGroupClasses(input.tracks);
40338
+ const mediaId = input.group.mediaKey ?? input.group.nativeKey ?? (input.group.memberCount === 1 ? input.memberTrackIds[0] : null);
40339
+ return {
40340
+ id: input.group.id,
40341
+ deviceId: input.group.deviceId,
40342
+ openedAt: input.group.openedAt,
40343
+ closedAt: input.group.closedAt,
40344
+ timestamp: input.group.timestamp,
40345
+ memberCount: input.memberTrackIds.length > 0 ? input.memberTrackIds.length : input.group.memberCount,
40346
+ memberTrackIds: [...input.memberTrackIds],
40347
+ className,
40348
+ classes: [...classes],
40349
+ mediaUrl: stampEventMediaUrl(input.eventMediaBaseUrl, mediaId),
40350
+ singleton: input.memberTrackIds.length <= 1
40351
+ };
40352
+ }
40353
+ function toGroupMember(member, track, eventMediaBaseUrl) {
40354
+ return {
40355
+ trackId: member.trackId,
40356
+ deviceId: member.deviceId,
40357
+ className: track?.className ?? "unknown",
40358
+ firstSeen: track?.firstSeen ?? member.timestamp,
40359
+ lastSeen: track?.lastSeen ?? member.timestamp,
40360
+ mediaUrl: stampEventMediaUrl(eventMediaBaseUrl, member.trackId)
40361
+ };
40362
+ }
40363
+ function trackToLite(track) {
40364
+ return {
40365
+ trackId: track.trackId,
40366
+ className: track.className,
40367
+ classes: track.classes,
40368
+ firstSeen: track.firstSeen,
40369
+ lastSeen: track.lastSeen
40370
+ };
40371
+ }
40372
+ async function listGroupRecords(ports, input) {
40373
+ const page = await ports.groupStore.listRecent(input);
40374
+ const groups = [];
40375
+ for (const group of page.groups) groups.push(await buildGroupRecord(ports, group));
40376
+ return {
40377
+ groups,
40378
+ hasMore: page.hasMore
40379
+ };
40380
+ }
40381
+ async function getGroupDetail(ports, groupId) {
40382
+ const group = await ports.groupStore.get(groupId);
40383
+ if (group === null) return null;
40384
+ const members = await ports.groupStore.listMembers(groupId);
40385
+ const lites = [];
40386
+ const rows = [];
40387
+ for (const member of members) {
40388
+ const track = await ports.loadTrack(member.trackId);
40389
+ const lite = track === null ? null : trackToLite(track);
40390
+ if (lite !== null) lites.push(lite);
40391
+ rows.push(toGroupMember(member, lite, ports.eventMediaBaseUrl()));
40392
+ }
40393
+ return {
40394
+ ...toGroupRecord({
40395
+ group,
40396
+ memberTrackIds: members.map((m) => m.trackId),
40397
+ tracks: lites,
40398
+ eventMediaBaseUrl: ports.eventMediaBaseUrl()
40399
+ }),
40400
+ members: rows
40401
+ };
40402
+ }
40403
+ async function buildGroupRecord(ports, group) {
40404
+ const members = await ports.groupStore.listMembers(group.id);
40405
+ const lites = [];
40406
+ for (const member of members) {
40407
+ const track = await ports.loadTrack(member.trackId);
40408
+ if (track !== null) lites.push(trackToLite(track));
40409
+ }
40410
+ return toGroupRecord({
40411
+ group,
40412
+ memberTrackIds: members.map((m) => m.trackId),
40413
+ tracks: lites,
40414
+ eventMediaBaseUrl: ports.eventMediaBaseUrl()
40415
+ });
40416
+ }
40417
+ /**
40418
+ * Gallery and digest read the same ladder: groupCrop, then the native-frame
40419
+ * pointer, then member subject crops. A multi with no crop still has a
40420
+ * picture if the sealer stored `nativeKey`.
40421
+ */
40422
+ function digestMediaRows(input) {
40423
+ const owned = [];
40424
+ if (input.mediaKey !== null && input.mediaKey.length > 0) owned.push({
40425
+ key: input.mediaKey,
40426
+ kind: "groupCrop",
40427
+ timestamp: input.timestamp
40428
+ });
40429
+ if (input.nativeKey !== null && input.nativeKey.length > 0) owned.push({
40430
+ key: input.nativeKey,
40431
+ kind: "nativeFrame",
40432
+ timestamp: input.timestamp
40433
+ });
40434
+ if (owned.length > 0) return owned;
40435
+ return input.memberRows;
40436
+ }
40437
+ var MATCH_WINDOW_MS = 1e3;
40438
+ var MOVE_EPS = .35;
40439
+ /** Live-assigner window: same predicate, recent positions only. */
40440
+ var LIVE_WINDOW_MS = 3e3;
40441
+ /** Registry stationary is out; audio has no bbox and is never a member or a singleton. */
40442
+ function isEligibleMember(track) {
40443
+ if (track.closedReason === "stationary-promotion") return false;
40444
+ if (track.className === "audio") return false;
40445
+ return true;
40446
+ }
40447
+ function diag(bbox) {
40448
+ return Math.hypot(bbox.w, bbox.h);
40449
+ }
40450
+ function nearestPosition(positions, t) {
40451
+ let best;
40452
+ let bestDt = 1001;
40453
+ for (const p of positions) {
40454
+ const dt = Math.abs(p.timestamp - t);
40455
+ if (dt <= 1e3 && dt < bestDt) {
40456
+ best = p;
40457
+ bestDt = dt;
40458
+ }
40459
+ }
40460
+ return best;
40461
+ }
40462
+ function meanDiag(positions) {
40463
+ if (positions.length === 0) return 0;
40464
+ let sum = 0;
40465
+ for (const p of positions) sum += diag(p.bbox);
40466
+ return sum / positions.length;
40467
+ }
40468
+ function vecLen(v) {
40469
+ return Math.hypot(v.x, v.y);
40470
+ }
40471
+ function cosine(a, b) {
40472
+ const la = vecLen(a);
40473
+ const lb = vecLen(b);
40474
+ if (la === 0 || lb === 0) return 1;
40475
+ return (a.x * b.x + a.y * b.y) / (la * lb);
40476
+ }
40477
+ function positionsInWindow(track, start, end) {
40478
+ return track.positions.filter((p) => p.timestamp >= start && p.timestamp <= end);
40479
+ }
40480
+ function windowDisplacement(positions) {
40481
+ const first = positions[0];
40482
+ const last = positions[positions.length - 1];
40483
+ if (!first || !last) return {
40484
+ x: 0,
40485
+ y: 0
40486
+ };
40487
+ const a = bboxCentroid(first.bbox);
40488
+ const b = bboxCentroid(last.bbox);
40489
+ return {
40490
+ x: b.x - a.x,
40491
+ y: b.y - a.y
40492
+ };
40493
+ }
40494
+ function windowRelativeMotion(positions) {
40495
+ const mean = meanDiag(positions);
40496
+ if (mean <= 0) return 0;
40497
+ return vecLen(windowDisplacement(positions)) / mean;
40498
+ }
40499
+ /**
40500
+ * Same cohesion + crossing veto as {@link pairTogether}, over `[now − 3 s, now]`.
40501
+ * Displacement/cosine use the window, not the whole track.
40502
+ */
40503
+ function pairTogetherNow(a, b, now) {
40504
+ const start = now - LIVE_WINDOW_MS;
40505
+ const overlapStart = Math.max(a.firstSeen, b.firstSeen, start);
40506
+ const overlapEnd = Math.min(a.lastSeen, b.lastSeen, now);
40507
+ if (overlapEnd < overlapStart) return false;
40508
+ let joint = 0;
40509
+ let close = 0;
40510
+ for (let t = overlapStart; t <= overlapEnd; t += 500) {
40511
+ const pa = nearestPosition(a.positions, t);
40512
+ const pb = nearestPosition(b.positions, t);
40513
+ if (!pa || !pb) continue;
40514
+ joint++;
40515
+ const ca = bboxCentroid(pa.bbox);
40516
+ const cb = bboxCentroid(pb.bbox);
40517
+ const gap = Math.hypot(ca.x - cb.x, ca.y - cb.y);
40518
+ const scale = (diag(pa.bbox) + diag(pb.bbox)) / 2;
40519
+ if ((scale > 0 ? gap / scale : Number.POSITIVE_INFINITY) <= 1.5) close++;
40520
+ }
40521
+ if (joint === 0) return false;
40522
+ if (close / joint < .5) return false;
40523
+ const wa = positionsInWindow(a, start, now);
40524
+ const wb = positionsInWindow(b, start, now);
40525
+ const aMoves = windowRelativeMotion(wa) >= MOVE_EPS;
40526
+ const bMoves = windowRelativeMotion(wb) >= MOVE_EPS;
40527
+ if (aMoves && bMoves && cosine(windowDisplacement(wa), windowDisplacement(wb)) < .3) return false;
40528
+ return true;
40529
+ }
40530
+ var GroupLiveAssigner = class {
40531
+ nextId;
40532
+ snapshots = /* @__PURE__ */ new Map();
40533
+ groups = /* @__PURE__ */ new Map();
40534
+ membership = /* @__PURE__ */ new Map();
40535
+ /** `${trackId}>${groupId}` → first instant the join predicate held. */
40536
+ joinSince = /* @__PURE__ */ new Map();
40537
+ /** trackId → first instant they failed pair against every remaining member. */
40538
+ leaveSince = /* @__PURE__ */ new Map();
40539
+ constructor(nextId = () => (0, node_crypto.randomUUID)()) {
40540
+ this.nextId = nextId;
40541
+ }
40542
+ observe(track, now) {
40543
+ this.snapshots.set(track.trackId, track);
40544
+ if (!isEligibleMember(track)) {
40545
+ this.removeFromGroup(track.trackId);
40546
+ return;
40547
+ }
40548
+ if (!this.membership.has(track.trackId)) this.birthSingleton(track, now);
40549
+ this.considerLeave(track, now);
40550
+ const groupId = this.membership.get(track.trackId);
40551
+ const group = groupId !== void 0 ? this.groups.get(groupId) : void 0;
40552
+ if (group) for (const id of [...group.members]) {
40553
+ if (id === track.trackId) continue;
40554
+ const snap = this.snapshots.get(id);
40555
+ if (snap) this.considerLeave(snap, now);
40556
+ }
40557
+ this.considerJoin(track, now);
40558
+ }
40559
+ groupOf(trackId) {
40560
+ return this.membership.get(trackId);
40561
+ }
40562
+ snapshot(trackId) {
40563
+ return this.snapshots.get(trackId);
40564
+ }
40565
+ liveGroups() {
40566
+ return [...this.groups.values()].map((g) => this.toLive(g));
40567
+ }
40568
+ idleGroups(now, idleMs) {
40569
+ return this.liveGroups().filter((g) => now >= g.lastActiveAt + idleMs);
40570
+ }
40571
+ drop(ids) {
40572
+ for (const id of ids) {
40573
+ const g = this.groups.get(id);
40574
+ if (!g) continue;
40575
+ for (const member of g.members) this.membership.delete(member);
40576
+ this.groups.delete(id);
40577
+ }
40578
+ }
40579
+ birthSingleton(track, now) {
40580
+ const id = this.nextId();
40581
+ this.groups.set(id, {
40582
+ id,
40583
+ deviceId: track.deviceId,
40584
+ openedAt: now,
40585
+ members: new Set([track.trackId])
40586
+ });
40587
+ this.membership.set(track.trackId, id);
40588
+ }
40589
+ considerLeave(track, now) {
40590
+ const groupId = this.membership.get(track.trackId);
40591
+ if (groupId === void 0) return;
40592
+ const group = this.groups.get(groupId);
40593
+ if (!group || group.members.size < 2) {
40594
+ this.leaveSince.delete(track.trackId);
40595
+ return;
40596
+ }
40597
+ if ([...group.members].filter((id) => id !== track.trackId).some((id) => {
40598
+ const other = this.snapshots.get(id);
40599
+ return other !== void 0 && pairTogetherNow(track, other, now);
40600
+ })) {
40601
+ this.leaveSince.delete(track.trackId);
40602
+ return;
40603
+ }
40604
+ const since = this.leaveSince.get(track.trackId) ?? now;
40605
+ this.leaveSince.set(track.trackId, since);
40606
+ if (now - since < 3e3) return;
40607
+ this.peel(track, now);
40608
+ }
40609
+ peel(track, now) {
40610
+ this.removeFromGroup(track.trackId);
40611
+ this.leaveSince.delete(track.trackId);
40612
+ this.birthSingleton(track, now);
40613
+ }
40614
+ considerJoin(track, now) {
40615
+ const mineId = this.membership.get(track.trackId);
40616
+ if (mineId === void 0) return;
40617
+ const mine = this.groups.get(mineId);
40618
+ if (!mine || mine.members.size !== 1) {
40619
+ this.clearJoinClocks(track.trackId);
40620
+ return;
40621
+ }
40622
+ let best;
40623
+ for (const group of this.groups.values()) {
40624
+ if (group.id === mineId || group.deviceId !== track.deviceId) continue;
40625
+ let gap = Number.POSITIVE_INFINITY;
40626
+ let together = false;
40627
+ for (const memberId of group.members) {
40628
+ const other = this.snapshots.get(memberId);
40629
+ if (!other) continue;
40630
+ if (pairTogetherNow(track, other, now)) {
40631
+ together = true;
40632
+ gap = Math.min(gap, centroidGap(track, other, now));
40633
+ }
40634
+ }
40635
+ if (!together) continue;
40636
+ if (best === void 0 || gap < best.gap) best = {
40637
+ groupId: group.id,
40638
+ gap
40639
+ };
40640
+ }
40641
+ if (best === void 0) {
40642
+ this.clearJoinClocks(track.trackId);
40643
+ return;
40644
+ }
40645
+ const key = `${track.trackId}>${best.groupId}`;
40646
+ for (const existing of [...this.joinSince.keys()]) if (existing.startsWith(`${track.trackId}>`) && existing !== key) this.joinSince.delete(existing);
40647
+ const since = this.joinSince.get(key) ?? now;
40648
+ this.joinSince.set(key, since);
40649
+ if (now - since < 1500) return;
40650
+ this.absorb(track.trackId, mineId, best.groupId);
40651
+ }
40652
+ absorb(trackId, fromId, intoId) {
40653
+ const into = this.groups.get(intoId);
40654
+ if (!into) return;
40655
+ into.members.add(trackId);
40656
+ this.membership.set(trackId, intoId);
40657
+ this.groups.delete(fromId);
40658
+ this.clearJoinClocks(trackId);
40659
+ this.leaveSince.delete(trackId);
40660
+ }
40661
+ removeFromGroup(trackId) {
40662
+ const groupId = this.membership.get(trackId);
40663
+ this.membership.delete(trackId);
40664
+ this.clearJoinClocks(trackId);
40665
+ this.leaveSince.delete(trackId);
40666
+ if (groupId === void 0) return;
40667
+ const group = this.groups.get(groupId);
40668
+ if (!group) return;
40669
+ group.members.delete(trackId);
40670
+ if (group.members.size === 0) this.groups.delete(groupId);
40671
+ }
40672
+ clearJoinClocks(trackId) {
40673
+ for (const key of [...this.joinSince.keys()]) if (key.startsWith(`${trackId}>`)) this.joinSince.delete(key);
40674
+ }
40675
+ toLive(group) {
40676
+ let lastActiveAt = group.openedAt;
40677
+ for (const id of group.members) {
40678
+ const snap = this.snapshots.get(id);
40679
+ if (snap && snap.lastSeen > lastActiveAt) lastActiveAt = snap.lastSeen;
40680
+ }
40681
+ return {
40682
+ id: group.id,
40683
+ deviceId: group.deviceId,
40684
+ memberTrackIds: [...group.members],
40685
+ openedAt: group.openedAt,
40686
+ lastActiveAt
40687
+ };
40688
+ }
40689
+ };
40690
+ function centroidGap(a, b, now) {
40691
+ const pa = a.positions.reduce((best, p) => Math.abs(p.timestamp - now) < Math.abs(best.timestamp - now) ? p : best, a.positions[0]);
40692
+ const pb = b.positions.reduce((best, p) => Math.abs(p.timestamp - now) < Math.abs(best.timestamp - now) ? p : best, b.positions[0]);
40693
+ if (!pa || !pb) return Number.POSITIVE_INFINITY;
40694
+ const ca = bboxCentroid(pa.bbox);
40695
+ const cb = bboxCentroid(pb.bbox);
40696
+ return Math.hypot(ca.x - cb.x, ca.y - cb.y);
40697
+ }
40698
+ //#endregion
40699
+ //#region src/pipeline-analytics/group/group-session.ts
40700
+ /** Same idle as the notification burst (`NC_GROUP_DEFAULT_IDLE_SEC`). */
40701
+ var GROUP_SESSION_IDLE_MS = 3e4;
40702
+ //#endregion
40703
+ //#region src/pipeline-analytics/group/group-live-buffer.ts
40704
+ function toGroupableTrack(track) {
40705
+ return {
40706
+ trackId: track.trackId,
40707
+ deviceId: track.deviceId,
40708
+ className: track.className,
40709
+ firstSeen: track.firstSeen,
40710
+ lastSeen: track.lastSeen,
40711
+ positions: track.positions.map((p) => ({
40712
+ ...p,
40713
+ bbox: { ...p.bbox }
40714
+ })),
40715
+ ...track.closedReason !== void 0 ? { closedReason: track.closedReason } : {}
40716
+ };
40717
+ }
40718
+ var GroupLiveBuffer = class {
40719
+ assigner;
40720
+ primed = /* @__PURE__ */ new Set();
40721
+ constructor(nextId) {
40722
+ this.assigner = new GroupLiveAssigner(nextId);
40723
+ }
40724
+ observe(track, now) {
40725
+ this.assigner.observe(track, now);
40726
+ }
40727
+ groupOf(trackId) {
40728
+ return this.assigner.groupOf(trackId);
40729
+ }
40730
+ snapshot(trackId) {
40731
+ return this.assigner.snapshot(trackId);
40732
+ }
40733
+ liveGroups() {
40734
+ return this.assigner.liveGroups();
40735
+ }
40736
+ idleGroups(now, idleMs = GROUP_SESSION_IDLE_MS) {
40737
+ return this.assigner.idleGroups(now, idleMs);
40738
+ }
40739
+ drop(ids) {
40740
+ this.assigner.drop(ids);
40741
+ }
40742
+ isPrimed(deviceId) {
40743
+ return this.primed.has(deviceId);
40744
+ }
40745
+ markPrimed(deviceId) {
40746
+ this.primed.add(deviceId);
40747
+ }
40748
+ };
40749
+ //#endregion
40750
+ //#region src/pipeline-analytics/group/group-crop.ts
40751
+ /** Always containSubject: true. Never the 0.5 delivered threshold. */
40752
+ function groupWideCentralSquareLayout(unionBbox, frame) {
40753
+ return wideCentralSquareLayout(unionBbox, frame, { containSubject: true });
40754
+ }
40755
+ async function cropGroupFromRgb(rgb, frameWidth, frameHeight, unionBbox) {
40756
+ const layout = groupWideCentralSquareLayout(unionBbox, {
40757
+ W: frameWidth,
40758
+ H: frameHeight
40759
+ });
40760
+ const { crop: slab } = await extractCrop(rgb, frameWidth, frameHeight, layout.fetch);
40761
+ return composeWideCentralSquareThumbnail(slab, layout);
40762
+ }
40763
+ //#endregion
40764
+ //#region src/pipeline-analytics/group/group-instant.ts
40765
+ /**
40766
+ * Chosen instant + union bbox for a group, at close, over stored positions[]
40767
+ * (`docs/design/2026-08-21-group-entity-and-group-media.md` §4.1).
40768
+ *
40769
+ * Tier order — the first that separates two candidates decides:
40770
+ * 1. completeness (members with a position within ±1 s)
40771
+ * 2. square containment of the union (D212)
40772
+ * 3. semantic (a member's confirmed recognition)
40773
+ * 4. edge-clear (union touches no frame border)
40774
+ * 5. effective score = mean(confidence) × containment × sizeFactor
40775
+ *
40776
+ * Retrospective. No new capture path.
40777
+ */
40778
+ var GROUP_MEDIA_MATCH_MS = 2e3;
40779
+ function nearest(positions, t, windowMs) {
40780
+ let best;
40781
+ let bestDt = windowMs + 1;
40782
+ for (const p of positions) {
40783
+ const dt = Math.abs(p.timestamp - t);
40784
+ if (dt <= windowMs && dt < bestDt) {
40785
+ best = p;
40786
+ bestDt = dt;
40787
+ }
40788
+ }
40789
+ return best;
40790
+ }
40791
+ function unionOf(boxes) {
40792
+ if (boxes.length === 0) return null;
40793
+ let x0 = Infinity;
40794
+ let y0 = Infinity;
40795
+ let x1 = -Infinity;
40796
+ let y1 = -Infinity;
40797
+ for (const b of boxes) {
40798
+ x0 = Math.min(x0, b.x);
40799
+ y0 = Math.min(y0, b.y);
40800
+ x1 = Math.max(x1, b.x + b.w);
40801
+ y1 = Math.max(y1, b.y + b.h);
40802
+ }
40803
+ return {
40804
+ x: x0,
40805
+ y: y0,
40806
+ w: x1 - x0,
40807
+ h: y1 - y0
40808
+ };
40809
+ }
40810
+ function touchesBorder(box, frame, tol = 1) {
40811
+ return box.x <= tol || box.y <= tol || box.x + box.w >= frame.width - tol || box.y + box.h >= frame.height - tol;
40812
+ }
40813
+ function scoreInstant(members, t, frame) {
40814
+ const boxes = [];
40815
+ let semantic = false;
40816
+ let confSum = 0;
40817
+ let confN = 0;
40818
+ for (const m of members) {
40819
+ const p = nearest(m.positions, t, MATCH_WINDOW_MS);
40820
+ if (!p) continue;
40821
+ boxes.push(p.bbox);
40822
+ if (m.semantic === true) semantic = true;
40823
+ if (m.confidence !== void 0) {
40824
+ confSum += m.confidence;
40825
+ confN += 1;
40826
+ }
40827
+ }
40828
+ const union = unionOf(boxes);
40829
+ if (!union) return null;
40830
+ const square = centralSquareContainmentFraction(union, {
40831
+ W: frame.width,
40832
+ H: frame.height
40833
+ });
40834
+ const containment = bboxContainment(union, frame.width, frame.height);
40835
+ const size = bboxSizeScore(union, frame.width, frame.height);
40836
+ const meanConf = confN > 0 ? confSum / confN : 1;
40837
+ return {
40838
+ timestamp: t,
40839
+ union,
40840
+ visibleCount: boxes.length,
40841
+ square,
40842
+ semantic,
40843
+ edgeClear: !touchesBorder(union, frame),
40844
+ effective: meanConf * containment * size
40845
+ };
40846
+ }
40847
+ function better(a, b) {
40848
+ if (a.visibleCount !== b.visibleCount) return a.visibleCount > b.visibleCount ? a : b;
40849
+ if (a.square !== b.square) return a.square > b.square ? a : b;
40850
+ if (a.semantic !== b.semantic) return a.semantic ? a : b;
40851
+ if (a.edgeClear !== b.edgeClear) return a.edgeClear ? a : b;
40852
+ if (a.effective !== b.effective) return a.effective > b.effective ? a : b;
40853
+ return a.timestamp <= b.timestamp ? a : b;
40854
+ }
40855
+ function chooseGroupInstant(members, frame) {
40856
+ if (members.length === 0) return null;
40857
+ const candidates = /* @__PURE__ */ new Set();
40858
+ for (const m of members) for (const p of m.positions) candidates.add(p.timestamp);
40859
+ let best = null;
40860
+ for (const t of candidates) {
40861
+ const scored = scoreInstant(members, t, frame);
40862
+ if (!scored) continue;
40863
+ best = best === null ? scored : better(scored, best);
40864
+ }
40865
+ if (!best) return null;
40866
+ const w = Math.max(frame.width, 1);
40867
+ const h = Math.max(frame.height, 1);
40868
+ return {
40869
+ timestamp: best.timestamp,
40870
+ union: best.union,
40871
+ ux: best.union.x / w,
40872
+ uy: best.union.y / h,
40873
+ uw: best.union.w / w,
40874
+ uh: best.union.h / h,
40875
+ visibleCount: best.visibleCount
40876
+ };
40877
+ }
40878
+ /** Longest-lived member that owns a clean full frame; ties break on trackId. */
40879
+ function chooseAnchorTrackId(members, ownsCleanFrame) {
40880
+ const eligible = members.filter((m) => ownsCleanFrame(m.trackId));
40881
+ if (eligible.length === 0) return null;
40882
+ return eligible.toSorted((a, b) => {
40883
+ const life = b.lastSeen - b.firstSeen - (a.lastSeen - a.firstSeen);
40884
+ return life !== 0 ? life : a.trackId.localeCompare(b.trackId);
40885
+ })[0].trackId;
40886
+ }
40887
+ /**
40888
+ * The RGB source for a group crop is a full native frame. Thumbnails are
40889
+ * already single-subject crops and cannot substitute for union-bbox geometry.
40890
+ *
40891
+ * Prefer a keyFrame within {@link GROUP_MEDIA_MATCH_MS} of the chosen instant;
40892
+ * if none, take the nearest keyFrame anyway — dropping the crop because the
40893
+ * best-shot was 3 s off the instant is how a recognised person left the
40894
+ * digest with no crop.
40895
+ */
40896
+ function pickCleanPixelRow(rows, instantMs) {
40897
+ const ranked = (maxDt) => {
40898
+ const candidates = [];
40899
+ for (const row of rows) {
40900
+ const rank = row.kind === "keyFrame" ? 0 : row.kind === "keyFrameSmall" ? 1 : -1;
40901
+ if (rank < 0) continue;
40902
+ const dt = Math.abs(row.timestamp - instantMs);
40903
+ if (maxDt !== null && dt > maxDt) continue;
40904
+ candidates.push({
40905
+ key: row.key,
40906
+ dt,
40907
+ rank
40908
+ });
40909
+ }
40910
+ return candidates.toSorted((a, b) => a.rank - b.rank || a.dt - b.dt)[0];
40911
+ };
40912
+ const near = ranked(GROUP_MEDIA_MATCH_MS);
40913
+ if (near !== void 0) return {
40914
+ key: near.key,
40915
+ usedFallback: false
40916
+ };
40917
+ const any = ranked(null);
40918
+ if (any === void 0) return null;
40919
+ return {
40920
+ key: any.key,
40921
+ usedFallback: true
40922
+ };
40923
+ }
40924
+ //#endregion
40925
+ //#region src/pipeline-analytics/group/group-live-replay.ts
40926
+ function sliceTrackAt(track, now) {
40927
+ const positions = track.positions.filter((p) => p.timestamp <= now);
40928
+ const last = positions[positions.length - 1];
40929
+ return {
40930
+ ...track,
40931
+ lastSeen: last?.timestamp ?? track.firstSeen,
40932
+ positions
40933
+ };
40934
+ }
40935
+ function replayInto(tracks, observer) {
40936
+ const events = [];
40937
+ const byId = /* @__PURE__ */ new Map();
40938
+ for (const track of tracks) {
40939
+ byId.set(track.trackId, track);
40940
+ if (track.positions.length === 0) {
40941
+ events.push({
40942
+ t: track.lastSeen,
40943
+ trackId: track.trackId
40944
+ });
40945
+ continue;
40946
+ }
40947
+ for (const p of track.positions) events.push({
40948
+ t: p.timestamp,
40949
+ trackId: track.trackId
40950
+ });
40951
+ }
40952
+ events.sort((a, b) => a.t - b.t || a.trackId.localeCompare(b.trackId));
40953
+ let lastKey = "";
40954
+ for (const e of events) {
40955
+ const key = `${e.trackId}@${e.t}`;
40956
+ if (key === lastKey) continue;
40957
+ lastKey = key;
40958
+ const track = byId.get(e.trackId);
40959
+ if (!track) continue;
40960
+ observer.observe(sliceTrackAt(track, e.t), e.t);
40961
+ }
40962
+ }
40963
+ function replayLive(tracks, assigner = new GroupLiveAssigner()) {
40964
+ replayInto(tracks, assigner);
40965
+ return assigner;
40966
+ }
40967
+ //#endregion
40968
+ //#region src/pipeline-analytics/group/group-sealer.ts
40969
+ var DEFAULT_FRAME = {
40970
+ width: 640,
40971
+ height: 360
40972
+ };
40973
+ var QUERY_LIMIT = 2e3;
40974
+ /** Crash recover: replay this camera only, on its next activity. */
40975
+ var GROUP_CRASH_LOOKBACK_MS = 2 * 6e4;
40976
+ function toInstant(t) {
40977
+ return {
40978
+ trackId: t.trackId,
40979
+ deviceId: t.deviceId,
40980
+ className: t.className,
40981
+ firstSeen: t.firstSeen,
40982
+ lastSeen: t.lastSeen,
40983
+ positions: t.positions,
40984
+ ...t.closedReason !== void 0 ? { closedReason: t.closedReason } : {},
40985
+ semantic: t.hasFace === true || t.hasPlate === true,
40986
+ confidence: t.importance
40987
+ };
40988
+ }
40989
+ function toSealer(track) {
40990
+ return {
40991
+ trackId: track.trackId,
40992
+ deviceId: track.deviceId,
40993
+ className: track.className,
40994
+ firstSeen: track.firstSeen,
40995
+ lastSeen: track.lastSeen,
40996
+ positions: track.positions,
40997
+ ...track.closedReason !== void 0 ? { closedReason: track.closedReason } : {}
40998
+ };
40999
+ }
41000
+ var GroupSealer = class {
41001
+ deps;
41002
+ constructor(deps) {
41003
+ this.deps = deps;
41004
+ }
41005
+ async flushIdle(buffer, now = this.deps.now()) {
41006
+ let written = 0;
41007
+ for (const group of buffer.idleGroups(now, GROUP_SESSION_IDLE_MS)) if (await this.persistLiveGroup(group, buffer)) {
41008
+ buffer.drop([group.id]);
41009
+ written += 1;
41010
+ }
41011
+ return written;
41012
+ }
41013
+ /**
41014
+ * First activity on camera D after a crash: replay 2 minutes of that camera
41015
+ * into the live buffer, persist whoever is already idle, keep the rest in RAM.
41016
+ */
41017
+ async recoverCamera(deviceId, buffer) {
41018
+ if (buffer.isPrimed(deviceId)) return 0;
41019
+ const now = this.deps.now();
41020
+ try {
41021
+ replayInto(await this.deps.trackStore.queryHistorical({
41022
+ deviceId,
41023
+ since: now - GROUP_CRASH_LOOKBACK_MS,
41024
+ until: now,
41025
+ limit: QUERY_LIMIT,
41026
+ projection: "full"
41027
+ }), buffer);
41028
+ } catch (err) {
41029
+ this.deps.logger.warn("group sealer: lookback failed", {
41030
+ tags: { deviceId },
41031
+ meta: { error: String(err) }
41032
+ });
41033
+ } finally {
41034
+ buffer.markPrimed(deviceId);
41035
+ }
41036
+ return this.flushIdle(buffer, now);
41037
+ }
41038
+ /**
41039
+ * Seal ONE camera over a caller-owned window via live replay.
41040
+ *
41041
+ * This is how a digest recovers groups without a boot scan: the producer
41042
+ * already knows the window. Walking every camera for 48h at initialize()
41043
+ * is what wedged hub-main.
41044
+ */
41045
+ async sealWindow(deviceId, since, until) {
41046
+ try {
41047
+ const closed = await this.deps.trackStore.queryHistorical({
41048
+ deviceId,
41049
+ since,
41050
+ until,
41051
+ limit: QUERY_LIMIT,
41052
+ projection: "full"
41053
+ });
41054
+ if (closed.length === 0) return 0;
41055
+ const already = new Set((await this.deps.groupStore.membersOfTracks(closed.map((t) => t.trackId))).map((m) => m.trackId));
41056
+ const pending = closed.filter((t) => !already.has(t.trackId));
41057
+ if (pending.length === 0) return 0;
41058
+ const assigner = replayLive(pending);
41059
+ const now = this.deps.now();
41060
+ const byId = new Map(pending.map((t) => [t.trackId, t]));
41061
+ let written = 0;
41062
+ for (const group of assigner.idleGroups(now, GROUP_SESSION_IDLE_MS)) {
41063
+ const members = group.memberTrackIds.map((id) => byId.get(id)).filter((t) => t !== void 0);
41064
+ if (members.length === 0) continue;
41065
+ try {
41066
+ await this.persistPartition(members, group.openedAt);
41067
+ written += 1;
41068
+ } catch (err) {
41069
+ this.deps.logger.warn("group sealer: persist failed", {
41070
+ tags: { deviceId },
41071
+ meta: {
41072
+ members: members.map((m) => m.trackId),
41073
+ error: String(err)
41074
+ }
41075
+ });
41076
+ }
41077
+ }
41078
+ return written;
41079
+ } catch (err) {
41080
+ this.deps.logger.warn("group sealer: device failed", {
41081
+ tags: { deviceId },
41082
+ meta: { error: String(err) }
41083
+ });
41084
+ return 0;
41085
+ }
41086
+ }
41087
+ async persistLiveGroup(group, buffer) {
41088
+ const deviceId = group.deviceId;
41089
+ const snaps = group.memberTrackIds.map((id) => buffer.snapshot(id)).filter((t) => t !== void 0);
41090
+ if (snaps.length === 0) return true;
41091
+ const already = new Set((await this.deps.groupStore.membersOfTracks(snaps.map((t) => t.trackId))).map((m) => m.trackId));
41092
+ const pending = snaps.filter((t) => !already.has(t.trackId)).map(toSealer);
41093
+ if (pending.length === 0) return true;
41094
+ try {
41095
+ await this.persistPartition(pending, group.openedAt);
41096
+ return true;
41097
+ } catch (err) {
41098
+ this.deps.logger.warn("group sealer: persist failed", {
41099
+ tags: { deviceId },
41100
+ meta: {
41101
+ members: pending.map((m) => m.trackId),
41102
+ error: String(err)
41103
+ }
41104
+ });
41105
+ return false;
41106
+ }
41107
+ }
41108
+ async persistPartition(members, openedAt) {
41109
+ const deviceId = members[0].deviceId;
41110
+ const frame = this.deps.frameDims(deviceId) ?? DEFAULT_FRAME;
41111
+ const instantMembers = members.map(toInstant);
41112
+ const instant = chooseGroupInstant(instantMembers, frame);
41113
+ const closedAt = Math.max(...members.map((m) => m.lastSeen));
41114
+ const timestamp = instant?.timestamp ?? closedAt;
41115
+ const mediaInfos = await Promise.all(members.map(async (m) => ({
41116
+ trackId: m.trackId,
41117
+ rows: await this.deps.mediaStore.listInfoByOwner("track", m.trackId)
41118
+ })));
41119
+ const ownsClean = (trackId) => {
41120
+ return mediaInfos.find((m) => m.trackId === trackId)?.rows.some((r) => r.kind === "keyFrame" || r.kind === "keyFrameSmall") ?? false;
41121
+ };
41122
+ const anchorTrackId = members.length >= 2 ? chooseAnchorTrackId(instantMembers, ownsClean) : null;
41123
+ let nativeKey = null;
41124
+ if (anchorTrackId !== null) {
41125
+ const rows = mediaInfos.find((m) => m.trackId === anchorTrackId)?.rows ?? [];
41126
+ nativeKey = (rows.find((r) => r.kind === "keyFrame") ?? rows.find((r) => r.kind === "keyFrameSmall"))?.key ?? null;
41127
+ }
41128
+ const draft = await this.deps.groupStore.insert({
41129
+ deviceId,
41130
+ timestamp,
41131
+ openedAt,
41132
+ closedAt,
41133
+ members: members.map((m) => ({
41134
+ trackId: m.trackId,
41135
+ firstSeen: m.firstSeen
41136
+ })),
41137
+ anchorTrackId,
41138
+ ux: instant?.ux ?? 0,
41139
+ uy: instant?.uy ?? 0,
41140
+ uw: instant?.uw ?? 0,
41141
+ uh: instant?.uh ?? 0,
41142
+ frameWidth: frame.width,
41143
+ frameHeight: frame.height,
41144
+ mediaKey: null,
41145
+ nativeKey
41146
+ });
41147
+ if (members.length < 2 || instant === null) return;
41148
+ const source = await this.pickCleanPixels(mediaInfos, timestamp, deviceId);
41149
+ if (source === null) {
41150
+ this.deps.logger.info("group sealer: no clean pixels for crop", {
41151
+ tags: { deviceId },
41152
+ meta: {
41153
+ groupId: draft.id,
41154
+ members: members.map((m) => m.trackId)
41155
+ }
41156
+ });
41157
+ return;
41158
+ }
41159
+ try {
41160
+ const raw = await (0, sharp.default)(source.jpeg).removeAlpha().raw().toBuffer({ resolveWithObject: true });
41161
+ if (raw.info.channels !== 3) return;
41162
+ const union = {
41163
+ x: instant.ux * raw.info.width,
41164
+ y: instant.uy * raw.info.height,
41165
+ w: instant.uw * raw.info.width,
41166
+ h: instant.uh * raw.info.height
41167
+ };
41168
+ const crop = await cropGroupFromRgb(raw.data, raw.info.width, raw.info.height, union);
41169
+ const mediaKey = await this.deps.mediaStore.put({
41170
+ deviceId,
41171
+ ownerKind: "group",
41172
+ ownerId: draft.id,
41173
+ kind: "thumbnail",
41174
+ timestamp,
41175
+ data: crop
41176
+ });
41177
+ await this.deps.groupStore.setMediaKey(draft.id, mediaKey);
41178
+ } catch (err) {
41179
+ this.deps.logger.warn("group sealer: crop failed", {
41180
+ tags: { deviceId },
41181
+ meta: {
41182
+ groupId: draft.id,
41183
+ error: String(err)
41184
+ }
41185
+ });
41186
+ }
41187
+ }
41188
+ async pickCleanPixels(mediaInfos, instantMs, deviceId) {
41189
+ const chosen = pickCleanPixelRow(mediaInfos.flatMap((info) => info.rows), instantMs);
41190
+ if (chosen === null) return null;
41191
+ if (chosen.usedFallback) this.deps.logger.info("group sealer: crop pixels not within 2s of instant, using nearest keyFrame", {
41192
+ tags: { deviceId },
41193
+ meta: {
41194
+ instantMs,
41195
+ mediaKey: chosen.key
41196
+ }
41197
+ });
41198
+ const file = await this.deps.mediaStore.getByKey(chosen.key);
41199
+ if (file === null) return null;
41200
+ return { jpeg: Buffer.from(file.base64, "base64") };
41201
+ }
41202
+ };
41203
+ //#endregion
41204
+ //#region src/pipeline-analytics/group/group-store.ts
41205
+ /**
41206
+ * The group ENTITY store — a co-moving partition of one session, with one
41207
+ * picture of its own (`docs/design/2026-08-21-group-entity-and-group-media.md`).
41208
+ *
41209
+ * Two tables, two questions:
41210
+ *
41211
+ * - `pipeline-analytics:track-groups` is the group row. It has N members and
41212
+ * no `trackId` column; it is owned by its members (owner-indirection).
41213
+ * - `pipeline-analytics:track-group-members` is one row per member. The column
41214
+ * is named exactly `trackId` so the classification derives `track-owned`.
41215
+ *
41216
+ * `anchorTrackId` and `nativeKey` are the only fields mutable after birth:
41217
+ * they are nulled when the anchor member is deleted alone. Everything else is
41218
+ * written once. Re-anchoring is deliberately not done.
41219
+ *
41220
+ * No age clock. A group dies when its last member does; a group with one live
41221
+ * member does not, and it never keeps a dead link.
41222
+ */
41223
+ /**
41224
+ * @durable class=ledger owner=pipeline-analytics
41225
+ * write="one row per co-moving group, sealed at idle — never a live row.
41226
+ * Membership is the RAM members at persist, not a session partition. mediaKey is
41227
+ * the group's own crop; nativeKey is a POINTER to the anchor member's
41228
+ * keyFrame, nulled if that member is deleted alone. anchorTrackId and
41229
+ * nativeKey are the only fields rewritten after birth."
41230
+ * retention="NO age clock (D61). Rows leave with their last member through
41231
+ * TrackGroupStore.deleteByTracks: memberships first; zero survivors
41232
+ * deletes the group's media then the row; survivors plus a lost anchor
41233
+ * null the pointer. The orphan audit verifies the same via a second
41234
+ * resolver (groupId → any member still present)."
41235
+ */
41236
+ var TRACK_GROUPS_COLLECTION = "pipeline-analytics:track-groups";
41237
+ /**
41238
+ * @durable class=ledger owner=pipeline-analytics
41239
+ * write="one row per member at group birth — id is '<groupId>:<trackId>'.
41240
+ * timestamp is the member's firstSeen so the orphan audit has a clock
41241
+ * column. Never rewritten."
41242
+ * retention="TRACK-OWNED and clockless (D61): the column is named exactly
41243
+ * trackId, so classification derives ownership and the cascade deletes
41244
+ * the row with its track. A group with surviving members stays."
41245
+ */
41246
+ var TRACK_GROUP_MEMBERS_COLLECTION = "pipeline-analytics:track-group-members";
41247
+ var TRACK_GROUPS_COLUMNS = [
41248
+ {
41249
+ name: "id",
41250
+ type: "TEXT",
41251
+ primaryKey: true,
41252
+ notNull: true
41253
+ },
41254
+ {
41255
+ name: "deviceId",
41256
+ type: "INTEGER",
41257
+ notNull: true
41258
+ },
41259
+ {
41260
+ name: "timestamp",
41261
+ type: "INTEGER",
41262
+ notNull: true
41263
+ },
41264
+ {
41265
+ name: "openedAt",
41266
+ type: "INTEGER",
41267
+ notNull: true
41268
+ },
41269
+ {
41270
+ name: "closedAt",
41271
+ type: "INTEGER",
41272
+ notNull: true
41273
+ },
41274
+ {
41275
+ name: "memberCount",
41276
+ type: "INTEGER",
41277
+ notNull: true
41278
+ },
41279
+ {
41280
+ name: "anchorTrackId",
41281
+ type: "TEXT"
41282
+ },
41283
+ {
41284
+ name: "ux",
41285
+ type: "REAL",
41286
+ notNull: true
41287
+ },
41288
+ {
41289
+ name: "uy",
41290
+ type: "REAL",
41291
+ notNull: true
41292
+ },
41293
+ {
41294
+ name: "uw",
41295
+ type: "REAL",
41296
+ notNull: true
41297
+ },
41298
+ {
41299
+ name: "uh",
41300
+ type: "REAL",
41301
+ notNull: true
41302
+ },
41303
+ {
41304
+ name: "frameWidth",
41305
+ type: "INTEGER",
41306
+ notNull: true
41307
+ },
41308
+ {
41309
+ name: "frameHeight",
41310
+ type: "INTEGER",
41311
+ notNull: true
41312
+ },
41313
+ {
41314
+ name: "mediaKey",
41315
+ type: "TEXT"
41316
+ },
41317
+ {
41318
+ name: "nativeKey",
41319
+ type: "TEXT"
41320
+ },
41321
+ {
41322
+ name: "sealed",
41323
+ type: "BOOLEAN",
41324
+ notNull: true
41325
+ }
41326
+ ];
41327
+ var TRACK_GROUP_MEMBERS_COLUMNS = [
41328
+ {
41329
+ name: "id",
41330
+ type: "TEXT",
41331
+ primaryKey: true,
41332
+ notNull: true
41333
+ },
41334
+ {
41335
+ name: "groupId",
41336
+ type: "TEXT",
41337
+ notNull: true
41338
+ },
41339
+ {
41340
+ name: "trackId",
41341
+ type: "TEXT",
41342
+ notNull: true
41343
+ },
41344
+ {
41345
+ name: "deviceId",
41346
+ type: "INTEGER",
41347
+ notNull: true
41348
+ },
41349
+ {
41350
+ name: "timestamp",
41351
+ type: "INTEGER",
41352
+ notNull: true
41353
+ }
41354
+ ];
41355
+ var TRACK_GROUPS_INDEXES = [{
41356
+ name: "idx_track_groups_device_closed",
41357
+ columns: ["deviceId", "closedAt"]
41358
+ }, {
41359
+ name: "idx_track_groups_device_opened",
41360
+ columns: ["deviceId", "openedAt"]
41361
+ }];
41362
+ var TRACK_GROUP_MEMBERS_INDEXES = [{
41363
+ name: "idx_track_group_members_track",
41364
+ columns: ["trackId"]
41365
+ }, {
41366
+ name: "idx_track_group_members_group",
41367
+ columns: ["groupId"]
41368
+ }];
41369
+ function newGroupId() {
41370
+ return `grp-${(0, node_crypto.randomUUID)()}`;
41371
+ }
41372
+ function memberRowId(groupId, trackId) {
41373
+ return `${groupId}:${trackId}`;
41374
+ }
41375
+ var TrackGroupStore = class {
41376
+ store;
41377
+ logger;
41378
+ media;
41379
+ constructor(deps) {
41380
+ this.store = deps.store;
41381
+ this.logger = deps.logger;
41382
+ this.media = deps.media ?? null;
41383
+ }
41384
+ static async declare(store) {
41385
+ await Promise.all([store.declareCollection.mutate({
41386
+ collection: TRACK_GROUPS_COLLECTION,
41387
+ columns: [...TRACK_GROUPS_COLUMNS],
41388
+ indexes: [...TRACK_GROUPS_INDEXES]
41389
+ }), store.declareCollection.mutate({
41390
+ collection: TRACK_GROUP_MEMBERS_COLLECTION,
41391
+ columns: [...TRACK_GROUP_MEMBERS_COLUMNS],
41392
+ indexes: [...TRACK_GROUP_MEMBERS_INDEXES]
41393
+ })]);
41394
+ }
41395
+ async insert(input) {
41396
+ const id = newGroupId();
41397
+ const group = {
41398
+ id,
41399
+ deviceId: input.deviceId,
41400
+ timestamp: input.timestamp,
41401
+ openedAt: input.openedAt,
41402
+ closedAt: input.closedAt,
41403
+ memberCount: input.members.length,
41404
+ anchorTrackId: input.anchorTrackId,
41405
+ ux: input.ux,
41406
+ uy: input.uy,
41407
+ uw: input.uw,
41408
+ uh: input.uh,
41409
+ frameWidth: input.frameWidth,
41410
+ frameHeight: input.frameHeight,
41411
+ mediaKey: input.mediaKey,
41412
+ nativeKey: input.nativeKey,
41413
+ sealed: true
41414
+ };
41415
+ await this.store.set.mutate({
41416
+ collection: TRACK_GROUPS_COLLECTION,
41417
+ key: id,
41418
+ value: this.toRow(group)
41419
+ });
41420
+ for (const member of input.members) await this.store.set.mutate({
41421
+ collection: TRACK_GROUP_MEMBERS_COLLECTION,
41422
+ key: memberRowId(id, member.trackId),
41423
+ value: {
41424
+ groupId: id,
41425
+ trackId: member.trackId,
41426
+ deviceId: input.deviceId,
41427
+ timestamp: member.firstSeen
41428
+ }
41429
+ });
41430
+ return group;
41431
+ }
41432
+ /** The crop is written after birth; this is the only post-birth write besides the pointer null. */
41433
+ async setMediaKey(groupId, mediaKey) {
41434
+ const current = await this.get(groupId);
41435
+ if (current === null) return;
41436
+ await this.store.set.mutate({
41437
+ collection: TRACK_GROUPS_COLLECTION,
41438
+ key: groupId,
41439
+ value: this.toRow({
41440
+ ...current,
41441
+ mediaKey
41442
+ })
41443
+ });
41444
+ }
41445
+ async get(groupId) {
41446
+ const raw = await this.store.get.query({
41447
+ collection: TRACK_GROUPS_COLLECTION,
41448
+ key: groupId
41449
+ });
41450
+ if (!raw) return null;
41451
+ return this.normalizeGroup({
41452
+ id: groupId,
41453
+ data: raw
41454
+ });
41455
+ }
41456
+ async listMembers(groupId) {
41457
+ return (await this.store.query.query({
41458
+ collection: TRACK_GROUP_MEMBERS_COLLECTION,
41459
+ filter: { where: { groupId } }
41460
+ })).map((r) => this.normalizeMember({
41461
+ id: r.id,
41462
+ data: r.data
41463
+ }));
41464
+ }
41465
+ async membersOfTracks(trackIds) {
41466
+ const out = [];
41467
+ for (const trackId of trackIds) {
41468
+ const rows = await this.store.query.query({
41469
+ collection: TRACK_GROUP_MEMBERS_COLLECTION,
41470
+ filter: { where: { trackId } }
41471
+ });
41472
+ for (const r of rows) out.push(this.normalizeMember({
41473
+ id: r.id,
41474
+ data: r.data
41475
+ }));
41476
+ }
41477
+ return out;
41478
+ }
41479
+ /**
41480
+ * Newest-closed page for the Library Groups tab.
41481
+ *
41482
+ * Time filter is overlap (`closedAt >= since` and `openedAt <= until`),
41483
+ * same as {@link listOverlapping}. Pagination is applied AFTER the window
41484
+ * so a day cursor is honest rather than a raw-row offset that skips live
41485
+ * groups on a busy camera.
41486
+ */
41487
+ async listRecent(input) {
41488
+ const limit = Math.min(Math.max(input.limit ?? 40, 1), 100);
41489
+ const offset = Math.max(input.offset ?? 0, 0);
41490
+ const slice = (await this.store.query.query({
41491
+ collection: TRACK_GROUPS_COLLECTION,
41492
+ filter: {
41493
+ ...input.deviceId !== void 0 ? { where: { deviceId: input.deviceId } } : {},
41494
+ orderBy: {
41495
+ field: "closedAt",
41496
+ direction: "desc"
41497
+ },
41498
+ limit: 2e3
41499
+ }
41500
+ })).map((r) => this.normalizeGroup({
41501
+ id: r.id,
41502
+ data: r.data
41503
+ })).filter((g) => {
41504
+ if (input.sinceMs !== void 0 && g.closedAt < input.sinceMs) return false;
41505
+ if (input.untilMs !== void 0 && g.openedAt > input.untilMs) return false;
41506
+ return true;
41507
+ }).slice(offset, offset + limit + 1);
41508
+ return {
41509
+ groups: slice.slice(0, limit),
41510
+ hasMore: slice.length > limit
41511
+ };
41512
+ }
41513
+ /** Groups whose life overlaps `[since, until]` on one camera. */
41514
+ async listOverlapping(input) {
41515
+ return (await this.store.query.query({
41516
+ collection: TRACK_GROUPS_COLLECTION,
41517
+ filter: {
41518
+ where: { deviceId: input.deviceId },
41519
+ orderBy: {
41520
+ field: "closedAt",
41521
+ direction: "desc"
41522
+ },
41523
+ limit: input.limit ?? 2e3
41524
+ }
41525
+ })).map((r) => this.normalizeGroup({
41526
+ id: r.id,
41527
+ data: r.data
41528
+ })).filter((g) => g.closedAt >= input.sinceMs && g.openedAt <= input.untilMs);
41529
+ }
41530
+ async existingGroupIds(ids) {
41531
+ const live = /* @__PURE__ */ new Set();
41532
+ for (const id of ids) if (await this.store.get.query({
41533
+ collection: "pipeline-analytics:track-groups",
41534
+ key: id
41535
+ })) live.add(id);
41536
+ return live;
41537
+ }
41538
+ /** Group ids that still have at least one membership row. */
41539
+ async groupsWithMembers(ids) {
41540
+ const live = /* @__PURE__ */ new Set();
41541
+ for (const groupId of ids) if ((await this.store.query.query({
41542
+ collection: "pipeline-analytics:track-group-members",
41543
+ filter: {
41544
+ where: { groupId },
41545
+ limit: 1
41546
+ }
41547
+ })).length > 0) live.add(groupId);
41548
+ return live;
41549
+ }
41550
+ /**
41551
+ * Cascade leaf: delete memberships for the batch; a group with zero survivors
41552
+ * dies (media then row); a group that lost only its anchor nulls the pointer.
41553
+ */
41554
+ async deleteByTracks(trackIds) {
41555
+ const batch = new Set(trackIds);
41556
+ let memberRows = 0;
41557
+ let groupRows = 0;
41558
+ let mediaRows = 0;
41559
+ let mediaBytes = 0;
41560
+ const affected = /* @__PURE__ */ new Set();
41561
+ try {
41562
+ const memberships = await this.membersOfTracks(trackIds);
41563
+ for (const member of memberships) try {
41564
+ await this.store.delete.mutate({
41565
+ collection: TRACK_GROUP_MEMBERS_COLLECTION,
41566
+ key: member.id
41567
+ });
41568
+ memberRows += 1;
41569
+ affected.add(member.groupId);
41570
+ } catch (err) {
41571
+ this.logger.warn("TrackGroupStore.deleteByTracks member delete failed", { meta: {
41572
+ memberId: member.id,
41573
+ error: String(err)
41574
+ } });
41575
+ }
41576
+ for (const groupId of affected) try {
41577
+ if ((await this.listMembers(groupId)).length === 0) {
41578
+ if (this.media) {
41579
+ const gone = await this.media.deleteByGroups([groupId]);
41580
+ mediaRows += gone.rows;
41581
+ mediaBytes += gone.bytes;
41582
+ }
41583
+ await this.store.delete.mutate({
41584
+ collection: TRACK_GROUPS_COLLECTION,
41585
+ key: groupId
41586
+ });
41587
+ groupRows += 1;
41588
+ continue;
41589
+ }
41590
+ const group = await this.get(groupId);
41591
+ if (group?.anchorTrackId && batch.has(group.anchorTrackId)) await this.store.set.mutate({
41592
+ collection: TRACK_GROUPS_COLLECTION,
41593
+ key: groupId,
41594
+ value: this.toRow({
41595
+ ...group,
41596
+ anchorTrackId: null,
41597
+ nativeKey: null
41598
+ })
41599
+ });
41600
+ } catch (err) {
41601
+ this.logger.warn("TrackGroupStore.deleteByTracks group update failed", { meta: {
41602
+ groupId,
41603
+ error: String(err)
41604
+ } });
41605
+ }
41606
+ } catch (err) {
41607
+ this.logger.warn("TrackGroupStore.deleteByTracks failed", { meta: { error: String(err) } });
41608
+ }
41609
+ return [{
41610
+ family: "groups",
41611
+ rows: memberRows + groupRows,
41612
+ bytes: 0
41613
+ }, {
41614
+ family: "media",
41615
+ rows: mediaRows,
41616
+ bytes: mediaBytes
41617
+ }];
41618
+ }
41619
+ toRow(group) {
41620
+ return {
41621
+ deviceId: group.deviceId,
41622
+ timestamp: group.timestamp,
41623
+ openedAt: group.openedAt,
41624
+ closedAt: group.closedAt,
41625
+ memberCount: group.memberCount,
41626
+ anchorTrackId: group.anchorTrackId,
41627
+ ux: group.ux,
41628
+ uy: group.uy,
41629
+ uw: group.uw,
41630
+ uh: group.uh,
41631
+ frameWidth: group.frameWidth,
41632
+ frameHeight: group.frameHeight,
41633
+ mediaKey: group.mediaKey,
41634
+ nativeKey: group.nativeKey,
41635
+ sealed: group.sealed
41636
+ };
41637
+ }
41638
+ normalizeGroup(r) {
41639
+ const data = r.data;
41640
+ return {
41641
+ id: r.id,
41642
+ deviceId: Number(data["deviceId"]),
41643
+ timestamp: Number(data["timestamp"]),
41644
+ openedAt: Number(data["openedAt"]),
41645
+ closedAt: Number(data["closedAt"]),
41646
+ memberCount: Number(data["memberCount"]),
41647
+ anchorTrackId: typeof data["anchorTrackId"] === "string" ? data["anchorTrackId"] : null,
41648
+ ux: Number(data["ux"]),
41649
+ uy: Number(data["uy"]),
41650
+ uw: Number(data["uw"]),
41651
+ uh: Number(data["uh"]),
41652
+ frameWidth: Number(data["frameWidth"]),
41653
+ frameHeight: Number(data["frameHeight"]),
41654
+ mediaKey: typeof data["mediaKey"] === "string" ? data["mediaKey"] : null,
41655
+ nativeKey: typeof data["nativeKey"] === "string" ? data["nativeKey"] : null,
41656
+ sealed: Boolean(data["sealed"])
41657
+ };
41658
+ }
41659
+ normalizeMember(r) {
41660
+ const data = r.data;
41661
+ return {
41662
+ id: r.id,
41663
+ groupId: String(data["groupId"]),
41664
+ trackId: String(data["trackId"]),
41665
+ deviceId: Number(data["deviceId"]),
41666
+ timestamp: Number(data["timestamp"])
41667
+ };
41668
+ }
41669
+ };
40134
41670
  //#endregion
40135
41671
  //#region src/pipeline-analytics/pipeline/dropout-skip.ts
40136
41672
  function shouldSkipDropoutFrame(activeTrackCount, detectionCount, consecutiveSkips, cfg) {
@@ -46748,6 +48284,15 @@ function isAfterCursor(row, cursor) {
46748
48284
  if (row.lastSeen < cursor.lastSeen) return true;
46749
48285
  return row.lastSeen === cursor.lastSeen && row.id < cursor.trackId;
46750
48286
  }
48287
+ function netDisplacementPx(positions) {
48288
+ const first = positions[0];
48289
+ const last = positions[positions.length - 1];
48290
+ if (!first || !last) return 0;
48291
+ return Math.hypot(last.x - first.x, last.y - first.y);
48292
+ }
48293
+ function isShortMotionlessTrack(lifeMs, movedPx) {
48294
+ return lifeMs <= 25e3 && movedPx <= 8;
48295
+ }
46751
48296
  //#endregion
46752
48297
  //#region src/pipeline-analytics/store/track-store.ts
46753
48298
  /**
@@ -47330,6 +48875,7 @@ var TrackStore = class {
47330
48875
  for (const [trackId, t] of this.active) {
47331
48876
  if (nowMs - t.lastSeen < this.config.ttlMs) continue;
47332
48877
  t.active = false;
48878
+ if (t.positions.length > 0 && isShortMotionlessTrack(t.lastSeen - t.firstSeen, netDisplacementPx(t.positions))) t.closedReason = "stationary-promotion";
47333
48879
  const record = cloneTrack(t);
47334
48880
  try {
47335
48881
  await this.persistCompleted(record);
@@ -48079,10 +49625,21 @@ var TrackStore = class {
48079
49625
  * in-memory dedup (the query cap has no DISTINCT); best-effort.
48080
49626
  */
48081
49627
  async listDeviceIds() {
49628
+ return this.collectDeviceIds(0);
49629
+ }
49630
+ /**
49631
+ * Distinct deviceIds with a persisted track whose `lastSeen` is at least
49632
+ * `since`. The group sealer's 10-minute window. Walking from epoch 0 is how
49633
+ * `sealRecent` wedged hub-main every TTL sweep.
49634
+ */
49635
+ async listDeviceIdsSince(since) {
49636
+ return this.collectDeviceIds(Math.max(0, Math.floor(since)));
49637
+ }
49638
+ async collectDeviceIds(fromLastSeen) {
48082
49639
  const seenDevices = /* @__PURE__ */ new Set();
48083
49640
  const seenIds = /* @__PURE__ */ new Set();
48084
49641
  const PAGE = 500;
48085
- let cursor = 0;
49642
+ let cursor = fromLastSeen;
48086
49643
  try {
48087
49644
  for (;;) {
48088
49645
  const rows = await this.store.query.query({
@@ -48878,12 +50435,24 @@ var ANALYTICS_COLLECTIONS = [
48878
50435
  collection: PLATES_COLLECTION,
48879
50436
  classification: derived(PLATE_COLUMNS)
48880
50437
  },
50438
+ {
50439
+ collection: TRACK_GROUP_MEMBERS_COLLECTION,
50440
+ classification: derived(TRACK_GROUP_MEMBERS_COLUMNS)
50441
+ },
48881
50442
  {
48882
50443
  collection: MEDIA_COLLECTION,
48883
50444
  classification: {
48884
50445
  kind: "owner-indirection",
48885
50446
  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."
50447
+ 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."
50448
+ }
50449
+ },
50450
+ {
50451
+ collection: TRACK_GROUPS_COLLECTION,
50452
+ classification: {
50453
+ kind: "owner-indirection",
50454
+ derivation: "explicit",
50455
+ 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
50456
  }
48888
50457
  },
48889
50458
  {
@@ -48995,7 +50564,7 @@ var ANALYTICS_COLLECTIONS = [
48995
50564
  collection: SCENE_LATCH_COLLECTION,
48996
50565
  classification: {
48997
50566
  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`."
50567
+ 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
50568
  }
49000
50569
  },
49001
50570
  {
@@ -49083,6 +50652,10 @@ function resolveMediaOwner(row) {
49083
50652
  kind: "unresolvable",
49084
50653
  why: `plate-owned media whose ownerId does not carry the '${PLATE_MEDIA_OWNER_PREFIX}' prefix`
49085
50654
  };
50655
+ case "group": return {
50656
+ kind: "group",
50657
+ groupId: ownerId
50658
+ };
49086
50659
  default: return {
49087
50660
  kind: "unresolvable",
49088
50661
  why: `unknown ownerKind '${kindRaw}'`
@@ -49094,6 +50667,13 @@ function resolveMediaOwner(row) {
49094
50667
  * is derived the same way the classification is — an enrolled row is out of
49095
50668
  * reach regardless of what happened to the track that produced it.
49096
50669
  */
50670
+ /** A group row lives iff any membership row still names it. */
50671
+ function resolveGroupOwner(row) {
50672
+ return {
50673
+ kind: "group-members",
50674
+ groupId: row.key
50675
+ };
50676
+ }
49097
50677
  function resolveDeclaredTrackOwner(row, enrolmentExempt) {
49098
50678
  if (enrolmentExempt && row.data["assigned"] === true) return {
49099
50679
  kind: "exempt",
@@ -49113,15 +50693,28 @@ function buildOrphanScopes(collections = auditableCollections()) {
49113
50693
  const scopes = [];
49114
50694
  for (const entry of collections) {
49115
50695
  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
- });
50696
+ if (entry.collection === "pipeline-analytics:media") {
50697
+ scopes.push({
50698
+ id: entry.collection,
50699
+ collection: entry.collection,
50700
+ resolve: resolveMediaOwner,
50701
+ timestampField: "timestamp",
50702
+ sizeField: "sizeBytes",
50703
+ deviceIdField: "deviceId"
50704
+ });
50705
+ continue;
50706
+ }
50707
+ if (entry.collection === "pipeline-analytics:track-groups") {
50708
+ scopes.push({
50709
+ id: entry.collection,
50710
+ collection: entry.collection,
50711
+ resolve: resolveGroupOwner,
50712
+ timestampField: "timestamp",
50713
+ sizeField: null,
50714
+ deviceIdField: "deviceId"
50715
+ });
50716
+ continue;
50717
+ }
49125
50718
  continue;
49126
50719
  }
49127
50720
  if (entry.classification.kind !== "track-owned") continue;
@@ -49153,11 +50746,17 @@ var DEFAULT_ORPHAN_LIMITS = {
49153
50746
  function questionsFor(refs) {
49154
50747
  const trackIds = /* @__PURE__ */ new Set();
49155
50748
  const eventIds = /* @__PURE__ */ new Set();
50749
+ const groupIds = /* @__PURE__ */ new Set();
50750
+ const groupMemberIds = /* @__PURE__ */ new Set();
49156
50751
  for (const ref of refs) if (ref.kind === "track") trackIds.add(ref.trackId);
49157
50752
  else if (ref.kind === "event") eventIds.add(ref.eventId);
50753
+ else if (ref.kind === "group") groupIds.add(ref.groupId);
50754
+ else if (ref.kind === "group-members") groupMemberIds.add(ref.groupId);
49158
50755
  return {
49159
50756
  trackIds: [...trackIds],
49160
- eventIds: [...eventIds]
50757
+ eventIds: [...eventIds],
50758
+ groupIds: [...groupIds],
50759
+ groupMemberIds: [...groupMemberIds]
49161
50760
  };
49162
50761
  }
49163
50762
  /**
@@ -49189,12 +50788,16 @@ async function auditScope(scope, deps, limits, cursor) {
49189
50788
  break;
49190
50789
  }
49191
50790
  const refs = rows.map((row) => scope.resolve(row));
49192
- const { trackIds, eventIds } = questionsFor(refs);
50791
+ const { trackIds, eventIds, groupIds, groupMemberIds } = questionsFor(refs);
49193
50792
  let liveTracks;
49194
50793
  let liveEvents;
50794
+ let liveGroups;
50795
+ let liveGroupsWithMembers;
49195
50796
  try {
49196
50797
  liveTracks = trackIds.length > 0 ? await deps.liveTrackIds(trackIds) : /* @__PURE__ */ new Set();
49197
50798
  liveEvents = eventIds.length > 0 ? await deps.liveEventIds(eventIds) : /* @__PURE__ */ new Set();
50799
+ liveGroups = groupIds.length > 0 && deps.liveGroupIds ? await deps.liveGroupIds(groupIds) : /* @__PURE__ */ new Set();
50800
+ liveGroupsWithMembers = groupMemberIds.length > 0 && deps.liveGroupsWithMembers ? await deps.liveGroupsWithMembers(groupMemberIds) : /* @__PURE__ */ new Set();
49198
50801
  } catch (err) {
49199
50802
  deps.logger.warn("orphan audit: liveness probe failed — scope abandoned for this run", { meta: {
49200
50803
  collection: scope.collection,
@@ -49218,7 +50821,7 @@ async function auditScope(scope, deps, limits, cursor) {
49218
50821
  tally.unresolvable += 1;
49219
50822
  continue;
49220
50823
  }
49221
- if (ref.kind === "track" ? liveTracks.has(ref.trackId) : liveEvents.has(ref.eventId)) continue;
50824
+ 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
50825
  const ts = num(row.data, scope.timestampField);
49223
50826
  if (ts !== null && ts > graceFloor) {
49224
50827
  tally.withinGrace += 1;
@@ -51310,6 +52913,13 @@ function squareSafeCropRegionNormalized(bbox, frame, padding) {
51310
52913
  };
51311
52914
  }
51312
52915
  //#endregion
52916
+ //#region src/pipeline-analytics/pipeline/usable-bbox.ts
52917
+ /** A box that can legally drive a crop. The leaving-event fallback
52918
+ * `{0,0,0,0}` must never become a face/plate JPEG. */
52919
+ function isUsableBbox(bbox) {
52920
+ return bbox.w > 0 && bbox.h > 0;
52921
+ }
52922
+ //#endregion
51313
52923
  //#region src/pipeline-analytics/services/event-media-dispatcher.ts
51314
52924
  /**
51315
52925
  * Long-side cap (px) for the boxed timeline tiles — `firstFrame`, `snapshot`,
@@ -51888,26 +53498,29 @@ var EventMediaDispatcher = class {
51888
53498
  }
51889
53499
  }
51890
53500
  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: {
53501
+ if (ev.childCrops) for (const child of ev.childCrops) {
53502
+ if (!isUsableBbox(child.bbox)) continue;
53503
+ try {
53504
+ const childCropData = await this.cropSubjectRegion(deviceId, frameHandle, frameData, fw, fh, child.bbox, cropPadding, `${ev.eventId}:${child.kind}`);
53505
+ if (!childCropData) continue;
53506
+ await this.deps.mediaStore.put({
51906
53507
  deviceId,
51907
- eventId: ev.eventId,
51908
- error: err instanceof Error ? err.message : String(err)
51909
- }
51910
- });
53508
+ ownerKind: "event",
53509
+ ownerId: ev.eventId,
53510
+ kind: child.kind,
53511
+ timestamp: ev.timestamp,
53512
+ data: childCropData
53513
+ });
53514
+ } catch (err) {
53515
+ this.deps.logger.warn(`event media: ${child.kind} failed`, {
53516
+ tags: { deviceId },
53517
+ meta: {
53518
+ deviceId,
53519
+ eventId: ev.eventId,
53520
+ error: err instanceof Error ? err.message : String(err)
53521
+ }
53522
+ });
53523
+ }
51911
53524
  }
51912
53525
  }
51913
53526
  async writeTrackFrame(deviceId, frameHandle, frameData, fw, fh, tf) {
@@ -55168,7 +56781,8 @@ var customActions = {
55168
56781
  ...orphanAuditActions,
55169
56782
  ...viewerSettingsActions,
55170
56783
  ...photoEnrollActions,
55171
- ...faceRescoreActions
56784
+ ...faceRescoreActions,
56785
+ ...groupActions
55172
56786
  };
55173
56787
  /**
55174
56788
  * Assist threshold when the caller does not pin one. Packages have their own
@@ -55347,11 +56961,6 @@ function decodeEmbeddingBase64(base64) {
55347
56961
  const view = new Float32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));
55348
56962
  return Array.from(view);
55349
56963
  }
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
56964
  /** Grid the spawn point is quantised to, so respawns whose boxes never repeat
55356
56965
  * to the pixel still land in one cell. */
55357
56966
  var PHANTOM_CELL_PX = 32;
@@ -55415,6 +57024,11 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
55415
57024
  faceStore = null;
55416
57025
  faceRecognizer = null;
55417
57026
  plateStore = null;
57027
+ groupStore = null;
57028
+ groupSealer = null;
57029
+ groupLive = null;
57030
+ /** Serialise first-activity lookback per camera, then observe in order. */
57031
+ groupObserveTail = /* @__PURE__ */ new Map();
55418
57032
  plateRecognizer = null;
55419
57033
  objectEmbeddingStore = null;
55420
57034
  /** Live state of the detached embedding rebuild — see `rebuildObjectEmbeddings`. */
@@ -56232,7 +57846,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
56232
57846
  ...orphanAuditActions,
56233
57847
  ...viewerSettingsActions,
56234
57848
  ...photoEnrollActions,
56235
- ...faceRescoreActions
57849
+ ...faceRescoreActions,
57850
+ ...groupActions
56236
57851
  },
56237
57852
  actionHandlers: {
56238
57853
  ...ncHandlers,
@@ -56240,6 +57855,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
56240
57855
  ...this.buildDebugActionHandlers(),
56241
57856
  ...this.buildPhotoEnrollActionHandlers(),
56242
57857
  ...this.buildFaceRescoreActionHandlers(),
57858
+ ...this.buildGroupActionHandlers(),
56243
57859
  ...this.viewerSettingsSnapshots ? makeViewerSettingsActionHandlers(this.viewerSettingsSnapshots) : {}
56244
57860
  }
56245
57861
  } : {}
@@ -56442,6 +58058,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
56442
58058
  SceneStore.declare(api.settingsStore),
56443
58059
  NotificationCenter.declare(api.settingsStore),
56444
58060
  ViewerSettingsSnapshotStore.declare(api.settingsStore),
58061
+ TrackGroupStore.declare(api.settingsStore),
56445
58062
  declareMigrationMarkers(api.settingsStore)
56446
58063
  ]);
56447
58064
  this.viewerSettingsSnapshots = new ViewerSettingsSnapshotStore(api.settingsStore);
@@ -56726,6 +58343,31 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
56726
58343
  logger: logger.child("PlateStore")
56727
58344
  });
56728
58345
  this.plateStore = plateStore;
58346
+ const vehicleStore = new VehicleStore({
58347
+ store: api.settingsStore,
58348
+ logger: logger.child("VehicleStore")
58349
+ });
58350
+ const groupStore = new TrackGroupStore({
58351
+ store: api.settingsStore,
58352
+ logger: logger.child("TrackGroupStore"),
58353
+ media: mediaStore
58354
+ });
58355
+ this.groupStore = groupStore;
58356
+ this.groupLive = new GroupLiveBuffer();
58357
+ this.groupSealer = new GroupSealer({
58358
+ trackStore,
58359
+ groupStore,
58360
+ mediaStore,
58361
+ logger: logger.child("GroupSealer"),
58362
+ frameDims: (deviceId) => {
58363
+ const dims = this.lastFrameDimsByDevice.get(deviceId);
58364
+ return dims ? {
58365
+ width: dims.w,
58366
+ height: dims.h
58367
+ } : void 0;
58368
+ },
58369
+ now: () => Date.now()
58370
+ });
56729
58371
  return {
56730
58372
  trackStore,
56731
58373
  mediaStore,
@@ -56733,10 +58375,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
56733
58375
  identityStore,
56734
58376
  faceStore,
56735
58377
  plateStore,
56736
- vehicleStore: new VehicleStore({
56737
- store: api.settingsStore,
56738
- logger: logger.child("VehicleStore")
56739
- })
58378
+ vehicleStore,
58379
+ groupStore
56740
58380
  };
56741
58381
  }
56742
58382
  /** Face + plate recognizers, the durable keyFrame-derived plate crop and the
@@ -57170,29 +58810,71 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
57170
58810
  buildSummaryPorts(stores) {
57171
58811
  return {
57172
58812
  listTracks: async ({ deviceId, sinceMs, untilMs, limit }) => {
57173
- const tracks = await stores.trackStore.queryHistorical({
58813
+ await this.groupSealer?.sealWindow(deviceId, sinceMs, untilMs);
58814
+ const groups = await stores.groupStore.listOverlapping({
58815
+ deviceId,
58816
+ sinceMs,
58817
+ untilMs,
58818
+ limit
58819
+ });
58820
+ const windowTracks = await stores.trackStore.queryHistorical({
57174
58821
  deviceId,
57175
58822
  since: sinceMs,
57176
58823
  until: untilMs,
57177
58824
  projection: "slim",
57178
- limit
58825
+ limit: Math.max(limit, 2e3)
57179
58826
  });
58827
+ const trackById = new Map(windowTracks.map((t) => [t.trackId, t]));
57180
58828
  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
- }));
58829
+ const out = [];
58830
+ for (const group of groups) {
58831
+ const members = await stores.groupStore.listMembers(group.id);
58832
+ const tracks = members.map((m) => trackById.get(m.trackId)).filter((t) => t !== void 0);
58833
+ if (tracks.length === 0) continue;
58834
+ const classes = [...new Set(tracks.flatMap((t) => [t.className, ...t.classes ?? []]))];
58835
+ const zones = [...new Set(tracks.flatMap((t) => [...t.zonesVisited]))];
58836
+ const importance = Math.max(0, ...tracks.map((t) => t.importance ?? 0));
58837
+ const primary = tracks.reduce((best, t) => (t.importance ?? 0) > (best.importance ?? 0) ? t : best);
58838
+ out.push({
58839
+ trackId: group.id,
58840
+ groupId: group.id,
58841
+ memberTrackIds: members.map((m) => m.trackId),
58842
+ deviceId,
58843
+ className: primary.className,
58844
+ firstSeen: group.openedAt,
58845
+ lastSeen: group.closedAt,
58846
+ importance,
58847
+ zones,
58848
+ classes,
58849
+ ...tracks.every((t) => t.closedReason === "stationary-promotion") ? { stationary: true } : {},
58850
+ confirmed: tracks.some((t) => t.observations !== void 0 && t.observations >= minHits)
58851
+ });
58852
+ }
58853
+ return out;
57193
58854
  },
57194
- listMediaInfo: async (trackId) => {
57195
- return (await stores.mediaStore.listInfoByOwner("track", trackId)).map((r) => ({
58855
+ listMediaInfo: async (id) => {
58856
+ const group = await stores.groupStore.get(id);
58857
+ if (group) {
58858
+ if (group.mediaKey || group.nativeKey) return digestMediaRows({
58859
+ mediaKey: group.mediaKey,
58860
+ nativeKey: group.nativeKey,
58861
+ timestamp: group.timestamp,
58862
+ memberRows: []
58863
+ });
58864
+ const members = await stores.groupStore.listMembers(group.id);
58865
+ const memberRows = (await Promise.all(members.map((m) => stores.mediaStore.listInfoByOwner("track", m.trackId)))).flatMap((rows) => rows.map((r) => ({
58866
+ key: r.key,
58867
+ kind: r.kind,
58868
+ timestamp: r.timestamp
58869
+ })));
58870
+ return digestMediaRows({
58871
+ mediaKey: group.mediaKey,
58872
+ nativeKey: group.nativeKey,
58873
+ timestamp: group.timestamp,
58874
+ memberRows
58875
+ });
58876
+ }
58877
+ return (await stores.mediaStore.listInfoByOwner("track", id)).map((r) => ({
57196
58878
  key: r.key,
57197
58879
  kind: r.kind,
57198
58880
  timestamp: r.timestamp
@@ -58437,6 +60119,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
58437
60119
  state: t.state
58438
60120
  });
58439
60121
  positionsCountById.set(t.trackId, upserted.positions.length);
60122
+ this.enqueueLiveGroup(upserted, result.timestamp);
58440
60123
  }
58441
60124
  if (result.rawTrackedDetections.length > 0) {
58442
60125
  const { minHits } = await this.resolveDeviceDetectionSensitivitySettings(deviceId);
@@ -59216,8 +60899,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
59216
60899
  const first = track.positions?.[0];
59217
60900
  if (!first) return;
59218
60901
  const lifeMs = track.lastSeen - track.firstSeen;
59219
- const moved = track.totalDistance ?? 0;
59220
- if (!(lifeMs <= SHORT_TRACK_MAX_MS && moved <= MOTIONLESS_MAX_PX)) {
60902
+ const moved = netDisplacementPx(track.positions ?? []);
60903
+ if (!isShortMotionlessTrack(lifeMs, moved)) {
59221
60904
  this.phantomCells?.noteMovingClose({
59222
60905
  deviceId: track.deviceId,
59223
60906
  className: track.className,
@@ -59360,7 +61043,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
59360
61043
  deviceId,
59361
61044
  trackId,
59362
61045
  timestamp: frame.timestamp,
59363
- bbox: { ...frame.bbox },
61046
+ bbox: { ...bboxForSemanticPromotion(d.className, d.bbox, frame.bbox) },
59364
61047
  frameWidth: frame.frameWidth,
59365
61048
  frameHeight: frame.frameHeight,
59366
61049
  kind: d.className === "plate" ? "plate" : "classification",
@@ -59793,7 +61476,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
59793
61476
  retries.push({
59794
61477
  trackId: t.trackId,
59795
61478
  timestamp,
59796
- bbox: { ...t.bbox },
61479
+ bbox: firstFrameRetryBbox(t.bbox, this.trackStore?.peekActive(t.trackId)?.positions[0]?.bbox),
59797
61480
  ...patchDisplayLabel(t.labelPatch) !== void 0 ? { label: patchDisplayLabel(t.labelPatch) } : {}
59798
61481
  });
59799
61482
  }
@@ -59850,7 +61533,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
59850
61533
  lastFrameAt: this.residents.lastFrameAt(t.trackId) ?? 0,
59851
61534
  now: timestamp,
59852
61535
  intervalMs: media.snapshotIntervalMs,
59853
- lastFrameIntervalMs: Math.min(LAST_FRAME_INTERVAL_MS, media.snapshotIntervalMs)
61536
+ lastFrameIntervalMs: Math.min(LAST_FRAME_INTERVAL_MS, media.snapshotIntervalMs),
61537
+ firstFrameLanded: this.residents.isFirstFrameLanded(t.trackId)
59854
61538
  });
59855
61539
  const rollingLastFrame = plan.rollingLastFrame && !this.captureScheduler.isKeyActive(captureCoalesceKey(deviceId, "lastFrame", t.trackId));
59856
61540
  if (plan.appendSnapshot) this.trackStore.markSnapshotPending(t.trackId, timestamp, t.bbox);
@@ -60359,6 +62043,28 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
60359
62043
  async sweepExpiredTracks() {
60360
62044
  if (this.shuttingDown || !this.trackStore) return;
60361
62045
  await this.trackCloser.sweep();
62046
+ if (this.isPostProcessingNode && this.groupLive) this.groupSealer?.flushIdle(this.groupLive);
62047
+ }
62048
+ /** Hub-only: assign this sighting in RAM. First activity on a camera
62049
+ * replays 2 minutes of that camera (crash recover), then observe is sync. */
62050
+ enqueueLiveGroup(track, now) {
62051
+ if (!this.isPostProcessingNode || !this.groupLive) return;
62052
+ const deviceId = track.deviceId;
62053
+ const snapshot = toGroupableTrack(track);
62054
+ if (this.groupLive.isPrimed(deviceId)) {
62055
+ this.groupLive.observe(snapshot, now);
62056
+ return;
62057
+ }
62058
+ const next = (this.groupObserveTail.get(deviceId) ?? Promise.resolve()).then(async () => {
62059
+ if (this.groupLive && this.groupSealer && !this.groupLive.isPrimed(deviceId)) await this.groupSealer.recoverCamera(deviceId, this.groupLive);
62060
+ this.groupLive?.observe(snapshot, now);
62061
+ }).catch((err) => {
62062
+ this.ctx.logger.warn("group live observe failed", {
62063
+ tags: { deviceId },
62064
+ meta: { error: String(err) }
62065
+ });
62066
+ });
62067
+ this.groupObserveTail.set(deviceId, next);
60362
62068
  }
60363
62069
  /** Unified retention (Phase 1): earliest retained footage per device — the
60364
62070
  * follow-recordings cutoff source. Cached 10 min: availability answers in
@@ -61003,6 +62709,16 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
61003
62709
  };
61004
62710
  if (plausible) this.stationaryRegistry?.promote(entry);
61005
62711
  processor.dropTrack(track.trackId);
62712
+ const peek = this.trackStore?.peekActive(track.trackId);
62713
+ if (peek) this.enqueueLiveGroup(toGroupableTrack({
62714
+ trackId: track.trackId,
62715
+ deviceId,
62716
+ className: track.className,
62717
+ firstSeen: peek.firstSeen,
62718
+ lastSeen: timestamp,
62719
+ positions: peek.positions,
62720
+ closedReason: "stationary-promotion"
62721
+ }), timestamp);
61006
62722
  this.trackStore?.closeActive(track.trackId, "stationary-promotion").catch((err) => {
61007
62723
  this.ctx.logger.warn("stationary promotion: closing the source track failed — its media, events and vector are now unowned", {
61008
62724
  tags: { deviceId },
@@ -61637,6 +63353,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
61637
63353
  },
61638
63354
  liveTrackIds: (ids) => trackStore.existingTrackIds(ids),
61639
63355
  liveEventIds: (ids) => eventStore.existingEventIds(ids),
63356
+ liveGroupIds: (ids) => this.groupStore?.existingGroupIds(ids) ?? Promise.resolve(/* @__PURE__ */ new Set()),
63357
+ liveGroupsWithMembers: (ids) => this.groupStore?.groupsWithMembers(ids) ?? Promise.resolve(/* @__PURE__ */ new Set()),
61640
63358
  reclaim: async (collection, key) => {
61641
63359
  if (collection === "pipeline-analytics:media") {
61642
63360
  await mediaStore.deleteByKey(key);
@@ -62360,6 +64078,36 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
62360
64078
  buildFaceRescoreActionHandlers() {
62361
64079
  return { "face.rescoreTrack": (input) => this.rescoreFaceForTrack(input) };
62362
64080
  }
64081
+ buildGroupActionHandlers() {
64082
+ const ports = () => {
64083
+ const groupStore = this.groupStore;
64084
+ if (groupStore === null) return null;
64085
+ return {
64086
+ groupStore,
64087
+ loadTrack: async (trackId) => {
64088
+ const tracks = this.trackStore;
64089
+ if (!tracks) return null;
64090
+ return tracks.getActiveByTrack(trackId) ?? await tracks.getPersistedByTrackId(trackId);
64091
+ },
64092
+ eventMediaBaseUrl: () => this.eventMediaBaseUrl
64093
+ };
64094
+ };
64095
+ return {
64096
+ "analytics.listGroups": async (input) => {
64097
+ const ready = ports();
64098
+ if (ready === null) return {
64099
+ groups: [],
64100
+ hasMore: false
64101
+ };
64102
+ return listGroupRecords(ready, input);
64103
+ },
64104
+ "analytics.getGroup": async (input) => {
64105
+ const ready = ports();
64106
+ if (ready === null) return { group: null };
64107
+ return { group: await getGroupDetail(ready, input.groupId) };
64108
+ }
64109
+ };
64110
+ }
62363
64111
  buildPhotoEnrollActionHandlers() {
62364
64112
  return {
62365
64113
  "photo.analyze": async (input) => {
@@ -62590,7 +64338,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
62590
64338
  mediaStore,
62591
64339
  faceStore: this.faceStore,
62592
64340
  plateStore: this.plateStore,
62593
- objectEmbeddingStore: this.objectEmbeddingStore
64341
+ objectEmbeddingStore: this.objectEmbeddingStore,
64342
+ groupStore: this.groupStore
62594
64343
  }),
62595
64344
  trackStore
62596
64345
  };
@@ -62739,7 +64488,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
62739
64488
  media: 0,
62740
64489
  faces: 0,
62741
64490
  plates: 0,
62742
- embeddings: 0
64491
+ embeddings: 0,
64492
+ groups: 0
62743
64493
  };
62744
64494
  this.lastPruneBytes = 0;
62745
64495
  const cascade = this.buildTrackCascadeRegistry();
@@ -62799,7 +64549,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
62799
64549
  media: tally.media,
62800
64550
  faces: tally.faces,
62801
64551
  plates: tally.plates,
62802
- embeddings: tally.embeddings
64552
+ embeddings: tally.embeddings,
64553
+ groups: tally.groups
62803
64554
  };
62804
64555
  }
62805
64556
  /**