@camstack/addon-post-analysis 1.2.117 → 1.2.119

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-Dk-BdVHw.js");
5
+ const require_dist = require("../dist-BtIolp2d.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");
@@ -4398,10 +4398,28 @@ var SCENE_LATCH_INDEXES = [{
4398
4398
  function sceneKey(deviceId, monitorId) {
4399
4399
  return `${deviceId}:${monitorId}`;
4400
4400
  }
4401
+ /**
4402
+ * Reseed cap for BOTH scene ledgers.
4403
+ *
4404
+ * `DurableLedger` defaults to 100_000 and its own docblock says the owner "sets
4405
+ * it from the row set it expects". Neither scene ledger did, so the 30s
4406
+ * reconcile asked SQLite — through hub-main's event loop, which is the
4407
+ * cluster's only queue (D181) — for up to a hundred thousand rows twice a tick,
4408
+ * on a hub holding one scene and one latch (measured live 2026-08-25). A bound
4409
+ * nobody chose is not a bound.
4410
+ *
4411
+ * A scene is an operator-drawn monitor on ONE camera and a latch is at most one
4412
+ * row per scene, so the row set is `cameras x monitors-per-camera`. 5_000 is
4413
+ * ~5 monitors on each of a thousand cameras — far past any installation this
4414
+ * ships to, and still small enough that a runaway write loop is CAUGHT by the
4415
+ * bound instead of being paged in whole.
4416
+ */
4417
+ var SCENE_LEDGER_LOAD_LIMIT = 5e3;
4401
4418
  var SCENE_CONFIG_SPEC = {
4402
4419
  collection: SCENE_COLLECTION,
4403
4420
  columns: SCENE_COLUMNS,
4404
4421
  indexes: SCENE_INDEXES,
4422
+ loadLimit: SCENE_LEDGER_LOAD_LIMIT,
4405
4423
  writeMode: "write-behind",
4406
4424
  keyOf: (row) => row.key,
4407
4425
  toValue: (row) => ({
@@ -4417,6 +4435,7 @@ var SCENE_LATCH_SPEC = {
4417
4435
  collection: SCENE_LATCH_COLLECTION,
4418
4436
  columns: SCENE_LATCH_COLUMNS,
4419
4437
  indexes: SCENE_LATCH_INDEXES,
4438
+ loadLimit: SCENE_LEDGER_LOAD_LIMIT,
4420
4439
  writeMode: "write-through",
4421
4440
  keyOf: (row) => row.key,
4422
4441
  toValue: (row) => ({
@@ -4446,6 +4465,9 @@ var SceneStore = class {
4446
4465
  config;
4447
4466
  latch;
4448
4467
  logger;
4468
+ /** `scenes/latches` as last announced. Null until the first load, so the
4469
+ * first one always speaks. */
4470
+ lastLoggedCounts = null;
4449
4471
  constructor(deps) {
4450
4472
  this.logger = deps.logger;
4451
4473
  this.config = new DurableLedger({
@@ -4470,10 +4492,20 @@ var SceneStore = class {
4470
4492
  * a container recreate, "loaded 0" is the one line that explains why every
4471
4493
  * scene suddenly reads `unknown`, and its absence is what turns a data loss
4472
4494
  * into a three-hour hunt.
4495
+ *
4496
+ * ON CHANGE ONLY, though — including the first load, where there is nothing
4497
+ * to compare against. This runs on the 30s `SCENE_RELOAD_INTERVAL_MS` tick on
4498
+ * every node, and it used to print unconditionally: measured live 2026-08-25,
4499
+ * `scene state loaded {scenes=1 latches=1}` every 30 seconds forever. A count
4500
+ * reprinted unchanged twice a minute is exactly how the "loaded 0" this line
4501
+ * exists for gets scrolled past.
4473
4502
  */
4474
4503
  async load() {
4475
4504
  const scenes = await this.config.load();
4476
4505
  const latches = await this.latch.load();
4506
+ const counts = `${scenes.length}/${latches.length}`;
4507
+ if (counts === this.lastLoggedCounts) return;
4508
+ this.lastLoggedCounts = counts;
4477
4509
  this.logger.info("scene state loaded", { meta: {
4478
4510
  scenes: scenes.length,
4479
4511
  latches: latches.length
@@ -8539,6 +8571,49 @@ var NC_LANGUAGES = ["en", "it"];
8539
8571
  * surface's view all validate against this one, so a third language is added
8540
8572
  * to `NC_LANGUAGES` and nowhere else. */
8541
8573
  var NcLanguageSchema = require_dist._enum(NC_LANGUAGES);
8574
+ /**
8575
+ * Whether two snapshots say the same thing.
8576
+ *
8577
+ * ## Why this is a function and not `===`
8578
+ *
8579
+ * The reload guard in `reloadTexts()` used to be
8580
+ * `before.overrides === next.overrides`, and `next` comes from
8581
+ * `NcTextCatalogStore.load()`, which `parse()`s the row through zod on every
8582
+ * call. The right-hand side is therefore a NEW object on every tick by
8583
+ * construction: the comparison was `false` at byte-identical content, forever.
8584
+ * Measured on the live hub 2026-08-25: 58 "notification texts loaded" lines in
8585
+ * 30 minutes on a hub whose language had not changed, from a line whose own
8586
+ * docblock promises it fires on CHANGE only.
8587
+ *
8588
+ * The document is two levels deep and installation-small (at most one entry per
8589
+ * language, each a flat key→string map), so the comparison is written out
8590
+ * rather than reached for through a deep-equal dependency — and written out it
8591
+ * can state the direction that matters: a REMOVED key must not read as equal,
8592
+ * which a one-directional key walk would get wrong.
8593
+ */
8594
+ function ncTextSnapshotsEqual(a, b) {
8595
+ if (a.language !== b.language) return false;
8596
+ return overrideMapsEqual(a.overrides, b.overrides);
8597
+ }
8598
+ function overrideMapsEqual(a, b) {
8599
+ const languages = new Set([...Object.keys(a), ...Object.keys(b)]);
8600
+ for (const language of languages) {
8601
+ const left = a[language];
8602
+ const right = b[language];
8603
+ if (left === void 0 || right === void 0) {
8604
+ if (Object.keys(left ?? right ?? {}).length > 0) return false;
8605
+ continue;
8606
+ }
8607
+ if (!flatMapsEqual(left, right)) return false;
8608
+ }
8609
+ return true;
8610
+ }
8611
+ function flatMapsEqual(a, b) {
8612
+ const keys = Object.keys(a);
8613
+ if (keys.length !== Object.keys(b).length) return false;
8614
+ for (const key of keys) if (a[key] !== b[key]) return false;
8615
+ return true;
8616
+ }
8542
8617
  /** The shipped files, keyed by language. Data, never mutated. */
8543
8618
  var NC_TEXT_BUNDLES = {
8544
8619
  en: en_default,
@@ -22484,7 +22559,7 @@ var NotificationCenter = class NotificationCenter {
22484
22559
  const before = this.texts.snapshot;
22485
22560
  const next = await this.textStore.load();
22486
22561
  this.texts.apply(next);
22487
- if (before.language === next.language && before.overrides === next.overrides) return;
22562
+ if (ncTextSnapshotsEqual(before, next)) return;
22488
22563
  this.logger.info("notification texts loaded", { meta: {
22489
22564
  language: next.language,
22490
22565
  overriddenKeys: Object.values(next.overrides).reduce((sum, keys) => sum + Object.keys(keys ?? {}).length, 0)
@@ -25924,7 +25999,7 @@ var CLASS_RANK_VEHICLE = .8;
25924
25999
  var CLASS_RANK_ANIMAL = .5;
25925
26000
  var CLASS_RANK_DEFAULT = .25;
25926
26001
  var PERSON_CLASSES = new Set(["person", "face"]);
25927
- var VEHICLE_CLASSES = new Set([
26002
+ var VEHICLE_CLASSES$1 = new Set([
25928
26003
  "vehicle",
25929
26004
  "car",
25930
26005
  "truck",
@@ -25955,7 +26030,7 @@ function clamp01$1(x) {
25955
26030
  function classRank(className) {
25956
26031
  const c = className.toLowerCase();
25957
26032
  if (PERSON_CLASSES.has(c)) return 1;
25958
- if (VEHICLE_CLASSES.has(c)) return CLASS_RANK_VEHICLE;
26033
+ if (VEHICLE_CLASSES$1.has(c)) return CLASS_RANK_VEHICLE;
25959
26034
  if (ANIMAL_CLASSES.has(c)) return CLASS_RANK_ANIMAL;
25960
26035
  return CLASS_RANK_DEFAULT;
25961
26036
  }
@@ -26558,7 +26633,7 @@ var FaceGalleryProvider = class {
26558
26633
  const identities = await this.identityStore.listIdentities();
26559
26634
  const nameMap = new Map(identities.map((i) => [i.id, i.name]));
26560
26635
  const identityName = face.recognizedIdentityId ? nameMap.get(face.recognizedIdentityId) : void 0;
26561
- const base64 = await this.faceCropBase64(face);
26636
+ const cropUrl = this.cropUrlFor(face);
26562
26637
  return {
26563
26638
  faceId: face.id,
26564
26639
  deviceId: face.deviceId,
@@ -26567,7 +26642,7 @@ var FaceGalleryProvider = class {
26567
26642
  ...face.recognizedIdentityId != null ? { recognizedIdentityId: face.recognizedIdentityId } : {},
26568
26643
  ...identityName !== void 0 ? { identityName } : {},
26569
26644
  assigned: face.assigned,
26570
- ...base64 !== void 0 ? { base64 } : {},
26645
+ ...cropUrl !== void 0 ? { cropUrl } : {},
26571
26646
  ...face.faceBbox !== void 0 ? { faceBbox: face.faceBbox } : {},
26572
26647
  ...face.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: face.keyFrameMediaKey } : {},
26573
26648
  ...face.bestMatchScore != null ? { bestMatchScore: face.bestMatchScore } : {},
@@ -29687,9 +29762,15 @@ var TrackCloser = class {
29687
29762
  await this.suppressZeroMedia(t.deviceId, t.trackId);
29688
29763
  return;
29689
29764
  }
29690
- if (outcome === "raster-fallback" && closure?.rasterFallback) await this.commitRasterFallback(t, closure.rasterFallback.jpeg, closure.rasterFallback.timestamp);
29765
+ let thumbnailLanded = ownedMedia.some((m) => m.kind === "thumbnail");
29766
+ if (outcome === "raster-fallback" && closure?.rasterFallback) {
29767
+ await this.commitRasterFallback(t, closure.rasterFallback.jpeg, closure.rasterFallback.timestamp);
29768
+ thumbnailLanded = true;
29769
+ }
29691
29770
  await this.maybePromoteLastFrame(t, ownedMedia);
29692
- await this.maybeDeriveThumbnailFromKeyFrame(t, ownedMedia);
29771
+ const hasRasterFallback = closure?.rasterFallback !== void 0;
29772
+ if (!thumbnailLanded) thumbnailLanded = await this.maybeDeriveThumbnailFromKeyFrame(t, ownedMedia, hasRasterFallback);
29773
+ if (!thumbnailLanded && closure?.rasterFallback) await this.commitRasterFallback(t, closure.rasterFallback.jpeg, closure.rasterFallback.timestamp);
29693
29774
  this.deps.logger.info("track ended", {
29694
29775
  tags: { deviceId: t.deviceId },
29695
29776
  meta: {
@@ -29807,14 +29888,23 @@ var TrackCloser = class {
29807
29888
  * and the rate could only ever be counted from the database, after the fact.
29808
29889
  * A branch that drops work silently reads as "never happened".
29809
29890
  */
29810
- async maybeDeriveThumbnailFromKeyFrame(t, ownedMedia) {
29811
- if (ownedMedia.some((m) => m.kind === "thumbnail")) return;
29891
+ async maybeDeriveThumbnailFromKeyFrame(t, ownedMedia, hasRasterFallback) {
29892
+ if (ownedMedia.some((m) => m.kind === "thumbnail")) return true;
29812
29893
  const derive = this.deps.deriveThumbnailFromKeyFrame;
29813
- if (!derive) return this.logNoThumbnailAtClose(t, "derive-not-wired");
29894
+ if (!derive) {
29895
+ if (!hasRasterFallback) this.logNoThumbnailAtClose(t, "derive-not-wired");
29896
+ return false;
29897
+ }
29814
29898
  const keyFrame = ownedMedia.find((m) => m.kind === "keyFrame");
29815
- if (!keyFrame) return this.logNoThumbnailAtClose(t, "no-key-frame");
29899
+ if (!keyFrame) {
29900
+ if (!hasRasterFallback) this.logNoThumbnailAtClose(t, "no-key-frame");
29901
+ return false;
29902
+ }
29816
29903
  const bestSeen = this.deps.residents.bestSeenBbox(t.trackId);
29817
- if (!bestSeen) return this.logNoThumbnailAtClose(t, "no-best-seen-bbox");
29904
+ if (!bestSeen) {
29905
+ if (!hasRasterFallback) this.logNoThumbnailAtClose(t, "no-best-seen-bbox");
29906
+ return false;
29907
+ }
29818
29908
  try {
29819
29909
  const derived = await derive({
29820
29910
  mediaKey: keyFrame.key,
@@ -29826,17 +29916,23 @@ var TrackCloser = class {
29826
29916
  },
29827
29917
  frameWidth: bestSeen.frameWidth,
29828
29918
  frameHeight: bestSeen.frameHeight,
29829
- timestamp: bestSeen.timestamp
29919
+ timestamp: keyFrame.timestamp
29830
29920
  });
29831
- if (!derived) return this.logNoThumbnailAtClose(t, "derive-returned-null", { keyFrameKey: keyFrame.key });
29921
+ if (!derived) {
29922
+ if (!hasRasterFallback) this.logNoThumbnailAtClose(t, "derive-returned-null", { keyFrameKey: keyFrame.key });
29923
+ return false;
29924
+ }
29832
29925
  const mediaStore = this.deps.mediaStore();
29833
- if (!mediaStore) return this.logNoThumbnailAtClose(t, "no-media-store");
29926
+ if (!mediaStore) {
29927
+ if (!hasRasterFallback) this.logNoThumbnailAtClose(t, "no-media-store");
29928
+ return false;
29929
+ }
29834
29930
  await mediaStore.put({
29835
29931
  deviceId: t.deviceId,
29836
29932
  ownerKind: "track",
29837
29933
  ownerId: t.trackId,
29838
29934
  kind: "thumbnail",
29839
- timestamp: bestSeen.timestamp,
29935
+ timestamp: keyFrame.timestamp,
29840
29936
  data: derived.thumbnail
29841
29937
  });
29842
29938
  await mediaStore.put({
@@ -29844,7 +29940,7 @@ var TrackCloser = class {
29844
29940
  ownerKind: "track",
29845
29941
  ownerId: t.trackId,
29846
29942
  kind: "thumbnailSmall",
29847
- timestamp: bestSeen.timestamp,
29943
+ timestamp: keyFrame.timestamp,
29848
29944
  data: derived.thumbnailSmall
29849
29945
  });
29850
29946
  this.deps.logger.info("thumbnail derived from keyFrame at close (no live best-shot landed)", {
@@ -29855,6 +29951,7 @@ var TrackCloser = class {
29855
29951
  skewMs: derived.skewMs
29856
29952
  }
29857
29953
  });
29954
+ return true;
29858
29955
  } catch (err) {
29859
29956
  this.deps.logger.debug("close-time keyFrame thumbnail derive failed", {
29860
29957
  tags: { deviceId: t.deviceId },
@@ -29863,10 +29960,11 @@ var TrackCloser = class {
29863
29960
  error: String(err)
29864
29961
  }
29865
29962
  });
29866
- this.logNoThumbnailAtClose(t, "derive-threw", {
29963
+ if (!hasRasterFallback) this.logNoThumbnailAtClose(t, "derive-threw", {
29867
29964
  keyFrameKey: keyFrame.key,
29868
29965
  error: String(err)
29869
29966
  });
29967
+ return false;
29870
29968
  }
29871
29969
  }
29872
29970
  /**
@@ -30736,6 +30834,7 @@ var PlateGalleryProvider = class {
30736
30834
  logger;
30737
30835
  refreshGallery;
30738
30836
  emitPlateGalleryChanged;
30837
+ eventMediaBaseUrl;
30739
30838
  constructor(deps) {
30740
30839
  this.plateStore = deps.plateStore;
30741
30840
  this.vehicleStore = deps.vehicleStore;
@@ -30745,6 +30844,7 @@ var PlateGalleryProvider = class {
30745
30844
  this.logger = deps.logger;
30746
30845
  this.refreshGallery = deps.refreshGallery;
30747
30846
  this.emitPlateGalleryChanged = deps.emitPlateGalleryChanged;
30847
+ this.eventMediaBaseUrl = deps.eventMediaBaseUrl;
30748
30848
  }
30749
30849
  /** Best-effort emit — never allowed to fail the mutation it follows. */
30750
30850
  safeEmitPlateGalleryChanged(payload) {
@@ -30752,6 +30852,12 @@ var PlateGalleryProvider = class {
30752
30852
  this.emitPlateGalleryChanged(payload);
30753
30853
  } catch {}
30754
30854
  }
30855
+ cropUrlFor(plate) {
30856
+ if (plate.mediaKey === void 0 || plate.mediaKey === "") return void 0;
30857
+ const base = this.eventMediaBaseUrl();
30858
+ if (base === null) return void 0;
30859
+ return `${base}/${encodeURIComponent(plate.mediaKey)}`;
30860
+ }
30755
30861
  async plateCropBase64(plate) {
30756
30862
  if (plate.mediaKey) {
30757
30863
  const m = await this.mediaStore.getByKey(plate.mediaKey);
@@ -30760,8 +30866,9 @@ var PlateGalleryProvider = class {
30760
30866
  const media = await this.mediaStore.listByOwner("plate", plate.id);
30761
30867
  return media.length > 0 ? media[0].base64 : void 0;
30762
30868
  }
30763
- async toPlateInfo(plate, nameMap) {
30764
- const base64 = await this.plateCropBase64(plate);
30869
+ async toPlateInfo(plate, nameMap, opts) {
30870
+ const base64 = opts?.includeCrops ?? true ? await this.plateCropBase64(plate) : void 0;
30871
+ const cropUrl = this.cropUrlFor(plate);
30765
30872
  const vehicleName = plate.recognizedVehicleId != null ? nameMap?.get(plate.recognizedVehicleId) : void 0;
30766
30873
  return {
30767
30874
  plateId: plate.id,
@@ -30776,7 +30883,8 @@ var PlateGalleryProvider = class {
30776
30883
  ...vehicleName !== void 0 ? { vehicleName } : {},
30777
30884
  ...plate.plateBbox !== void 0 ? { plateBbox: plate.plateBbox } : {},
30778
30885
  ...plate.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: plate.keyFrameMediaKey } : {},
30779
- ...base64 !== void 0 ? { base64 } : {}
30886
+ ...base64 !== void 0 ? { base64 } : {},
30887
+ ...cropUrl !== void 0 ? { cropUrl } : {}
30780
30888
  };
30781
30889
  }
30782
30890
  async vehicleNameMap() {
@@ -30795,7 +30903,7 @@ var PlateGalleryProvider = class {
30795
30903
  const plate = await this.plateStore.get(`plate-${input.trackId}`);
30796
30904
  if (!plate || plate.deviceId !== input.deviceId) return null;
30797
30905
  const nameMap = await this.vehicleNameMap();
30798
- return this.toPlateInfo(plate, nameMap);
30906
+ return this.toPlateInfo(plate, nameMap, { includeCrops: false });
30799
30907
  }
30800
30908
  async getPlateMedia(input) {
30801
30909
  return (await this.mediaStore.listByOwner("plate", input.plateId)).map((m) => ({
@@ -35426,6 +35534,18 @@ var EmbeddingDispatcher = class {
35426
35534
  logger;
35427
35535
  getRemoteFrame;
35428
35536
  lastEmbedTime = /* @__PURE__ */ new Map();
35537
+ /**
35538
+ * trackId -> the frame pinned for it. Each entry holds a WHOLE decoded RGB
35539
+ * frame (`PendingCrop.frameData`, ~0.7 MB), not a crop.
35540
+ *
35541
+ * Under the shipped `cropStrategy: 'first'` this doubles as the in-flight
35542
+ * DEDUPE FLAG — `'first'` gates on `!pendingCrops.has(trackId)` — and there
35543
+ * is no flush timer for that strategy, so nothing ever sweeps it. Until
35544
+ * 2026-08-25 the delete lived on `processOne`'s success path only: an
35545
+ * `extractCrop` throw or an `encoder.encode` rejection (a cross-process cap
35546
+ * over UDS, with no timeout) stranded the frame permanently AND made that
35547
+ * trackId un-embeddable forever. `processOne` now releases in a `finally`.
35548
+ */
35429
35549
  pendingCrops = /* @__PURE__ */ new Map();
35430
35550
  flushTimer = null;
35431
35551
  unsubscribe = null;
@@ -35583,7 +35703,6 @@ var EmbeddingDispatcher = class {
35583
35703
  this._processedCount++;
35584
35704
  this._totalInferenceMs += inferenceMs;
35585
35705
  this.lastEmbedTime.set(pending.deviceId, Date.now());
35586
- this.pendingCrops.delete(pending.trackId);
35587
35706
  const embeddingId = `${pending.deviceId}/${pending.trackId}/${Date.now()}`;
35588
35707
  const payload = {
35589
35708
  deviceId: Number(pending.deviceId),
@@ -35615,8 +35734,21 @@ var EmbeddingDispatcher = class {
35615
35734
  error: String(err)
35616
35735
  }
35617
35736
  });
35737
+ } finally {
35738
+ if (this.pendingCrops.get(pending.trackId) === pending) this.pendingCrops.delete(pending.trackId);
35618
35739
  }
35619
35740
  }
35741
+ /**
35742
+ * Decoded frames currently pinned in {@link pendingCrops}.
35743
+ *
35744
+ * ~0.7 MB each (a whole RGB detection frame, not a crop), so this number IS
35745
+ * the dispatcher's memory footprint. Exposed because the entries are
35746
+ * invisible in every functional read: a stranded one looks exactly like a
35747
+ * track that was never detected.
35748
+ */
35749
+ pendingFrameCount() {
35750
+ return this.pendingCrops.size;
35751
+ }
35620
35752
  };
35621
35753
  /**
35622
35754
  * Re-embed every listed track from its key frame.
@@ -35936,6 +36068,19 @@ function createEventMediaHandler(deps) {
35936
36068
  };
35937
36069
  }
35938
36070
  //#endregion
36071
+ //#region src/pipeline-analytics/copy-face-crop-preview.ts
36072
+ var TRACK_PREVIEW_KINDS = new Set(["thumbnail", "thumbnailSmall"]);
36073
+ async function copyFaceCropIfTrackHasNoThumbnail(store, input) {
36074
+ if ((await store.listTrackMedia(input.trackId)).some((m) => TRACK_PREVIEW_KINDS.has(m.kind))) return false;
36075
+ await store.putTrackThumbnail({
36076
+ deviceId: input.deviceId,
36077
+ trackId: input.trackId,
36078
+ timestamp: input.timestamp,
36079
+ data: input.crop
36080
+ });
36081
+ return true;
36082
+ }
36083
+ //#endregion
35939
36084
  //#region src/pipeline-analytics/event-thumbnail-resolver.ts
35940
36085
  /** Pick an event-OWNED media blob: the tight `crop`, else the native-res boxed
35941
36086
  * `fullFrameBoxed`, else any available file. Both preferred kinds are RETIRED
@@ -35944,29 +36089,47 @@ function createEventMediaHandler(deps) {
35944
36089
  function pickEventOwnedMedia(files) {
35945
36090
  return files.find((f) => f.kind === "crop") ?? files.find((f) => f.kind === "fullFrameBoxed") ?? files[0];
35946
36091
  }
35947
- /** Pick a track's fallback media in the shared cadence-preference order the
35948
- * KeyEvent path uses: `thumbnailSmall` (480 fast-load) → best `thumbnail` →
35949
- * rolling `lastFrame` → `firstFrame` → newest `snapshot` → any track blob.
35950
- * Undefined when the track owns no media. */
36092
+ /** Pick a track's fallback media when no subject crop exists: `thumbnailSmall`
36093
+ * → `thumbnail` → native `keyFrameSmall`/`keyFrame`/`fullFrame` → rolling
36094
+ * `lastFrame` → `firstFrame` → newest `snapshot` → any blob. A full frame
36095
+ * beats a class icon. Undefined when the owner has no media. */
35951
36096
  function pickTrackFallbackMedia(files) {
35952
- return files.find((f) => f.kind === "thumbnailSmall") ?? files.find((f) => f.kind === "thumbnail") ?? files.find((f) => f.kind === "lastFrame") ?? files.find((f) => f.kind === "firstFrame") ?? [...files].reverse().find((f) => f.kind === "snapshot") ?? files[files.length - 1];
36097
+ return files.find((f) => f.kind === "thumbnailSmall") ?? files.find((f) => f.kind === "thumbnail") ?? files.find((f) => f.kind === "keyFrameSmall") ?? files.find((f) => f.kind === "keyFrame") ?? files.find((f) => f.kind === "fullFrame") ?? files.find((f) => f.kind === "lastFrame") ?? files.find((f) => f.kind === "firstFrame") ?? [...files].reverse().find((f) => f.kind === "snapshot") ?? files[files.length - 1];
35953
36098
  }
35954
36099
  /**
35955
- * Crop-forced resolution (the `?kind=crop` reel/timeline path). Prefer a CLEAN
35956
- * (never-boxed) frame via the injected `pickClean`; when the track owns NO clean
35957
- * frame at all — the regression class created by retiring `crop`/`fullFrame`
35958
- * production (2026-07-21), leaving degenerate tracks with only boxed
35959
- * `lastFrame`/`snapshot`/`firstFrame` — degrade to ANY track media rather than
35960
- * returning undefined. A boxed frame (with the target's box drawn) is a real
35961
- * preview; an empty tile is not. Clean is always tried FIRST so the common case
35962
- * still serves an unboxed tile. Undefined only when the owner has NO media.
36100
+ * Crop-forced resolution (the `?kind=crop` / `?kind=thumbnailSmall` reel path).
36101
+ *
36102
+ * Ladder: body crop (track/event thumbnail) → tight face/plate crop → any
36103
+ * remaining image (keyFrame / lastFrame / …). The class icon is only honest
36104
+ * when the owner has no stored bytes at all.
35963
36105
  *
35964
36106
  * `pickClean` is injected because the clean-kind picker (`pickCleanMedia`) lives
35965
36107
  * in the addon `index.ts` over the concrete `MediaFileKind` set; this keeps the
35966
36108
  * composition pure + unit-testable here.
35967
36109
  */
35968
- function resolveCropForcedMedia(files, pickClean) {
35969
- return pickClean(files) ?? pickTrackFallbackMedia(files);
36110
+ function resolveCropForcedMedia(files, pickClean, detailFiles = []) {
36111
+ return pickClean(files) ?? pickClean(detailFiles) ?? pickTrackFallbackMedia([...files, ...detailFiles]);
36112
+ }
36113
+ /**
36114
+ * Event + track media (the body/scene) and face/plate media (the detail
36115
+ * crops). Face rows live under `face-${trackId}`; a track can close with
36116
+ * `hasEmbeddedFace` and ZERO track files (0e343f86).
36117
+ */
36118
+ async function collectCropForcedMedia(source, id) {
36119
+ const eventFiles = await source.listByOwner("event", id);
36120
+ const trackFiles = await source.listByOwner("track", id);
36121
+ const ownerTrackId = trackFiles.length === 0 ? await source.getTrackIdForEvent(id) : null;
36122
+ const owningTrackFiles = ownerTrackId === null ? [] : await source.listByOwner("track", ownerTrackId);
36123
+ const trackId = ownerTrackId ?? id;
36124
+ const [faceFiles, plateFiles] = await Promise.all([source.listByOwner("face", `face-${trackId}`), source.listByOwner("plate", `plate-${trackId}`)]);
36125
+ return {
36126
+ bodyFiles: [
36127
+ ...eventFiles,
36128
+ ...trackFiles,
36129
+ ...owningTrackFiles
36130
+ ],
36131
+ detailFiles: [...faceFiles, ...plateFiles]
36132
+ };
35970
36133
  }
35971
36134
  /**
35972
36135
  * Resolve the default-path media for an event id: the event's own media when it
@@ -35980,6 +36143,14 @@ async function resolveDefaultEventMedia(deps) {
35980
36143
  return pickTrackFallbackMedia(await deps.listTrackMedia(trackId)) ?? null;
35981
36144
  }
35982
36145
  //#endregion
36146
+ //#region src/pipeline-analytics/ensure-track-hierarchy.ts
36147
+ async function ensureTrackHierarchyMedia(deps, input) {
36148
+ const tasks = [];
36149
+ if (!deps.isThumbnailLanded(input.trackId)) tasks.push(deps.captureBodyThumbnail(input));
36150
+ if (!deps.isKeyFrameLanded(input.trackId)) tasks.push(deps.captureKeyFrame(input));
36151
+ await Promise.all(tasks);
36152
+ }
36153
+ //#endregion
35983
36154
  //#region src/pipeline-analytics/face-settings.ts
35984
36155
  /**
35985
36156
  * Per-device face-recognition settings. Cascade: a per-device override on top
@@ -36498,6 +36669,21 @@ var FaceRecognizer = class {
36498
36669
  ...held,
36499
36670
  crop
36500
36671
  });
36672
+ if (crop !== void 0 && input.frameHandle !== void 0) this.deps.onLiveHierarchy?.({
36673
+ deviceId: input.deviceId,
36674
+ trackId: c.trackId,
36675
+ timestamp: input.timestamp,
36676
+ bodyBbox: {
36677
+ x: c.bbox.x,
36678
+ y: c.bbox.y,
36679
+ w: c.bbox.w,
36680
+ h: c.bbox.h
36681
+ },
36682
+ frameWidth: input.frameWidth,
36683
+ frameHeight: input.frameHeight,
36684
+ cropPadding: input.cropPadding,
36685
+ frameHandle: input.frameHandle
36686
+ });
36501
36687
  this.deps.logger.debug("face: best-face held", {
36502
36688
  tags: {
36503
36689
  deviceId: input.deviceId,
@@ -36647,6 +36833,22 @@ var FaceRecognizer = class {
36647
36833
  });
36648
36834
  return;
36649
36835
  }
36836
+ try {
36837
+ await this.deps.copyCropToTrackPreview?.({
36838
+ deviceId,
36839
+ trackId,
36840
+ crop: held.crop,
36841
+ timestamp: held.timestamp
36842
+ });
36843
+ } catch (err) {
36844
+ this.deps.logger.debug("face: track preview copy failed", {
36845
+ tags: { deviceId },
36846
+ meta: {
36847
+ trackId,
36848
+ error: String(err)
36849
+ }
36850
+ });
36851
+ }
36650
36852
  const keyFrameMediaKey = this.deps.getKeyFrameMediaKey?.(trackId);
36651
36853
  try {
36652
36854
  await this.deps.faceStore.insert({
@@ -44227,6 +44429,12 @@ var PlateRecognizer = class {
44227
44429
  text: c.plateText,
44228
44430
  score: c.plateScore,
44229
44431
  bbox: c.plateBbox,
44432
+ bodyBbox: {
44433
+ x: c.bbox.x,
44434
+ y: c.bbox.y,
44435
+ w: c.bbox.w,
44436
+ h: c.bbox.h
44437
+ },
44230
44438
  timestamp: input.timestamp,
44231
44439
  frameWidth: input.frameWidth,
44232
44440
  frameHeight: input.frameHeight,
@@ -44300,6 +44508,16 @@ var PlateRecognizer = class {
44300
44508
  }
44301
44509
  });
44302
44510
  }
44511
+ if (crop !== void 0 && input.frameHandle !== void 0 && input.bodyBbox !== void 0) this.deps.onLiveHierarchy?.({
44512
+ deviceId: input.deviceId,
44513
+ trackId: input.trackId,
44514
+ timestamp: input.timestamp,
44515
+ bodyBbox: input.bodyBbox,
44516
+ frameWidth: input.frameWidth,
44517
+ frameHeight: input.frameHeight,
44518
+ cropPadding: input.cropPadding,
44519
+ frameHandle: input.frameHandle
44520
+ });
44303
44521
  this.bestPlate.set(input.trackId, {
44304
44522
  text: input.text,
44305
44523
  score: input.score,
@@ -48334,14 +48552,23 @@ function isAfterCursor(row, cursor) {
48334
48552
  if (row.lastSeen < cursor.lastSeen) return true;
48335
48553
  return row.lastSeen === cursor.lastSeen && row.id < cursor.trackId;
48336
48554
  }
48555
+ var VEHICLE_CLASSES = new Set([
48556
+ "vehicle",
48557
+ "car",
48558
+ "truck",
48559
+ "bus"
48560
+ ]);
48561
+ function motionlessMaxPxForClass(className) {
48562
+ return VEHICLE_CLASSES.has(className.toLowerCase()) ? 8 : 40;
48563
+ }
48337
48564
  function netDisplacementPx(positions) {
48338
48565
  const first = positions[0];
48339
48566
  const last = positions[positions.length - 1];
48340
48567
  if (!first || !last) return 0;
48341
48568
  return Math.hypot(last.x - first.x, last.y - first.y);
48342
48569
  }
48343
- function isShortMotionlessTrack(lifeMs, movedPx) {
48344
- return lifeMs <= 25e3 && movedPx <= 8;
48570
+ function isShortMotionlessTrack(lifeMs, movedPx, className) {
48571
+ return lifeMs <= 25e3 && movedPx <= motionlessMaxPxForClass(className);
48345
48572
  }
48346
48573
  //#endregion
48347
48574
  //#region src/pipeline-analytics/store/track-store.ts
@@ -48925,7 +49152,7 @@ var TrackStore = class {
48925
49152
  for (const [trackId, t] of this.active) {
48926
49153
  if (nowMs - t.lastSeen < this.config.ttlMs) continue;
48927
49154
  t.active = false;
48928
- if (t.positions.length > 0 && isShortMotionlessTrack(t.lastSeen - t.firstSeen, netDisplacementPx(t.positions))) t.closedReason = "stationary-promotion";
49155
+ if (t.positions.length > 0 && isShortMotionlessTrack(t.lastSeen - t.firstSeen, netDisplacementPx(t.positions), t.className)) t.closedReason = "stationary-promotion";
48929
49156
  const record = cloneTrack(t);
48930
49157
  try {
48931
49158
  await this.persistCompleted(record);
@@ -51316,9 +51543,14 @@ function deriveFrameDispatchKind(content) {
51316
51543
  }
51317
51544
  var DEFAULT_MAX_IN_FLIGHT_PER_DEVICE = 3;
51318
51545
  var DEFAULT_LOG_WINDOW_MS = 6e4;
51546
+ /** One `capture queue full` line per device per this interval. The condition
51547
+ * holds for as long as the lane is saturated; one line per shed request would
51548
+ * be thousands. */
51549
+ var OVERFLOW_WARN_THROTTLE_MS = 6e4;
51319
51550
  var CaptureScheduler = class {
51320
51551
  logger;
51321
51552
  budget;
51553
+ maxQueued;
51322
51554
  windowMs;
51323
51555
  now;
51324
51556
  lanes = /* @__PURE__ */ new Map();
@@ -51327,9 +51559,12 @@ var CaptureScheduler = class {
51327
51559
  /** holdKey → refcount of queued/running requests holding it. */
51328
51560
  heldKeys = /* @__PURE__ */ new Map();
51329
51561
  windows = /* @__PURE__ */ new Map();
51562
+ /** Per-device throttle for the `capture queue full` warn. */
51563
+ lastOverflowWarnAt = /* @__PURE__ */ new Map();
51330
51564
  constructor(options) {
51331
51565
  this.logger = options.logger;
51332
51566
  this.budget = options.maxInFlightPerDevice ?? DEFAULT_MAX_IN_FLIGHT_PER_DEVICE;
51567
+ this.maxQueued = Math.max(1, options.maxQueuedPerDevice ?? 24);
51333
51568
  this.windowMs = options.logWindowMs ?? DEFAULT_LOG_WINDOW_MS;
51334
51569
  this.now = options.now ?? Date.now;
51335
51570
  }
@@ -51399,6 +51634,7 @@ var CaptureScheduler = class {
51399
51634
  failed: w.failed,
51400
51635
  coalesced: w.coalesced,
51401
51636
  droppedStale: w.droppedStale,
51637
+ droppedOverflow: w.droppedOverflow,
51402
51638
  cancelled: w.cancelled,
51403
51639
  maxQueueDepth: w.maxQueueDepth
51404
51640
  };
@@ -51447,7 +51683,37 @@ var CaptureScheduler = class {
51447
51683
  throw err;
51448
51684
  });
51449
51685
  }
51450
- /** Queue an over-budget request (priority-ordered, FIFO within priority). */
51686
+ /**
51687
+ * Queue an over-budget request (priority-ordered, FIFO within priority),
51688
+ * under a BOUND enforced here rather than at dequeue.
51689
+ *
51690
+ * ## Why the queue needs its own bound
51691
+ *
51692
+ * `staleAfterMs` is evaluated in {@link pump}, and `pump` only touches the
51693
+ * queue while `inFlight < budget`. A lane that stays saturated therefore
51694
+ * never inspects its queue at all — nothing expires, nothing is shed, and the
51695
+ * queue grows for as long as the pressure lasts. The budget bounds concurrent
51696
+ * WORK; it has never bounded queued work.
51697
+ *
51698
+ * That gap is not theoretical: the per-frame batch dispatch PINS a ~0.7 MB
51699
+ * copy of the detection frame BEFORE queueing (so ring recycling cannot
51700
+ * invalidate it), and the queued closure holds that copy alive. Measured on
51701
+ * the live hub, `hub/pipeline-analytics` ran at rss=3911MB with heapUsed only
51702
+ * 153MB and arrayBuffers=2281MB — 2.28 GB of JS-reachable Buffers, roughly
51703
+ * 3 200 pinned frames, in a runner whose heap limit is 1216 MB. The call site
51704
+ * that pins them says the cost is "bounded by the lane"; it was not.
51705
+ *
51706
+ * Two things happen on the way in, in this order:
51707
+ *
51708
+ * 1. **expire what is already dead.** Entries past their `staleAfterMs` are
51709
+ * dropped now. They were doomed the moment they expired; `pump` simply
51710
+ * never got to look. This is the reclaim that costs nothing.
51711
+ * 2. **shed the least important.** Still over the cap, the TAIL goes — the
51712
+ * queue is ordered by priority, so the tail is by construction the work
51713
+ * this scheduler already ranked last. Whether that is the newcomer or an
51714
+ * older low-priority entry is decided by the same ordering that decides
51715
+ * execution, so a `firstFrame` never loses its place to an `embed`.
51716
+ */
51451
51717
  enqueue(lane, input) {
51452
51718
  return new Promise((resolve, reject) => {
51453
51719
  const entry = {
@@ -51460,11 +51726,53 @@ var CaptureScheduler = class {
51460
51726
  },
51461
51727
  drop: () => resolve(null)
51462
51728
  };
51729
+ this.dropExpired(input.deviceId, lane);
51463
51730
  this.insertByPriority(lane, entry);
51731
+ this.shedToCap(input.deviceId, lane);
51464
51732
  const w = this.window(input.deviceId);
51465
51733
  if (lane.queue.length > w.maxQueueDepth) w.maxQueueDepth = lane.queue.length;
51466
51734
  });
51467
51735
  }
51736
+ /** Drop every queued entry already past its `staleAfterMs`. Entries without
51737
+ * one (e.g. `confirm`, which gates track birth) are never expired here. */
51738
+ dropExpired(deviceId, lane) {
51739
+ const nowMs = this.now();
51740
+ for (let i = lane.queue.length - 1; i >= 0; i -= 1) {
51741
+ const entry = lane.queue[i];
51742
+ if (entry === void 0) continue;
51743
+ if (entry.staleAfterMs === void 0 || nowMs - entry.enqueuedAt <= entry.staleAfterMs) continue;
51744
+ lane.queue.splice(i, 1);
51745
+ this.bump(deviceId, "droppedStale");
51746
+ entry.drop();
51747
+ }
51748
+ }
51749
+ /** Shed from the tail (lowest priority, newest within it) until the lane is
51750
+ * at its cap. Loud: a capture that is dropped rather than merely late is a
51751
+ * missing thumbnail or first frame, and silence would read as "never asked". */
51752
+ shedToCap(deviceId, lane) {
51753
+ let shed = 0;
51754
+ while (lane.queue.length > this.maxQueued) {
51755
+ const victim = lane.queue.pop();
51756
+ if (victim === void 0) break;
51757
+ this.bump(deviceId, "droppedOverflow");
51758
+ victim.drop();
51759
+ shed += 1;
51760
+ }
51761
+ if (shed === 0) return;
51762
+ const nowMs = this.now();
51763
+ if (nowMs - (this.lastOverflowWarnAt.get(deviceId) ?? Number.NEGATIVE_INFINITY) < OVERFLOW_WARN_THROTTLE_MS) return;
51764
+ this.lastOverflowWarnAt.set(deviceId, nowMs);
51765
+ this.logger.warn("capture queue full — shedding the lowest-priority captures for this camera", {
51766
+ tags: { deviceId },
51767
+ meta: {
51768
+ shed,
51769
+ cap: this.maxQueued,
51770
+ queueDepth: lane.queue.length,
51771
+ inFlight: lane.inFlight,
51772
+ budget: this.budget
51773
+ }
51774
+ });
51775
+ }
51468
51776
  insertByPriority(lane, entry) {
51469
51777
  let index = lane.queue.length;
51470
51778
  while (index > 0) {
@@ -51511,6 +51819,7 @@ var CaptureScheduler = class {
51511
51819
  failed: 0,
51512
51820
  coalesced: 0,
51513
51821
  droppedStale: 0,
51822
+ droppedOverflow: 0,
51514
51823
  cancelled: 0,
51515
51824
  maxQueueDepth: 0
51516
51825
  };
@@ -51535,6 +51844,7 @@ var CaptureScheduler = class {
51535
51844
  failed: w.failed,
51536
51845
  coalesced: w.coalesced,
51537
51846
  droppedStale: w.droppedStale,
51847
+ droppedOverflow: w.droppedOverflow,
51538
51848
  cancelled: w.cancelled,
51539
51849
  maxQueueDepth: w.maxQueueDepth,
51540
51850
  queueDepth: this.queueDepth(deviceId),
@@ -53105,8 +53415,14 @@ var EventMediaDispatcher = class {
53105
53415
  * scheduler request against a 3-slot device lane and can start seconds late,
53106
53416
  * by which time the handle's ring slot is usually recycled and the by-handle
53107
53417
  * resolve in {@link captureForFrame} finds nothing (the 2026-07-23 boxed-tile
53108
- * regression). ~0.7MB per pinned frame, freed with the batch — bounded by the
53109
- * lane. Returns `null` on a genuine live-window miss.
53418
+ * regression). ~0.7MB per pinned frame, freed with the batch.
53419
+ *
53420
+ * THE COPY IS ONLY AS BOUNDED AS THE QUEUE THAT HOLDS IT. This said "bounded
53421
+ * by the lane" while the lane bounded in-flight execs (3) and nothing at all
53422
+ * bounded the queue behind them, so a saturated camera accumulated pinned
53423
+ * frames without limit — 2.28 GB of `arrayBuffers` measured on the live hub.
53424
+ * Every caller must therefore pass `staleAfterMs`, and `CaptureScheduler`
53425
+ * caps queue depth per device. Returns `null` on a genuine live-window miss.
53110
53426
  *
53111
53427
  * Both miss paths are WARN, throttled to one line per device per minute. They
53112
53428
  * are the FIRST half of a pair whose second half (`capture batch lost`) has
@@ -56931,10 +57247,12 @@ var CONFIRMATION_CROP_MAX_WIDTH = 320;
56931
57247
  * a late confirmation is still worth running; see `DEFAULT_CAPTURE_PRIORITY`). */
56932
57248
  var ENRICHMENT_CAPTURE_STALE_MS = 3e3;
56933
57249
  /** Reconcile cadence for the per-device `zones` / `zoneRules` slice handles.
56934
- * The `device.state-changed` push doesn't reliably reach a forked child, so
56935
- * each cached proxy re-pulls both slices on this timer (see ensureProxy) —
56936
- * a zone drawn in the editor shows up in per-zone stats within one tick. */
56937
- var ZONE_SLICE_RECONCILE_MS = 3e4;
57250
+ * The `device.state-changed` push reaches a forked child through
57251
+ * `createEventBusSliceSource` (the DeviceProxy no longer waits on
57252
+ * `live.onEvent`). Events are still lossy telemetry (D8); this timer
57253
+ * is the RPC reconcile, not the mechanism. Five minutes matches the
57254
+ * viewer's `RECONCILE_MS`. */
57255
+ var ZONE_SLICE_RECONCILE_MS = 3e5;
56938
57256
  /** How often the evaluating node re-reads the central scene set. */
56939
57257
  var SCENE_RELOAD_INTERVAL_MS = 3e4;
56940
57258
  /** How long the install coordinates are trusted between settings reads. */
@@ -56998,38 +57316,31 @@ var MOTION_EVENT_HEARTBEAT_MS = 5e3;
56998
57316
  *
56999
57317
  * `thumbnail` joined the clean set on 2026-07-17: the track best-thumbnail is
57000
57318
  * now a subject-centered crop (event-media-dispatcher), and for DEGENERATE
57001
- * tracks (no object event → no `crop`) it is the ONLY subject-centered media —
57002
- * without it a crop-forced request degraded to the `keyFrame` FULL FRAME,
57003
- * which is how 65% of the timeline tiles rendered as whole scenes. It sits
57004
- * right after a real `crop`, before the full-scene `fullFrame`/`keyFrame`.
57005
- *
57006
- * `keyFrameSmall` (the 960px no-box downscale of the best-shot `keyFrame`)
57007
- * joined the clean set on 2026-07-21 so a crop-forced request can degrade to it
57008
- * for tracks whose only clean media is the small key frame; it sits last
57009
- * (fullest-scene, but subject-centered enough to serve as a reasonable fallback
57010
- * over 404). The retired-but-still-stored kinds (`crop`) stay listed so old rows
57011
- * keep resolving.
57319
+ * tracks (no object event → no `crop`) it is the ONLY subject-centered media.
57320
+ *
57321
+ * Full-scene kinds (`fullFrame`, `keyFrame`, `keyFrameSmall`) stay out of
57322
+ * this list so a crop is preferred when one exists. `resolveCropForcedMedia`
57323
+ * then falls through to those full-scene kinds — a keyFrame beats an icon.
57324
+ * An *explicit* `?kind=keyFrame` / `?kind=snapshot` still matches exactly, so
57325
+ * videoclips and other kind-specific fetches keep working.
57326
+ *
57327
+ * The retired-but-still-stored kind (`crop`) stays listed so old rows resolve.
57012
57328
  */
57013
57329
  var CLEAN_MEDIA_KINDS = [
57014
57330
  "crop",
57015
57331
  "thumbnail",
57016
- "thumbnailSmall",
57017
- "fullFrame",
57018
- "keyFrame",
57019
- "keyFrameSmall"
57332
+ "thumbnailSmall"
57020
57333
  ];
57021
57334
  /**
57022
- * Pick a CLEAN (never boxed) media file for a crop-forced request: the exact
57023
- * `preferKind` if it is itself clean and present, else the first available
57024
- * {@link CLEAN_MEDIA_KINDS} frame. Returns undefined when only boxed / no media
57025
- * exists (caller 404s → the viewer shows an icon).
57335
+ * Pick media for a crop-forced request: the exact `preferKind` when present
57336
+ * (so `?kind=snapshot` still serves a snapshot), else the first available
57337
+ * subject crop in {@link CLEAN_MEDIA_KINDS}. Returns undefined when no crop
57338
+ * is in the list — the caller then falls through to any remaining image
57339
+ * (`resolveCropForcedMedia`); the class icon is only for a total miss.
57026
57340
  */
57027
57341
  function pickCleanMedia(files, preferKind) {
57028
- const isClean = (k) => CLEAN_MEDIA_KINDS.includes(k);
57029
- if (isClean(preferKind)) {
57030
- const exact = files.find((f) => f.kind === preferKind);
57031
- if (exact) return exact;
57032
- }
57342
+ const exact = files.find((f) => f.kind === preferKind);
57343
+ if (exact) return exact;
57033
57344
  for (const kind of CLEAN_MEDIA_KINDS) {
57034
57345
  const found = files.find((f) => f.kind === kind);
57035
57346
  if (found) return found;
@@ -58493,6 +58804,19 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
58493
58804
  }, trackId);
58494
58805
  },
58495
58806
  markEmbeddedFace: (trackId) => stores.trackStore.markEmbeddedFace(trackId),
58807
+ copyCropToTrackPreview: async (input) => {
58808
+ await copyFaceCropIfTrackHasNoThumbnail({
58809
+ listTrackMedia: (trackId) => stores.mediaStore.listByOwner("track", trackId),
58810
+ putTrackThumbnail: (put) => stores.mediaStore.put({
58811
+ deviceId: put.deviceId,
58812
+ ownerKind: "track",
58813
+ ownerId: put.trackId,
58814
+ kind: "thumbnail",
58815
+ timestamp: put.timestamp,
58816
+ data: put.data
58817
+ })
58818
+ }, input);
58819
+ },
58496
58820
  getKeyFrameMediaKey: (trackId) => this.residents.keyFrameKey(trackId),
58497
58821
  emitFaceGalleryChanged: (payload) => this.emitFaceGalleryChanged(payload),
58498
58822
  onSemanticMatch: (match) => this.promoteSemanticRecognition({
@@ -58507,6 +58831,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
58507
58831
  confidence: match.confidence,
58508
58832
  ...match.frameHandle !== void 0 ? { frameHandle: match.frameHandle } : {}
58509
58833
  }),
58834
+ onLiveHierarchy: (input) => this.requestTrackHierarchy(input),
58510
58835
  logger: logger.child("FaceRecognizer")
58511
58836
  });
58512
58837
  this.faceRecognizer.refreshGallery();
@@ -58539,6 +58864,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
58539
58864
  deriveCropFromKeyFrame,
58540
58865
  getKeyFrameMediaKey: (trackId) => this.residents.keyFrameKey(trackId),
58541
58866
  emitPlateGalleryChanged: (payload) => this.emitPlateGalleryChanged(payload),
58867
+ onLiveHierarchy: (input) => this.requestTrackHierarchy(input),
58542
58868
  logger: logger.child("PlateRecognizer")
58543
58869
  });
58544
58870
  this.plateRecognizer.refreshGallery();
@@ -59908,6 +60234,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
59908
60234
  refreshGallery: () => {
59909
60235
  this.plateRecognizer?.refreshGallery();
59910
60236
  },
60237
+ eventMediaBaseUrl: () => this.eventMediaBaseUrl,
59911
60238
  emitPlateGalleryChanged: (payload) => this.emitPlateGalleryChanged(payload)
59912
60239
  });
59913
60240
  return [
@@ -60569,6 +60896,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
60569
60896
  hasSnapshot: snapshotTargets.some((t) => t.appendSnapshot)
60570
60897
  }),
60571
60898
  holdKeys: heldCaptureKeys,
60899
+ staleAfterMs: ENRICHMENT_CAPTURE_STALE_MS,
60572
60900
  exec: () => pinnedFramePromise.then((pinnedFrame) => dispatcher.captureForFrame({
60573
60901
  deviceId,
60574
60902
  frameHandle,
@@ -60964,7 +61292,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
60964
61292
  if (!first) return;
60965
61293
  const lifeMs = track.lastSeen - track.firstSeen;
60966
61294
  const moved = netDisplacementPx(track.positions ?? []);
60967
- if (!isShortMotionlessTrack(lifeMs, moved)) {
61295
+ if (!isShortMotionlessTrack(lifeMs, moved, track.className)) {
60968
61296
  this.phantomCells?.noteMovingClose({
60969
61297
  deviceId: track.deviceId,
60970
61298
  className: track.className,
@@ -61082,6 +61410,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
61082
61410
  }, d.score, d.label, frame.timestamp);
61083
61411
  if (this.plateRecognizer) {
61084
61412
  const mediaSettings = await this.resolveDeviceMediaSettings(deviceId);
61413
+ const body = this.residents.bestSeenBbox(trackId);
61085
61414
  await this.plateRecognizer.observePlateRead({
61086
61415
  deviceId,
61087
61416
  trackId,
@@ -61097,7 +61426,13 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
61097
61426
  frameWidth: frame.frameWidth,
61098
61427
  frameHeight: frame.frameHeight,
61099
61428
  cropPadding: mediaSettings.cropPadding,
61100
- ...frame.frameHandle !== void 0 ? { frameHandle: frame.frameHandle } : {}
61429
+ ...frame.frameHandle !== void 0 ? { frameHandle: frame.frameHandle } : {},
61430
+ ...body !== void 0 ? { bodyBbox: {
61431
+ x: body.x,
61432
+ y: body.y,
61433
+ w: body.w,
61434
+ h: body.h
61435
+ } } : {}
61101
61436
  });
61102
61437
  }
61103
61438
  }
@@ -61398,6 +61733,51 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
61398
61733
  }
61399
61734
  }).catch(() => {})));
61400
61735
  }
61736
+ /**
61737
+ * A face or plate crop just landed from a still-live handle. Fill the
61738
+ * missing parent rungs — person/vehicle thumbnail (display crop, same
61739
+ * surface that produced the detail) and native keyFrame — so Events never
61740
+ * has a child crop without a body and a scene.
61741
+ */
61742
+ requestTrackHierarchy(input) {
61743
+ ensureTrackHierarchyMedia({
61744
+ isThumbnailLanded: (trackId) => this.residents.isThumbnailLanded(trackId),
61745
+ isKeyFrameLanded: (trackId) => this.residents.keyFrameCapture(trackId).landed,
61746
+ captureBodyThumbnail: (req) => this.captureHierarchyBodyThumbnail(req),
61747
+ captureKeyFrame: async (req) => {
61748
+ await this.persistKeyFrames(req.deviceId, req.timestamp, [{
61749
+ trackId: req.trackId,
61750
+ retry: false
61751
+ }], req.frameHandle);
61752
+ }
61753
+ }, input);
61754
+ }
61755
+ async captureHierarchyBodyThumbnail(input) {
61756
+ const displayCrop = this.captureDisplayCropFn;
61757
+ const mediaStore = this.mediaStore;
61758
+ if (!displayCrop || !mediaStore) return;
61759
+ await this.captureScheduler.request({
61760
+ deviceId: input.deviceId,
61761
+ trackId: input.trackId,
61762
+ kind: "thumbnail",
61763
+ coalesceKey: captureCoalesceKey(input.deviceId, "thumbnail", `${input.trackId}:hierarchy`),
61764
+ staleAfterMs: ENRICHMENT_CAPTURE_STALE_MS,
61765
+ exec: async () => {
61766
+ if (this.residents.isThumbnailLanded(input.trackId)) return;
61767
+ const jpeg = await displayCrop(input.frameHandle, input.bodyBbox, input.frameWidth, input.frameHeight, input.cropPadding, input.trackId);
61768
+ if (!jpeg) return;
61769
+ await mediaStore.put({
61770
+ deviceId: input.deviceId,
61771
+ ownerKind: "track",
61772
+ ownerId: input.trackId,
61773
+ kind: "thumbnail",
61774
+ timestamp: input.timestamp,
61775
+ data: jpeg
61776
+ });
61777
+ this.residents.markThumbnailLanded(input.deviceId, input.trackId);
61778
+ }
61779
+ }).catch(() => {});
61780
+ }
61401
61781
  /** Attribution-time SEMANTIC best-shot promotion (D-semantic-bestshot,
61402
61782
  * 2026-08-21; see `pipeline/semantic-promotion.ts`): offer a frame from
61403
61783
  * which a recognition SUCCEEDED to the best-frame ranking with the
@@ -61446,7 +61826,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
61446
61826
  this.captureScheduler.request({
61447
61827
  deviceId,
61448
61828
  kind: "thumbnail",
61829
+ trackId,
61449
61830
  holdKeys: [captureCoalesceKey(deviceId, "thumbnail", trackId)],
61831
+ staleAfterMs: ENRICHMENT_CAPTURE_STALE_MS,
61450
61832
  exec: async () => {
61451
61833
  const [pinnedFrame, media] = await Promise.all([pinnedFramePromise, this.resolveDeviceMediaSettings(deviceId)]);
61452
61834
  const res = await dispatcher.captureForFrame({
@@ -62903,15 +63285,11 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
62903
63285
  * applies to the very next frame even when frames stop briefly
62904
63286
  * (e.g. during binding flips).
62905
63287
  *
62906
- * RECONCILE: the push channel behind `subscribe` (`device.state-changed`
62907
- * via `live.onEvent`) does not reliably reach a forked addon child — a
62908
- * zone created AFTER the proxy's cold read stayed invisible until the
62909
- * addon respawned (live-diagnosed on device 617, 2026-07-16: zone slice
62910
- * populated hub-side, `zones: []` in every snapshot). Events are lossy
62911
- * telemetry (D8); the durable channel is RPC + reconcile — so each
62912
- * proxy also refreshes its two slices on a slow timer. `refresh()`
62913
- * round-trips `deviceState.getCapSlice` and fans out through the SAME
62914
- * subscribe callbacks above, so a zone edit lands within one interval.
63288
+ * RECONCILE: the push channel behind `subscribe` is `device.state-changed`
63289
+ * via the addon's `ctx.eventBus` (`createEventBusSliceSource`). That
63290
+ * declares UDS interest so the parent actually fans the slice here. Events
63291
+ * remain lossy (D8); `refresh()` round-trips `deviceState.getCapSlice` on
63292
+ * this timer as the backstop.
62915
63293
  */
62916
63294
  async ensureProxy(deviceId) {
62917
63295
  const cached = this.proxies.get(deviceId);
@@ -64842,14 +65220,11 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
64842
65220
  async readEventThumbnail(id, preferKind) {
64843
65221
  const eventFiles = await (this.mediaStore?.listByOwner("event", id) ?? Promise.resolve([]));
64844
65222
  if (preferKind !== void 0 && preferKind.length > 0) {
64845
- const trackFiles = await (this.mediaStore?.listByOwner("track", id) ?? Promise.resolve([]));
64846
- const ownerTrackId = trackFiles.length === 0 ? await (this.eventStore?.getTrackIdForEvent(id) ?? Promise.resolve(null)) : null;
64847
- const owningTrackFiles = ownerTrackId === null ? [] : await (this.mediaStore?.listByOwner("track", ownerTrackId) ?? Promise.resolve([]));
64848
- const chosen = resolveCropForcedMedia([
64849
- ...eventFiles,
64850
- ...trackFiles,
64851
- ...owningTrackFiles
64852
- ], (files) => pickCleanMedia(files, preferKind));
65223
+ const layers = await collectCropForcedMedia({
65224
+ listByOwner: (ownerKind, ownerId) => this.mediaStore?.listByOwner(ownerKind, ownerId) ?? Promise.resolve([]),
65225
+ getTrackIdForEvent: (eventId) => this.eventStore?.getTrackIdForEvent(eventId) ?? Promise.resolve(null)
65226
+ }, id);
65227
+ const chosen = resolveCropForcedMedia(layers.bodyFiles, (files) => pickCleanMedia(files, preferKind), layers.detailFiles);
64853
65228
  if (!chosen) return null;
64854
65229
  return {
64855
65230
  bytes: Buffer.from(chosen.base64, "base64"),