@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.
@@ -1,4 +1,4 @@
1
- import { $ as customAction, A as NcTaxonomySchema, At as sleep, B as TIMELAPSE_DENSE_FLOOR_SEC, Bt as string, C as NcRuleSchema, Ct as errMsg, D as NcSnoozeSchema, Dt as hydrateSchema, E as NcSnoozeInputSchema, Et as createEvent, Ft as literal, G as VISIT_MERGE_GAP_MS, H as TimelapseRulePatchSchema, Ht as EventCategory, I as SCENE_DEFAULT_ANCHOR_THRESHOLD, It as number, J as assertTimelapseCadences, K as addonWidgetsSourceCapability, L as SCENE_DEFAULT_UNCOVERED_POLICY, Lt as object, Mt as array, N as RECORDING_EXPORT_MAX_READ_BYTES, Nt as boolean, O as NcSnoozeSuppressedSchema, Ot as isDeviceScopedCap, P as RetrainStatusSchema, Pt as discriminatedUnion, Q as cosineSimilarity$1, R as SCENE_DIVERGED, Rt as partialRecord, S as NcRulePatchSchema, St as zoneAnalyticsCapability, T as NcScheduleSchema, Tt as DeviceType, U as TimelapseRuleSchema, V as TimelapseRuleInputSchema, Vt as unknown, W as TrackSourceSchema, X as audioModeOf, Y as audioMetricsCapability, Z as buildEventKindDescriptor, _ as NC_DEFAULT_SNOOZE_MINUTES, _t as sceneMonitorCapability, a as DEFAULT_EVENT_COLOR, b as NcConditionDescriptorSchema, bt as vectorDimFromBase64, ct as kebabToCamel, d as EVENT_PAD_MS, dt as pickClusterStepModels, et as defineCustomActions, f as FULL_IMAGE_BBOX, ft as pipelineAnalyticsCapability, g as NC_CONDITION_CATALOG, h as NC_ALARM_SYSTEM_EVENT_KINDS, ht as readTimelapseGeneratedAt, i as CLUSTER_MODEL_SCOPED_STEPS, it as faceGalleryCapability, j as OpsLogEntrySchema, jt as _enum, k as NcSystemEventKindSchema, kt as nodePin, l as DeclaredDevices, lt as notificationRulesCapability, m as MACRO_LABELS, mt as readDeviceStateFrom, n as AnalyticsGroupRecordSchema, ot as isDetectionMacroClass, p as LabelAttributionSchema, pt as plateGalleryCapability, q as alarmPanelCapability, r as BaseDevice, rt as encodeVectorBase64, s as DETECTION_MACRO_CLASSES, st as isScheduleActive, t as AnalyticsGroupDetailSchema, tt as deriveRecordingMode, u as EVENT_KIND_BY_CAP, vt as subKindsOf, w as NcRuleTargetSchema, wt as BaseAddon, x as NcRuleInputSchema, xt as videoclipsCapability, y as NC_TAXONOMY, yt as systemEventFilterApplies, z as SceneMonitorSchema, zt as record } from "../dist-PMOtjzh2.mjs";
1
+ import { $ as customAction, A as NcTaxonomySchema, At as sleep, B as TIMELAPSE_DENSE_FLOOR_SEC, Bt as string, C as NcRuleSchema, Ct as errMsg, D as NcSnoozeSchema, Dt as hydrateSchema, E as NcSnoozeInputSchema, Et as createEvent, Ft as literal, G as VISIT_MERGE_GAP_MS, H as TimelapseRulePatchSchema, Ht as EventCategory, I as SCENE_DEFAULT_ANCHOR_THRESHOLD, It as number, J as assertTimelapseCadences, K as addonWidgetsSourceCapability, L as SCENE_DEFAULT_UNCOVERED_POLICY, Lt as object, Mt as array, N as RECORDING_EXPORT_MAX_READ_BYTES, Nt as boolean, O as NcSnoozeSuppressedSchema, Ot as isDeviceScopedCap, P as RetrainStatusSchema, Pt as discriminatedUnion, Q as cosineSimilarity$1, R as SCENE_DIVERGED, Rt as partialRecord, S as NcRulePatchSchema, St as zoneAnalyticsCapability, T as NcScheduleSchema, Tt as DeviceType, U as TimelapseRuleSchema, V as TimelapseRuleInputSchema, Vt as unknown, W as TrackSourceSchema, X as audioModeOf, Y as audioMetricsCapability, Z as buildEventKindDescriptor, _ as NC_DEFAULT_SNOOZE_MINUTES, _t as sceneMonitorCapability, a as DEFAULT_EVENT_COLOR, b as NcConditionDescriptorSchema, bt as vectorDimFromBase64, ct as kebabToCamel, d as EVENT_PAD_MS, dt as pickClusterStepModels, et as defineCustomActions, f as FULL_IMAGE_BBOX, ft as pipelineAnalyticsCapability, g as NC_CONDITION_CATALOG, h as NC_ALARM_SYSTEM_EVENT_KINDS, ht as readTimelapseGeneratedAt, i as CLUSTER_MODEL_SCOPED_STEPS, it as faceGalleryCapability, j as OpsLogEntrySchema, jt as _enum, k as NcSystemEventKindSchema, kt as nodePin, l as DeclaredDevices, lt as notificationRulesCapability, m as MACRO_LABELS, mt as readDeviceStateFrom, n as AnalyticsGroupRecordSchema, ot as isDetectionMacroClass, p as LabelAttributionSchema, pt as plateGalleryCapability, q as alarmPanelCapability, r as BaseDevice, rt as encodeVectorBase64, s as DETECTION_MACRO_CLASSES, st as isScheduleActive, t as AnalyticsGroupDetailSchema, tt as deriveRecordingMode, u as EVENT_KIND_BY_CAP, vt as subKindsOf, w as NcRuleTargetSchema, wt as BaseAddon, x as NcRuleInputSchema, xt as videoclipsCapability, y as NC_TAXONOMY, yt as systemEventFilterApplies, z as SceneMonitorSchema, zt as record } from "../dist-R0Wd863b.mjs";
2
2
  import { t as __exportAll } from "../embedding-encoder/index.mjs";
3
3
  import * as fs from "node:fs";
4
4
  import { promises } from "node:fs";
@@ -4391,10 +4391,28 @@ var SCENE_LATCH_INDEXES = [{
4391
4391
  function sceneKey(deviceId, monitorId) {
4392
4392
  return `${deviceId}:${monitorId}`;
4393
4393
  }
4394
+ /**
4395
+ * Reseed cap for BOTH scene ledgers.
4396
+ *
4397
+ * `DurableLedger` defaults to 100_000 and its own docblock says the owner "sets
4398
+ * it from the row set it expects". Neither scene ledger did, so the 30s
4399
+ * reconcile asked SQLite — through hub-main's event loop, which is the
4400
+ * cluster's only queue (D181) — for up to a hundred thousand rows twice a tick,
4401
+ * on a hub holding one scene and one latch (measured live 2026-08-25). A bound
4402
+ * nobody chose is not a bound.
4403
+ *
4404
+ * A scene is an operator-drawn monitor on ONE camera and a latch is at most one
4405
+ * row per scene, so the row set is `cameras x monitors-per-camera`. 5_000 is
4406
+ * ~5 monitors on each of a thousand cameras — far past any installation this
4407
+ * ships to, and still small enough that a runaway write loop is CAUGHT by the
4408
+ * bound instead of being paged in whole.
4409
+ */
4410
+ var SCENE_LEDGER_LOAD_LIMIT = 5e3;
4394
4411
  var SCENE_CONFIG_SPEC = {
4395
4412
  collection: SCENE_COLLECTION,
4396
4413
  columns: SCENE_COLUMNS,
4397
4414
  indexes: SCENE_INDEXES,
4415
+ loadLimit: SCENE_LEDGER_LOAD_LIMIT,
4398
4416
  writeMode: "write-behind",
4399
4417
  keyOf: (row) => row.key,
4400
4418
  toValue: (row) => ({
@@ -4410,6 +4428,7 @@ var SCENE_LATCH_SPEC = {
4410
4428
  collection: SCENE_LATCH_COLLECTION,
4411
4429
  columns: SCENE_LATCH_COLUMNS,
4412
4430
  indexes: SCENE_LATCH_INDEXES,
4431
+ loadLimit: SCENE_LEDGER_LOAD_LIMIT,
4413
4432
  writeMode: "write-through",
4414
4433
  keyOf: (row) => row.key,
4415
4434
  toValue: (row) => ({
@@ -4439,6 +4458,9 @@ var SceneStore = class {
4439
4458
  config;
4440
4459
  latch;
4441
4460
  logger;
4461
+ /** `scenes/latches` as last announced. Null until the first load, so the
4462
+ * first one always speaks. */
4463
+ lastLoggedCounts = null;
4442
4464
  constructor(deps) {
4443
4465
  this.logger = deps.logger;
4444
4466
  this.config = new DurableLedger({
@@ -4463,10 +4485,20 @@ var SceneStore = class {
4463
4485
  * a container recreate, "loaded 0" is the one line that explains why every
4464
4486
  * scene suddenly reads `unknown`, and its absence is what turns a data loss
4465
4487
  * into a three-hour hunt.
4488
+ *
4489
+ * ON CHANGE ONLY, though — including the first load, where there is nothing
4490
+ * to compare against. This runs on the 30s `SCENE_RELOAD_INTERVAL_MS` tick on
4491
+ * every node, and it used to print unconditionally: measured live 2026-08-25,
4492
+ * `scene state loaded {scenes=1 latches=1}` every 30 seconds forever. A count
4493
+ * reprinted unchanged twice a minute is exactly how the "loaded 0" this line
4494
+ * exists for gets scrolled past.
4466
4495
  */
4467
4496
  async load() {
4468
4497
  const scenes = await this.config.load();
4469
4498
  const latches = await this.latch.load();
4499
+ const counts = `${scenes.length}/${latches.length}`;
4500
+ if (counts === this.lastLoggedCounts) return;
4501
+ this.lastLoggedCounts = counts;
4470
4502
  this.logger.info("scene state loaded", { meta: {
4471
4503
  scenes: scenes.length,
4472
4504
  latches: latches.length
@@ -8532,6 +8564,49 @@ var NC_LANGUAGES = ["en", "it"];
8532
8564
  * surface's view all validate against this one, so a third language is added
8533
8565
  * to `NC_LANGUAGES` and nowhere else. */
8534
8566
  var NcLanguageSchema = _enum(NC_LANGUAGES);
8567
+ /**
8568
+ * Whether two snapshots say the same thing.
8569
+ *
8570
+ * ## Why this is a function and not `===`
8571
+ *
8572
+ * The reload guard in `reloadTexts()` used to be
8573
+ * `before.overrides === next.overrides`, and `next` comes from
8574
+ * `NcTextCatalogStore.load()`, which `parse()`s the row through zod on every
8575
+ * call. The right-hand side is therefore a NEW object on every tick by
8576
+ * construction: the comparison was `false` at byte-identical content, forever.
8577
+ * Measured on the live hub 2026-08-25: 58 "notification texts loaded" lines in
8578
+ * 30 minutes on a hub whose language had not changed, from a line whose own
8579
+ * docblock promises it fires on CHANGE only.
8580
+ *
8581
+ * The document is two levels deep and installation-small (at most one entry per
8582
+ * language, each a flat key→string map), so the comparison is written out
8583
+ * rather than reached for through a deep-equal dependency — and written out it
8584
+ * can state the direction that matters: a REMOVED key must not read as equal,
8585
+ * which a one-directional key walk would get wrong.
8586
+ */
8587
+ function ncTextSnapshotsEqual(a, b) {
8588
+ if (a.language !== b.language) return false;
8589
+ return overrideMapsEqual(a.overrides, b.overrides);
8590
+ }
8591
+ function overrideMapsEqual(a, b) {
8592
+ const languages = new Set([...Object.keys(a), ...Object.keys(b)]);
8593
+ for (const language of languages) {
8594
+ const left = a[language];
8595
+ const right = b[language];
8596
+ if (left === void 0 || right === void 0) {
8597
+ if (Object.keys(left ?? right ?? {}).length > 0) return false;
8598
+ continue;
8599
+ }
8600
+ if (!flatMapsEqual(left, right)) return false;
8601
+ }
8602
+ return true;
8603
+ }
8604
+ function flatMapsEqual(a, b) {
8605
+ const keys = Object.keys(a);
8606
+ if (keys.length !== Object.keys(b).length) return false;
8607
+ for (const key of keys) if (a[key] !== b[key]) return false;
8608
+ return true;
8609
+ }
8535
8610
  /** The shipped files, keyed by language. Data, never mutated. */
8536
8611
  var NC_TEXT_BUNDLES = {
8537
8612
  en: en_default,
@@ -22462,7 +22537,7 @@ var NotificationCenter = class NotificationCenter {
22462
22537
  const before = this.texts.snapshot;
22463
22538
  const next = await this.textStore.load();
22464
22539
  this.texts.apply(next);
22465
- if (before.language === next.language && before.overrides === next.overrides) return;
22540
+ if (ncTextSnapshotsEqual(before, next)) return;
22466
22541
  this.logger.info("notification texts loaded", { meta: {
22467
22542
  language: next.language,
22468
22543
  overriddenKeys: Object.values(next.overrides).reduce((sum, keys) => sum + Object.keys(keys ?? {}).length, 0)
@@ -25902,7 +25977,7 @@ var CLASS_RANK_VEHICLE = .8;
25902
25977
  var CLASS_RANK_ANIMAL = .5;
25903
25978
  var CLASS_RANK_DEFAULT = .25;
25904
25979
  var PERSON_CLASSES = new Set(["person", "face"]);
25905
- var VEHICLE_CLASSES = new Set([
25980
+ var VEHICLE_CLASSES$1 = new Set([
25906
25981
  "vehicle",
25907
25982
  "car",
25908
25983
  "truck",
@@ -25933,7 +26008,7 @@ function clamp01$1(x) {
25933
26008
  function classRank(className) {
25934
26009
  const c = className.toLowerCase();
25935
26010
  if (PERSON_CLASSES.has(c)) return 1;
25936
- if (VEHICLE_CLASSES.has(c)) return CLASS_RANK_VEHICLE;
26011
+ if (VEHICLE_CLASSES$1.has(c)) return CLASS_RANK_VEHICLE;
25937
26012
  if (ANIMAL_CLASSES.has(c)) return CLASS_RANK_ANIMAL;
25938
26013
  return CLASS_RANK_DEFAULT;
25939
26014
  }
@@ -26536,7 +26611,7 @@ var FaceGalleryProvider = class {
26536
26611
  const identities = await this.identityStore.listIdentities();
26537
26612
  const nameMap = new Map(identities.map((i) => [i.id, i.name]));
26538
26613
  const identityName = face.recognizedIdentityId ? nameMap.get(face.recognizedIdentityId) : void 0;
26539
- const base64 = await this.faceCropBase64(face);
26614
+ const cropUrl = this.cropUrlFor(face);
26540
26615
  return {
26541
26616
  faceId: face.id,
26542
26617
  deviceId: face.deviceId,
@@ -26545,7 +26620,7 @@ var FaceGalleryProvider = class {
26545
26620
  ...face.recognizedIdentityId != null ? { recognizedIdentityId: face.recognizedIdentityId } : {},
26546
26621
  ...identityName !== void 0 ? { identityName } : {},
26547
26622
  assigned: face.assigned,
26548
- ...base64 !== void 0 ? { base64 } : {},
26623
+ ...cropUrl !== void 0 ? { cropUrl } : {},
26549
26624
  ...face.faceBbox !== void 0 ? { faceBbox: face.faceBbox } : {},
26550
26625
  ...face.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: face.keyFrameMediaKey } : {},
26551
26626
  ...face.bestMatchScore != null ? { bestMatchScore: face.bestMatchScore } : {},
@@ -29663,9 +29738,15 @@ var TrackCloser = class {
29663
29738
  await this.suppressZeroMedia(t.deviceId, t.trackId);
29664
29739
  return;
29665
29740
  }
29666
- if (outcome === "raster-fallback" && closure?.rasterFallback) await this.commitRasterFallback(t, closure.rasterFallback.jpeg, closure.rasterFallback.timestamp);
29741
+ let thumbnailLanded = ownedMedia.some((m) => m.kind === "thumbnail");
29742
+ if (outcome === "raster-fallback" && closure?.rasterFallback) {
29743
+ await this.commitRasterFallback(t, closure.rasterFallback.jpeg, closure.rasterFallback.timestamp);
29744
+ thumbnailLanded = true;
29745
+ }
29667
29746
  await this.maybePromoteLastFrame(t, ownedMedia);
29668
- await this.maybeDeriveThumbnailFromKeyFrame(t, ownedMedia);
29747
+ const hasRasterFallback = closure?.rasterFallback !== void 0;
29748
+ if (!thumbnailLanded) thumbnailLanded = await this.maybeDeriveThumbnailFromKeyFrame(t, ownedMedia, hasRasterFallback);
29749
+ if (!thumbnailLanded && closure?.rasterFallback) await this.commitRasterFallback(t, closure.rasterFallback.jpeg, closure.rasterFallback.timestamp);
29669
29750
  this.deps.logger.info("track ended", {
29670
29751
  tags: { deviceId: t.deviceId },
29671
29752
  meta: {
@@ -29783,14 +29864,23 @@ var TrackCloser = class {
29783
29864
  * and the rate could only ever be counted from the database, after the fact.
29784
29865
  * A branch that drops work silently reads as "never happened".
29785
29866
  */
29786
- async maybeDeriveThumbnailFromKeyFrame(t, ownedMedia) {
29787
- if (ownedMedia.some((m) => m.kind === "thumbnail")) return;
29867
+ async maybeDeriveThumbnailFromKeyFrame(t, ownedMedia, hasRasterFallback) {
29868
+ if (ownedMedia.some((m) => m.kind === "thumbnail")) return true;
29788
29869
  const derive = this.deps.deriveThumbnailFromKeyFrame;
29789
- if (!derive) return this.logNoThumbnailAtClose(t, "derive-not-wired");
29870
+ if (!derive) {
29871
+ if (!hasRasterFallback) this.logNoThumbnailAtClose(t, "derive-not-wired");
29872
+ return false;
29873
+ }
29790
29874
  const keyFrame = ownedMedia.find((m) => m.kind === "keyFrame");
29791
- if (!keyFrame) return this.logNoThumbnailAtClose(t, "no-key-frame");
29875
+ if (!keyFrame) {
29876
+ if (!hasRasterFallback) this.logNoThumbnailAtClose(t, "no-key-frame");
29877
+ return false;
29878
+ }
29792
29879
  const bestSeen = this.deps.residents.bestSeenBbox(t.trackId);
29793
- if (!bestSeen) return this.logNoThumbnailAtClose(t, "no-best-seen-bbox");
29880
+ if (!bestSeen) {
29881
+ if (!hasRasterFallback) this.logNoThumbnailAtClose(t, "no-best-seen-bbox");
29882
+ return false;
29883
+ }
29794
29884
  try {
29795
29885
  const derived = await derive({
29796
29886
  mediaKey: keyFrame.key,
@@ -29802,17 +29892,23 @@ var TrackCloser = class {
29802
29892
  },
29803
29893
  frameWidth: bestSeen.frameWidth,
29804
29894
  frameHeight: bestSeen.frameHeight,
29805
- timestamp: bestSeen.timestamp
29895
+ timestamp: keyFrame.timestamp
29806
29896
  });
29807
- if (!derived) return this.logNoThumbnailAtClose(t, "derive-returned-null", { keyFrameKey: keyFrame.key });
29897
+ if (!derived) {
29898
+ if (!hasRasterFallback) this.logNoThumbnailAtClose(t, "derive-returned-null", { keyFrameKey: keyFrame.key });
29899
+ return false;
29900
+ }
29808
29901
  const mediaStore = this.deps.mediaStore();
29809
- if (!mediaStore) return this.logNoThumbnailAtClose(t, "no-media-store");
29902
+ if (!mediaStore) {
29903
+ if (!hasRasterFallback) this.logNoThumbnailAtClose(t, "no-media-store");
29904
+ return false;
29905
+ }
29810
29906
  await mediaStore.put({
29811
29907
  deviceId: t.deviceId,
29812
29908
  ownerKind: "track",
29813
29909
  ownerId: t.trackId,
29814
29910
  kind: "thumbnail",
29815
- timestamp: bestSeen.timestamp,
29911
+ timestamp: keyFrame.timestamp,
29816
29912
  data: derived.thumbnail
29817
29913
  });
29818
29914
  await mediaStore.put({
@@ -29820,7 +29916,7 @@ var TrackCloser = class {
29820
29916
  ownerKind: "track",
29821
29917
  ownerId: t.trackId,
29822
29918
  kind: "thumbnailSmall",
29823
- timestamp: bestSeen.timestamp,
29919
+ timestamp: keyFrame.timestamp,
29824
29920
  data: derived.thumbnailSmall
29825
29921
  });
29826
29922
  this.deps.logger.info("thumbnail derived from keyFrame at close (no live best-shot landed)", {
@@ -29831,6 +29927,7 @@ var TrackCloser = class {
29831
29927
  skewMs: derived.skewMs
29832
29928
  }
29833
29929
  });
29930
+ return true;
29834
29931
  } catch (err) {
29835
29932
  this.deps.logger.debug("close-time keyFrame thumbnail derive failed", {
29836
29933
  tags: { deviceId: t.deviceId },
@@ -29839,10 +29936,11 @@ var TrackCloser = class {
29839
29936
  error: String(err)
29840
29937
  }
29841
29938
  });
29842
- this.logNoThumbnailAtClose(t, "derive-threw", {
29939
+ if (!hasRasterFallback) this.logNoThumbnailAtClose(t, "derive-threw", {
29843
29940
  keyFrameKey: keyFrame.key,
29844
29941
  error: String(err)
29845
29942
  });
29943
+ return false;
29846
29944
  }
29847
29945
  }
29848
29946
  /**
@@ -30712,6 +30810,7 @@ var PlateGalleryProvider = class {
30712
30810
  logger;
30713
30811
  refreshGallery;
30714
30812
  emitPlateGalleryChanged;
30813
+ eventMediaBaseUrl;
30715
30814
  constructor(deps) {
30716
30815
  this.plateStore = deps.plateStore;
30717
30816
  this.vehicleStore = deps.vehicleStore;
@@ -30721,6 +30820,7 @@ var PlateGalleryProvider = class {
30721
30820
  this.logger = deps.logger;
30722
30821
  this.refreshGallery = deps.refreshGallery;
30723
30822
  this.emitPlateGalleryChanged = deps.emitPlateGalleryChanged;
30823
+ this.eventMediaBaseUrl = deps.eventMediaBaseUrl;
30724
30824
  }
30725
30825
  /** Best-effort emit — never allowed to fail the mutation it follows. */
30726
30826
  safeEmitPlateGalleryChanged(payload) {
@@ -30728,6 +30828,12 @@ var PlateGalleryProvider = class {
30728
30828
  this.emitPlateGalleryChanged(payload);
30729
30829
  } catch {}
30730
30830
  }
30831
+ cropUrlFor(plate) {
30832
+ if (plate.mediaKey === void 0 || plate.mediaKey === "") return void 0;
30833
+ const base = this.eventMediaBaseUrl();
30834
+ if (base === null) return void 0;
30835
+ return `${base}/${encodeURIComponent(plate.mediaKey)}`;
30836
+ }
30731
30837
  async plateCropBase64(plate) {
30732
30838
  if (plate.mediaKey) {
30733
30839
  const m = await this.mediaStore.getByKey(plate.mediaKey);
@@ -30736,8 +30842,9 @@ var PlateGalleryProvider = class {
30736
30842
  const media = await this.mediaStore.listByOwner("plate", plate.id);
30737
30843
  return media.length > 0 ? media[0].base64 : void 0;
30738
30844
  }
30739
- async toPlateInfo(plate, nameMap) {
30740
- const base64 = await this.plateCropBase64(plate);
30845
+ async toPlateInfo(plate, nameMap, opts) {
30846
+ const base64 = opts?.includeCrops ?? true ? await this.plateCropBase64(plate) : void 0;
30847
+ const cropUrl = this.cropUrlFor(plate);
30741
30848
  const vehicleName = plate.recognizedVehicleId != null ? nameMap?.get(plate.recognizedVehicleId) : void 0;
30742
30849
  return {
30743
30850
  plateId: plate.id,
@@ -30752,7 +30859,8 @@ var PlateGalleryProvider = class {
30752
30859
  ...vehicleName !== void 0 ? { vehicleName } : {},
30753
30860
  ...plate.plateBbox !== void 0 ? { plateBbox: plate.plateBbox } : {},
30754
30861
  ...plate.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: plate.keyFrameMediaKey } : {},
30755
- ...base64 !== void 0 ? { base64 } : {}
30862
+ ...base64 !== void 0 ? { base64 } : {},
30863
+ ...cropUrl !== void 0 ? { cropUrl } : {}
30756
30864
  };
30757
30865
  }
30758
30866
  async vehicleNameMap() {
@@ -30771,7 +30879,7 @@ var PlateGalleryProvider = class {
30771
30879
  const plate = await this.plateStore.get(`plate-${input.trackId}`);
30772
30880
  if (!plate || plate.deviceId !== input.deviceId) return null;
30773
30881
  const nameMap = await this.vehicleNameMap();
30774
- return this.toPlateInfo(plate, nameMap);
30882
+ return this.toPlateInfo(plate, nameMap, { includeCrops: false });
30775
30883
  }
30776
30884
  async getPlateMedia(input) {
30777
30885
  return (await this.mediaStore.listByOwner("plate", input.plateId)).map((m) => ({
@@ -35364,6 +35472,18 @@ var EmbeddingDispatcher = class {
35364
35472
  logger;
35365
35473
  getRemoteFrame;
35366
35474
  lastEmbedTime = /* @__PURE__ */ new Map();
35475
+ /**
35476
+ * trackId -> the frame pinned for it. Each entry holds a WHOLE decoded RGB
35477
+ * frame (`PendingCrop.frameData`, ~0.7 MB), not a crop.
35478
+ *
35479
+ * Under the shipped `cropStrategy: 'first'` this doubles as the in-flight
35480
+ * DEDUPE FLAG — `'first'` gates on `!pendingCrops.has(trackId)` — and there
35481
+ * is no flush timer for that strategy, so nothing ever sweeps it. Until
35482
+ * 2026-08-25 the delete lived on `processOne`'s success path only: an
35483
+ * `extractCrop` throw or an `encoder.encode` rejection (a cross-process cap
35484
+ * over UDS, with no timeout) stranded the frame permanently AND made that
35485
+ * trackId un-embeddable forever. `processOne` now releases in a `finally`.
35486
+ */
35367
35487
  pendingCrops = /* @__PURE__ */ new Map();
35368
35488
  flushTimer = null;
35369
35489
  unsubscribe = null;
@@ -35521,7 +35641,6 @@ var EmbeddingDispatcher = class {
35521
35641
  this._processedCount++;
35522
35642
  this._totalInferenceMs += inferenceMs;
35523
35643
  this.lastEmbedTime.set(pending.deviceId, Date.now());
35524
- this.pendingCrops.delete(pending.trackId);
35525
35644
  const embeddingId = `${pending.deviceId}/${pending.trackId}/${Date.now()}`;
35526
35645
  const payload = {
35527
35646
  deviceId: Number(pending.deviceId),
@@ -35553,8 +35672,21 @@ var EmbeddingDispatcher = class {
35553
35672
  error: String(err)
35554
35673
  }
35555
35674
  });
35675
+ } finally {
35676
+ if (this.pendingCrops.get(pending.trackId) === pending) this.pendingCrops.delete(pending.trackId);
35556
35677
  }
35557
35678
  }
35679
+ /**
35680
+ * Decoded frames currently pinned in {@link pendingCrops}.
35681
+ *
35682
+ * ~0.7 MB each (a whole RGB detection frame, not a crop), so this number IS
35683
+ * the dispatcher's memory footprint. Exposed because the entries are
35684
+ * invisible in every functional read: a stranded one looks exactly like a
35685
+ * track that was never detected.
35686
+ */
35687
+ pendingFrameCount() {
35688
+ return this.pendingCrops.size;
35689
+ }
35558
35690
  };
35559
35691
  /**
35560
35692
  * Re-embed every listed track from its key frame.
@@ -35874,6 +36006,19 @@ function createEventMediaHandler(deps) {
35874
36006
  };
35875
36007
  }
35876
36008
  //#endregion
36009
+ //#region src/pipeline-analytics/copy-face-crop-preview.ts
36010
+ var TRACK_PREVIEW_KINDS = new Set(["thumbnail", "thumbnailSmall"]);
36011
+ async function copyFaceCropIfTrackHasNoThumbnail(store, input) {
36012
+ if ((await store.listTrackMedia(input.trackId)).some((m) => TRACK_PREVIEW_KINDS.has(m.kind))) return false;
36013
+ await store.putTrackThumbnail({
36014
+ deviceId: input.deviceId,
36015
+ trackId: input.trackId,
36016
+ timestamp: input.timestamp,
36017
+ data: input.crop
36018
+ });
36019
+ return true;
36020
+ }
36021
+ //#endregion
35877
36022
  //#region src/pipeline-analytics/event-thumbnail-resolver.ts
35878
36023
  /** Pick an event-OWNED media blob: the tight `crop`, else the native-res boxed
35879
36024
  * `fullFrameBoxed`, else any available file. Both preferred kinds are RETIRED
@@ -35882,29 +36027,47 @@ function createEventMediaHandler(deps) {
35882
36027
  function pickEventOwnedMedia(files) {
35883
36028
  return files.find((f) => f.kind === "crop") ?? files.find((f) => f.kind === "fullFrameBoxed") ?? files[0];
35884
36029
  }
35885
- /** Pick a track's fallback media in the shared cadence-preference order the
35886
- * KeyEvent path uses: `thumbnailSmall` (480 fast-load) → best `thumbnail` →
35887
- * rolling `lastFrame` → `firstFrame` → newest `snapshot` → any track blob.
35888
- * Undefined when the track owns no media. */
36030
+ /** Pick a track's fallback media when no subject crop exists: `thumbnailSmall`
36031
+ * → `thumbnail` → native `keyFrameSmall`/`keyFrame`/`fullFrame` → rolling
36032
+ * `lastFrame` → `firstFrame` → newest `snapshot` → any blob. A full frame
36033
+ * beats a class icon. Undefined when the owner has no media. */
35889
36034
  function pickTrackFallbackMedia(files) {
35890
- 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];
36035
+ 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];
35891
36036
  }
35892
36037
  /**
35893
- * Crop-forced resolution (the `?kind=crop` reel/timeline path). Prefer a CLEAN
35894
- * (never-boxed) frame via the injected `pickClean`; when the track owns NO clean
35895
- * frame at all — the regression class created by retiring `crop`/`fullFrame`
35896
- * production (2026-07-21), leaving degenerate tracks with only boxed
35897
- * `lastFrame`/`snapshot`/`firstFrame` — degrade to ANY track media rather than
35898
- * returning undefined. A boxed frame (with the target's box drawn) is a real
35899
- * preview; an empty tile is not. Clean is always tried FIRST so the common case
35900
- * still serves an unboxed tile. Undefined only when the owner has NO media.
36038
+ * Crop-forced resolution (the `?kind=crop` / `?kind=thumbnailSmall` reel path).
36039
+ *
36040
+ * Ladder: body crop (track/event thumbnail) → tight face/plate crop → any
36041
+ * remaining image (keyFrame / lastFrame / …). The class icon is only honest
36042
+ * when the owner has no stored bytes at all.
35901
36043
  *
35902
36044
  * `pickClean` is injected because the clean-kind picker (`pickCleanMedia`) lives
35903
36045
  * in the addon `index.ts` over the concrete `MediaFileKind` set; this keeps the
35904
36046
  * composition pure + unit-testable here.
35905
36047
  */
35906
- function resolveCropForcedMedia(files, pickClean) {
35907
- return pickClean(files) ?? pickTrackFallbackMedia(files);
36048
+ function resolveCropForcedMedia(files, pickClean, detailFiles = []) {
36049
+ return pickClean(files) ?? pickClean(detailFiles) ?? pickTrackFallbackMedia([...files, ...detailFiles]);
36050
+ }
36051
+ /**
36052
+ * Event + track media (the body/scene) and face/plate media (the detail
36053
+ * crops). Face rows live under `face-${trackId}`; a track can close with
36054
+ * `hasEmbeddedFace` and ZERO track files (0e343f86).
36055
+ */
36056
+ async function collectCropForcedMedia(source, id) {
36057
+ const eventFiles = await source.listByOwner("event", id);
36058
+ const trackFiles = await source.listByOwner("track", id);
36059
+ const ownerTrackId = trackFiles.length === 0 ? await source.getTrackIdForEvent(id) : null;
36060
+ const owningTrackFiles = ownerTrackId === null ? [] : await source.listByOwner("track", ownerTrackId);
36061
+ const trackId = ownerTrackId ?? id;
36062
+ const [faceFiles, plateFiles] = await Promise.all([source.listByOwner("face", `face-${trackId}`), source.listByOwner("plate", `plate-${trackId}`)]);
36063
+ return {
36064
+ bodyFiles: [
36065
+ ...eventFiles,
36066
+ ...trackFiles,
36067
+ ...owningTrackFiles
36068
+ ],
36069
+ detailFiles: [...faceFiles, ...plateFiles]
36070
+ };
35908
36071
  }
35909
36072
  /**
35910
36073
  * Resolve the default-path media for an event id: the event's own media when it
@@ -35918,6 +36081,14 @@ async function resolveDefaultEventMedia(deps) {
35918
36081
  return pickTrackFallbackMedia(await deps.listTrackMedia(trackId)) ?? null;
35919
36082
  }
35920
36083
  //#endregion
36084
+ //#region src/pipeline-analytics/ensure-track-hierarchy.ts
36085
+ async function ensureTrackHierarchyMedia(deps, input) {
36086
+ const tasks = [];
36087
+ if (!deps.isThumbnailLanded(input.trackId)) tasks.push(deps.captureBodyThumbnail(input));
36088
+ if (!deps.isKeyFrameLanded(input.trackId)) tasks.push(deps.captureKeyFrame(input));
36089
+ await Promise.all(tasks);
36090
+ }
36091
+ //#endregion
35921
36092
  //#region src/pipeline-analytics/face-settings.ts
35922
36093
  /**
35923
36094
  * Per-device face-recognition settings. Cascade: a per-device override on top
@@ -36436,6 +36607,21 @@ var FaceRecognizer = class {
36436
36607
  ...held,
36437
36608
  crop
36438
36609
  });
36610
+ if (crop !== void 0 && input.frameHandle !== void 0) this.deps.onLiveHierarchy?.({
36611
+ deviceId: input.deviceId,
36612
+ trackId: c.trackId,
36613
+ timestamp: input.timestamp,
36614
+ bodyBbox: {
36615
+ x: c.bbox.x,
36616
+ y: c.bbox.y,
36617
+ w: c.bbox.w,
36618
+ h: c.bbox.h
36619
+ },
36620
+ frameWidth: input.frameWidth,
36621
+ frameHeight: input.frameHeight,
36622
+ cropPadding: input.cropPadding,
36623
+ frameHandle: input.frameHandle
36624
+ });
36439
36625
  this.deps.logger.debug("face: best-face held", {
36440
36626
  tags: {
36441
36627
  deviceId: input.deviceId,
@@ -36585,6 +36771,22 @@ var FaceRecognizer = class {
36585
36771
  });
36586
36772
  return;
36587
36773
  }
36774
+ try {
36775
+ await this.deps.copyCropToTrackPreview?.({
36776
+ deviceId,
36777
+ trackId,
36778
+ crop: held.crop,
36779
+ timestamp: held.timestamp
36780
+ });
36781
+ } catch (err) {
36782
+ this.deps.logger.debug("face: track preview copy failed", {
36783
+ tags: { deviceId },
36784
+ meta: {
36785
+ trackId,
36786
+ error: String(err)
36787
+ }
36788
+ });
36789
+ }
36588
36790
  const keyFrameMediaKey = this.deps.getKeyFrameMediaKey?.(trackId);
36589
36791
  try {
36590
36792
  await this.deps.faceStore.insert({
@@ -44164,6 +44366,12 @@ var PlateRecognizer = class {
44164
44366
  text: c.plateText,
44165
44367
  score: c.plateScore,
44166
44368
  bbox: c.plateBbox,
44369
+ bodyBbox: {
44370
+ x: c.bbox.x,
44371
+ y: c.bbox.y,
44372
+ w: c.bbox.w,
44373
+ h: c.bbox.h
44374
+ },
44167
44375
  timestamp: input.timestamp,
44168
44376
  frameWidth: input.frameWidth,
44169
44377
  frameHeight: input.frameHeight,
@@ -44237,6 +44445,16 @@ var PlateRecognizer = class {
44237
44445
  }
44238
44446
  });
44239
44447
  }
44448
+ if (crop !== void 0 && input.frameHandle !== void 0 && input.bodyBbox !== void 0) this.deps.onLiveHierarchy?.({
44449
+ deviceId: input.deviceId,
44450
+ trackId: input.trackId,
44451
+ timestamp: input.timestamp,
44452
+ bodyBbox: input.bodyBbox,
44453
+ frameWidth: input.frameWidth,
44454
+ frameHeight: input.frameHeight,
44455
+ cropPadding: input.cropPadding,
44456
+ frameHandle: input.frameHandle
44457
+ });
44240
44458
  this.bestPlate.set(input.trackId, {
44241
44459
  text: input.text,
44242
44460
  score: input.score,
@@ -48271,14 +48489,23 @@ function isAfterCursor(row, cursor) {
48271
48489
  if (row.lastSeen < cursor.lastSeen) return true;
48272
48490
  return row.lastSeen === cursor.lastSeen && row.id < cursor.trackId;
48273
48491
  }
48492
+ var VEHICLE_CLASSES = new Set([
48493
+ "vehicle",
48494
+ "car",
48495
+ "truck",
48496
+ "bus"
48497
+ ]);
48498
+ function motionlessMaxPxForClass(className) {
48499
+ return VEHICLE_CLASSES.has(className.toLowerCase()) ? 8 : 40;
48500
+ }
48274
48501
  function netDisplacementPx(positions) {
48275
48502
  const first = positions[0];
48276
48503
  const last = positions[positions.length - 1];
48277
48504
  if (!first || !last) return 0;
48278
48505
  return Math.hypot(last.x - first.x, last.y - first.y);
48279
48506
  }
48280
- function isShortMotionlessTrack(lifeMs, movedPx) {
48281
- return lifeMs <= 25e3 && movedPx <= 8;
48507
+ function isShortMotionlessTrack(lifeMs, movedPx, className) {
48508
+ return lifeMs <= 25e3 && movedPx <= motionlessMaxPxForClass(className);
48282
48509
  }
48283
48510
  //#endregion
48284
48511
  //#region src/pipeline-analytics/store/track-store.ts
@@ -48862,7 +49089,7 @@ var TrackStore = class {
48862
49089
  for (const [trackId, t] of this.active) {
48863
49090
  if (nowMs - t.lastSeen < this.config.ttlMs) continue;
48864
49091
  t.active = false;
48865
- if (t.positions.length > 0 && isShortMotionlessTrack(t.lastSeen - t.firstSeen, netDisplacementPx(t.positions))) t.closedReason = "stationary-promotion";
49092
+ if (t.positions.length > 0 && isShortMotionlessTrack(t.lastSeen - t.firstSeen, netDisplacementPx(t.positions), t.className)) t.closedReason = "stationary-promotion";
48866
49093
  const record = cloneTrack(t);
48867
49094
  try {
48868
49095
  await this.persistCompleted(record);
@@ -51253,9 +51480,14 @@ function deriveFrameDispatchKind(content) {
51253
51480
  }
51254
51481
  var DEFAULT_MAX_IN_FLIGHT_PER_DEVICE = 3;
51255
51482
  var DEFAULT_LOG_WINDOW_MS = 6e4;
51483
+ /** One `capture queue full` line per device per this interval. The condition
51484
+ * holds for as long as the lane is saturated; one line per shed request would
51485
+ * be thousands. */
51486
+ var OVERFLOW_WARN_THROTTLE_MS = 6e4;
51256
51487
  var CaptureScheduler = class {
51257
51488
  logger;
51258
51489
  budget;
51490
+ maxQueued;
51259
51491
  windowMs;
51260
51492
  now;
51261
51493
  lanes = /* @__PURE__ */ new Map();
@@ -51264,9 +51496,12 @@ var CaptureScheduler = class {
51264
51496
  /** holdKey → refcount of queued/running requests holding it. */
51265
51497
  heldKeys = /* @__PURE__ */ new Map();
51266
51498
  windows = /* @__PURE__ */ new Map();
51499
+ /** Per-device throttle for the `capture queue full` warn. */
51500
+ lastOverflowWarnAt = /* @__PURE__ */ new Map();
51267
51501
  constructor(options) {
51268
51502
  this.logger = options.logger;
51269
51503
  this.budget = options.maxInFlightPerDevice ?? DEFAULT_MAX_IN_FLIGHT_PER_DEVICE;
51504
+ this.maxQueued = Math.max(1, options.maxQueuedPerDevice ?? 24);
51270
51505
  this.windowMs = options.logWindowMs ?? DEFAULT_LOG_WINDOW_MS;
51271
51506
  this.now = options.now ?? Date.now;
51272
51507
  }
@@ -51336,6 +51571,7 @@ var CaptureScheduler = class {
51336
51571
  failed: w.failed,
51337
51572
  coalesced: w.coalesced,
51338
51573
  droppedStale: w.droppedStale,
51574
+ droppedOverflow: w.droppedOverflow,
51339
51575
  cancelled: w.cancelled,
51340
51576
  maxQueueDepth: w.maxQueueDepth
51341
51577
  };
@@ -51384,7 +51620,37 @@ var CaptureScheduler = class {
51384
51620
  throw err;
51385
51621
  });
51386
51622
  }
51387
- /** Queue an over-budget request (priority-ordered, FIFO within priority). */
51623
+ /**
51624
+ * Queue an over-budget request (priority-ordered, FIFO within priority),
51625
+ * under a BOUND enforced here rather than at dequeue.
51626
+ *
51627
+ * ## Why the queue needs its own bound
51628
+ *
51629
+ * `staleAfterMs` is evaluated in {@link pump}, and `pump` only touches the
51630
+ * queue while `inFlight < budget`. A lane that stays saturated therefore
51631
+ * never inspects its queue at all — nothing expires, nothing is shed, and the
51632
+ * queue grows for as long as the pressure lasts. The budget bounds concurrent
51633
+ * WORK; it has never bounded queued work.
51634
+ *
51635
+ * That gap is not theoretical: the per-frame batch dispatch PINS a ~0.7 MB
51636
+ * copy of the detection frame BEFORE queueing (so ring recycling cannot
51637
+ * invalidate it), and the queued closure holds that copy alive. Measured on
51638
+ * the live hub, `hub/pipeline-analytics` ran at rss=3911MB with heapUsed only
51639
+ * 153MB and arrayBuffers=2281MB — 2.28 GB of JS-reachable Buffers, roughly
51640
+ * 3 200 pinned frames, in a runner whose heap limit is 1216 MB. The call site
51641
+ * that pins them says the cost is "bounded by the lane"; it was not.
51642
+ *
51643
+ * Two things happen on the way in, in this order:
51644
+ *
51645
+ * 1. **expire what is already dead.** Entries past their `staleAfterMs` are
51646
+ * dropped now. They were doomed the moment they expired; `pump` simply
51647
+ * never got to look. This is the reclaim that costs nothing.
51648
+ * 2. **shed the least important.** Still over the cap, the TAIL goes — the
51649
+ * queue is ordered by priority, so the tail is by construction the work
51650
+ * this scheduler already ranked last. Whether that is the newcomer or an
51651
+ * older low-priority entry is decided by the same ordering that decides
51652
+ * execution, so a `firstFrame` never loses its place to an `embed`.
51653
+ */
51388
51654
  enqueue(lane, input) {
51389
51655
  return new Promise((resolve, reject) => {
51390
51656
  const entry = {
@@ -51397,11 +51663,53 @@ var CaptureScheduler = class {
51397
51663
  },
51398
51664
  drop: () => resolve(null)
51399
51665
  };
51666
+ this.dropExpired(input.deviceId, lane);
51400
51667
  this.insertByPriority(lane, entry);
51668
+ this.shedToCap(input.deviceId, lane);
51401
51669
  const w = this.window(input.deviceId);
51402
51670
  if (lane.queue.length > w.maxQueueDepth) w.maxQueueDepth = lane.queue.length;
51403
51671
  });
51404
51672
  }
51673
+ /** Drop every queued entry already past its `staleAfterMs`. Entries without
51674
+ * one (e.g. `confirm`, which gates track birth) are never expired here. */
51675
+ dropExpired(deviceId, lane) {
51676
+ const nowMs = this.now();
51677
+ for (let i = lane.queue.length - 1; i >= 0; i -= 1) {
51678
+ const entry = lane.queue[i];
51679
+ if (entry === void 0) continue;
51680
+ if (entry.staleAfterMs === void 0 || nowMs - entry.enqueuedAt <= entry.staleAfterMs) continue;
51681
+ lane.queue.splice(i, 1);
51682
+ this.bump(deviceId, "droppedStale");
51683
+ entry.drop();
51684
+ }
51685
+ }
51686
+ /** Shed from the tail (lowest priority, newest within it) until the lane is
51687
+ * at its cap. Loud: a capture that is dropped rather than merely late is a
51688
+ * missing thumbnail or first frame, and silence would read as "never asked". */
51689
+ shedToCap(deviceId, lane) {
51690
+ let shed = 0;
51691
+ while (lane.queue.length > this.maxQueued) {
51692
+ const victim = lane.queue.pop();
51693
+ if (victim === void 0) break;
51694
+ this.bump(deviceId, "droppedOverflow");
51695
+ victim.drop();
51696
+ shed += 1;
51697
+ }
51698
+ if (shed === 0) return;
51699
+ const nowMs = this.now();
51700
+ if (nowMs - (this.lastOverflowWarnAt.get(deviceId) ?? Number.NEGATIVE_INFINITY) < OVERFLOW_WARN_THROTTLE_MS) return;
51701
+ this.lastOverflowWarnAt.set(deviceId, nowMs);
51702
+ this.logger.warn("capture queue full — shedding the lowest-priority captures for this camera", {
51703
+ tags: { deviceId },
51704
+ meta: {
51705
+ shed,
51706
+ cap: this.maxQueued,
51707
+ queueDepth: lane.queue.length,
51708
+ inFlight: lane.inFlight,
51709
+ budget: this.budget
51710
+ }
51711
+ });
51712
+ }
51405
51713
  insertByPriority(lane, entry) {
51406
51714
  let index = lane.queue.length;
51407
51715
  while (index > 0) {
@@ -51448,6 +51756,7 @@ var CaptureScheduler = class {
51448
51756
  failed: 0,
51449
51757
  coalesced: 0,
51450
51758
  droppedStale: 0,
51759
+ droppedOverflow: 0,
51451
51760
  cancelled: 0,
51452
51761
  maxQueueDepth: 0
51453
51762
  };
@@ -51472,6 +51781,7 @@ var CaptureScheduler = class {
51472
51781
  failed: w.failed,
51473
51782
  coalesced: w.coalesced,
51474
51783
  droppedStale: w.droppedStale,
51784
+ droppedOverflow: w.droppedOverflow,
51475
51785
  cancelled: w.cancelled,
51476
51786
  maxQueueDepth: w.maxQueueDepth,
51477
51787
  queueDepth: this.queueDepth(deviceId),
@@ -53042,8 +53352,14 @@ var EventMediaDispatcher = class {
53042
53352
  * scheduler request against a 3-slot device lane and can start seconds late,
53043
53353
  * by which time the handle's ring slot is usually recycled and the by-handle
53044
53354
  * resolve in {@link captureForFrame} finds nothing (the 2026-07-23 boxed-tile
53045
- * regression). ~0.7MB per pinned frame, freed with the batch — bounded by the
53046
- * lane. Returns `null` on a genuine live-window miss.
53355
+ * regression). ~0.7MB per pinned frame, freed with the batch.
53356
+ *
53357
+ * THE COPY IS ONLY AS BOUNDED AS THE QUEUE THAT HOLDS IT. This said "bounded
53358
+ * by the lane" while the lane bounded in-flight execs (3) and nothing at all
53359
+ * bounded the queue behind them, so a saturated camera accumulated pinned
53360
+ * frames without limit — 2.28 GB of `arrayBuffers` measured on the live hub.
53361
+ * Every caller must therefore pass `staleAfterMs`, and `CaptureScheduler`
53362
+ * caps queue depth per device. Returns `null` on a genuine live-window miss.
53047
53363
  *
53048
53364
  * Both miss paths are WARN, throttled to one line per device per minute. They
53049
53365
  * are the FIRST half of a pair whose second half (`capture batch lost`) has
@@ -56868,10 +57184,12 @@ var CONFIRMATION_CROP_MAX_WIDTH = 320;
56868
57184
  * a late confirmation is still worth running; see `DEFAULT_CAPTURE_PRIORITY`). */
56869
57185
  var ENRICHMENT_CAPTURE_STALE_MS = 3e3;
56870
57186
  /** Reconcile cadence for the per-device `zones` / `zoneRules` slice handles.
56871
- * The `device.state-changed` push doesn't reliably reach a forked child, so
56872
- * each cached proxy re-pulls both slices on this timer (see ensureProxy) —
56873
- * a zone drawn in the editor shows up in per-zone stats within one tick. */
56874
- var ZONE_SLICE_RECONCILE_MS = 3e4;
57187
+ * The `device.state-changed` push reaches a forked child through
57188
+ * `createEventBusSliceSource` (the DeviceProxy no longer waits on
57189
+ * `live.onEvent`). Events are still lossy telemetry (D8); this timer
57190
+ * is the RPC reconcile, not the mechanism. Five minutes matches the
57191
+ * viewer's `RECONCILE_MS`. */
57192
+ var ZONE_SLICE_RECONCILE_MS = 3e5;
56875
57193
  /** How often the evaluating node re-reads the central scene set. */
56876
57194
  var SCENE_RELOAD_INTERVAL_MS = 3e4;
56877
57195
  /** How long the install coordinates are trusted between settings reads. */
@@ -56935,38 +57253,31 @@ var MOTION_EVENT_HEARTBEAT_MS = 5e3;
56935
57253
  *
56936
57254
  * `thumbnail` joined the clean set on 2026-07-17: the track best-thumbnail is
56937
57255
  * now a subject-centered crop (event-media-dispatcher), and for DEGENERATE
56938
- * tracks (no object event → no `crop`) it is the ONLY subject-centered media —
56939
- * without it a crop-forced request degraded to the `keyFrame` FULL FRAME,
56940
- * which is how 65% of the timeline tiles rendered as whole scenes. It sits
56941
- * right after a real `crop`, before the full-scene `fullFrame`/`keyFrame`.
56942
- *
56943
- * `keyFrameSmall` (the 960px no-box downscale of the best-shot `keyFrame`)
56944
- * joined the clean set on 2026-07-21 so a crop-forced request can degrade to it
56945
- * for tracks whose only clean media is the small key frame; it sits last
56946
- * (fullest-scene, but subject-centered enough to serve as a reasonable fallback
56947
- * over 404). The retired-but-still-stored kinds (`crop`) stay listed so old rows
56948
- * keep resolving.
57256
+ * tracks (no object event → no `crop`) it is the ONLY subject-centered media.
57257
+ *
57258
+ * Full-scene kinds (`fullFrame`, `keyFrame`, `keyFrameSmall`) stay out of
57259
+ * this list so a crop is preferred when one exists. `resolveCropForcedMedia`
57260
+ * then falls through to those full-scene kinds — a keyFrame beats an icon.
57261
+ * An *explicit* `?kind=keyFrame` / `?kind=snapshot` still matches exactly, so
57262
+ * videoclips and other kind-specific fetches keep working.
57263
+ *
57264
+ * The retired-but-still-stored kind (`crop`) stays listed so old rows resolve.
56949
57265
  */
56950
57266
  var CLEAN_MEDIA_KINDS = [
56951
57267
  "crop",
56952
57268
  "thumbnail",
56953
- "thumbnailSmall",
56954
- "fullFrame",
56955
- "keyFrame",
56956
- "keyFrameSmall"
57269
+ "thumbnailSmall"
56957
57270
  ];
56958
57271
  /**
56959
- * Pick a CLEAN (never boxed) media file for a crop-forced request: the exact
56960
- * `preferKind` if it is itself clean and present, else the first available
56961
- * {@link CLEAN_MEDIA_KINDS} frame. Returns undefined when only boxed / no media
56962
- * exists (caller 404s → the viewer shows an icon).
57272
+ * Pick media for a crop-forced request: the exact `preferKind` when present
57273
+ * (so `?kind=snapshot` still serves a snapshot), else the first available
57274
+ * subject crop in {@link CLEAN_MEDIA_KINDS}. Returns undefined when no crop
57275
+ * is in the list — the caller then falls through to any remaining image
57276
+ * (`resolveCropForcedMedia`); the class icon is only for a total miss.
56963
57277
  */
56964
57278
  function pickCleanMedia(files, preferKind) {
56965
- const isClean = (k) => CLEAN_MEDIA_KINDS.includes(k);
56966
- if (isClean(preferKind)) {
56967
- const exact = files.find((f) => f.kind === preferKind);
56968
- if (exact) return exact;
56969
- }
57279
+ const exact = files.find((f) => f.kind === preferKind);
57280
+ if (exact) return exact;
56970
57281
  for (const kind of CLEAN_MEDIA_KINDS) {
56971
57282
  const found = files.find((f) => f.kind === kind);
56972
57283
  if (found) return found;
@@ -58430,6 +58741,19 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
58430
58741
  }, trackId);
58431
58742
  },
58432
58743
  markEmbeddedFace: (trackId) => stores.trackStore.markEmbeddedFace(trackId),
58744
+ copyCropToTrackPreview: async (input) => {
58745
+ await copyFaceCropIfTrackHasNoThumbnail({
58746
+ listTrackMedia: (trackId) => stores.mediaStore.listByOwner("track", trackId),
58747
+ putTrackThumbnail: (put) => stores.mediaStore.put({
58748
+ deviceId: put.deviceId,
58749
+ ownerKind: "track",
58750
+ ownerId: put.trackId,
58751
+ kind: "thumbnail",
58752
+ timestamp: put.timestamp,
58753
+ data: put.data
58754
+ })
58755
+ }, input);
58756
+ },
58433
58757
  getKeyFrameMediaKey: (trackId) => this.residents.keyFrameKey(trackId),
58434
58758
  emitFaceGalleryChanged: (payload) => this.emitFaceGalleryChanged(payload),
58435
58759
  onSemanticMatch: (match) => this.promoteSemanticRecognition({
@@ -58444,6 +58768,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
58444
58768
  confidence: match.confidence,
58445
58769
  ...match.frameHandle !== void 0 ? { frameHandle: match.frameHandle } : {}
58446
58770
  }),
58771
+ onLiveHierarchy: (input) => this.requestTrackHierarchy(input),
58447
58772
  logger: logger.child("FaceRecognizer")
58448
58773
  });
58449
58774
  this.faceRecognizer.refreshGallery();
@@ -58476,6 +58801,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
58476
58801
  deriveCropFromKeyFrame,
58477
58802
  getKeyFrameMediaKey: (trackId) => this.residents.keyFrameKey(trackId),
58478
58803
  emitPlateGalleryChanged: (payload) => this.emitPlateGalleryChanged(payload),
58804
+ onLiveHierarchy: (input) => this.requestTrackHierarchy(input),
58479
58805
  logger: logger.child("PlateRecognizer")
58480
58806
  });
58481
58807
  this.plateRecognizer.refreshGallery();
@@ -59845,6 +60171,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
59845
60171
  refreshGallery: () => {
59846
60172
  this.plateRecognizer?.refreshGallery();
59847
60173
  },
60174
+ eventMediaBaseUrl: () => this.eventMediaBaseUrl,
59848
60175
  emitPlateGalleryChanged: (payload) => this.emitPlateGalleryChanged(payload)
59849
60176
  });
59850
60177
  return [
@@ -60506,6 +60833,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
60506
60833
  hasSnapshot: snapshotTargets.some((t) => t.appendSnapshot)
60507
60834
  }),
60508
60835
  holdKeys: heldCaptureKeys,
60836
+ staleAfterMs: ENRICHMENT_CAPTURE_STALE_MS,
60509
60837
  exec: () => pinnedFramePromise.then((pinnedFrame) => dispatcher.captureForFrame({
60510
60838
  deviceId,
60511
60839
  frameHandle,
@@ -60901,7 +61229,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
60901
61229
  if (!first) return;
60902
61230
  const lifeMs = track.lastSeen - track.firstSeen;
60903
61231
  const moved = netDisplacementPx(track.positions ?? []);
60904
- if (!isShortMotionlessTrack(lifeMs, moved)) {
61232
+ if (!isShortMotionlessTrack(lifeMs, moved, track.className)) {
60905
61233
  this.phantomCells?.noteMovingClose({
60906
61234
  deviceId: track.deviceId,
60907
61235
  className: track.className,
@@ -61019,6 +61347,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
61019
61347
  }, d.score, d.label, frame.timestamp);
61020
61348
  if (this.plateRecognizer) {
61021
61349
  const mediaSettings = await this.resolveDeviceMediaSettings(deviceId);
61350
+ const body = this.residents.bestSeenBbox(trackId);
61022
61351
  await this.plateRecognizer.observePlateRead({
61023
61352
  deviceId,
61024
61353
  trackId,
@@ -61034,7 +61363,13 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
61034
61363
  frameWidth: frame.frameWidth,
61035
61364
  frameHeight: frame.frameHeight,
61036
61365
  cropPadding: mediaSettings.cropPadding,
61037
- ...frame.frameHandle !== void 0 ? { frameHandle: frame.frameHandle } : {}
61366
+ ...frame.frameHandle !== void 0 ? { frameHandle: frame.frameHandle } : {},
61367
+ ...body !== void 0 ? { bodyBbox: {
61368
+ x: body.x,
61369
+ y: body.y,
61370
+ w: body.w,
61371
+ h: body.h
61372
+ } } : {}
61038
61373
  });
61039
61374
  }
61040
61375
  }
@@ -61335,6 +61670,51 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
61335
61670
  }
61336
61671
  }).catch(() => {})));
61337
61672
  }
61673
+ /**
61674
+ * A face or plate crop just landed from a still-live handle. Fill the
61675
+ * missing parent rungs — person/vehicle thumbnail (display crop, same
61676
+ * surface that produced the detail) and native keyFrame — so Events never
61677
+ * has a child crop without a body and a scene.
61678
+ */
61679
+ requestTrackHierarchy(input) {
61680
+ ensureTrackHierarchyMedia({
61681
+ isThumbnailLanded: (trackId) => this.residents.isThumbnailLanded(trackId),
61682
+ isKeyFrameLanded: (trackId) => this.residents.keyFrameCapture(trackId).landed,
61683
+ captureBodyThumbnail: (req) => this.captureHierarchyBodyThumbnail(req),
61684
+ captureKeyFrame: async (req) => {
61685
+ await this.persistKeyFrames(req.deviceId, req.timestamp, [{
61686
+ trackId: req.trackId,
61687
+ retry: false
61688
+ }], req.frameHandle);
61689
+ }
61690
+ }, input);
61691
+ }
61692
+ async captureHierarchyBodyThumbnail(input) {
61693
+ const displayCrop = this.captureDisplayCropFn;
61694
+ const mediaStore = this.mediaStore;
61695
+ if (!displayCrop || !mediaStore) return;
61696
+ await this.captureScheduler.request({
61697
+ deviceId: input.deviceId,
61698
+ trackId: input.trackId,
61699
+ kind: "thumbnail",
61700
+ coalesceKey: captureCoalesceKey(input.deviceId, "thumbnail", `${input.trackId}:hierarchy`),
61701
+ staleAfterMs: ENRICHMENT_CAPTURE_STALE_MS,
61702
+ exec: async () => {
61703
+ if (this.residents.isThumbnailLanded(input.trackId)) return;
61704
+ const jpeg = await displayCrop(input.frameHandle, input.bodyBbox, input.frameWidth, input.frameHeight, input.cropPadding, input.trackId);
61705
+ if (!jpeg) return;
61706
+ await mediaStore.put({
61707
+ deviceId: input.deviceId,
61708
+ ownerKind: "track",
61709
+ ownerId: input.trackId,
61710
+ kind: "thumbnail",
61711
+ timestamp: input.timestamp,
61712
+ data: jpeg
61713
+ });
61714
+ this.residents.markThumbnailLanded(input.deviceId, input.trackId);
61715
+ }
61716
+ }).catch(() => {});
61717
+ }
61338
61718
  /** Attribution-time SEMANTIC best-shot promotion (D-semantic-bestshot,
61339
61719
  * 2026-08-21; see `pipeline/semantic-promotion.ts`): offer a frame from
61340
61720
  * which a recognition SUCCEEDED to the best-frame ranking with the
@@ -61383,7 +61763,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
61383
61763
  this.captureScheduler.request({
61384
61764
  deviceId,
61385
61765
  kind: "thumbnail",
61766
+ trackId,
61386
61767
  holdKeys: [captureCoalesceKey(deviceId, "thumbnail", trackId)],
61768
+ staleAfterMs: ENRICHMENT_CAPTURE_STALE_MS,
61387
61769
  exec: async () => {
61388
61770
  const [pinnedFrame, media] = await Promise.all([pinnedFramePromise, this.resolveDeviceMediaSettings(deviceId)]);
61389
61771
  const res = await dispatcher.captureForFrame({
@@ -62840,15 +63222,11 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
62840
63222
  * applies to the very next frame even when frames stop briefly
62841
63223
  * (e.g. during binding flips).
62842
63224
  *
62843
- * RECONCILE: the push channel behind `subscribe` (`device.state-changed`
62844
- * via `live.onEvent`) does not reliably reach a forked addon child — a
62845
- * zone created AFTER the proxy's cold read stayed invisible until the
62846
- * addon respawned (live-diagnosed on device 617, 2026-07-16: zone slice
62847
- * populated hub-side, `zones: []` in every snapshot). Events are lossy
62848
- * telemetry (D8); the durable channel is RPC + reconcile — so each
62849
- * proxy also refreshes its two slices on a slow timer. `refresh()`
62850
- * round-trips `deviceState.getCapSlice` and fans out through the SAME
62851
- * subscribe callbacks above, so a zone edit lands within one interval.
63225
+ * RECONCILE: the push channel behind `subscribe` is `device.state-changed`
63226
+ * via the addon's `ctx.eventBus` (`createEventBusSliceSource`). That
63227
+ * declares UDS interest so the parent actually fans the slice here. Events
63228
+ * remain lossy (D8); `refresh()` round-trips `deviceState.getCapSlice` on
63229
+ * this timer as the backstop.
62852
63230
  */
62853
63231
  async ensureProxy(deviceId) {
62854
63232
  const cached = this.proxies.get(deviceId);
@@ -64779,14 +65157,11 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
64779
65157
  async readEventThumbnail(id, preferKind) {
64780
65158
  const eventFiles = await (this.mediaStore?.listByOwner("event", id) ?? Promise.resolve([]));
64781
65159
  if (preferKind !== void 0 && preferKind.length > 0) {
64782
- const trackFiles = await (this.mediaStore?.listByOwner("track", id) ?? Promise.resolve([]));
64783
- const ownerTrackId = trackFiles.length === 0 ? await (this.eventStore?.getTrackIdForEvent(id) ?? Promise.resolve(null)) : null;
64784
- const owningTrackFiles = ownerTrackId === null ? [] : await (this.mediaStore?.listByOwner("track", ownerTrackId) ?? Promise.resolve([]));
64785
- const chosen = resolveCropForcedMedia([
64786
- ...eventFiles,
64787
- ...trackFiles,
64788
- ...owningTrackFiles
64789
- ], (files) => pickCleanMedia(files, preferKind));
65160
+ const layers = await collectCropForcedMedia({
65161
+ listByOwner: (ownerKind, ownerId) => this.mediaStore?.listByOwner(ownerKind, ownerId) ?? Promise.resolve([]),
65162
+ getTrackIdForEvent: (eventId) => this.eventStore?.getTrackIdForEvent(eventId) ?? Promise.resolve(null)
65163
+ }, id);
65164
+ const chosen = resolveCropForcedMedia(layers.bodyFiles, (files) => pickCleanMedia(files, preferKind), layers.detailFiles);
64790
65165
  if (!chosen) return null;
64791
65166
  return {
64792
65167
  bytes: Buffer.from(chosen.base64, "base64"),