@camstack/addon-post-analysis 1.1.26 → 1.1.28

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,8 +1,7 @@
1
- import { C as number, S as boolean, T as string, c as nodePin, d as videoclipsCapability, f as zoneAnalyticsCapability, g as EventCategory, h as DeviceType, i as cosineSimilarity, l as pipelineAnalyticsCapability, m as BaseAddon, n as addonWidgetsSourceCapability, o as faceGalleryCapability, p as errMsg, r as audioMetricsCapability, t as EVENT_PAD_MS, u as plateGalleryCapability, w as object, y as hydrateSchema } from "../dist-DytVmDZg.mjs";
2
- import { n as extractCrop, t as resolveFrame } from "../resolve-frame-CT1T1tWy.mjs";
3
- import { FrameRingReaderCache } from "@camstack/shm-ring";
4
- import sharp from "sharp";
1
+ import { S as string, _ as createEvent, b as number, c as nodePin, d as videoclipsCapability, f as zoneAnalyticsCapability, g as EventCategory, h as DeviceType, i as cosineSimilarity, l as pipelineAnalyticsCapability, m as BaseAddon, n as addonWidgetsSourceCapability, o as faceGalleryCapability, p as errMsg, r as audioMetricsCapability, t as EVENT_PAD_MS, u as plateGalleryCapability, v as hydrateSchema, x as object, y as boolean } from "../dist-Blpsv-M0.mjs";
5
2
  import { randomUUID } from "node:crypto";
3
+ import sharp from "sharp";
4
+ import { FrameRingReaderCache } from "@camstack/shm-ring";
6
5
  //#region src/pipeline-analytics/videoclips-provider.ts
7
6
  var SOURCE = "analytics";
8
7
  function clipIdFor(eventId, startMs, endMs) {
@@ -261,6 +260,7 @@ var FaceGalleryProvider = class {
261
260
  trackStore;
262
261
  eventStore;
263
262
  refreshGallery;
263
+ emitFaceGalleryChanged;
264
264
  constructor(deps) {
265
265
  this.identityStore = deps.identityStore;
266
266
  this.faceStore = deps.faceStore;
@@ -268,6 +268,13 @@ var FaceGalleryProvider = class {
268
268
  this.trackStore = deps.trackStore;
269
269
  this.eventStore = deps.eventStore;
270
270
  this.refreshGallery = deps.refreshGallery;
271
+ this.emitFaceGalleryChanged = deps.emitFaceGalleryChanged;
272
+ }
273
+ /** Best-effort emit — never allowed to fail the mutation it follows. */
274
+ safeEmitFaceGalleryChanged(payload) {
275
+ try {
276
+ this.emitFaceGalleryChanged(payload);
277
+ } catch {}
271
278
  }
272
279
  async listIdentities() {
273
280
  const rows = await this.identityStore.listIdentities();
@@ -486,6 +493,11 @@ var FaceGalleryProvider = class {
486
493
  }, currentFace.trackId);
487
494
  }
488
495
  this.refreshGallery();
496
+ this.safeEmitFaceGalleryChanged({
497
+ deviceId: currentFace.deviceId,
498
+ faceId,
499
+ kind: "assigned"
500
+ });
489
501
  }
490
502
  /**
491
503
  * Unassign a buffered face from its identity:
@@ -516,6 +528,11 @@ var FaceGalleryProvider = class {
516
528
  await this.eventStore.clearLabelForTrack(face.trackId);
517
529
  await this.trackStore.clearLabel(face.trackId);
518
530
  this.refreshGallery();
531
+ this.safeEmitFaceGalleryChanged({
532
+ deviceId: face.deviceId,
533
+ faceId,
534
+ kind: "unassigned"
535
+ });
519
536
  }
520
537
  /**
521
538
  * Delete a buffered face entirely (row + its crop media). If the face is
@@ -529,6 +546,11 @@ var FaceGalleryProvider = class {
529
546
  await this.mediaStore.deleteForOwner("face", [input.faceId]);
530
547
  await this.faceStore.delete(input.faceId);
531
548
  this.refreshGallery();
549
+ this.safeEmitFaceGalleryChanged({
550
+ deviceId: face.deviceId,
551
+ faceId: input.faceId,
552
+ kind: "deleted"
553
+ });
532
554
  }
533
555
  /** Batch-assign many faces to one identity. Per-face failures are collected,
534
556
  * not thrown, so one bad face doesn't abort the rest. */
@@ -686,10 +708,26 @@ function clusterByText(items, maxDistance = 1) {
686
708
  //#region src/pipeline-analytics/plate-gallery-provider.ts
687
709
  var PlateGalleryProvider = class {
688
710
  plateStore;
711
+ vehicleStore;
689
712
  mediaStore;
713
+ trackStore;
714
+ eventStore;
715
+ refreshGallery;
716
+ emitPlateGalleryChanged;
690
717
  constructor(deps) {
691
718
  this.plateStore = deps.plateStore;
719
+ this.vehicleStore = deps.vehicleStore;
692
720
  this.mediaStore = deps.mediaStore;
721
+ this.trackStore = deps.trackStore;
722
+ this.eventStore = deps.eventStore;
723
+ this.refreshGallery = deps.refreshGallery;
724
+ this.emitPlateGalleryChanged = deps.emitPlateGalleryChanged;
725
+ }
726
+ /** Best-effort emit — never allowed to fail the mutation it follows. */
727
+ safeEmitPlateGalleryChanged(payload) {
728
+ try {
729
+ this.emitPlateGalleryChanged(payload);
730
+ } catch {}
693
731
  }
694
732
  async plateCropBase64(plate) {
695
733
  if (plate.mediaKey) {
@@ -699,8 +737,9 @@ var PlateGalleryProvider = class {
699
737
  const media = await this.mediaStore.listByOwner("plate", plate.id);
700
738
  return media.length > 0 ? media[0].base64 : void 0;
701
739
  }
702
- async toPlateInfo(plate) {
740
+ async toPlateInfo(plate, nameMap) {
703
741
  const base64 = await this.plateCropBase64(plate);
742
+ const vehicleName = plate.recognizedVehicleId != null ? nameMap?.get(plate.recognizedVehicleId) : void 0;
704
743
  return {
705
744
  plateId: plate.id,
706
745
  deviceId: plate.deviceId,
@@ -709,20 +748,31 @@ var PlateGalleryProvider = class {
709
748
  text: plate.text,
710
749
  score: plate.score,
711
750
  corrected: plate.corrected,
751
+ assigned: plate.assigned,
752
+ ...plate.recognizedVehicleId != null ? { recognizedVehicleId: plate.recognizedVehicleId } : {},
753
+ ...vehicleName !== void 0 ? { vehicleName } : {},
754
+ ...plate.plateBbox !== void 0 ? { plateBbox: plate.plateBbox } : {},
755
+ ...plate.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: plate.keyFrameMediaKey } : {},
712
756
  ...base64 !== void 0 ? { base64 } : {}
713
757
  };
714
758
  }
759
+ async vehicleNameMap() {
760
+ const vehicles = await this.vehicleStore.listVehicles();
761
+ return new Map(vehicles.map((v) => [v.id, v.name]));
762
+ }
715
763
  async listPlates(input) {
716
764
  const rows = await this.plateStore.listRecentPlates({
717
765
  ...input?.deviceId !== void 0 ? { deviceId: input.deviceId } : {},
718
766
  ...input?.limit !== void 0 ? { limit: input.limit } : {}
719
767
  });
720
- return Promise.all(rows.map((p) => this.toPlateInfo(p)));
768
+ const nameMap = await this.vehicleNameMap();
769
+ return Promise.all(rows.map((p) => this.toPlateInfo(p, nameMap)));
721
770
  }
722
771
  async getPlateByTrack(input) {
723
772
  const plate = await this.plateStore.get(`plate-${input.trackId}`);
724
773
  if (!plate || plate.deviceId !== input.deviceId) return null;
725
- return this.toPlateInfo(plate);
774
+ const nameMap = await this.vehicleNameMap();
775
+ return this.toPlateInfo(plate, nameMap);
726
776
  }
727
777
  async getPlateMedia(input) {
728
778
  return (await this.mediaStore.listByOwner("plate", input.plateId)).map((m) => ({
@@ -740,7 +790,8 @@ var PlateGalleryProvider = class {
740
790
  dist: plateDistance(input.text, p.text)
741
791
  })).filter((x) => x.dist <= maxDistance).toSorted((a, b) => a.dist - b.dist || b.p.timestamp - a.p.timestamp);
742
792
  const limited = input.limit !== void 0 ? scored.slice(0, input.limit) : scored;
743
- return Promise.all(limited.map((x) => this.toPlateInfo(x.p)));
793
+ const nameMap = await this.vehicleNameMap();
794
+ return Promise.all(limited.map((x) => this.toPlateInfo(x.p, nameMap)));
744
795
  }
745
796
  async suggestPlateClusters(input) {
746
797
  const maxDistance = input?.maxDistance ?? 1;
@@ -759,8 +810,203 @@ var PlateGalleryProvider = class {
759
810
  });
760
811
  }
761
812
  async deletePlate(input) {
813
+ const plate = await this.plateStore.get(input.plateId);
814
+ if (plate?.assigned) await this.unassignPlate({ plateId: input.plateId });
762
815
  await this.plateStore.delete(input.plateId);
763
816
  await this.mediaStore.deleteForOwner("plate", [input.plateId]);
817
+ this.refreshGallery();
818
+ if (plate) this.safeEmitPlateGalleryChanged({
819
+ deviceId: plate.deviceId,
820
+ plateId: input.plateId,
821
+ kind: "deleted"
822
+ });
823
+ }
824
+ async listVehicles() {
825
+ const rows = await this.vehicleStore.listVehicles();
826
+ const result = [];
827
+ for (const r of rows) {
828
+ let coverKey = r.coverMediaKey ?? void 0;
829
+ if (!coverKey) coverKey = (await this.vehicleStore.listSamples(r.id)).find((s) => s.mediaKey)?.mediaKey;
830
+ let coverBase64;
831
+ if (coverKey) {
832
+ const media = await this.mediaStore.getByKey(coverKey);
833
+ if (media) coverBase64 = media.base64;
834
+ }
835
+ result.push({
836
+ id: r.id,
837
+ name: r.name,
838
+ sampleCount: r.sampleCount,
839
+ ...coverKey != null ? { coverMediaKey: coverKey } : {},
840
+ ...coverBase64 !== void 0 ? { coverBase64 } : {}
841
+ });
842
+ }
843
+ return result;
844
+ }
845
+ async createVehicle(input) {
846
+ const raw = await this.vehicleStore.createVehicle({ name: input.name });
847
+ this.refreshGallery();
848
+ return {
849
+ id: raw.id,
850
+ name: raw.name,
851
+ sampleCount: raw.sampleCount,
852
+ ...raw.coverMediaKey !== void 0 ? { coverMediaKey: raw.coverMediaKey } : {}
853
+ };
854
+ }
855
+ async renameVehicle(input) {
856
+ await this.vehicleStore.renameVehicle(input.id, input.name);
857
+ }
858
+ async deleteVehicle(input) {
859
+ const plates = await this.plateStore.listAllRecentPlates({});
860
+ for (const p of plates) if (p.recognizedVehicleId === input.id) await this.unassignPlate({ plateId: p.id });
861
+ await this.mediaStore.deleteForOwner("vehicle", [input.id]);
862
+ await this.vehicleStore.deleteVehicle(input.id);
863
+ this.refreshGallery();
864
+ }
865
+ async listVehicleSamples(input) {
866
+ const rows = await this.vehicleStore.listSamples(input.vehicleId);
867
+ const result = [];
868
+ for (const row of rows) {
869
+ let base64;
870
+ if (row.mediaKey) {
871
+ const media = await this.mediaStore.getByKey(row.mediaKey);
872
+ if (media) base64 = media.base64;
873
+ }
874
+ result.push({
875
+ id: row.id,
876
+ text: row.text,
877
+ score: row.score,
878
+ addedAt: row.addedAt,
879
+ ...row.deviceId !== void 0 ? { deviceId: row.deviceId } : {},
880
+ ...base64 !== void 0 ? { base64 } : {}
881
+ });
882
+ }
883
+ return result;
884
+ }
885
+ async removeVehicleSample(input) {
886
+ const sample = (await this.vehicleStore.listSamples(input.vehicleId)).find((s) => s.id === input.sampleId);
887
+ if (sample?.mediaKey) await this.mediaStore.deleteByKey(sample.mediaKey);
888
+ await this.vehicleStore.removeSample(input.vehicleId, input.sampleId);
889
+ }
890
+ async assignPlate(input) {
891
+ const { plateId, vehicleId } = input;
892
+ const plate = await this.plateStore.get(plateId);
893
+ if (!plate) throw new Error(`PlateGalleryProvider.assignPlate: plate not found: ${plateId}`);
894
+ if (plate.assignedSampleId !== void 0 && plate.recognizedVehicleId !== void 0) {
895
+ await this.removeVehicleSample({
896
+ vehicleId: plate.recognizedVehicleId,
897
+ sampleId: plate.assignedSampleId
898
+ });
899
+ await this.plateStore.clearAssignment(plateId);
900
+ await this.plateStore.update(plateId, { mediaKey: null });
901
+ }
902
+ const current = await this.plateStore.get(plateId);
903
+ if (!current) throw new Error(`PlateGalleryProvider.assignPlate: plate disappeared: ${plateId}`);
904
+ let newKey;
905
+ if (current.mediaKey) {
906
+ newKey = await this.mediaStore.reown({
907
+ fromOwnerKind: "plate",
908
+ fromOwnerId: plateId,
909
+ toOwnerKind: "vehicle",
910
+ toOwnerId: vehicleId,
911
+ key: current.mediaKey
912
+ });
913
+ await this.plateStore.update(plateId, { mediaKey: newKey });
914
+ }
915
+ let sampleId;
916
+ try {
917
+ sampleId = (await this.vehicleStore.addSample({
918
+ vehicleId,
919
+ text: current.text,
920
+ score: current.score,
921
+ sourcePlateId: plateId,
922
+ ...newKey !== void 0 ? { mediaKey: newKey } : {},
923
+ ...current.deviceId !== void 0 ? { deviceId: current.deviceId } : {}
924
+ })).id;
925
+ await this.plateStore.markAssigned(plateId, vehicleId, sampleId);
926
+ } catch (err) {
927
+ if (newKey !== void 0) try {
928
+ await this.mediaStore.reown({
929
+ fromOwnerKind: "vehicle",
930
+ fromOwnerId: vehicleId,
931
+ toOwnerKind: "plate",
932
+ toOwnerId: plateId,
933
+ key: newKey
934
+ });
935
+ } catch {}
936
+ throw err;
937
+ }
938
+ const vehicle = (await this.vehicleStore.listVehicles()).find((v) => v.id === vehicleId);
939
+ if (vehicle?.name !== void 0) {
940
+ await this.trackStore.setLabel(current.trackId, vehicle.name);
941
+ await this.eventStore.setLabelForTrack(current.trackId, vehicle.name);
942
+ await recomputeTrackImportance({
943
+ trackStore: this.trackStore,
944
+ eventStore: this.eventStore
945
+ }, current.trackId);
946
+ }
947
+ this.refreshGallery();
948
+ this.safeEmitPlateGalleryChanged({
949
+ deviceId: current.deviceId,
950
+ plateId,
951
+ kind: "assigned"
952
+ });
953
+ }
954
+ async unassignPlate(input) {
955
+ const { plateId } = input;
956
+ const plate = await this.plateStore.get(plateId);
957
+ if (!plate) throw new Error(`PlateGalleryProvider.unassignPlate: plate not found: ${plateId}`);
958
+ const owningVehicleId = plate.recognizedVehicleId;
959
+ if (plate.mediaKey && owningVehicleId !== void 0) try {
960
+ const restoredKey = await this.mediaStore.reown({
961
+ fromOwnerKind: "vehicle",
962
+ fromOwnerId: owningVehicleId,
963
+ toOwnerKind: "plate",
964
+ toOwnerId: plateId,
965
+ key: plate.mediaKey
966
+ });
967
+ await this.plateStore.update(plateId, { mediaKey: restoredKey });
968
+ } catch {}
969
+ if (plate.assignedSampleId !== void 0 && owningVehicleId !== void 0) await this.vehicleStore.removeSample(owningVehicleId, plate.assignedSampleId);
970
+ await this.plateStore.clearAssignment(plateId);
971
+ await this.eventStore.clearLabelForTrack(plate.trackId);
972
+ await this.trackStore.clearLabel(plate.trackId);
973
+ this.refreshGallery();
974
+ this.safeEmitPlateGalleryChanged({
975
+ deviceId: plate.deviceId,
976
+ plateId,
977
+ kind: "unassigned"
978
+ });
979
+ }
980
+ async assignPlates(input) {
981
+ let assigned = 0;
982
+ const failed = [];
983
+ for (const plateId of input.plateIds) try {
984
+ await this.assignPlate({
985
+ plateId,
986
+ vehicleId: input.vehicleId
987
+ });
988
+ assigned += 1;
989
+ } catch {
990
+ failed.push(plateId);
991
+ }
992
+ return {
993
+ assigned,
994
+ failed
995
+ };
996
+ }
997
+ async unassignPlates(input) {
998
+ let unassigned = 0;
999
+ const failed = [];
1000
+ for (const plateId of input.plateIds) try {
1001
+ await this.unassignPlate({ plateId });
1002
+ unassigned += 1;
1003
+ } catch {
1004
+ failed.push(plateId);
1005
+ }
1006
+ return {
1007
+ unassigned,
1008
+ failed
1009
+ };
764
1010
  }
765
1011
  };
766
1012
  //#endregion
@@ -848,7 +1094,6 @@ var DEFAULT_TRACKER_CONFIG = {
848
1094
  stationarySpeedPx: 2
849
1095
  };
850
1096
  var MAX_PATH_LENGTH = 300;
851
- var nextTrackId = 1;
852
1097
  function clamp(value, min, max) {
853
1098
  return Math.max(min, Math.min(max, value));
854
1099
  }
@@ -1055,7 +1300,7 @@ var SortTracker = class {
1055
1300
  const det = detections[di];
1056
1301
  if (this.config.occlusionEnabled && occluderBoxes.some((ob) => containment(det.bbox, ob) >= this.config.occlusionContainment)) continue;
1057
1302
  surviving.push({
1058
- id: `track-${nextTrackId++}`,
1303
+ id: randomUUID(),
1059
1304
  bbox: det.bbox,
1060
1305
  class: det.class,
1061
1306
  originalClass: det.originalClass,
@@ -1521,7 +1766,9 @@ var FrameProcessor = class {
1521
1766
  const labelsByBbox = /* @__PURE__ */ new Map();
1522
1767
  const embeddingByBbox = /* @__PURE__ */ new Map();
1523
1768
  const firstLevelBboxById = /* @__PURE__ */ new Map();
1769
+ const sourceIdByBbox = /* @__PURE__ */ new Map();
1524
1770
  const faceBboxByBbox = /* @__PURE__ */ new Map();
1771
+ const faceAlignedCropByBbox = /* @__PURE__ */ new Map();
1525
1772
  const plateByBbox = /* @__PURE__ */ new Map();
1526
1773
  const maskByBbox = /* @__PURE__ */ new Map();
1527
1774
  const flatDetections = frame.detections.filter((d) => d.kind === "first-level").map((det) => {
@@ -1548,6 +1795,7 @@ var FrameProcessor = class {
1548
1795
  height: det.maskHeight
1549
1796
  });
1550
1797
  firstLevelBboxById.set(det.id, bbox);
1798
+ sourceIdByBbox.set(bbox, det.id);
1551
1799
  return {
1552
1800
  ...bbox,
1553
1801
  detection,
@@ -1564,6 +1812,7 @@ var FrameProcessor = class {
1564
1812
  w: det.bbox.width,
1565
1813
  h: det.bbox.height
1566
1814
  });
1815
+ if (det.faceAlignedCrop !== void 0) faceAlignedCropByBbox.set(parentBbox, det.faceAlignedCrop);
1567
1816
  if (det.embedding !== void 0 && !embeddingByBbox.has(parentBbox)) embeddingByBbox.set(parentBbox, {
1568
1817
  embedding: det.embedding,
1569
1818
  ...det.embeddingModelId !== void 0 ? { embeddingModelId: det.embeddingModelId } : {}
@@ -1606,9 +1855,12 @@ var FrameProcessor = class {
1606
1855
  });
1607
1856
  const emb = embeddingByBbox.get(td.bbox);
1608
1857
  const faceBbox = faceBboxByBbox.get(td.bbox);
1858
+ const faceAlignedCrop = faceAlignedCropByBbox.get(td.bbox);
1609
1859
  const plate = plateByBbox.get(td.bbox);
1860
+ const sourceDetectionId = sourceIdByBbox.get(td.bbox);
1610
1861
  return {
1611
1862
  trackId: td.trackId,
1863
+ ...sourceDetectionId !== void 0 ? { sourceDetectionId } : {},
1612
1864
  className: td.class,
1613
1865
  confidence: td.score,
1614
1866
  bbox: { ...td.bbox },
@@ -1620,6 +1872,7 @@ var FrameProcessor = class {
1620
1872
  ...emb.embeddingModelId !== void 0 ? { embeddingModelId: emb.embeddingModelId } : {}
1621
1873
  } : {},
1622
1874
  ...faceBbox !== void 0 ? { faceBbox } : {},
1875
+ ...faceAlignedCrop !== void 0 ? { faceAlignedCrop } : {},
1623
1876
  ...plate !== void 0 ? {
1624
1877
  plateText: plate.text,
1625
1878
  plateScore: plate.score,
@@ -1670,6 +1923,84 @@ var FrameProcessor = class {
1670
1923
  };
1671
1924
  }
1672
1925
  };
1926
+ var DEFAULT_UPDATE_GATE_CONFIG = {
1927
+ minConfDelta: .1,
1928
+ minIntervalMs: 4e3,
1929
+ minCropAreaGrowth: .15
1930
+ };
1931
+ /**
1932
+ * Decide whether a `phase:'update'` event should fire this frame.
1933
+ *
1934
+ * Priority of triggers: newly-resolved identity/plate > confidence
1935
+ * improvement > materially larger crop. All triggers are debounced by
1936
+ * `minIntervalMs` relative to the last emit. Deltas are measured against
1937
+ * the LAST-EMITTED memory, so a run of sub-threshold new-bests accumulates
1938
+ * and fires once it crosses `minConfDelta`.
1939
+ */
1940
+ function evaluateTrackLifecycleUpdate(signal, memory, config) {
1941
+ const identityTrigger = signal.label !== void 0 && signal.label !== "" && (memory === void 0 || memory.lastLabel !== signal.label);
1942
+ const confidenceTrigger = signal.isNewBest && (memory === void 0 || signal.confidence - memory.lastConfidence >= config.minConfDelta);
1943
+ const cropTrigger = signal.bboxArea !== void 0 && memory?.lastBboxArea !== void 0 && memory.lastBboxArea > 0 && signal.bboxArea >= memory.lastBboxArea * (1 + config.minCropAreaGrowth);
1944
+ const reason = identityTrigger ? "identity" : confidenceTrigger ? "confidence" : cropTrigger ? "crop" : void 0;
1945
+ const keptMemory = memory ?? {
1946
+ lastConfidence: signal.confidence,
1947
+ lastEmitAt: signal.now
1948
+ };
1949
+ if (reason === void 0) return {
1950
+ emit: false,
1951
+ memory: keptMemory
1952
+ };
1953
+ if (memory !== void 0 && signal.now - memory.lastEmitAt < config.minIntervalMs) return {
1954
+ emit: false,
1955
+ memory
1956
+ };
1957
+ const nextLabel = signal.label ?? memory?.lastLabel;
1958
+ const nextBboxArea = signal.bboxArea ?? memory?.lastBboxArea;
1959
+ return {
1960
+ emit: true,
1961
+ reason,
1962
+ memory: {
1963
+ lastConfidence: memory === void 0 ? signal.confidence : Math.max(memory.lastConfidence, signal.confidence),
1964
+ lastEmitAt: signal.now,
1965
+ ...nextLabel !== void 0 ? { lastLabel: nextLabel } : {},
1966
+ ...nextBboxArea !== void 0 ? { lastBboxArea: nextBboxArea } : {}
1967
+ }
1968
+ };
1969
+ }
1970
+ /**
1971
+ * Assemble the typed lifecycle payload. Optional fields (and the whole
1972
+ * `media` object) are omitted when absent so consumers get a clean shape.
1973
+ */
1974
+ function buildTrackLifecyclePayload(input) {
1975
+ const media = {
1976
+ ...input.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: input.keyFrameMediaKey } : {},
1977
+ ...input.bestCropMediaKey !== void 0 ? { bestCropMediaKey: input.bestCropMediaKey } : {},
1978
+ ...input.bestEventId !== void 0 ? { bestEventId: input.bestEventId } : {}
1979
+ };
1980
+ const hasMedia = Object.keys(media).length > 0;
1981
+ return {
1982
+ deviceId: input.deviceId,
1983
+ trackId: input.trackId,
1984
+ phase: input.phase,
1985
+ classes: [...input.classes],
1986
+ bestClassName: input.bestClassName,
1987
+ bestConfidence: input.bestConfidence,
1988
+ firstSeen: input.firstSeen,
1989
+ lastSeen: input.lastSeen,
1990
+ durationMs: Math.max(0, input.lastSeen - input.firstSeen),
1991
+ ...input.label !== void 0 ? { label: input.label } : {},
1992
+ ...input.identityId !== void 0 ? { identityId: input.identityId } : {},
1993
+ ...input.plateText !== void 0 ? { plateText: input.plateText } : {},
1994
+ ...input.zonesVisited !== void 0 ? { zonesVisited: [...input.zonesVisited] } : {},
1995
+ ...input.totalDistance !== void 0 ? { totalDistance: input.totalDistance } : {},
1996
+ ...input.positionsCount !== void 0 ? { positionsCount: input.positionsCount } : {},
1997
+ ...input.importance !== void 0 ? { importance: input.importance } : {},
1998
+ ...input.importanceReason !== void 0 ? { importanceReason: input.importanceReason } : {},
1999
+ ...input.embeddingId !== void 0 ? { embeddingId: input.embeddingId } : {},
2000
+ ...input.embeddingModelId !== void 0 ? { embeddingModelId: input.embeddingModelId } : {},
2001
+ ...hasMedia ? { media } : {}
2002
+ };
2003
+ }
1673
2004
  //#endregion
1674
2005
  //#region src/pipeline-analytics/pipeline/best-detection-tracker.ts
1675
2006
  var BestDetectionTracker = class {
@@ -1874,6 +2205,50 @@ function nativeDetectionsToFrame(input) {
1874
2205
  };
1875
2206
  }
1876
2207
  //#endregion
2208
+ //#region src/pipeline-analytics/pipeline/delete-track-cascade.ts
2209
+ /**
2210
+ * Extensible whole-track deletion cascade. Runs every registered
2211
+ * `TrackScopedStore` (each removes its ephemeral rows + owned media for the
2212
+ * tracks), THEN deletes the track ROOT rows LAST.
2213
+ *
2214
+ * ORDER MATTERS: every leaf store first, the track row last. The intended
2215
+ * production registry (leaves first, root last):
2216
+ *
2217
+ * [ eventStore, // object events + their event-owned media
2218
+ * mediaStore, // track + unassigned face/plate crops (NEVER identity/vehicle)
2219
+ * faceStore, // unassigned face rows (assigned/enrolled exempt)
2220
+ * plateStore, // unassigned plate reads (assigned/enrolled exempt)
2221
+ * objectEmbeddingStore, // per-track CLIP search vectors
2222
+ * ]
2223
+ */
2224
+ async function cascadeDeleteTracks(registry, trackStore, trackIds) {
2225
+ for (const store of registry) await store.deleteByTracks(trackIds);
2226
+ for (const trackId of trackIds) await trackStore.deletePersisted(trackId);
2227
+ }
2228
+ /**
2229
+ * Widened batch whole-track deletion — the ONE engine behind the three §5 entry
2230
+ * points. Runs the FULL registry cascade per track (via `cascadeDeleteTracks`
2231
+ * with a single-id list so the track root is deleted last), fires cleanup on
2232
+ * success only, and collects the ids whose cascade threw. Per-track ISOLATED —
2233
+ * one bad id never aborts the rest of the batch.
2234
+ */
2235
+ async function runTrackCascadeBatch(deps, trackIds) {
2236
+ let deleted = 0;
2237
+ const failed = [];
2238
+ for (const trackId of trackIds) try {
2239
+ await cascadeDeleteTracks(deps.registry, deps.trackStore, [trackId]);
2240
+ deps.onTrackCleanup(deps.deviceId, trackId);
2241
+ deleted += 1;
2242
+ } catch (err) {
2243
+ deps.onFailure?.(trackId, err);
2244
+ failed.push(trackId);
2245
+ }
2246
+ return {
2247
+ deleted,
2248
+ failed
2249
+ };
2250
+ }
2251
+ //#endregion
1877
2252
  //#region src/pipeline-analytics/runtime/binding-cache.ts
1878
2253
  var BindingCache = class {
1879
2254
  api;
@@ -1979,6 +2354,10 @@ var TRACKS_COLUMNS = [
1979
2354
  name: "zonesVisited",
1980
2355
  type: "JSON"
1981
2356
  },
2357
+ {
2358
+ name: "classes",
2359
+ type: "JSON"
2360
+ },
1982
2361
  {
1983
2362
  name: "totalDistance",
1984
2363
  type: "REAL"
@@ -2027,6 +2406,7 @@ function cloneTrack(t) {
2027
2406
  }
2028
2407
  })),
2029
2408
  zonesVisited: [...t.zonesVisited],
2409
+ classes: [...t.classes],
2030
2410
  totalDistance: t.totalDistance,
2031
2411
  state: t.state,
2032
2412
  active: t.active,
@@ -2071,6 +2451,7 @@ var TrackStore = class {
2071
2451
  existing.positions.push(params.position);
2072
2452
  } else existing.positions.push(params.position);
2073
2453
  for (const z of params.zones) if (!existing.zonesVisited.includes(z)) existing.zonesVisited.push(z);
2454
+ if (!existing.classes.includes(params.className)) existing.classes.push(params.className);
2074
2455
  return existing;
2075
2456
  }
2076
2457
  const fresh = {
@@ -2083,6 +2464,7 @@ var TrackStore = class {
2083
2464
  positions: [params.position],
2084
2465
  snapshots: [],
2085
2466
  zonesVisited: [...params.zones],
2467
+ classes: [params.className],
2086
2468
  totalDistance: 0,
2087
2469
  state: params.state,
2088
2470
  active: true,
@@ -2225,6 +2607,92 @@ var TrackStore = class {
2225
2607
  clearAll() {
2226
2608
  this.active.clear();
2227
2609
  }
2610
+ /** Delete the persisted track row (keyed by trackId) and drop the in-RAM
2611
+ * active entry if present. Used by the whole-track deletion cascade. */
2612
+ async deletePersisted(trackId) {
2613
+ await this.store.delete.mutate({
2614
+ collection: TRACKS_COLLECTION,
2615
+ key: trackId
2616
+ });
2617
+ this.active.delete(trackId);
2618
+ }
2619
+ /**
2620
+ * A page of persisted track ids for a device whose `lastSeen < cutoffMs`
2621
+ * (exclusive), oldest first. Feeds the `pruneTracksBefore` drain loop (design
2622
+ * §5.1): the caller cascades the returned ids (which deletes their rows) and
2623
+ * calls again until an empty page. Backed by `idx_tracks_device_lastSeen`.
2624
+ *
2625
+ * Filter shape mirrors `EventStore.pruneBefore`: inclusive `[0, cutoffMs - 1]`
2626
+ * → strictly `< cutoffMs`. Best-effort — a query failure yields [].
2627
+ */
2628
+ async listIdsBefore(deviceId, cutoffMs, limit) {
2629
+ try {
2630
+ return (await this.store.query.query({
2631
+ collection: TRACKS_COLLECTION,
2632
+ filter: {
2633
+ where: { deviceId },
2634
+ whereBetween: { lastSeen: [0, cutoffMs - 1] },
2635
+ orderBy: {
2636
+ field: "lastSeen",
2637
+ direction: "asc"
2638
+ },
2639
+ limit
2640
+ }
2641
+ })).map((r) => r.id).filter((id) => typeof id === "string");
2642
+ } catch (err) {
2643
+ this.logger.warn("TrackStore.listIdsBefore failed", { meta: {
2644
+ deviceId,
2645
+ cutoffMs,
2646
+ error: String(err)
2647
+ } });
2648
+ return [];
2649
+ }
2650
+ }
2651
+ /**
2652
+ * The distinct set of deviceIds that currently have persisted tracks. Feeds
2653
+ * the per-device retention sweep (design §6) so a device that stopped
2654
+ * producing frames still has its aged track debris pruned. Paged scan +
2655
+ * in-memory dedup (the query cap has no DISTINCT); best-effort.
2656
+ */
2657
+ async listDeviceIds() {
2658
+ const seenDevices = /* @__PURE__ */ new Set();
2659
+ const seenIds = /* @__PURE__ */ new Set();
2660
+ const PAGE = 500;
2661
+ let cursor = 0;
2662
+ try {
2663
+ for (;;) {
2664
+ const rows = await this.store.query.query({
2665
+ collection: TRACKS_COLLECTION,
2666
+ filter: {
2667
+ whereBetween: { lastSeen: [cursor, Number.MAX_SAFE_INTEGER] },
2668
+ orderBy: {
2669
+ field: "lastSeen",
2670
+ direction: "asc"
2671
+ },
2672
+ limit: PAGE
2673
+ }
2674
+ });
2675
+ if (rows.length === 0) break;
2676
+ let newInPage = 0;
2677
+ let maxLastSeen = cursor;
2678
+ for (const r of rows) {
2679
+ if (typeof r.id === "string" && !seenIds.has(r.id)) {
2680
+ seenIds.add(r.id);
2681
+ newInPage++;
2682
+ }
2683
+ const ls = Number(r.data["lastSeen"]);
2684
+ if (Number.isFinite(ls) && ls > maxLastSeen) maxLastSeen = ls;
2685
+ const deviceId = Number(r.data["deviceId"]);
2686
+ if (Number.isFinite(deviceId)) seenDevices.add(deviceId);
2687
+ }
2688
+ if (rows.length < PAGE || newInPage === 0) break;
2689
+ cursor = maxLastSeen;
2690
+ }
2691
+ } catch (err) {
2692
+ this.logger.warn("TrackStore.listDeviceIds failed", { meta: { error: String(err) } });
2693
+ }
2694
+ return [...seenDevices];
2695
+ }
2228
2696
  /** Historical query — hits the persisted collection. */
2229
2697
  async queryHistorical(params) {
2230
2698
  const filter = { where: { deviceId: params.deviceId } };
@@ -2266,6 +2734,7 @@ var TrackStore = class {
2266
2734
  positions: [...t.positions],
2267
2735
  snapshots: [...t.snapshots],
2268
2736
  zonesVisited: [...t.zonesVisited],
2737
+ ...t.classes !== void 0 ? { classes: [...t.classes] } : {},
2269
2738
  totalDistance: t.totalDistance,
2270
2739
  state: t.state,
2271
2740
  ...t.importance !== void 0 ? { importance: t.importance } : {},
@@ -2278,6 +2747,7 @@ var TrackStore = class {
2278
2747
  const positions = data["positions"] ?? [];
2279
2748
  const snapshots = data["snapshots"] ?? [];
2280
2749
  const zones = data["zonesVisited"] ?? [];
2750
+ const classes = data["classes"];
2281
2751
  const label = data["label"];
2282
2752
  const importance = data["importance"];
2283
2753
  const bestEventId = data["bestEventId"];
@@ -2292,6 +2762,7 @@ var TrackStore = class {
2292
2762
  positions,
2293
2763
  snapshots,
2294
2764
  zonesVisited: zones,
2765
+ ...classes !== null ? { classes } : {},
2295
2766
  totalDistance: Number(data["totalDistance"] ?? 0),
2296
2767
  state: data["state"] ?? "idle",
2297
2768
  active: false,
@@ -2304,6 +2775,18 @@ var TrackStore = class {
2304
2775
  //#endregion
2305
2776
  //#region src/pipeline-analytics/store/media-store.ts
2306
2777
  var MEDIA_COLLECTION = "pipeline-analytics:media";
2778
+ /** Owner-id prefix for a track's buffered FACE crop. Invariant established by
2779
+ * the face recognizer (`face-recognizer.ts`): `faceId === 'face-' + trackId`
2780
+ * (one buffered face per track), so a track's face crops are owned by
2781
+ * `face-<trackId>`. Used by `deleteByTracks` to derive the face crop owners of
2782
+ * a set of tracks without a `trackId` column on media rows. */
2783
+ var FACE_MEDIA_OWNER_PREFIX = "face-";
2784
+ /** Owner-id prefix for a track's buffered PLATE crop. Invariant established by
2785
+ * the plate recognizer (`plate-recognizer.ts`): `plateId === 'plate-' + trackId`
2786
+ * (one buffered plate per track), so a track's plate crops are owned by
2787
+ * `plate-<trackId>`. Used by `deleteByTracks` to derive the plate crop owners
2788
+ * of a set of tracks without a `trackId` column on media rows. */
2789
+ var PLATE_MEDIA_OWNER_PREFIX = "plate-";
2307
2790
  var MEDIA_COLUMNS = [
2308
2791
  {
2309
2792
  name: "id",
@@ -2668,6 +3151,28 @@ var MediaStore = class {
2668
3151
  async deleteForEvents(eventIds) {
2669
3152
  return this.deleteForOwner("event", eventIds);
2670
3153
  }
3154
+ /** Delete all media owned by these tracks (keyFrame/snapshot/thumbnail). */
3155
+ async deleteForTracks(trackIds) {
3156
+ return this.deleteForOwner("track", trackIds);
3157
+ }
3158
+ /**
3159
+ * `TrackScopedStore` contract for the retention cascade: delete all media
3160
+ * owned by the given tracks — the tracks' OWN media (`ownerKind:'track'`) plus
3161
+ * the UNassigned face crops (`ownerKind:'face'`, owner `face-<trackId>`).
3162
+ *
3163
+ * NEVER deletes `ownerKind:'identity'` or `ownerKind:'vehicle'` media: an
3164
+ * ENROLLED face/plate has its crop REOWNED to `identity`/`vehicle` at
3165
+ * assignment, so deleting only the `'face'`/`'plate'` owner removes unassigned
3166
+ * crops and leaves the enrolled gallery intact. Event-owned media
3167
+ * (`ownerKind:'event'`) is handled by `EventStore` (which holds the eventId
3168
+ * set). `deleteForOwner` is best-effort per row, so one bad row can't abort the
3169
+ * batch.
3170
+ */
3171
+ async deleteByTracks(trackIds) {
3172
+ await this.deleteForOwner("track", trackIds);
3173
+ await this.deleteForOwner("face", trackIds.map((id) => FACE_MEDIA_OWNER_PREFIX + id));
3174
+ await this.deleteForOwner("plate", trackIds.map((id) => PLATE_MEDIA_OWNER_PREFIX + id));
3175
+ }
2671
3176
  /** Retention sweep: delete any media row + blob older than cutoff.
2672
3177
  * Returns number of entries removed. */
2673
3178
  async evictBefore(cutoffMs) {
@@ -2830,9 +3335,11 @@ var COMMON_INDEXES = (prefix) => [{
2830
3335
  var EventStore = class {
2831
3336
  store;
2832
3337
  logger;
3338
+ media;
2833
3339
  constructor(deps) {
2834
3340
  this.store = deps.store;
2835
3341
  this.logger = deps.logger;
3342
+ this.media = deps.media;
2836
3343
  }
2837
3344
  static async declare(store) {
2838
3345
  await store.declareCollection.mutate({
@@ -3045,6 +3552,12 @@ var EventStore = class {
3045
3552
  let bestEventId;
3046
3553
  let peakBboxAreaFrac = 0;
3047
3554
  for (const row of rows) {
3555
+ const bbox = row.data["bbox"];
3556
+ if (bbox !== null && typeof bbox === "object") {
3557
+ const bw = "w" in bbox && typeof bbox.w === "number" ? bbox.w : 0;
3558
+ const bh = "h" in bbox && typeof bbox.h === "number" ? bbox.h : 0;
3559
+ if (bw <= 0 || bh <= 0) continue;
3560
+ }
3048
3561
  const conf = typeof row.data["confidence"] === "number" ? row.data["confidence"] : 0;
3049
3562
  if (conf <= bestConf) continue;
3050
3563
  bestConf = conf;
@@ -3234,6 +3747,72 @@ var EventStore = class {
3234
3747
  ]
3235
3748
  };
3236
3749
  }
3750
+ /**
3751
+ * Delete every OBJECT_EVENTS_COLLECTION row for a single track and RETURN the
3752
+ * deleted event ids so the caller can delete each event's media in lockstep
3753
+ * (the whole-track delete cascade). Motion/audio collections have no
3754
+ * `trackId` column and are never touched.
3755
+ *
3756
+ * The underlying settings-store `query` cap applies a default row limit
3757
+ * (~500). This drains a page at a time — repeatedly querying up to
3758
+ * `PRUNE_PAGE_SIZE` rows for the track and deleting them — until a page
3759
+ * returns 0 rows, so ALL of the track's events are removed regardless of
3760
+ * count. Mirrors the drain loop in `pruneBefore`, including the
3761
+ * infinite-loop guard (a non-empty page that deletes nothing stops the loop).
3762
+ */
3763
+ async deleteByTrack(trackId) {
3764
+ const ids = [];
3765
+ for (;;) {
3766
+ const rows = await this.store.query.query({
3767
+ collection: OBJECT_EVENTS_COLLECTION,
3768
+ filter: {
3769
+ where: { trackId },
3770
+ limit: 500
3771
+ }
3772
+ });
3773
+ if (rows.length === 0) break;
3774
+ let deletedInPage = 0;
3775
+ for (const row of rows) {
3776
+ const id = row.id;
3777
+ if (typeof id !== "string") continue;
3778
+ try {
3779
+ await this.store.delete.mutate({
3780
+ collection: OBJECT_EVENTS_COLLECTION,
3781
+ key: id
3782
+ });
3783
+ ids.push(id);
3784
+ deletedInPage++;
3785
+ } catch {}
3786
+ }
3787
+ if (deletedInPage === 0) break;
3788
+ }
3789
+ return ids;
3790
+ }
3791
+ /**
3792
+ * `TrackScopedStore` contract for the retention cascade: delete every object
3793
+ * event of the given tracks AND (folded in) the event-owned media of those
3794
+ * events, so a caller can never skip the media wipe. Motion/audio collections
3795
+ * carry no `trackId` and are untouched. Reuses the paged `deleteByTrack` drain
3796
+ * per track and is per-track ISOLATED — a failure on one track never aborts
3797
+ * the others (mirrors the best-effort contract in §4 of the design).
3798
+ */
3799
+ async deleteByTracks(trackIds) {
3800
+ const eventIds = [];
3801
+ for (const trackId of trackIds) try {
3802
+ const ids = await this.deleteByTrack(trackId);
3803
+ eventIds.push(...ids);
3804
+ } catch (err) {
3805
+ this.logger.warn("EventStore.deleteByTracks: track failed", { meta: {
3806
+ trackId,
3807
+ error: String(err)
3808
+ } });
3809
+ }
3810
+ if (eventIds.length > 0 && this.media !== void 0) try {
3811
+ await this.media.deleteForEvents(eventIds);
3812
+ } catch (err) {
3813
+ this.logger.warn("EventStore.deleteByTracks: event media delete failed", { meta: { error: String(err) } });
3814
+ }
3815
+ }
3237
3816
  };
3238
3817
  function slimMotion(id, data) {
3239
3818
  return {
@@ -3300,6 +3879,18 @@ function stripNulls(data) {
3300
3879
  return out;
3301
3880
  }
3302
3881
  //#endregion
3882
+ //#region src/shared/frame/resolve-frame.ts
3883
+ /**
3884
+ * Resolve the pixels a `FrameHandle` refers to. Local shm read when the
3885
+ * handle's `nodeId` matches `deps.ownNodeId`, else routed via
3886
+ * `deps.getRemoteFrame`. Returns `null` when the frame is no longer
3887
+ * available (slot recycled locally, or the remote node reports no frame).
3888
+ */
3889
+ async function resolveFrame(handle, deps) {
3890
+ if (handle.nodeId === deps.ownNodeId) return deps.readers.read(handle);
3891
+ return deps.getRemoteFrame(handle);
3892
+ }
3893
+ //#endregion
3303
3894
  //#region src/shared/frame/square-safe-crop.ts
3304
3895
  /**
3305
3896
  * Compute a square-safe 16:9 crop region in pixel space.
@@ -3422,6 +4013,34 @@ function caption(className, confidence, label) {
3422
4013
  return typeof confidence === "number" ? `${base} ${Math.round(confidence * 100)}%` : base;
3423
4014
  }
3424
4015
  /**
4016
+ * True when a packed-RGB frame is (near-)uniform in every channel — the
4017
+ * signature of a blank / hwaccel-unmapped decode frame. Samples a strided set
4018
+ * of pixels and checks each channel's min→max spread stays within a small
4019
+ * epsilon; a real image always has spread in at least one channel. Cheap
4020
+ * (~512 samples) so it runs on every capture.
4021
+ */
4022
+ function isUniformRgbFrame(data, width, height) {
4023
+ const pixels = width * height;
4024
+ if (pixels === 0 || data.length < pixels * 3) return false;
4025
+ const step = Math.max(1, Math.floor(pixels / 512)) * 3;
4026
+ const mins = [
4027
+ 255,
4028
+ 255,
4029
+ 255
4030
+ ];
4031
+ const maxs = [
4032
+ 0,
4033
+ 0,
4034
+ 0
4035
+ ];
4036
+ for (let i = 0; i + 2 < data.length; i += step) for (let c = 0; c < 3; c++) {
4037
+ const v = data[i + c];
4038
+ mins[c] = Math.min(mins[c], v);
4039
+ maxs[c] = Math.max(maxs[c], v);
4040
+ }
4041
+ return maxs.every((mx, c) => mx - mins[c] <= 6);
4042
+ }
4043
+ /**
3425
4044
  * Generates object-event + track media FROM the decoded detection-pipeline
3426
4045
  * frame (the `frameHandle`/shm frame the detector ran on), NEVER the device
3427
4046
  * snapshot cap. Per object event it writes: a `crop` (square-safe 16:9
@@ -3481,9 +4100,21 @@ var EventMediaDispatcher = class {
3481
4100
  });
3482
4101
  return empty;
3483
4102
  }
3484
- const frameData = Buffer.isBuffer(decoded.data) ? decoded.data : Buffer.from(decoded.data);
4103
+ const frameData = Buffer.from(decoded.data);
3485
4104
  const fw = decoded.width;
3486
4105
  const fh = decoded.height;
4106
+ if (isUniformRgbFrame(frameData, fw, fh)) {
4107
+ this.deps.logger.debug("event media: resolved frame uniform (blank/hwaccel) — skipping crop", {
4108
+ tags: { deviceId },
4109
+ meta: {
4110
+ deviceId,
4111
+ shmId: frameHandle.shmId,
4112
+ width: fw,
4113
+ height: fh
4114
+ }
4115
+ });
4116
+ return empty;
4117
+ }
3487
4118
  for (const ev of events) await this.writeEventMedia(deviceId, frameData, fw, fh, ev, input.cropPadding);
3488
4119
  for (const tf of trackFrames) await this.writeTrackFrame(deviceId, frameData, fw, fh, tf);
3489
4120
  const storedSnapshots = [];
@@ -3709,8 +4340,264 @@ var EventMediaDispatcher = class {
3709
4340
  }
3710
4341
  };
3711
4342
  //#endregion
3712
- //#region src/pipeline-analytics/runtime/slice-throttler.ts
3713
- var SliceThrottler = class {
4343
+ //#region src/shared/frame/crop-extractor.ts
4344
+ /**
4345
+ * Extracts a JPEG-encoded crop from a raw frame buffer using a normalized bounding box.
4346
+ * Coordinates are clamped to frame bounds to avoid out-of-range errors.
4347
+ */
4348
+ async function extractCrop(frameData, frameWidth, frameHeight, bbox) {
4349
+ const rawLeft = Math.round(bbox.x * frameWidth);
4350
+ const rawTop = Math.round(bbox.y * frameHeight);
4351
+ const rawWidth = Math.round(bbox.w * frameWidth);
4352
+ const rawHeight = Math.round(bbox.h * frameHeight);
4353
+ const left = Math.max(0, Math.min(rawLeft, frameWidth - 1));
4354
+ const top = Math.max(0, Math.min(rawTop, frameHeight - 1));
4355
+ const width = Math.max(1, Math.min(rawWidth, frameWidth - left));
4356
+ const height = Math.max(1, Math.min(rawHeight, frameHeight - top));
4357
+ return {
4358
+ crop: await sharp(frameData, { raw: {
4359
+ width: frameWidth,
4360
+ height: frameHeight,
4361
+ channels: 3
4362
+ } }).extract({
4363
+ left,
4364
+ top,
4365
+ width,
4366
+ height
4367
+ }).jpeg({ quality: 90 }).toBuffer(),
4368
+ width,
4369
+ height
4370
+ };
4371
+ }
4372
+ //#endregion
4373
+ //#region src/pipeline-analytics/embedding/embedding-dispatcher.ts
4374
+ function selectCropBbox(detection) {
4375
+ return detection.refinedBbox ?? detection.bbox;
4376
+ }
4377
+ var EmbeddingDispatcher = class {
4378
+ config;
4379
+ encoder;
4380
+ eventBus;
4381
+ logger;
4382
+ ownNodeId;
4383
+ readers;
4384
+ getRemoteFrame;
4385
+ lastEmbedTime = /* @__PURE__ */ new Map();
4386
+ pendingCrops = /* @__PURE__ */ new Map();
4387
+ flushTimer = null;
4388
+ unsubscribe = null;
4389
+ _processedCount = 0;
4390
+ _totalInferenceMs = 0;
4391
+ constructor(deps) {
4392
+ this.config = deps.config;
4393
+ this.encoder = deps.encoder;
4394
+ this.eventBus = deps.eventBus;
4395
+ this.logger = deps.logger;
4396
+ this.ownNodeId = deps.ownNodeId;
4397
+ this.readers = deps.readers;
4398
+ this.getRemoteFrame = deps.getRemoteFrame;
4399
+ }
4400
+ async start() {
4401
+ if (!this.config.enabled) {
4402
+ this.logger.info("EmbeddingDispatcher disabled");
4403
+ return;
4404
+ }
4405
+ this.unsubscribe = this.eventBus.subscribe({ category: EventCategory.DetectionResult }, (event) => {
4406
+ this.handleDetectionResult(event);
4407
+ });
4408
+ if (this.config.cropStrategy === "best-confidence") this.flushTimer = setInterval(() => {
4409
+ this.flushPending();
4410
+ }, 1e3);
4411
+ this.logger.info("EmbeddingDispatcher started", { meta: {
4412
+ strategy: this.config.cropStrategy,
4413
+ maxPerSec: this.config.maxPerSecPerCamera
4414
+ } });
4415
+ }
4416
+ async stop() {
4417
+ this.unsubscribe?.();
4418
+ this.unsubscribe = null;
4419
+ if (this.flushTimer) {
4420
+ clearInterval(this.flushTimer);
4421
+ this.flushTimer = null;
4422
+ }
4423
+ await this.flushPending();
4424
+ }
4425
+ get processedCount() {
4426
+ return this._processedCount;
4427
+ }
4428
+ get avgInferenceMs() {
4429
+ return this._processedCount > 0 ? this._totalInferenceMs / this._processedCount : 0;
4430
+ }
4431
+ get queueDepth() {
4432
+ return this.pendingCrops.size;
4433
+ }
4434
+ async handleDetectionResult(event) {
4435
+ const data = event.data;
4436
+ const deviceId = event.source.id !== void 0 ? String(event.source.id) : "";
4437
+ if (!deviceId) return;
4438
+ const detections = data.analysisResults ?? [];
4439
+ const handle = data.frameHandle;
4440
+ if (!handle) {
4441
+ this.logger.debug("skip: no frameHandle on DetectionResult", {
4442
+ tags: { deviceId: Number(deviceId) },
4443
+ meta: { deviceId }
4444
+ });
4445
+ return;
4446
+ }
4447
+ let decoded;
4448
+ try {
4449
+ decoded = await resolveFrame(handle, {
4450
+ ownNodeId: this.ownNodeId,
4451
+ readers: this.readers,
4452
+ getRemoteFrame: this.getRemoteFrame
4453
+ });
4454
+ } catch (err) {
4455
+ this.logger.debug("skip: resolveFrame threw", {
4456
+ tags: { deviceId: Number(deviceId) },
4457
+ meta: {
4458
+ deviceId,
4459
+ shmId: handle.shmId,
4460
+ error: String(err)
4461
+ }
4462
+ });
4463
+ return;
4464
+ }
4465
+ if (!decoded) {
4466
+ this.logger.debug("skip: frame recycled before resolve", {
4467
+ tags: { deviceId: Number(deviceId) },
4468
+ meta: {
4469
+ deviceId,
4470
+ shmId: handle.shmId
4471
+ }
4472
+ });
4473
+ return;
4474
+ }
4475
+ if (decoded.format !== "rgb") {
4476
+ this.logger.debug("skip: resolved frame is not RGB", {
4477
+ tags: { deviceId: Number(deviceId) },
4478
+ meta: {
4479
+ deviceId,
4480
+ format: decoded.format
4481
+ }
4482
+ });
4483
+ return;
4484
+ }
4485
+ const frameData = Buffer.isBuffer(decoded.data) ? decoded.data : Buffer.from(decoded.data);
4486
+ const frameWidth = decoded.width;
4487
+ const frameHeight = decoded.height;
4488
+ for (const det of detections) {
4489
+ const detection = det.detection;
4490
+ if (!detection) continue;
4491
+ if (this.config.classes.length > 0 && !this.config.classes.includes(detection.class)) continue;
4492
+ if (detection.score < this.config.minConfidence) continue;
4493
+ const now = Date.now();
4494
+ const minInterval = 1e3 / this.config.maxPerSecPerCamera;
4495
+ if (now - (this.lastEmbedTime.get(deviceId) ?? 0) < minInterval) continue;
4496
+ const trackId = detection.trackId ?? `${deviceId}-${now}`;
4497
+ const pending = {
4498
+ trackId,
4499
+ deviceId,
4500
+ class: detection.class,
4501
+ confidence: detection.score,
4502
+ frameData,
4503
+ frameWidth,
4504
+ frameHeight,
4505
+ bbox: selectCropBbox(detection),
4506
+ receivedAt: now
4507
+ };
4508
+ switch (this.config.cropStrategy) {
4509
+ case "first":
4510
+ if (!this.pendingCrops.has(trackId)) {
4511
+ this.pendingCrops.set(trackId, pending);
4512
+ this.processOne(pending);
4513
+ }
4514
+ break;
4515
+ case "best-confidence": {
4516
+ const existing = this.pendingCrops.get(trackId);
4517
+ if (!existing || pending.confidence > existing.confidence) this.pendingCrops.set(trackId, pending);
4518
+ break;
4519
+ }
4520
+ case "track-end":
4521
+ if (det.objectState?.state === "leaving") {
4522
+ this.pendingCrops.set(trackId, pending);
4523
+ this.processOne(pending);
4524
+ } else {
4525
+ const existing = this.pendingCrops.get(trackId);
4526
+ if (!existing || pending.confidence > existing.confidence) this.pendingCrops.set(trackId, pending);
4527
+ }
4528
+ break;
4529
+ }
4530
+ }
4531
+ }
4532
+ async flushPending() {
4533
+ const now = Date.now();
4534
+ const toFlush = [];
4535
+ for (const [trackId, pending] of this.pendingCrops) if (now - pending.receivedAt > 3e3) {
4536
+ toFlush.push(pending);
4537
+ this.pendingCrops.delete(trackId);
4538
+ }
4539
+ await Promise.all(toFlush.map((p) => this.processOne(p)));
4540
+ }
4541
+ async processOne(pending) {
4542
+ try {
4543
+ const { crop, width, height } = await extractCrop(pending.frameData, pending.frameWidth, pending.frameHeight, pending.bbox);
4544
+ const { embedding: _embedding, inferenceMs } = await this.encoder.encode(crop, width, height);
4545
+ const info = await this.encoder.getInfo();
4546
+ this._processedCount++;
4547
+ this._totalInferenceMs += inferenceMs;
4548
+ this.lastEmbedTime.set(pending.deviceId, Date.now());
4549
+ this.pendingCrops.delete(pending.trackId);
4550
+ const embeddingId = `${pending.deviceId}/${pending.trackId}/${Date.now()}`;
4551
+ const payload = {
4552
+ deviceId: Number(pending.deviceId),
4553
+ trackId: pending.trackId,
4554
+ class: pending.class,
4555
+ embeddingId,
4556
+ modelId: info.modelId,
4557
+ embeddingDim: info.embeddingDim,
4558
+ inferenceMs,
4559
+ timestamp: Date.now()
4560
+ };
4561
+ this.eventBus.emit(createEvent(EventCategory.EnrichmentEmbeddingStored, {
4562
+ type: "addon",
4563
+ id: "pipeline-analytics"
4564
+ }, payload));
4565
+ this.logger.debug("Embedded track", {
4566
+ tags: { deviceId: Number(pending.deviceId) },
4567
+ meta: {
4568
+ class: pending.class,
4569
+ trackId: pending.trackId,
4570
+ inferenceMs: Number(inferenceMs.toFixed(1))
4571
+ }
4572
+ });
4573
+ } catch (err) {
4574
+ this.logger.warn("Failed to embed track", {
4575
+ tags: { deviceId: Number(pending.deviceId) },
4576
+ meta: {
4577
+ trackId: pending.trackId,
4578
+ error: String(err)
4579
+ }
4580
+ });
4581
+ }
4582
+ }
4583
+ };
4584
+ //#endregion
4585
+ //#region src/pipeline-analytics/embedding/embedding-config.ts
4586
+ var DEFAULT_EMBEDDING_CONFIG = {
4587
+ enabled: true,
4588
+ modelId: "clip-vit-b32",
4589
+ agentId: "local",
4590
+ runtime: "node",
4591
+ backend: "cpu",
4592
+ classes: [],
4593
+ minConfidence: .5,
4594
+ maxPerSecPerCamera: 1,
4595
+ cropStrategy: "first",
4596
+ retentionDays: 30
4597
+ };
4598
+ //#endregion
4599
+ //#region src/pipeline-analytics/runtime/slice-throttler.ts
4600
+ var SliceThrottler = class {
3714
4601
  opts;
3715
4602
  lastWrittenAt = /* @__PURE__ */ new Map();
3716
4603
  lastWritten = /* @__PURE__ */ new Map();
@@ -4585,6 +5472,56 @@ function resolveMediaSettings(raw) {
4585
5472
  };
4586
5473
  }
4587
5474
  //#endregion
5475
+ //#region src/pipeline-analytics/track-retention-sweep.ts
5476
+ /**
5477
+ * Periodic track retention sweep (design §6) — the piece that makes retention
5478
+ * actually BOUNDED. Without it, `pruneTracksBefore` only runs when something
5479
+ * calls it; this sweep ages persisted tracks out on the provider's existing
5480
+ * retention interval.
5481
+ *
5482
+ * `retentionMs` is a PER-DEVICE setting (`trackRetentionDays`, default 7 days;
5483
+ * `0` = keep forever → the device is skipped). Enrolled gallery is exempt by the
5484
+ * cascade store layer (design §4), not here.
5485
+ *
5486
+ * Kept as a pure, dependency-injected function so the loop is unit-testable
5487
+ * without booting the addon.
5488
+ */
5489
+ var DAY_MS = 1440 * 60 * 1e3;
5490
+ /** Per-device track retention setting. Default 7 days; 0 = keep forever. */
5491
+ var TrackRetentionSettingsSchema = object({ trackRetentionDays: number().min(0).default(7) });
5492
+ var TRACK_RETENTION_DEFAULT_DAYS = TrackRetentionSettingsSchema.parse({}).trackRetentionDays;
5493
+ /** Resolve `trackRetentionDays` from a raw per-device store blob — an invalid or
5494
+ * missing value falls back to the default (parse never throws). */
5495
+ function resolveTrackRetentionDays(raw) {
5496
+ const parsed = TrackRetentionSettingsSchema.shape.trackRetentionDays.safeParse(raw["trackRetentionDays"]);
5497
+ return parsed.success ? parsed.data : TRACK_RETENTION_DEFAULT_DAYS;
5498
+ }
5499
+ /** Cutoff timestamp for a retention window. `null` when retention is disabled
5500
+ * (`retentionDays <= 0` → keep forever, the sweep skips the device). */
5501
+ function trackRetentionCutoff(nowMs, retentionDays) {
5502
+ if (retentionDays <= 0) return null;
5503
+ return nowMs - retentionDays * DAY_MS;
5504
+ }
5505
+ /**
5506
+ * Sweep every device with persisted tracks: skip retention-disabled devices,
5507
+ * prune the rest at `now − retentionMs`. Per-device ISOLATED — one bad device
5508
+ * never aborts the sweep. Returns the total tracks pruned across devices.
5509
+ */
5510
+ async function sweepTrackRetention(deps) {
5511
+ const devices = await deps.listDeviceIds();
5512
+ const nowMs = deps.now();
5513
+ let totalTracks = 0;
5514
+ for (const deviceId of devices) try {
5515
+ const cutoffMs = trackRetentionCutoff(nowMs, await deps.resolveRetentionDays(deviceId));
5516
+ if (cutoffMs === null) continue;
5517
+ const counts = await deps.pruneTracksBefore(deviceId, cutoffMs);
5518
+ totalTracks += counts.tracks;
5519
+ } catch (err) {
5520
+ deps.onError?.(deviceId, err);
5521
+ }
5522
+ return totalTracks;
5523
+ }
5524
+ //#endregion
4588
5525
  //#region src/pipeline-analytics/store/identity-store.ts
4589
5526
  /**
4590
5527
  * IdentityStore — per-person identity registry for face recognition.
@@ -4634,7 +5571,7 @@ var IDENTITY_COLUMNS = [
4634
5571
  type: "TEXT"
4635
5572
  }
4636
5573
  ];
4637
- var SAMPLE_COLUMNS = [
5574
+ var SAMPLE_COLUMNS$1 = [
4638
5575
  {
4639
5576
  name: "id",
4640
5577
  type: "TEXT",
@@ -4695,7 +5632,7 @@ var IdentityStore = class {
4695
5632
  });
4696
5633
  await store.declareCollection.mutate({
4697
5634
  collection: IDENTITY_SAMPLES_COLLECTION,
4698
- columns: [...SAMPLE_COLUMNS],
5635
+ columns: [...SAMPLE_COLUMNS$1],
4699
5636
  indexes: [{
4700
5637
  name: "idx_sample_identity",
4701
5638
  columns: ["identityId"]
@@ -5130,6 +6067,38 @@ var FaceStore = class {
5130
6067
  } });
5131
6068
  }
5132
6069
  }
6070
+ /**
6071
+ * `TrackScopedStore` contract for the retention cascade: delete the buffered
6072
+ * face rows of the given tracks that are NOT assigned. Reuses the EXACT
6073
+ * `assigned` exemption that `prune`/`pruneAll` apply (see the
6074
+ * `!(r.data.assigned)` predicate above) so ENROLLED faces SURVIVE retention.
6075
+ * Per-track ISOLATED + best-effort per row — one bad track or row never
6076
+ * aborts the batch. (Crop media of these rows is removed by `MediaStore`.)
6077
+ */
6078
+ async deleteByTracks(trackIds) {
6079
+ for (const trackId of trackIds) try {
6080
+ const eligible = (await this.store.query.query({
6081
+ collection: FACES_COLLECTION,
6082
+ filter: { where: { trackId } }
6083
+ })).filter((r) => !r.data.assigned);
6084
+ for (const row of eligible) try {
6085
+ await this.store.delete.mutate({
6086
+ collection: FACES_COLLECTION,
6087
+ key: row.id
6088
+ });
6089
+ } catch (err) {
6090
+ this.logger.warn("FaceStore.deleteByTracks delete failed", { meta: {
6091
+ faceId: row.id,
6092
+ error: String(err)
6093
+ } });
6094
+ }
6095
+ } catch (err) {
6096
+ this.logger.warn("FaceStore.deleteByTracks query failed", { meta: {
6097
+ trackId,
6098
+ error: String(err)
6099
+ } });
6100
+ }
6101
+ }
5133
6102
  /** Delete a single buffered face row by id (its crop media is removed by the
5134
6103
  * caller — the FaceStore owns rows, not blobs). Best-effort. */
5135
6104
  async delete(faceId) {
@@ -5401,6 +6370,27 @@ var ObjectEmbeddingStore = class {
5401
6370
  }
5402
6371
  return ids;
5403
6372
  }
6373
+ /**
6374
+ * `TrackScopedStore` contract for the retention cascade: delete the per-track
6375
+ * CLIP search vector for the given tracks. The row id IS the trackId (one row
6376
+ * per track), so this deletes by key directly. Pure EPHEMERAL track state —
6377
+ * the identity MATCHING embeddings (ArcFace) live in a SEPARATE durable store
6378
+ * (`identity-samples`) and are never touched here, so pruning this never
6379
+ * breaks recognition (design §2/§4). Per-track ISOLATED + best-effort.
6380
+ */
6381
+ async deleteByTracks(trackIds) {
6382
+ for (const trackId of trackIds) try {
6383
+ await this.store.delete.mutate({
6384
+ collection: OBJECT_EMBEDDINGS_COLLECTION,
6385
+ key: trackId
6386
+ });
6387
+ } catch (err) {
6388
+ this.logger.warn("ObjectEmbeddingStore.deleteByTracks failed", { meta: {
6389
+ trackId,
6390
+ error: String(err)
6391
+ } });
6392
+ }
6393
+ }
5404
6394
  };
5405
6395
  //#endregion
5406
6396
  //#region src/pipeline-analytics/pipeline/face-matcher.ts
@@ -5495,6 +6485,14 @@ function updateTrackAggregate(prev, match, opts) {
5495
6485
  //#region src/pipeline-analytics/face-recognizer.ts
5496
6486
  /** At most one "dropping imageless track" log per this interval, per recognizer. */
5497
6487
  var FACE_IMAGELESS_LOG_THROTTLE_MS = 6e4;
6488
+ /**
6489
+ * arcface model id stamped on a detail-plane face candidate when the gallery is
6490
+ * empty (collect-only). The detail-subtree result carries no `embeddingModelId`
6491
+ * (the two-plane `DetailResult` schema omits it), so recognition uses the
6492
+ * gallery's own model id (all enrolled samples share one) and this constant is
6493
+ * only a placeholder for the collect-only case where the id is never compared.
6494
+ */
6495
+ var FALLBACK_FACE_MODEL_ID = "arcface";
5498
6496
  var FaceRecognizer = class {
5499
6497
  deps;
5500
6498
  gallery = [];
@@ -5522,6 +6520,38 @@ var FaceRecognizer = class {
5522
6520
  this.deps.logger.warn("FaceRecognizer.refreshGallery failed", { meta: { error: String(err) } });
5523
6521
  }
5524
6522
  }
6523
+ /**
6524
+ * Two-plane detail feed: ingest ONE `runDetailSubtree` face result for a
6525
+ * track and run it through the SAME `processFrame` logic (candidate → best
6526
+ * face → gallery match → crop hold). The result is synthesized into a single
6527
+ * `TrackedDetectionOut` candidate so no recognizer logic changes — only the
6528
+ * input source moves from the per-frame plane to this per-track call.
6529
+ */
6530
+ async ingestFaceDetail(input) {
6531
+ const modelId = this.gallery[0]?.modelId ?? FALLBACK_FACE_MODEL_ID;
6532
+ const candidate = {
6533
+ trackId: input.trackId,
6534
+ className: "face",
6535
+ confidence: input.score,
6536
+ bbox: input.parentBbox,
6537
+ zones: [],
6538
+ state: "moving",
6539
+ embedding: input.embedding,
6540
+ embeddingModelId: modelId,
6541
+ ...input.faceBbox !== void 0 ? { faceBbox: input.faceBbox } : {},
6542
+ ...input.alignedCropJpeg !== void 0 ? { faceAlignedCrop: input.alignedCropJpeg } : {}
6543
+ };
6544
+ await this.processFrame({
6545
+ deviceId: input.deviceId,
6546
+ timestamp: input.timestamp,
6547
+ frameWidth: input.frameWidth,
6548
+ frameHeight: input.frameHeight,
6549
+ tracked: [candidate],
6550
+ settings: input.settings,
6551
+ cropPadding: input.cropPadding,
6552
+ ...input.frameHandle !== void 0 ? { frameHandle: input.frameHandle } : {}
6553
+ });
6554
+ }
5525
6555
  async processFrame(input) {
5526
6556
  const { settings } = input;
5527
6557
  const candidates = input.tracked.filter((t) => Array.isArray(t.embedding) && t.embedding.length > 0 && typeof t.embeddingModelId === "string" && t.confidence >= settings.minFaceConfidence);
@@ -5559,7 +6589,8 @@ var FaceRecognizer = class {
5559
6589
  if (isNewBest || needsCrop) {
5560
6590
  const cropBbox = c.faceBbox ?? c.bbox;
5561
6591
  let crop;
5562
- if (c.faceBbox !== void 0 && input.frameHandle !== void 0) try {
6592
+ if (c.faceAlignedCrop !== void 0) crop = Buffer.from(c.faceAlignedCrop, "base64");
6593
+ else if (c.faceBbox !== void 0 && input.frameHandle !== void 0) try {
5563
6594
  crop = await this.deps.captureCrop(input.frameHandle, c.faceBbox, input.frameWidth, input.frameHeight, input.cropPadding) ?? void 0;
5564
6595
  } catch (err) {
5565
6596
  this.deps.logger.debug("FaceRecognizer crop capture failed", {
@@ -5738,6 +6769,24 @@ var FaceRecognizer = class {
5738
6769
  score: held.score
5739
6770
  }
5740
6771
  });
6772
+ try {
6773
+ this.deps.emitFaceGalleryChanged?.({
6774
+ deviceId,
6775
+ faceId,
6776
+ kind: "buffered"
6777
+ });
6778
+ } catch (err) {
6779
+ this.deps.logger.debug("face-gallery-changed emit failed", {
6780
+ tags: {
6781
+ deviceId,
6782
+ trackId
6783
+ },
6784
+ meta: {
6785
+ faceId,
6786
+ error: String(err)
6787
+ }
6788
+ });
6789
+ }
5741
6790
  } catch (err) {
5742
6791
  this.deps.logger.warn("FaceRecognizer faceStore insert failed", {
5743
6792
  tags: { deviceId },
@@ -5750,103 +6799,667 @@ var FaceRecognizer = class {
5750
6799
  }
5751
6800
  };
5752
6801
  //#endregion
5753
- //#region src/pipeline-analytics/store/plate-store.ts
5754
- var PLATES_COLLECTION = "pipeline-analytics:plates";
5755
- var PLATE_COLUMNS = [
5756
- {
5757
- name: "id",
5758
- type: "TEXT",
5759
- primaryKey: true,
5760
- notNull: true
5761
- },
5762
- {
5763
- name: "deviceId",
5764
- type: "INTEGER",
5765
- notNull: true
5766
- },
5767
- {
5768
- name: "trackId",
5769
- type: "TEXT",
5770
- notNull: true
5771
- },
5772
- {
5773
- name: "timestamp",
5774
- type: "INTEGER",
5775
- notNull: true
5776
- },
5777
- {
5778
- name: "text",
5779
- type: "TEXT",
5780
- notNull: true
5781
- },
5782
- {
5783
- name: "score",
5784
- type: "REAL",
5785
- notNull: true
5786
- },
5787
- {
5788
- name: "mediaKey",
5789
- type: "TEXT"
5790
- },
5791
- {
5792
- name: "corrected",
5793
- type: "BOOLEAN",
5794
- notNull: true
5795
- }
5796
- ];
5797
- var PLATE_INDEXES = [{
5798
- name: "idx_plates_device_ts",
5799
- columns: ["deviceId", "timestamp"]
5800
- }, {
5801
- name: "idx_plates_track",
5802
- columns: ["trackId"]
5803
- }];
5804
- var PlateStore = class {
5805
- store;
5806
- logger;
5807
- constructor(deps) {
5808
- this.store = deps.store;
5809
- this.logger = deps.logger;
5810
- }
5811
- static async declare(store) {
5812
- await store.declareCollection.mutate({
5813
- collection: PLATES_COLLECTION,
5814
- columns: [...PLATE_COLUMNS],
5815
- indexes: [...PLATE_INDEXES]
5816
- });
5817
- }
5818
- /** Upsert one buffered plate read (keyed by `plate.id` = `plate-${trackId}`). */
5819
- async insert(plate) {
5820
- const { id, ...rest } = plate;
5821
- try {
5822
- await this.store.set.mutate({
5823
- collection: PLATES_COLLECTION,
5824
- key: id,
5825
- value: rest
5826
- });
5827
- } catch (err) {
5828
- this.logger.warn("PlateStore.insert failed", {
5829
- tags: { deviceId: plate.deviceId },
5830
- meta: {
5831
- plateId: id,
5832
- error: String(err)
6802
+ //#region src/pipeline-analytics/detail-scheduler.ts
6803
+ /** Default backoff/period when a step's cadence omits `minIntervalMs`. */
6804
+ var DEFAULT_MIN_INTERVAL_MS = 1e3;
6805
+ /** Default fire cap for a `once` step when its announce omits `maxPerTrack`. */
6806
+ var DEFAULT_ONCE_MAX_PER_TRACK = 3;
6807
+ /**
6808
+ * Pure per-(track, step) scheduling state machine for detail-subtree
6809
+ * dispatch. Given a camera's announced child steps (`DetailStepAnnounce[]`,
6810
+ * read off `PipelineInferenceResultPayload.detailSteps`), decides WHEN each
6811
+ * step should run for a given track — independent of transport, I/O, or
6812
+ * timers. The caller drives it with wall-clock `nowMs` and dispatches the
6813
+ * returned `DetailRequest[]`.
6814
+ */
6815
+ var DetailScheduler = class {
6816
+ tracks = /* @__PURE__ */ new Map();
6817
+ /** Track appeared with class + announce; returns immediate requests. */
6818
+ onTrackStarted(trackId, className, announce, nowMs) {
6819
+ const steps = /* @__PURE__ */ new Map();
6820
+ const requests = [];
6821
+ for (const stepAnnounce of announce) {
6822
+ if (!stepAnnounce.inputClasses.includes(className)) continue;
6823
+ const state = {
6824
+ announce: stepAnnounce,
6825
+ firedCount: 1,
6826
+ lastFiredAt: nowMs,
6827
+ sticky: false,
6828
+ retryPending: false
6829
+ };
6830
+ steps.set(stepAnnounce.stepId, state);
6831
+ requests.push({
6832
+ trackId,
6833
+ stepId: stepAnnounce.stepId,
6834
+ reason: "new-track"
6835
+ });
6836
+ }
6837
+ this.tracks.set(trackId, steps);
6838
+ return requests;
6839
+ }
6840
+ /** Better candidate crop observed for the track. */
6841
+ onCandidateImproved(trackId, nowMs) {
6842
+ const steps = this.tracks.get(trackId);
6843
+ if (!steps) return [];
6844
+ const requests = [];
6845
+ for (const state of steps.values()) {
6846
+ if (state.announce.cadence.trigger !== "improve") continue;
6847
+ if (!this.canFire(state, nowMs)) continue;
6848
+ this.markFired(state, nowMs);
6849
+ requests.push({
6850
+ trackId,
6851
+ stepId: state.announce.stepId,
6852
+ reason: "improve"
6853
+ });
6854
+ }
6855
+ return requests;
6856
+ }
6857
+ /** Periodic tick (call ~1/s). Also carries pending retries for any trigger kind. */
6858
+ tick(nowMs) {
6859
+ const requests = [];
6860
+ for (const [trackId, steps] of this.tracks) for (const state of steps.values()) {
6861
+ if (state.sticky) continue;
6862
+ if (state.retryPending) {
6863
+ if (!this.intervalElapsed(state, nowMs)) continue;
6864
+ if (!this.underMaxPerTrack(state)) {
6865
+ state.retryPending = false;
6866
+ continue;
5833
6867
  }
6868
+ this.markFired(state, nowMs);
6869
+ requests.push({
6870
+ trackId,
6871
+ stepId: state.announce.stepId,
6872
+ reason: "retry"
6873
+ });
6874
+ continue;
6875
+ }
6876
+ if (state.announce.cadence.trigger !== "periodic") continue;
6877
+ if (!this.canFire(state, nowMs)) continue;
6878
+ this.markFired(state, nowMs);
6879
+ requests.push({
6880
+ trackId,
6881
+ stepId: state.announce.stepId,
6882
+ reason: "periodic"
5834
6883
  });
5835
6884
  }
6885
+ return requests;
5836
6886
  }
5837
- normalizeRow(r) {
5838
- const data = r.data;
5839
- return {
5840
- id: r.id,
5841
- ...data,
5842
- score: Number(r.data.score ?? 0),
5843
- corrected: Boolean(r.data.corrected),
5844
- mediaKey: data.mediaKey ?? void 0
5845
- };
6887
+ /**
6888
+ * Result arrived; confidence drives sticky/retry. null = failed (retry per
6889
+ * policy). `_nowMs` is part of the public signature for symmetry with the
6890
+ * other methods but isn't needed here — retry backoff is anchored to
6891
+ * `lastFiredAt` (set when the step was actually dispatched), not to when
6892
+ * its result came back.
6893
+ */
6894
+ onResult(trackId, stepId, confidence, _nowMs) {
6895
+ const steps = this.tracks.get(trackId);
6896
+ if (!steps) return;
6897
+ const state = steps.get(stepId);
6898
+ if (!state) return;
6899
+ if (state.sticky) return;
6900
+ const { stickyOnConfidence } = state.announce.cadence;
6901
+ if (confidence !== null && stickyOnConfidence !== void 0 && confidence >= stickyOnConfidence) {
6902
+ state.sticky = true;
6903
+ state.retryPending = false;
6904
+ return;
6905
+ }
6906
+ if (confidence === null) {
6907
+ if (this.underMaxPerTrack(state)) state.retryPending = true;
6908
+ return;
6909
+ }
6910
+ if (state.announce.cadence.trigger === "once" && stickyOnConfidence !== void 0) {
6911
+ if (this.underMaxPerTrack(state)) state.retryPending = true;
6912
+ }
5846
6913
  }
5847
- /** Recent plate reads for one device (or all when deviceId omitted), newest first. */
5848
- async listRecentPlates(input) {
5849
- const where = {};
6914
+ onTrackEnded(trackId) {
6915
+ this.tracks.delete(trackId);
6916
+ }
6917
+ canFire(state, nowMs) {
6918
+ if (state.sticky) return false;
6919
+ if (!this.underMaxPerTrack(state)) return false;
6920
+ return this.intervalElapsed(state, nowMs);
6921
+ }
6922
+ intervalElapsed(state, nowMs) {
6923
+ if (state.lastFiredAt === null) return true;
6924
+ const minIntervalMs = state.announce.cadence.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS;
6925
+ return nowMs - state.lastFiredAt >= minIntervalMs;
6926
+ }
6927
+ underMaxPerTrack(state) {
6928
+ const maxPerTrack = state.announce.cadence.maxPerTrack ?? (state.announce.cadence.trigger === "once" ? DEFAULT_ONCE_MAX_PER_TRACK : Infinity);
6929
+ return state.firedCount < maxPerTrack;
6930
+ }
6931
+ markFired(state, nowMs) {
6932
+ state.firedCount += 1;
6933
+ state.lastFiredAt = nowMs;
6934
+ state.retryPending = false;
6935
+ }
6936
+ };
6937
+ //#endregion
6938
+ //#region src/pipeline-analytics/detail-dispatcher.ts
6939
+ /**
6940
+ * Compose the `steps` list sent to `runDetailSubtree` for one request —
6941
+ * identity-aware for the face chain.
6942
+ *
6943
+ * A `face-detection` request ALSO includes `'face-embedding'` (the full
6944
+ * detect→recognize chain) EXCEPT when it is a PERIODIC geometry refresh on a
6945
+ * track that already carries an identity label: re-embedding a known face every
6946
+ * second is wasted ArcFace inference (250-1900ms per call on N100 agents), so
6947
+ * that case runs the detector geometry ALONE. Every recognition-bearing reason
6948
+ * (new-track / improve / retry) keeps the embedding regardless of the label.
6949
+ *
6950
+ * Non-face steps are unchanged (`[req.stepId]`). Pairs with the pipeline's
6951
+ * strict-`steps` pruning (`pruneChildStepsToRequested`) — including
6952
+ * `'face-embedding'` here is what keeps the nested child in the executed chain.
6953
+ */
6954
+ function composeDetailSteps(req, hasTrackLabel) {
6955
+ if (req.stepId !== "face-detection") return [req.stepId];
6956
+ return req.reason === "periodic" && hasTrackLabel(req.trackId) ? ["face-detection"] : ["face-detection", "face-embedding"];
6957
+ }
6958
+ /** Throttle for the per-device "detail call failed" warn — one line / minute. */
6959
+ var FAIL_WARN_THROTTLE_MS = 6e4;
6960
+ var TrackDetailDispatcher = class {
6961
+ deps;
6962
+ devices = /* @__PURE__ */ new Map();
6963
+ maxInFlight;
6964
+ tickIntervalMs;
6965
+ disposed = false;
6966
+ constructor(deps) {
6967
+ this.deps = deps;
6968
+ this.maxInFlight = deps.maxInFlightPerDevice ?? 2;
6969
+ this.tickIntervalMs = deps.tickIntervalMs ?? 1e3;
6970
+ }
6971
+ /** A track appeared: record its frame, seed its best-confidence, and dispatch
6972
+ * the scheduler's immediate (new-track) requests. */
6973
+ onTrackStarted(deviceId, trackId, className, announce, frame, nowMs) {
6974
+ if (this.disposed) return;
6975
+ const dev = this.ensureDevice(deviceId);
6976
+ dev.tracks.set(trackId, frame);
6977
+ dev.candidateBest.observe(trackId, frame.confidence, frame.timestamp);
6978
+ const requests = dev.scheduler.onTrackStarted(trackId, className, announce, nowMs);
6979
+ this.enqueue(deviceId, dev, requests);
6980
+ this.ensureTimer(deviceId, dev);
6981
+ }
6982
+ /** A subsequent frame for a live track: refresh its frame + fire
6983
+ * `improve`-cadence steps when the detector confidence strictly improves.
6984
+ *
6985
+ * `announce` is the frame's currently-announced detail chain. When a track
6986
+ * is alive but has NO dispatcher state yet — it existed before `detailSteps`
6987
+ * first appeared (a mid-track redeploy / config change) — this adopts it as a
6988
+ * new track so it starts getting scheduled instead of starving for its whole
6989
+ * life. Idempotent: guarded by the per-track state check, so a track that
6990
+ * already has state is never reset. */
6991
+ onFrame(deviceId, trackId, announce, frame, nowMs) {
6992
+ if (this.disposed) return;
6993
+ const dev = this.devices.get(deviceId);
6994
+ if (!dev || !dev.tracks.has(trackId)) {
6995
+ if (announce.length > 0) this.onTrackStarted(deviceId, trackId, frame.className, announce, frame, nowMs);
6996
+ return;
6997
+ }
6998
+ dev.tracks.set(trackId, frame);
6999
+ if (!dev.candidateBest.observe(trackId, frame.confidence, frame.timestamp)) return;
7000
+ const requests = dev.scheduler.onCandidateImproved(trackId, nowMs);
7001
+ this.enqueue(deviceId, dev, requests);
7002
+ }
7003
+ /** A track ended (durable TTL expiry): drop its scheduler + frame state. Any
7004
+ * queued request for it is discarded at dequeue. */
7005
+ onTrackEnded(deviceId, trackId) {
7006
+ const dev = this.devices.get(deviceId);
7007
+ if (!dev) return;
7008
+ dev.scheduler.onTrackEnded(trackId);
7009
+ dev.tracks.delete(trackId);
7010
+ dev.candidateBest.delete(trackId);
7011
+ if (dev.tracks.size === 0) this.clearTimer(dev);
7012
+ }
7013
+ dispose() {
7014
+ this.disposed = true;
7015
+ for (const dev of this.devices.values()) {
7016
+ this.clearTimer(dev);
7017
+ dev.queue.length = 0;
7018
+ dev.tracks.clear();
7019
+ dev.candidateBest.clear();
7020
+ }
7021
+ this.devices.clear();
7022
+ }
7023
+ ensureDevice(deviceId) {
7024
+ let dev = this.devices.get(deviceId);
7025
+ if (!dev) {
7026
+ dev = {
7027
+ scheduler: new DetailScheduler(),
7028
+ tracks: /* @__PURE__ */ new Map(),
7029
+ candidateBest: new BestDetectionTracker(),
7030
+ queue: [],
7031
+ inFlight: 0,
7032
+ timer: null,
7033
+ lastFailWarnAt: 0
7034
+ };
7035
+ this.devices.set(deviceId, dev);
7036
+ }
7037
+ return dev;
7038
+ }
7039
+ ensureTimer(deviceId, dev) {
7040
+ if (dev.timer) return;
7041
+ const timer = setInterval(() => this.tick(deviceId, dev), this.tickIntervalMs);
7042
+ if (typeof timer.unref === "function") timer.unref();
7043
+ dev.timer = timer;
7044
+ }
7045
+ clearTimer(dev) {
7046
+ if (dev.timer) {
7047
+ clearInterval(dev.timer);
7048
+ dev.timer = null;
7049
+ }
7050
+ }
7051
+ tick(deviceId, dev) {
7052
+ if (this.disposed) return;
7053
+ const requests = dev.scheduler.tick(Date.now());
7054
+ this.enqueue(deviceId, dev, requests);
7055
+ }
7056
+ enqueue(deviceId, dev, requests) {
7057
+ if (requests.length === 0) return;
7058
+ for (const r of requests) dev.queue.push(r);
7059
+ this.pump(deviceId, dev);
7060
+ }
7061
+ pump(deviceId, dev) {
7062
+ while (dev.inFlight < this.maxInFlight && dev.queue.length > 0) {
7063
+ const req = dev.queue.shift();
7064
+ if (req === void 0) break;
7065
+ const frame = dev.tracks.get(req.trackId);
7066
+ if (frame === void 0) continue;
7067
+ dev.inFlight += 1;
7068
+ this.dispatch(deviceId, dev, req, frame).finally(() => {
7069
+ dev.inFlight -= 1;
7070
+ this.pump(deviceId, dev);
7071
+ });
7072
+ }
7073
+ }
7074
+ async dispatch(deviceId, dev, req, frame) {
7075
+ const details = await this.runOnce(deviceId, dev, req, frame);
7076
+ let topScore = null;
7077
+ if (details !== null && details.length > 0) {
7078
+ topScore = details.reduce((max, d) => d.score > max ? d.score : max, Number.NEGATIVE_INFINITY);
7079
+ try {
7080
+ await this.deps.routeResults(deviceId, req.trackId, details, frame);
7081
+ } catch (err) {
7082
+ this.deps.logger.warn("detail result routing failed", {
7083
+ tags: { deviceId },
7084
+ meta: {
7085
+ trackId: req.trackId,
7086
+ stepId: req.stepId,
7087
+ error: String(err)
7088
+ }
7089
+ });
7090
+ }
7091
+ }
7092
+ dev.scheduler.onResult(req.trackId, req.stepId, topScore, Date.now());
7093
+ }
7094
+ /**
7095
+ * Run the request once via the frameHandle, and — on a miss (null OR throw)
7096
+ * — retry ONCE with a `cropJpeg` fallback when one can be captured. Returns
7097
+ * the detail list, or `null` when both attempts fail to produce a result.
7098
+ */
7099
+ async runOnce(deviceId, dev, req, frame) {
7100
+ const parent = {
7101
+ bbox: { ...frame.bbox },
7102
+ className: frame.className
7103
+ };
7104
+ const steps = composeDetailSteps(req, (trackId) => this.deps.hasTrackLabel?.(trackId) ?? false);
7105
+ if (frame.frameHandle !== void 0) try {
7106
+ const primary = await this.deps.runDetailSubtree({
7107
+ deviceId,
7108
+ frameHandle: frame.frameHandle,
7109
+ parent,
7110
+ steps
7111
+ }, frame.nodeId);
7112
+ if (primary !== null) return primary.details;
7113
+ } catch (err) {
7114
+ this.deps.logger.debug("detail primary call failed — trying crop fallback", {
7115
+ tags: { deviceId },
7116
+ meta: {
7117
+ trackId: req.trackId,
7118
+ stepId: req.stepId,
7119
+ error: String(err)
7120
+ }
7121
+ });
7122
+ }
7123
+ if (this.deps.captureCropBase64 !== void 0) try {
7124
+ const cropJpeg = await this.deps.captureCropBase64(frame);
7125
+ if (cropJpeg !== null) {
7126
+ const retry = await this.deps.runDetailSubtree({
7127
+ deviceId,
7128
+ cropJpeg,
7129
+ parent,
7130
+ steps
7131
+ }, frame.nodeId);
7132
+ if (retry !== null) return retry.details;
7133
+ }
7134
+ } catch (err) {
7135
+ this.deps.logger.debug("detail crop-fallback call failed", {
7136
+ tags: { deviceId },
7137
+ meta: {
7138
+ trackId: req.trackId,
7139
+ stepId: req.stepId,
7140
+ error: String(err)
7141
+ }
7142
+ });
7143
+ }
7144
+ this.warnFailThrottled(deviceId, dev, req);
7145
+ return null;
7146
+ }
7147
+ warnFailThrottled(deviceId, dev, req) {
7148
+ const now = Date.now();
7149
+ if (now - dev.lastFailWarnAt < FAIL_WARN_THROTTLE_MS) return;
7150
+ dev.lastFailWarnAt = now;
7151
+ this.deps.logger.warn("runDetailSubtree produced no result (frame + crop both missed)", {
7152
+ tags: { deviceId },
7153
+ meta: {
7154
+ trackId: req.trackId,
7155
+ stepId: req.stepId,
7156
+ reason: req.reason
7157
+ }
7158
+ });
7159
+ }
7160
+ };
7161
+ //#endregion
7162
+ //#region src/pipeline-analytics/overlay-state.ts
7163
+ function key(deviceId, trackId) {
7164
+ return `${deviceId}:${trackId}`;
7165
+ }
7166
+ var OverlayDetailStateStore = class {
7167
+ byTrack = /* @__PURE__ */ new Map();
7168
+ noteFaceDetail(deviceId, trackId, faceBbox, score, capturedAt) {
7169
+ const k = key(deviceId, trackId);
7170
+ const prev = this.byTrack.get(k);
7171
+ this.byTrack.set(k, {
7172
+ ...prev,
7173
+ face: {
7174
+ bbox: faceBbox,
7175
+ score,
7176
+ capturedAt
7177
+ }
7178
+ });
7179
+ }
7180
+ notePlateDetail(deviceId, trackId, plateBbox, score, text, capturedAt) {
7181
+ const k = key(deviceId, trackId);
7182
+ const prev = this.byTrack.get(k);
7183
+ this.byTrack.set(k, {
7184
+ ...prev,
7185
+ plate: {
7186
+ bbox: plateBbox,
7187
+ score,
7188
+ capturedAt,
7189
+ text
7190
+ }
7191
+ });
7192
+ }
7193
+ get(deviceId, trackId) {
7194
+ return this.byTrack.get(key(deviceId, trackId));
7195
+ }
7196
+ onTrackEnded(deviceId, trackId) {
7197
+ this.byTrack.delete(key(deviceId, trackId));
7198
+ }
7199
+ clearDevice(deviceId) {
7200
+ const prefix = `${deviceId}:`;
7201
+ for (const k of this.byTrack.keys()) if (k.startsWith(prefix)) this.byTrack.delete(k);
7202
+ }
7203
+ clear() {
7204
+ this.byTrack.clear();
7205
+ }
7206
+ };
7207
+ function toDetectionBbox(bbox) {
7208
+ return {
7209
+ x: bbox.x,
7210
+ y: bbox.y,
7211
+ width: bbox.w,
7212
+ height: bbox.h
7213
+ };
7214
+ }
7215
+ function buildOverlayDetections(input) {
7216
+ const trackedBySourceId = /* @__PURE__ */ new Map();
7217
+ for (const t of input.tracked) if (t.sourceDetectionId !== void 0) trackedBySourceId.set(t.sourceDetectionId, t);
7218
+ const realDetailKindsByParent = /* @__PURE__ */ new Map();
7219
+ for (const d of input.frameDetections) {
7220
+ if (d.kind !== "detail" || d.parentId === void 0) continue;
7221
+ const set = realDetailKindsByParent.get(d.parentId) ?? /* @__PURE__ */ new Set();
7222
+ set.add(d.macroClass);
7223
+ realDetailKindsByParent.set(d.parentId, set);
7224
+ }
7225
+ const out = [];
7226
+ const synthesized = [];
7227
+ for (const det of input.frameDetections) {
7228
+ const t = det.kind === "first-level" ? trackedBySourceId.get(det.id) : void 0;
7229
+ if (t === void 0) {
7230
+ out.push(det);
7231
+ continue;
7232
+ }
7233
+ const name = input.labelFor(t.trackId) ?? "";
7234
+ const hasName = name.length > 0;
7235
+ const alreadyLabeled = hasName && det.labels.some((l) => l.label === name);
7236
+ out.push({
7237
+ ...det,
7238
+ track: {
7239
+ trackId: t.trackId,
7240
+ zones: t.zones
7241
+ },
7242
+ labels: hasName && !alreadyLabeled ? [...det.labels, {
7243
+ label: name,
7244
+ score: 1
7245
+ }] : det.labels
7246
+ });
7247
+ const overlay = input.overlayFor(t.trackId);
7248
+ if (overlay === void 0) continue;
7249
+ const realKinds = realDetailKindsByParent.get(det.id);
7250
+ if (overlay.face !== void 0 && input.nowMs - overlay.face.capturedAt <= 2500 && overlay.face !== void 0 && realKinds?.has("face") !== true) synthesized.push({
7251
+ id: `detail-${t.trackId}-face`,
7252
+ kind: "detail",
7253
+ macroClass: "face",
7254
+ score: overlay.face.score,
7255
+ bbox: toDetectionBbox(overlay.face.bbox),
7256
+ parentId: det.id,
7257
+ labels: hasName ? [{
7258
+ label: name,
7259
+ score: overlay.face.score
7260
+ }] : []
7261
+ });
7262
+ if (overlay.plate !== void 0 && input.nowMs - overlay.plate.capturedAt <= 2500 && overlay.plate !== void 0 && realKinds?.has("plate") !== true) synthesized.push({
7263
+ id: `detail-${t.trackId}-plate`,
7264
+ kind: "detail",
7265
+ macroClass: "plate",
7266
+ score: overlay.plate.score,
7267
+ bbox: toDetectionBbox(overlay.plate.bbox),
7268
+ parentId: det.id,
7269
+ labels: [{
7270
+ label: overlay.plate.text,
7271
+ score: overlay.plate.score
7272
+ }]
7273
+ });
7274
+ }
7275
+ return [...out, ...synthesized];
7276
+ }
7277
+ //#endregion
7278
+ //#region src/pipeline-analytics/media-capture-log.ts
7279
+ var DEFAULT_FLUSH_INTERVAL_MS = 6e4;
7280
+ var MediaCaptureLogAggregator = class {
7281
+ flushIntervalMs;
7282
+ byDevice = /* @__PURE__ */ new Map();
7283
+ constructor(flushIntervalMs = DEFAULT_FLUSH_INTERVAL_MS) {
7284
+ this.flushIntervalMs = flushIntervalMs;
7285
+ }
7286
+ /**
7287
+ * Record one frame's capture counts. Returns the window aggregate (and
7288
+ * resets the window) once `flushIntervalMs` has elapsed since the window
7289
+ * opened; `null` while still accumulating.
7290
+ */
7291
+ note(deviceId, counts, nowMs) {
7292
+ const w = this.byDevice.get(deviceId);
7293
+ if (w === void 0) {
7294
+ this.byDevice.set(deviceId, {
7295
+ sinceMs: nowMs,
7296
+ frames: 1,
7297
+ events: counts.events,
7298
+ trackFrames: counts.trackFrames,
7299
+ snapshots: counts.snapshots,
7300
+ faceCrops: counts.faceCrops,
7301
+ plateCrops: counts.plateCrops
7302
+ });
7303
+ return null;
7304
+ }
7305
+ w.frames += 1;
7306
+ w.events += counts.events;
7307
+ w.trackFrames += counts.trackFrames;
7308
+ w.snapshots += counts.snapshots;
7309
+ w.faceCrops += counts.faceCrops;
7310
+ w.plateCrops += counts.plateCrops;
7311
+ if (nowMs - w.sinceMs < this.flushIntervalMs) return null;
7312
+ return this.drain(deviceId, w, nowMs);
7313
+ }
7314
+ /**
7315
+ * Drain a device's pending window (e.g. when its last live track ends),
7316
+ * so short activity bursts still surface an aggregate. Returns `null`
7317
+ * when nothing is pending.
7318
+ */
7319
+ flush(deviceId, nowMs) {
7320
+ const w = this.byDevice.get(deviceId);
7321
+ if (w === void 0) return null;
7322
+ return this.drain(deviceId, w, nowMs);
7323
+ }
7324
+ drain(deviceId, w, nowMs) {
7325
+ this.byDevice.delete(deviceId);
7326
+ return {
7327
+ frames: w.frames,
7328
+ windowMs: nowMs - w.sinceMs,
7329
+ sums: {
7330
+ events: w.events,
7331
+ trackFrames: w.trackFrames,
7332
+ snapshots: w.snapshots,
7333
+ faceCrops: w.faceCrops,
7334
+ plateCrops: w.plateCrops
7335
+ }
7336
+ };
7337
+ }
7338
+ };
7339
+ //#endregion
7340
+ //#region src/pipeline-analytics/store/plate-store.ts
7341
+ var PLATES_COLLECTION = "pipeline-analytics:plates";
7342
+ var PLATE_COLUMNS = [
7343
+ {
7344
+ name: "id",
7345
+ type: "TEXT",
7346
+ primaryKey: true,
7347
+ notNull: true
7348
+ },
7349
+ {
7350
+ name: "deviceId",
7351
+ type: "INTEGER",
7352
+ notNull: true
7353
+ },
7354
+ {
7355
+ name: "trackId",
7356
+ type: "TEXT",
7357
+ notNull: true
7358
+ },
7359
+ {
7360
+ name: "timestamp",
7361
+ type: "INTEGER",
7362
+ notNull: true
7363
+ },
7364
+ {
7365
+ name: "text",
7366
+ type: "TEXT",
7367
+ notNull: true
7368
+ },
7369
+ {
7370
+ name: "score",
7371
+ type: "REAL",
7372
+ notNull: true
7373
+ },
7374
+ {
7375
+ name: "mediaKey",
7376
+ type: "TEXT"
7377
+ },
7378
+ {
7379
+ name: "corrected",
7380
+ type: "BOOLEAN",
7381
+ notNull: true
7382
+ },
7383
+ {
7384
+ name: "recognizedVehicleId",
7385
+ type: "TEXT"
7386
+ },
7387
+ {
7388
+ name: "assigned",
7389
+ type: "BOOLEAN",
7390
+ notNull: true
7391
+ },
7392
+ {
7393
+ name: "assignedSampleId",
7394
+ type: "TEXT"
7395
+ },
7396
+ {
7397
+ name: "keyFrameMediaKey",
7398
+ type: "TEXT"
7399
+ },
7400
+ {
7401
+ name: "plateBbox",
7402
+ type: "JSON"
7403
+ }
7404
+ ];
7405
+ var PLATE_INDEXES = [{
7406
+ name: "idx_plates_device_ts",
7407
+ columns: ["deviceId", "timestamp"]
7408
+ }, {
7409
+ name: "idx_plates_track",
7410
+ columns: ["trackId"]
7411
+ }];
7412
+ var PlateStore = class {
7413
+ store;
7414
+ logger;
7415
+ constructor(deps) {
7416
+ this.store = deps.store;
7417
+ this.logger = deps.logger;
7418
+ }
7419
+ static async declare(store) {
7420
+ await store.declareCollection.mutate({
7421
+ collection: PLATES_COLLECTION,
7422
+ columns: [...PLATE_COLUMNS],
7423
+ indexes: [...PLATE_INDEXES]
7424
+ });
7425
+ }
7426
+ /** Upsert one buffered plate read (keyed by `plate.id` = `plate-${trackId}`). */
7427
+ async insert(plate) {
7428
+ const { id, ...rest } = plate;
7429
+ try {
7430
+ await this.store.set.mutate({
7431
+ collection: PLATES_COLLECTION,
7432
+ key: id,
7433
+ value: rest
7434
+ });
7435
+ } catch (err) {
7436
+ this.logger.warn("PlateStore.insert failed", {
7437
+ tags: { deviceId: plate.deviceId },
7438
+ meta: {
7439
+ plateId: id,
7440
+ error: String(err)
7441
+ }
7442
+ });
7443
+ }
7444
+ }
7445
+ normalizeRow(r) {
7446
+ const data = r.data;
7447
+ return {
7448
+ id: r.id,
7449
+ ...data,
7450
+ score: Number(r.data.score ?? 0),
7451
+ corrected: Boolean(r.data.corrected),
7452
+ assigned: Boolean(r.data.assigned),
7453
+ mediaKey: data.mediaKey ?? void 0,
7454
+ recognizedVehicleId: data.recognizedVehicleId ?? void 0,
7455
+ assignedSampleId: data.assignedSampleId ?? void 0,
7456
+ keyFrameMediaKey: data.keyFrameMediaKey ?? void 0,
7457
+ plateBbox: data.plateBbox ?? void 0
7458
+ };
7459
+ }
7460
+ /** Recent plate reads for one device (or all when deviceId omitted), newest first. */
7461
+ async listRecentPlates(input) {
7462
+ const where = {};
5850
7463
  if (input.deviceId !== void 0) where["deviceId"] = input.deviceId;
5851
7464
  return (await this.store.query.query({
5852
7465
  collection: PLATES_COLLECTION,
@@ -5912,6 +7525,76 @@ var PlateStore = class {
5912
7525
  } });
5913
7526
  }
5914
7527
  }
7528
+ /** Flip `assigned` to true and record the vehicle + enrolled sample. */
7529
+ async markAssigned(plateId, vehicleId, sampleId) {
7530
+ const data = {
7531
+ assigned: true,
7532
+ recognizedVehicleId: vehicleId
7533
+ };
7534
+ if (sampleId !== void 0) data["assignedSampleId"] = sampleId;
7535
+ try {
7536
+ await this.store.update.mutate({
7537
+ collection: PLATES_COLLECTION,
7538
+ id: plateId,
7539
+ data
7540
+ });
7541
+ } catch (err) {
7542
+ this.logger.warn("PlateStore.markAssigned failed", { meta: {
7543
+ plateId,
7544
+ error: String(err)
7545
+ } });
7546
+ }
7547
+ }
7548
+ /** Clear the assignment (assigned=false, null recognizedVehicleId + assignedSampleId). */
7549
+ async clearAssignment(plateId) {
7550
+ try {
7551
+ await this.store.update.mutate({
7552
+ collection: PLATES_COLLECTION,
7553
+ id: plateId,
7554
+ data: {
7555
+ assigned: false,
7556
+ recognizedVehicleId: null,
7557
+ assignedSampleId: null
7558
+ }
7559
+ });
7560
+ } catch (err) {
7561
+ this.logger.warn("PlateStore.clearAssignment failed", { meta: {
7562
+ plateId,
7563
+ error: String(err)
7564
+ } });
7565
+ }
7566
+ }
7567
+ /**
7568
+ * `TrackScopedStore` contract for the retention cascade: delete the buffered
7569
+ * plate rows of the given tracks that are NOT assigned. Mirrors
7570
+ * `FaceStore.deleteByTracks` — ENROLLED (`assigned`) reads SURVIVE retention.
7571
+ * Per-track ISOLATED + best-effort per row — one bad track or row never aborts
7572
+ * the batch. (Crop media of these rows is removed by `MediaStore`.)
7573
+ */
7574
+ async deleteByTracks(trackIds) {
7575
+ for (const trackId of trackIds) try {
7576
+ const eligible = (await this.store.query.query({
7577
+ collection: PLATES_COLLECTION,
7578
+ filter: { where: { trackId } }
7579
+ })).filter((r) => !r.data.assigned);
7580
+ for (const row of eligible) try {
7581
+ await this.store.delete.mutate({
7582
+ collection: PLATES_COLLECTION,
7583
+ key: row.id
7584
+ });
7585
+ } catch (err) {
7586
+ this.logger.warn("PlateStore.deleteByTracks delete failed", { meta: {
7587
+ plateId: row.id,
7588
+ error: String(err)
7589
+ } });
7590
+ }
7591
+ } catch (err) {
7592
+ this.logger.warn("PlateStore.deleteByTracks query failed", { meta: {
7593
+ trackId,
7594
+ error: String(err)
7595
+ } });
7596
+ }
7597
+ }
5915
7598
  /**
5916
7599
  * Retention across ALL devices: delete reads older than `cutoffMs` AND any
5917
7600
  * beyond the newest `maxPerDevice` per device. Corrected reads are exempt
@@ -5928,7 +7611,7 @@ var PlateStore = class {
5928
7611
  const byDevice = /* @__PURE__ */ new Map();
5929
7612
  for (const r of rows) {
5930
7613
  const data = r.data;
5931
- if (data.corrected) continue;
7614
+ if (data.corrected || data.assigned) continue;
5932
7615
  const deviceId = Number(data.deviceId ?? -1);
5933
7616
  const list = byDevice.get(deviceId) ?? [];
5934
7617
  list.push({
@@ -5958,10 +7641,320 @@ var PlateStore = class {
5958
7641
  error: String(err)
5959
7642
  } });
5960
7643
  }
5961
- return deleted;
7644
+ return deleted;
7645
+ }
7646
+ };
7647
+ //#endregion
7648
+ //#region src/pipeline-analytics/store/vehicle-store.ts
7649
+ /**
7650
+ * VehicleStore — per-vehicle identity registry for license-plate recognition.
7651
+ *
7652
+ * Two SQL-backed collections (parallel to IdentityStore, text-not-embedding):
7653
+ * - pipeline-analytics:vehicles — named vehicle records
7654
+ * - pipeline-analytics:vehicle-samples — enrolled plate reads (text + score)
7655
+ *
7656
+ * A plate is self-labeling (the OCR text IS the key), so a sample carries
7657
+ * `text` + `score` instead of an embedding + modelId + dim — no model-version
7658
+ * gate is needed. deleteVehicle cascades: removes all sample rows first.
7659
+ */
7660
+ var VEHICLES_COLLECTION = "pipeline-analytics:vehicles";
7661
+ var VEHICLE_SAMPLES_COLLECTION = "pipeline-analytics:vehicle-samples";
7662
+ var VEHICLE_COLUMNS = [
7663
+ {
7664
+ name: "id",
7665
+ type: "TEXT",
7666
+ primaryKey: true,
7667
+ notNull: true
7668
+ },
7669
+ {
7670
+ name: "name",
7671
+ type: "TEXT",
7672
+ notNull: true
7673
+ },
7674
+ {
7675
+ name: "createdAt",
7676
+ type: "INTEGER",
7677
+ notNull: true
7678
+ },
7679
+ {
7680
+ name: "updatedAt",
7681
+ type: "INTEGER",
7682
+ notNull: true
7683
+ },
7684
+ {
7685
+ name: "sampleCount",
7686
+ type: "INTEGER",
7687
+ notNull: true
7688
+ },
7689
+ {
7690
+ name: "coverMediaKey",
7691
+ type: "TEXT"
7692
+ }
7693
+ ];
7694
+ var SAMPLE_COLUMNS = [
7695
+ {
7696
+ name: "id",
7697
+ type: "TEXT",
7698
+ primaryKey: true,
7699
+ notNull: true
7700
+ },
7701
+ {
7702
+ name: "vehicleId",
7703
+ type: "TEXT",
7704
+ notNull: true
7705
+ },
7706
+ {
7707
+ name: "text",
7708
+ type: "TEXT",
7709
+ notNull: true
7710
+ },
7711
+ {
7712
+ name: "score",
7713
+ type: "REAL",
7714
+ notNull: true
7715
+ },
7716
+ {
7717
+ name: "sourcePlateId",
7718
+ type: "TEXT"
7719
+ },
7720
+ {
7721
+ name: "mediaKey",
7722
+ type: "TEXT"
7723
+ },
7724
+ {
7725
+ name: "deviceId",
7726
+ type: "INTEGER"
7727
+ },
7728
+ {
7729
+ name: "addedAt",
7730
+ type: "INTEGER",
7731
+ notNull: true
7732
+ }
7733
+ ];
7734
+ var VehicleStore = class {
7735
+ store;
7736
+ logger;
7737
+ constructor(deps) {
7738
+ this.store = deps.store;
7739
+ this.logger = deps.logger;
7740
+ }
7741
+ static async declare(store) {
7742
+ await store.declareCollection.mutate({
7743
+ collection: VEHICLES_COLLECTION,
7744
+ columns: [...VEHICLE_COLUMNS],
7745
+ indexes: []
7746
+ });
7747
+ await store.declareCollection.mutate({
7748
+ collection: VEHICLE_SAMPLES_COLLECTION,
7749
+ columns: [...SAMPLE_COLUMNS],
7750
+ indexes: [{
7751
+ name: "idx_vehicle_sample",
7752
+ columns: ["vehicleId"]
7753
+ }]
7754
+ });
7755
+ }
7756
+ async createVehicle(input) {
7757
+ const now = Date.now();
7758
+ const vehicle = {
7759
+ id: randomUUID(),
7760
+ name: input.name,
7761
+ createdAt: now,
7762
+ updatedAt: now,
7763
+ sampleCount: 0
7764
+ };
7765
+ const { id, ...rest } = vehicle;
7766
+ await this.store.insert.mutate({
7767
+ collection: VEHICLES_COLLECTION,
7768
+ record: {
7769
+ id,
7770
+ data: rest
7771
+ }
7772
+ });
7773
+ return vehicle;
7774
+ }
7775
+ async renameVehicle(id, name) {
7776
+ await this.store.update.mutate({
7777
+ collection: VEHICLES_COLLECTION,
7778
+ id,
7779
+ data: {
7780
+ name,
7781
+ updatedAt: Date.now()
7782
+ }
7783
+ });
7784
+ }
7785
+ async deleteVehicle(id) {
7786
+ const samples = await this.store.query.query({
7787
+ collection: VEHICLE_SAMPLES_COLLECTION,
7788
+ filter: { where: { vehicleId: id } }
7789
+ });
7790
+ for (const s of samples) try {
7791
+ await this.store.delete.mutate({
7792
+ collection: VEHICLE_SAMPLES_COLLECTION,
7793
+ key: s.id
7794
+ });
7795
+ } catch (err) {
7796
+ this.logger.warn("deleteVehicle sample delete failed", { meta: {
7797
+ vehicleId: id,
7798
+ sampleId: s.id,
7799
+ error: String(err)
7800
+ } });
7801
+ }
7802
+ await this.store.delete.mutate({
7803
+ collection: VEHICLES_COLLECTION,
7804
+ key: id
7805
+ });
7806
+ }
7807
+ async listVehicles() {
7808
+ return (await this.store.query.query({
7809
+ collection: VEHICLES_COLLECTION,
7810
+ filter: { orderBy: {
7811
+ field: "createdAt",
7812
+ direction: "asc"
7813
+ } }
7814
+ })).map((r) => {
7815
+ const data = r.data;
7816
+ return {
7817
+ id: r.id,
7818
+ ...data,
7819
+ coverMediaKey: data.coverMediaKey ?? void 0
7820
+ };
7821
+ });
7822
+ }
7823
+ async addSample(input) {
7824
+ const sample = {
7825
+ id: randomUUID(),
7826
+ vehicleId: input.vehicleId,
7827
+ text: input.text,
7828
+ score: input.score,
7829
+ ...input.sourcePlateId !== void 0 ? { sourcePlateId: input.sourcePlateId } : {},
7830
+ ...input.mediaKey !== void 0 ? { mediaKey: input.mediaKey } : {},
7831
+ ...input.deviceId !== void 0 ? { deviceId: input.deviceId } : {},
7832
+ addedAt: Date.now()
7833
+ };
7834
+ const { id, ...rest } = sample;
7835
+ await this.store.insert.mutate({
7836
+ collection: VEHICLE_SAMPLES_COLLECTION,
7837
+ record: {
7838
+ id,
7839
+ data: rest
7840
+ }
7841
+ });
7842
+ const vehicle = (await this.listVehicles()).find((v) => v.id === input.vehicleId);
7843
+ if (vehicle) {
7844
+ const patch = {
7845
+ sampleCount: vehicle.sampleCount + 1,
7846
+ updatedAt: Date.now()
7847
+ };
7848
+ if (vehicle.coverMediaKey == null && input.mediaKey !== void 0) patch["coverMediaKey"] = input.mediaKey;
7849
+ await this.store.update.mutate({
7850
+ collection: VEHICLES_COLLECTION,
7851
+ id: input.vehicleId,
7852
+ data: patch
7853
+ });
7854
+ }
7855
+ return sample;
7856
+ }
7857
+ async listSamples(vehicleId) {
7858
+ return (await this.store.query.query({
7859
+ collection: VEHICLE_SAMPLES_COLLECTION,
7860
+ filter: {
7861
+ where: { vehicleId },
7862
+ orderBy: {
7863
+ field: "addedAt",
7864
+ direction: "asc"
7865
+ }
7866
+ }
7867
+ })).map((r) => {
7868
+ const d = r.data;
7869
+ return {
7870
+ id: r.id,
7871
+ text: String(d.text ?? ""),
7872
+ score: Number(d.score ?? 0),
7873
+ addedAt: Number(d.addedAt ?? 0),
7874
+ ...d.mediaKey != null ? { mediaKey: d.mediaKey } : {},
7875
+ ...d.deviceId != null ? { deviceId: d.deviceId } : {}
7876
+ };
7877
+ });
7878
+ }
7879
+ async removeSample(vehicleId, sampleId) {
7880
+ await this.store.delete.mutate({
7881
+ collection: VEHICLE_SAMPLES_COLLECTION,
7882
+ key: sampleId
7883
+ });
7884
+ const vehicle = (await this.listVehicles()).find((v) => v.id === vehicleId);
7885
+ if (vehicle) await this.store.update.mutate({
7886
+ collection: VEHICLES_COLLECTION,
7887
+ id: vehicleId,
7888
+ data: {
7889
+ sampleCount: Math.max(0, vehicle.sampleCount - 1),
7890
+ updatedAt: Date.now()
7891
+ }
7892
+ });
7893
+ }
7894
+ async loadGallery() {
7895
+ const rows = await this.store.query.query({
7896
+ collection: VEHICLE_SAMPLES_COLLECTION,
7897
+ filter: {}
7898
+ });
7899
+ const gallery = [];
7900
+ for (const r of rows) {
7901
+ const d = r.data;
7902
+ if (typeof d.text !== "string" || typeof d.vehicleId !== "string") continue;
7903
+ gallery.push({
7904
+ vehicleId: d.vehicleId,
7905
+ text: d.text
7906
+ });
7907
+ }
7908
+ return gallery;
5962
7909
  }
5963
7910
  };
5964
7911
  //#endregion
7912
+ //#region src/pipeline-analytics/pipeline/plate-matcher.ts
7913
+ /**
7914
+ * plate-matcher — name a plate read against the vehicle gallery by text vicinity.
7915
+ *
7916
+ * Parallel to face-matcher's `matchEmbedding`, but a plate is self-labeling:
7917
+ * distance is `plateDistance` (OCR-confusion-folded Levenshtein), not cosine.
7918
+ * There is no model-version gate (text has no embedding drift) and no ambiguity
7919
+ * `margin` (Levenshtein has no comparable normalized gap) — a tie is resolved
7920
+ * conservatively to NO match. The read's `score` weights how far it may match:
7921
+ * a high-confidence read (`score >= lowScoreFloor`) may match at `maxDistance`;
7922
+ * a low-confidence read is held to `lowScoreMaxDistance` (exact fold only).
7923
+ * "Prefer unassigned over wrong vehicle."
7924
+ */
7925
+ /** Calibration placeholders — re-tune against real OCR score distributions. */
7926
+ var DEFAULT_PLATE_MATCH_OPTS = {
7927
+ maxDistance: 1,
7928
+ lowScoreFloor: .5,
7929
+ lowScoreMaxDistance: 0
7930
+ };
7931
+ function matchPlateText(probe, gallery, opts) {
7932
+ const allowed = probe.score >= opts.lowScoreFloor ? opts.maxDistance : opts.lowScoreMaxDistance;
7933
+ const bestByVehicle = /* @__PURE__ */ new Map();
7934
+ for (const s of gallery) {
7935
+ const dist = plateDistance(probe.text, s.text);
7936
+ const prev = bestByVehicle.get(s.vehicleId);
7937
+ if (prev === void 0 || dist < prev) bestByVehicle.set(s.vehicleId, dist);
7938
+ }
7939
+ if (bestByVehicle.size === 0) return null;
7940
+ let bestId = null;
7941
+ let bestDist = Infinity;
7942
+ let tie = false;
7943
+ for (const [vehicleId, dist] of bestByVehicle) {
7944
+ if (dist > allowed) continue;
7945
+ if (dist < bestDist) {
7946
+ bestDist = dist;
7947
+ bestId = vehicleId;
7948
+ tie = false;
7949
+ } else if (dist === bestDist) tie = true;
7950
+ }
7951
+ if (bestId === null || tie) return null;
7952
+ return {
7953
+ vehicleId: bestId,
7954
+ distance: bestDist
7955
+ };
7956
+ }
7957
+ //#endregion
5965
7958
  //#region src/pipeline-analytics/plate-recognizer.ts
5966
7959
  /**
5967
7960
  * PlateRecognizer — buffers the best license-plate OCR read per vehicle track
@@ -5973,8 +7966,38 @@ var PlateStore = class {
5973
7966
  var PlateRecognizer = class {
5974
7967
  deps;
5975
7968
  bestPlate = /* @__PURE__ */ new Map();
7969
+ gallery = [];
7970
+ nameById = /* @__PURE__ */ new Map();
7971
+ matchOpts;
5976
7972
  constructor(deps) {
5977
7973
  this.deps = deps;
7974
+ this.matchOpts = deps.matchOpts ?? DEFAULT_PLATE_MATCH_OPTS;
7975
+ }
7976
+ /** Reload the in-memory vehicle gallery + name map (after a gallery mutation). */
7977
+ async refreshGallery() {
7978
+ try {
7979
+ this.gallery = await this.deps.vehicleStore.loadGallery();
7980
+ this.nameById = new Map((await this.deps.vehicleStore.listVehicles()).map((v) => [v.id, v.name]));
7981
+ } catch (err) {
7982
+ this.deps.logger.warn("PlateRecognizer.refreshGallery failed", { meta: { error: String(err) } });
7983
+ }
7984
+ }
7985
+ matchVehicle(text, score) {
7986
+ const m = matchPlateText({
7987
+ text: normalizePlate(text),
7988
+ score
7989
+ }, this.gallery, this.matchOpts);
7990
+ if (m === null) return null;
7991
+ const name = this.nameById.get(m.vehicleId);
7992
+ return name !== void 0 ? {
7993
+ vehicleId: m.vehicleId,
7994
+ name
7995
+ } : null;
7996
+ }
7997
+ /** Live label for a plate read: the recognized vehicle NAME when matched, else
7998
+ * the raw OCR text (today's behavior). Used by the ingest label path. */
7999
+ resolveLabel(text, score) {
8000
+ return this.matchVehicle(text, score)?.name ?? text;
5978
8001
  }
5979
8002
  async processFrame(input) {
5980
8003
  const minConfidence = input.minConfidence ?? 0;
@@ -6030,6 +8053,8 @@ var PlateRecognizer = class {
6030
8053
  }
6031
8054
  });
6032
8055
  }
8056
+ const match = this.matchVehicle(held.text, held.score);
8057
+ const keyFrameMediaKey = this.deps.getKeyFrameMediaKey?.(trackId);
6033
8058
  try {
6034
8059
  await this.deps.plateStore.insert({
6035
8060
  id: plateId,
@@ -6039,7 +8064,16 @@ var PlateRecognizer = class {
6039
8064
  text: normalizePlate(held.text),
6040
8065
  score: held.score,
6041
8066
  ...mediaKey !== void 0 ? { mediaKey } : {},
6042
- corrected: false
8067
+ corrected: false,
8068
+ assigned: false,
8069
+ ...match !== null ? { recognizedVehicleId: match.vehicleId } : {},
8070
+ ...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {},
8071
+ plateBbox: held.bbox
8072
+ });
8073
+ this.deps.emitPlateGalleryChanged?.({
8074
+ deviceId,
8075
+ plateId,
8076
+ kind: "buffered"
6043
8077
  });
6044
8078
  this.deps.logger.info("plate: buffered to gallery", {
6045
8079
  tags: {
@@ -6050,7 +8084,8 @@ var PlateRecognizer = class {
6050
8084
  plateId,
6051
8085
  text: normalizePlate(held.text),
6052
8086
  score: held.score,
6053
- hasCrop: mediaKey !== void 0
8087
+ hasCrop: mediaKey !== void 0,
8088
+ recognizedVehicleId: match?.vehicleId ?? null
6054
8089
  }
6055
8090
  });
6056
8091
  } catch (err) {
@@ -6394,6 +8429,12 @@ function createEventMediaHandler(deps) {
6394
8429
  * surface to turn the refinement pipeline on/off for a camera.
6395
8430
  */
6396
8431
  var TTL_SWEEP_INTERVAL_MS = 5e3;
8432
+ /** Padding applied to the parent bbox for the `cropJpeg` retry fallback when a
8433
+ * scheduled detail call's frameHandle lease is already gone. */
8434
+ var DETAIL_FALLBACK_CROP_PADDING = .15;
8435
+ /** How long the active CLIP model id (from the embedding-encoder) is cached
8436
+ * before re-reading. */
8437
+ var CLIP_MODEL_ID_CACHE_TTL_MS = 6e4;
6397
8438
  var SETTINGS_CACHE_TTL_MS = 5e3;
6398
8439
  /** §5 best-frame: a track's `thumbnail` is overwritten only when the current
6399
8440
  * detection confidence beats the held best by at least this margin (hysteresis
@@ -6421,11 +8462,23 @@ var POST_PROCESSING_NODE_DEFAULT = "hub";
6421
8462
  * Absent / empty / non-string all fall back to the hub default — the exact
6422
8463
  * narrowing the old raw read applied inline. */
6423
8464
  var PostProcessingNodeIdSchema = string().min(1);
8465
+ var EmbeddingEnabledSchema = boolean();
6424
8466
  var SILENCE_FLOOR_DBFS = -55;
6425
8467
  var RETENTION_SWEEP_INTERVAL_MS = 5 * 6e4;
8468
+ /** Throttle for the "overlay synthesis failed" warn — one line / minute / device. */
8469
+ var OVERLAY_SYNTHESIS_WARN_THROTTLE_MS = 6e4;
6426
8470
  var AUDIO_EVENT_HEARTBEAT_MS = 5e3;
6427
8471
  var MOTION_EVENT_HEARTBEAT_MS = 5e3;
6428
8472
  /**
8473
+ * Decode a base64 little-endian float32 vector (the `DetailResult.embedding`
8474
+ * wire encoding produced by `runDetailSubtree`) back into a plain number[].
8475
+ */
8476
+ function decodeEmbeddingBase64(base64) {
8477
+ const bytes = Buffer.from(base64, "base64");
8478
+ const view = new Float32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));
8479
+ return Array.from(view);
8480
+ }
8481
+ /**
6429
8482
  * Re-home the global analytics sections into the per-device `Analytics`
6430
8483
  * top-tab. Every section defaults to the `Analytics` tab (so the
6431
8484
  * device-manager aggregator groups them) AND is marked
@@ -6473,8 +8526,21 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
6473
8526
  faceStore = null;
6474
8527
  faceRecognizer = null;
6475
8528
  plateStore = null;
8529
+ vehicleStore = null;
6476
8530
  plateRecognizer = null;
6477
8531
  objectEmbeddingStore = null;
8532
+ /** Two-plane detail scheduler/dispatcher: per-track on-demand face/clip/
8533
+ * classifier/plate enrichment via `pipelineRunner.runDetailSubtree`. Replaces
8534
+ * the per-frame child consumption the executor no longer emits. */
8535
+ detailDispatcher = null;
8536
+ /** Per-track detail geometry for the live-overlay synthesis (two-plane). */
8537
+ overlayState = new OverlayDetailStateStore();
8538
+ /** Throttle state for the "overlay synthesis failed" warn, one per device. */
8539
+ overlaySynthesisWarnAt = /* @__PURE__ */ new Map();
8540
+ /** Active CLIP image-embedding model id (== the text encoder's), TTL-cached.
8541
+ * Stamped on object-embedding rows from the detail plane so semantic search's
8542
+ * same-model gate keeps matching. */
8543
+ clipModelIdCache = null;
6478
8544
  /** Frame-based event/track media (crop + boxed full-frame) from the
6479
8545
  * detection-pipeline DECODED frame — the ONLY image source (never the
6480
8546
  * snapshot cap). Null when shm frame access is unavailable. */
@@ -6482,6 +8548,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
6482
8548
  /** Shared shm-ring reader cache for resolving `frameHandle`s to pixels.
6483
8549
  * Owned here so segments stay open across frames; closed once on shutdown. */
6484
8550
  frameReaders = null;
8551
+ /** Object/face embedding dispatcher — migrated from the retired
8552
+ * enrichment-engine addon. Runs ONLY on the post-processing node; on each
8553
+ * detection it resolves the frame, crops the ROI, and calls the
8554
+ * embedding-encoder cap (which persists the vector for the search/face
8555
+ * path). Null on non-post-processing nodes or when disabled. */
8556
+ embeddingDispatcher = null;
6485
8557
  bindingCache = null;
6486
8558
  zoneAnalytics = null;
6487
8559
  audioMetrics = null;
@@ -6532,6 +8604,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
6532
8604
  hysteresis: BEST_FRAME_HYSTERESIS,
6533
8605
  minGapMs: BEST_FRAME_MIN_GAP_MS
6534
8606
  });
8607
+ /** Windowed per-device aggregation of the "media capture" diagnostic — the
8608
+ * per-capture line is debug; a 60s per-counter SUM lands at info (~1
8609
+ * line/min/device instead of one per §5 cadence tick, see media-capture-log.ts). */
8610
+ mediaCaptureLog = new MediaCaptureLogAggregator();
6535
8611
  /** Best (highest-confidence) CLIP-object detection per track — drives ONE
6536
8612
  * tight object-crop capture whose media key is written onto the embedding
6537
8613
  * row (so a semantic-search hit's thumbnail IS the embedded crop). Shares the
@@ -6543,6 +8619,11 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
6543
8619
  * at track end so a face row links to the SAME single key frame. Cleared on
6544
8620
  * track end. */
6545
8621
  keyFrameKeyByTrackId = /* @__PURE__ */ new Map();
8622
+ /** Per-track update-gate memory for `PipelineAnalyticsTrackLifecycle`
8623
+ * `phase:'update'` — the last-emitted best (confidence / label / crop
8624
+ * area) + emit time, so a material improvement is measured against the
8625
+ * last emit and debounced. Seeded at `start`, dropped at `end`. */
8626
+ trackLifecycleUpdateMem = /* @__PURE__ */ new Map();
6546
8627
  /** The shared crop extractor (native-res first, detection-frame fallback),
6547
8628
  * captured in the constructor so `processFrame` can crop object thumbnails in
6548
8629
  * the same live-frame window as the face/plate/event-media captures. The
@@ -6558,6 +8639,15 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
6558
8639
  if (!this._postProcessingNodeState) this._postProcessingNodeState = this.state("postProcessingNodeId", PostProcessingNodeIdSchema, POST_PROCESSING_NODE_DEFAULT);
6559
8640
  return this._postProcessingNodeState;
6560
8641
  }
8642
+ /** Master toggle for the migrated object/face EmbeddingDispatcher (default
8643
+ * ON — the enrichment-engine default was `embeddingEnabled:true`). Read at
8644
+ * init; a change takes effect on addon restart (same convention as
8645
+ * `postProcessingNodeId`). */
8646
+ _embeddingEnabledState = null;
8647
+ get embeddingEnabledState() {
8648
+ if (!this._embeddingEnabledState) this._embeddingEnabledState = this.state("embeddingEnabled", EmbeddingEnabledSchema, true);
8649
+ return this._embeddingEnabledState;
8650
+ }
6561
8651
  /** GLOBAL face-recognition master switch (`enabled` key). Schema +
6562
8652
  * fallback derived from the face-settings source of truth. */
6563
8653
  _faceGlobalEnabledState = null;
@@ -6577,6 +8667,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
6577
8667
  await IdentityStore.declare(api.settingsStore);
6578
8668
  await FaceStore.declare(api.settingsStore);
6579
8669
  await PlateStore.declare(api.settingsStore);
8670
+ await VehicleStore.declare(api.settingsStore);
6580
8671
  await ObjectEmbeddingStore.declare(api.settingsStore);
6581
8672
  const logger = this.ctx.logger;
6582
8673
  let storage = this.ctx.kernel.storage;
@@ -6598,7 +8689,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
6598
8689
  });
6599
8690
  this.eventStore = new EventStore({
6600
8691
  store: api.settingsStore,
6601
- logger: logger.child("EventStore")
8692
+ logger: logger.child("EventStore"),
8693
+ media: this.mediaStore
6602
8694
  });
6603
8695
  this.identityStore = new IdentityStore({
6604
8696
  store: api.settingsStore,
@@ -6612,6 +8704,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
6612
8704
  store: api.settingsStore,
6613
8705
  logger: logger.child("PlateStore")
6614
8706
  });
8707
+ this.vehicleStore = new VehicleStore({
8708
+ store: api.settingsStore,
8709
+ logger: logger.child("VehicleStore")
8710
+ });
6615
8711
  const rawNodeId = this.ctx.kernel.localNodeId ?? this.ctx.id;
6616
8712
  const ownNodeId = rawNodeId.includes("/") ? rawNodeId.split("/")[0] : rawNodeId;
6617
8713
  {
@@ -6721,19 +8817,43 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
6721
8817
  }, trackId);
6722
8818
  },
6723
8819
  getKeyFrameMediaKey: (trackId) => this.keyFrameKeyByTrackId.get(trackId),
8820
+ emitFaceGalleryChanged: (payload) => this.emitFaceGalleryChanged(payload),
6724
8821
  logger: logger.child("FaceRecognizer")
6725
8822
  });
6726
8823
  this.faceRecognizer.refreshGallery();
6727
8824
  this.plateRecognizer = new PlateRecognizer({
6728
8825
  plateStore: this.plateStore,
8826
+ vehicleStore: this.vehicleStore,
6729
8827
  mediaStore: this.mediaStore,
6730
8828
  captureCrop,
8829
+ getKeyFrameMediaKey: (trackId) => this.keyFrameKeyByTrackId.get(trackId),
8830
+ emitPlateGalleryChanged: (payload) => this.emitPlateGalleryChanged(payload),
6731
8831
  logger: logger.child("PlateRecognizer")
6732
8832
  });
8833
+ this.plateRecognizer.refreshGallery();
6733
8834
  this.objectEmbeddingStore = new ObjectEmbeddingStore({
6734
8835
  store: api.settingsStore,
6735
8836
  logger: logger.child("ObjectEmbeddingStore")
6736
8837
  });
8838
+ const runnerApi = api.pipelineRunner;
8839
+ this.detailDispatcher = new TrackDetailDispatcher({
8840
+ logger: logger.child("DetailDispatcher"),
8841
+ runDetailSubtree: async (input, nodeId) => {
8842
+ if (!runnerApi?.runDetailSubtree) return null;
8843
+ if (nodeId !== void 0) return runnerApi.runDetailSubtree.mutate(input, nodePin(nodeId));
8844
+ return runnerApi.runDetailSubtree.mutate(input);
8845
+ },
8846
+ routeResults: (deviceId, trackId, details, frame) => this.routeDetailResults(deviceId, trackId, details, frame),
8847
+ captureCropBase64: async (frame) => {
8848
+ if (frame.frameHandle === void 0 || !this.captureCrop) return null;
8849
+ const buf = await this.captureCrop(frame.frameHandle, { ...frame.bbox }, frame.frameWidth, frame.frameHeight, DETAIL_FALLBACK_CROP_PADDING);
8850
+ return buf ? buf.toString("base64") : null;
8851
+ },
8852
+ hasTrackLabel: (trackId) => {
8853
+ const label = this.trackStore?.getActiveByTrack(trackId)?.label;
8854
+ return label !== void 0 && label.length > 0;
8855
+ }
8856
+ });
6737
8857
  this.bindingCache = new BindingCache({
6738
8858
  api,
6739
8859
  logger: logger.child("BindingCache")
@@ -6799,12 +8919,37 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
6799
8919
  const data = ev.data;
6800
8920
  this.handleNativeDetection(data);
6801
8921
  });
8922
+ if (await this.embeddingEnabledState.get()) {
8923
+ const encoderClient = {
8924
+ encode: (crop, width, height) => this.ctx.api.embeddingEncoder.encode.query({
8925
+ crop: new Uint8Array(crop),
8926
+ width,
8927
+ height
8928
+ }),
8929
+ getInfo: () => this.ctx.api.embeddingEncoder.getInfo.query()
8930
+ };
8931
+ this.embeddingDispatcher = new EmbeddingDispatcher({
8932
+ config: {
8933
+ ...DEFAULT_EMBEDDING_CONFIG,
8934
+ enabled: true
8935
+ },
8936
+ encoder: encoderClient,
8937
+ eventBus: this.ctx.eventBus,
8938
+ logger: logger.child("EmbeddingDispatcher"),
8939
+ ownNodeId,
8940
+ readers: frameReadersForFaces,
8941
+ getRemoteFrame
8942
+ });
8943
+ await this.embeddingDispatcher.start();
8944
+ } else logger.info("EmbeddingDispatcher disabled via settings (embeddingEnabled=false)");
6802
8945
  }
6803
8946
  this.unsubBindings = this.ctx.eventBus.subscribe({ category: EventCategory.DeviceBindingsChanged }, (ev) => {
6804
8947
  const data = ev.data;
6805
8948
  this.bindingCache?.onBindingsChanged(data);
6806
8949
  if (data.capName === "pipeline-analytics" && data.reason === "wrapper-deactivated") {
6807
8950
  this.trackStore?.clearDevice(data.deviceId);
8951
+ this.overlayState.clearDevice(data.deviceId);
8952
+ this.overlaySynthesisWarnAt.delete(data.deviceId);
6808
8953
  this.forgetDeviceProcessors(data.deviceId);
6809
8954
  this.levelStateByDevice.delete(data.deviceId);
6810
8955
  this.settingsCacheByDevice.delete(data.deviceId);
@@ -6817,6 +8962,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
6817
8962
  this.unsubDeviceUnreg = this.ctx.eventBus.subscribe({ category: EventCategory.DeviceUnregistered }, (ev) => {
6818
8963
  const { deviceId } = ev.data;
6819
8964
  this.trackStore?.clearDevice(deviceId);
8965
+ this.overlayState.clearDevice(deviceId);
8966
+ this.overlaySynthesisWarnAt.delete(deviceId);
6820
8967
  this.forgetDeviceProcessors(deviceId);
6821
8968
  this.levelStateByDevice.delete(deviceId);
6822
8969
  this.settingsCacheByDevice.delete(deviceId);
@@ -6834,6 +8981,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
6834
8981
  }, TTL_SWEEP_INTERVAL_MS);
6835
8982
  this.retentionSweepTimer = setInterval(() => {
6836
8983
  this.sweepRetention();
8984
+ this.runTrackRetentionSweep();
6837
8985
  }, RETENTION_SWEEP_INTERVAL_MS);
6838
8986
  this.ctx.logger.info("pipeline-analytics subscribers installed");
6839
8987
  const widgetsProvider = { listWidgets: async () => [
@@ -7074,11 +9222,19 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
7074
9222
  mediaStore: this.mediaStore,
7075
9223
  trackStore: this.trackStore,
7076
9224
  eventStore: this.eventStore,
7077
- refreshGallery: () => this.faceRecognizer?.refreshGallery()
9225
+ refreshGallery: () => this.faceRecognizer?.refreshGallery(),
9226
+ emitFaceGalleryChanged: (payload) => this.emitFaceGalleryChanged(payload)
7078
9227
  });
7079
9228
  const plateGallery = new PlateGalleryProvider({
7080
9229
  plateStore: this.plateStore,
7081
- mediaStore: this.mediaStore
9230
+ vehicleStore: this.vehicleStore,
9231
+ mediaStore: this.mediaStore,
9232
+ trackStore: this.trackStore,
9233
+ eventStore: this.eventStore,
9234
+ refreshGallery: () => {
9235
+ this.plateRecognizer?.refreshGallery();
9236
+ },
9237
+ emitPlateGalleryChanged: (payload) => this.emitPlateGalleryChanged(payload)
7082
9238
  });
7083
9239
  return [
7084
9240
  {
@@ -7127,6 +9283,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
7127
9283
  this.unsubBindings = null;
7128
9284
  this.unsubDeviceUnreg?.();
7129
9285
  this.unsubDeviceUnreg = null;
9286
+ await this.embeddingDispatcher?.stop();
9287
+ this.embeddingDispatcher = null;
7130
9288
  for (const id of this.proxies.keys()) this.releaseProxy(id);
7131
9289
  if (this.ttlSweepTimer) {
7132
9290
  clearInterval(this.ttlSweepTimer);
@@ -7138,10 +9296,15 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
7138
9296
  }
7139
9297
  this.zoneAnalytics?.destroy();
7140
9298
  this.audioMetrics?.destroy();
9299
+ this.detailDispatcher?.dispose();
9300
+ this.detailDispatcher = null;
9301
+ this.overlayState.clear();
9302
+ this.overlaySynthesisWarnAt.clear();
7141
9303
  this.processors.clear();
7142
9304
  this.lastActiveTrackIds.clear();
7143
9305
  this.dropoutSkipsByKey.clear();
7144
9306
  this.bestFrameTracker.clear();
9307
+ this.trackLifecycleUpdateMem.clear();
7145
9308
  this.objectEmbeddingBestSelector.clear();
7146
9309
  this.levelStateByDevice.clear();
7147
9310
  this.settingsCacheByDevice.clear();
@@ -7162,7 +9325,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
7162
9325
  async handleInferenceResult(data) {
7163
9326
  if (this.shuttingDown) return;
7164
9327
  const { deviceId, frame } = data;
7165
- await this.processFrame(deviceId, frame, "pipeline", data.frameHandle);
9328
+ await this.processFrame(deviceId, frame, "pipeline", data.frameHandle, data.detailSteps);
7166
9329
  }
7167
9330
  /**
7168
9331
  * Run one detection frame through the analysis layers for a given
@@ -7172,7 +9335,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
7172
9335
  * tracking/zone/event state never crosses between sources. Emits the
7173
9336
  * SAME canonical events, distinguished only by `source`.
7174
9337
  */
7175
- async processFrame(deviceId, frame, source, frameHandle) {
9338
+ async processFrame(deviceId, frame, source, frameHandle, detailSteps) {
7176
9339
  if (this.shuttingDown) return;
7177
9340
  if (!await this.bindingCache.isActive(deviceId)) return;
7178
9341
  const key = this.procKey(deviceId, source);
@@ -7273,6 +9436,24 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
7273
9436
  className: t.className
7274
9437
  }
7275
9438
  });
9439
+ const startPayload = buildTrackLifecyclePayload({
9440
+ deviceId,
9441
+ trackId: id,
9442
+ phase: "start",
9443
+ classes: [t.className],
9444
+ bestClassName: t.className,
9445
+ bestConfidence: t.confidence,
9446
+ firstSeen: result.timestamp,
9447
+ lastSeen: result.timestamp,
9448
+ ...t.label !== void 0 ? { label: t.label } : {}
9449
+ });
9450
+ this.emitTrackLifecycle(startPayload, result.timestamp);
9451
+ this.trackLifecycleUpdateMem.set(id, {
9452
+ lastConfidence: t.confidence,
9453
+ lastEmitAt: result.timestamp,
9454
+ ...t.label !== void 0 ? { lastLabel: t.label } : {},
9455
+ lastBboxArea: t.bbox.w * t.bbox.h
9456
+ });
7276
9457
  }
7277
9458
  }
7278
9459
  let lostTrackCount = 0;
@@ -7284,6 +9465,26 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
7284
9465
  } });
7285
9466
  }
7286
9467
  this.lastActiveTrackIds.set(key, currentTrackIds);
9468
+ if (this.detailDispatcher && detailSteps && detailSteps.length > 0) {
9469
+ const dispatcher = this.detailDispatcher;
9470
+ const steps = detailSteps;
9471
+ for (const t of result.tracked) {
9472
+ const detailFrame = {
9473
+ bbox: { ...t.bbox },
9474
+ frameWidth: result.frameWidth,
9475
+ frameHeight: result.frameHeight,
9476
+ className: t.className,
9477
+ confidence: t.confidence,
9478
+ timestamp: result.timestamp,
9479
+ ...frameHandle !== void 0 ? {
9480
+ frameHandle,
9481
+ nodeId: frameHandle.nodeId
9482
+ } : {}
9483
+ };
9484
+ if (prevIds.has(t.trackId)) dispatcher.onFrame(deviceId, t.trackId, steps, detailFrame, result.timestamp);
9485
+ else dispatcher.onTrackStarted(deviceId, t.trackId, t.className, steps, detailFrame, result.timestamp);
9486
+ }
9487
+ }
7287
9488
  if (newTrackCount > 0 || lostTrackCount > 0 || result.objectEvents.length > 0) {
7288
9489
  const byState = {};
7289
9490
  for (const t of result.tracked) byState[t.state] = (byState[t.state] ?? 0) + 1;
@@ -7316,7 +9517,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
7316
9517
  const mediaSettings = await this.resolveDeviceMediaSettings(deviceId);
7317
9518
  if (this.eventMediaDispatcher && frameHandle) {
7318
9519
  const childCropsByEvent = buildEventChildCrops(result.objectEvents, frame.detections);
7319
- const eventTargets = result.objectEvents.filter((e) => e.bbox).map((e) => {
9520
+ const eventTargets = result.objectEvents.filter((e) => e.bbox !== void 0 && e.bbox.w > 0 && e.bbox.h > 0).map((e) => {
7320
9521
  const childCrops = childCropsByEvent.get(e.id);
7321
9522
  return {
7322
9523
  eventId: e.id,
@@ -7332,15 +9533,25 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
7332
9533
  let plateCrops = 0;
7333
9534
  for (const crops of childCropsByEvent.values()) for (const c of crops) if (c.kind === "faceCrop") faceCrops += 1;
7334
9535
  else plateCrops += 1;
7335
- const snapshotTargets = this.buildSnapshotTargets(result.tracked, result.timestamp, mediaSettings);
9536
+ const snapshotTargets = this.buildSnapshotTargets(deviceId, result.tracked, result.timestamp, mediaSettings);
7336
9537
  if (eventTargets.length > 0 || firstFrameTargets.length > 0 || snapshotTargets.length > 0) {
7337
- log.info("media capture", { meta: {
7338
- source,
9538
+ const captureCounts = {
7339
9539
  events: eventTargets.length,
7340
9540
  trackFrames: firstFrameTargets.length,
7341
9541
  snapshots: snapshotTargets.length,
7342
9542
  faceCrops,
7343
9543
  plateCrops
9544
+ };
9545
+ log.debug("media capture", { meta: {
9546
+ source,
9547
+ ...captureCounts
9548
+ } });
9549
+ const captureAgg = this.mediaCaptureLog.note(deviceId, captureCounts, Date.now());
9550
+ if (captureAgg !== null) log.info("media capture (window)", { meta: {
9551
+ source,
9552
+ windowSec: Math.round(captureAgg.windowMs / 1e3),
9553
+ frames: captureAgg.frames,
9554
+ ...captureAgg.sums
7344
9555
  } });
7345
9556
  this.eventMediaDispatcher.captureForFrame({
7346
9557
  deviceId,
@@ -7403,6 +9614,26 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
7403
9614
  zones: e.zones ?? []
7404
9615
  }
7405
9616
  });
9617
+ let overlayDetections;
9618
+ try {
9619
+ overlayDetections = buildOverlayDetections({
9620
+ frameDetections: frame.detections,
9621
+ tracked: result.tracked,
9622
+ overlayFor: (trackId) => this.overlayState.get(deviceId, trackId),
9623
+ labelFor: (trackId) => this.trackStore?.getActiveByTrack(trackId)?.label,
9624
+ nowMs: Date.now()
9625
+ });
9626
+ } catch (err) {
9627
+ const now = Date.now();
9628
+ if (now - (this.overlaySynthesisWarnAt.get(deviceId) ?? 0) >= OVERLAY_SYNTHESIS_WARN_THROTTLE_MS) {
9629
+ this.overlaySynthesisWarnAt.set(deviceId, now);
9630
+ this.ctx.logger.warn("overlay synthesis failed — emitting unenriched frame detections", {
9631
+ tags: { deviceId },
9632
+ meta: { error: String(err) }
9633
+ });
9634
+ }
9635
+ overlayDetections = frame.detections;
9636
+ }
7406
9637
  this.ctx.eventBus.emit({
7407
9638
  id: `pa-${randomUUID()}`,
7408
9639
  timestamp: new Date(result.timestamp),
@@ -7417,7 +9648,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
7417
9648
  timestamp: result.timestamp,
7418
9649
  frameWidth: result.frameWidth,
7419
9650
  frameHeight: result.frameHeight,
7420
- detections: result.tracked
9651
+ detections: overlayDetections
7421
9652
  }
7422
9653
  });
7423
9654
  }
@@ -7495,6 +9726,158 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
7495
9726
  return settings;
7496
9727
  }
7497
9728
  /**
9729
+ * Route one track's `runDetailSubtree` results (two-plane detail dispatch)
9730
+ * into the EXISTING per-track consumers, discriminated by payload SHAPE:
9731
+ * • embedding + alignedCropJpeg → FACE (arcface): the FaceRecognizer's
9732
+ * candidate/best-face/gallery/keyFrame path (input source cut-over);
9733
+ * • embedding only → CLIP object embedding → the object-embedding store
9734
+ * (semantic search);
9735
+ * • label only → classifier answer / plate OCR text → the track's
9736
+ * enrichment label the notifier/UI already read.
9737
+ * Best-effort (D8): a per-detail failure is logged and never propagated.
9738
+ */
9739
+ async routeDetailResults(deviceId, trackId, details, frame) {
9740
+ for (const d of details) try {
9741
+ const isFaceDetail = d.className === "face";
9742
+ if (isFaceDetail && d.bbox !== void 0) this.overlayState.noteFaceDetail(deviceId, trackId, {
9743
+ x: d.bbox.x,
9744
+ y: d.bbox.y,
9745
+ w: d.bbox.w,
9746
+ h: d.bbox.h
9747
+ }, d.score, frame.timestamp);
9748
+ if (isFaceDetail && d.embedding !== void 0) await this.routeFaceDetail(deviceId, trackId, d, frame);
9749
+ else if (d.embedding !== void 0) await this.routeClipDetail(deviceId, trackId, d, frame.timestamp);
9750
+ else if (d.label !== void 0 && d.label.length > 0) {
9751
+ if (d.className === "plate" && d.bbox !== void 0) this.overlayState.notePlateDetail(deviceId, trackId, {
9752
+ x: d.bbox.x,
9753
+ y: d.bbox.y,
9754
+ w: d.bbox.w,
9755
+ h: d.bbox.h
9756
+ }, d.score, d.label, frame.timestamp);
9757
+ const label = d.className === "plate" ? this.plateRecognizer?.resolveLabel(d.label, d.score) ?? d.label : d.label;
9758
+ await this.applyTrackEnrichmentLabel(deviceId, trackId, label);
9759
+ }
9760
+ } catch (err) {
9761
+ this.ctx.logger.warn("detail result route failed", {
9762
+ tags: { deviceId },
9763
+ meta: {
9764
+ trackId,
9765
+ stepId: d.stepId,
9766
+ error: errMsg(err)
9767
+ }
9768
+ });
9769
+ }
9770
+ }
9771
+ /** Face-embedding detail → the FaceRecognizer (same gate + logic as the
9772
+ * former per-frame face path; only the input source moved). */
9773
+ async routeFaceDetail(deviceId, trackId, detail, frame) {
9774
+ if (!this.faceRecognizer || detail.embedding === void 0) return;
9775
+ if (!await this.resolveGlobalFaceEnabled()) return;
9776
+ const [settings, media] = await Promise.all([this.resolveDeviceFaceSettings(deviceId), this.resolveDeviceMediaSettings(deviceId)]);
9777
+ await this.faceRecognizer.ingestFaceDetail({
9778
+ deviceId,
9779
+ trackId,
9780
+ timestamp: frame.timestamp,
9781
+ frameWidth: frame.frameWidth,
9782
+ frameHeight: frame.frameHeight,
9783
+ score: detail.score,
9784
+ embedding: decodeEmbeddingBase64(detail.embedding),
9785
+ parentBbox: { ...frame.bbox },
9786
+ ...detail.bbox !== void 0 ? { faceBbox: { ...detail.bbox } } : {},
9787
+ ...detail.alignedCropJpeg !== void 0 ? { alignedCropJpeg: detail.alignedCropJpeg } : {},
9788
+ settings,
9789
+ cropPadding: media.cropPadding,
9790
+ ...frame.frameHandle !== void 0 ? { frameHandle: frame.frameHandle } : {}
9791
+ });
9792
+ }
9793
+ /** CLIP object-embedding detail → the object-embedding store (semantic
9794
+ * search). Stamps the active encoder's model id so the same-model search
9795
+ * gate keeps matching. */
9796
+ async routeClipDetail(deviceId, trackId, detail, timestamp) {
9797
+ const store = this.objectEmbeddingStore;
9798
+ if (!store || detail.embedding === void 0) return;
9799
+ const modelId = await this.resolveClipModelId();
9800
+ if (modelId === null) {
9801
+ this.ctx.logger.debug("clip detail dropped — no active embedding model id", {
9802
+ tags: { deviceId },
9803
+ meta: {
9804
+ trackId,
9805
+ stepId: detail.stepId
9806
+ }
9807
+ });
9808
+ return;
9809
+ }
9810
+ await store.upsertIfBetter({
9811
+ trackId,
9812
+ deviceId,
9813
+ timestamp,
9814
+ className: detail.className,
9815
+ embedding: decodeEmbeddingBase64(detail.embedding),
9816
+ modelId,
9817
+ confidence: detail.score
9818
+ });
9819
+ }
9820
+ /** Classifier answer / plate OCR text → the track's enrichment label
9821
+ * (TrackStore + persisted events + importance), mirroring the FaceRecognizer
9822
+ * label-propagation path. */
9823
+ async applyTrackEnrichmentLabel(deviceId, trackId, label) {
9824
+ try {
9825
+ await this.trackStore?.setLabel(trackId, label);
9826
+ } catch (err) {
9827
+ this.ctx.logger.warn("detail label setLabel failed", {
9828
+ tags: { deviceId },
9829
+ meta: {
9830
+ trackId,
9831
+ error: errMsg(err)
9832
+ }
9833
+ });
9834
+ }
9835
+ try {
9836
+ await this.eventStore?.setLabelForTrack(trackId, label);
9837
+ } catch (err) {
9838
+ this.ctx.logger.warn("detail label setLabelForTrack failed", {
9839
+ tags: { deviceId },
9840
+ meta: {
9841
+ trackId,
9842
+ error: errMsg(err)
9843
+ }
9844
+ });
9845
+ }
9846
+ try {
9847
+ const trackStore = this.trackStore;
9848
+ const eventStore = this.eventStore;
9849
+ if (trackStore && eventStore) await recomputeTrackImportance({
9850
+ trackStore,
9851
+ eventStore
9852
+ }, trackId);
9853
+ } catch (err) {
9854
+ this.ctx.logger.debug("detail label recomputeImportance failed", {
9855
+ tags: { deviceId },
9856
+ meta: {
9857
+ trackId,
9858
+ error: errMsg(err)
9859
+ }
9860
+ });
9861
+ }
9862
+ }
9863
+ /** Active CLIP image-embedding model id (== the text encoder's), TTL-cached.
9864
+ * Returns null when the embedding-encoder cap is unavailable. */
9865
+ async resolveClipModelId() {
9866
+ const now = Date.now();
9867
+ if (this.clipModelIdCache && now < this.clipModelIdCache.expiresAt) return this.clipModelIdCache.value;
9868
+ let value = null;
9869
+ try {
9870
+ value = (await this.ctx.api.embeddingEncoder.getInfo.query())?.modelId ?? null;
9871
+ } catch (err) {
9872
+ this.ctx.logger.debug("resolveClipModelId: getInfo failed", { meta: { error: errMsg(err) } });
9873
+ }
9874
+ this.clipModelIdCache = {
9875
+ value,
9876
+ expiresAt: now + CLIP_MODEL_ID_CACHE_TTL_MS
9877
+ };
9878
+ return value;
9879
+ }
9880
+ /**
7498
9881
  * §5 — decide which active tracks need periodic media THIS frame. Pure over
7499
9882
  * TrackStore.lastSnapshotAt + the per-track best-confidence map:
7500
9883
  * • `snapshot` (append) + `lastFrame` (rolling overwrite) fire together on
@@ -7581,12 +9964,67 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
7581
9964
  });
7582
9965
  }));
7583
9966
  }
7584
- buildSnapshotTargets(tracked, timestamp, media) {
9967
+ /** Emit a `PipelineAnalyticsTrackLifecycle` event (start / update / end). */
9968
+ emitTrackLifecycle(payload, timestamp) {
9969
+ this.ctx.eventBus.emit({
9970
+ id: `pa-life-${randomUUID()}`,
9971
+ timestamp: new Date(timestamp),
9972
+ source: {
9973
+ type: "addon",
9974
+ id: "pipeline-analytics",
9975
+ addonId: "pipeline-analytics"
9976
+ },
9977
+ category: EventCategory.PipelineAnalyticsTrackLifecycle,
9978
+ data: payload
9979
+ });
9980
+ }
9981
+ /**
9982
+ * Fire a `phase:'update'` lifecycle event when this frame's observation is
9983
+ * a MATERIAL improvement over the last emit (confidence delta, a
9984
+ * newly-resolved identity/plate label, or a materially larger crop),
9985
+ * debounced per `DEFAULT_UPDATE_GATE_CONFIG`. Reuses the already-computed
9986
+ * `isNewBest`; the gate is pure and the per-track memory is advanced in
9987
+ * place. Carries the CURRENT best incl. the improved key-frame media key.
9988
+ */
9989
+ maybeEmitTrackLifecycleUpdate(deviceId, t, timestamp, isNewBest) {
9990
+ const memory = this.trackLifecycleUpdateMem.get(t.trackId);
9991
+ const decision = evaluateTrackLifecycleUpdate({
9992
+ isNewBest,
9993
+ confidence: t.confidence,
9994
+ now: timestamp,
9995
+ ...t.label !== void 0 ? { label: t.label } : {},
9996
+ bboxArea: t.bbox.w * t.bbox.h
9997
+ }, memory, DEFAULT_UPDATE_GATE_CONFIG);
9998
+ this.trackLifecycleUpdateMem.set(t.trackId, decision.memory);
9999
+ if (!decision.emit) return;
10000
+ const track = this.trackStore?.getActiveByTrack(t.trackId);
10001
+ const keyFrameMediaKey = this.keyFrameKeyByTrackId.get(t.trackId);
10002
+ const payload = buildTrackLifecyclePayload({
10003
+ deviceId,
10004
+ trackId: t.trackId,
10005
+ phase: "update",
10006
+ classes: track?.classes ?? [t.className],
10007
+ bestClassName: t.className,
10008
+ bestConfidence: t.confidence,
10009
+ firstSeen: track?.firstSeen ?? timestamp,
10010
+ lastSeen: track?.lastSeen ?? timestamp,
10011
+ ...t.label !== void 0 ? { label: t.label } : {},
10012
+ ...t.plateText !== void 0 ? { plateText: t.plateText } : {},
10013
+ ...track?.zonesVisited !== void 0 ? { zonesVisited: track.zonesVisited } : {},
10014
+ ...track?.totalDistance !== void 0 ? { totalDistance: track.totalDistance } : {},
10015
+ ...track?.positions !== void 0 ? { positionsCount: track.positions.length } : {},
10016
+ ...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {},
10017
+ ...t.embeddingModelId !== void 0 ? { embeddingModelId: t.embeddingModelId } : {}
10018
+ });
10019
+ this.emitTrackLifecycle(payload, timestamp);
10020
+ }
10021
+ buildSnapshotTargets(deviceId, tracked, timestamp, media) {
7585
10022
  const targets = [];
7586
10023
  for (const t of tracked) {
7587
10024
  const lastSnap = this.trackStore.lastSnapshotAt(t.trackId);
7588
10025
  const dueSnapshot = media.saveThumbnails && lastSnap > 0 && timestamp - lastSnap >= media.snapshotIntervalMs;
7589
10026
  const isNewBest = this.bestFrameTracker.observe(t.trackId, t.confidence, timestamp);
10027
+ this.maybeEmitTrackLifecycleUpdate(deviceId, t, timestamp, isNewBest);
7590
10028
  if (!dueSnapshot && !isNewBest) continue;
7591
10029
  targets.push({
7592
10030
  trackId: t.trackId,
@@ -7852,6 +10290,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
7852
10290
  positions: t.positions.length
7853
10291
  }
7854
10292
  });
10293
+ const keyFrameMediaKey = this.keyFrameKeyByTrackId.get(t.trackId);
7855
10294
  const faceEnd = this.faceRecognizer?.onTrackEnd(t.deviceId, t.trackId);
7856
10295
  const dropKeyFrameKey = () => {
7857
10296
  this.keyFrameKeyByTrackId.delete(t.trackId);
@@ -7859,11 +10298,16 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
7859
10298
  if (faceEnd) faceEnd.finally(dropKeyFrameKey);
7860
10299
  else dropKeyFrameKey();
7861
10300
  this.plateRecognizer?.onTrackEnd(t.deviceId, t.trackId);
10301
+ const trackerPeak = this.bestFrameTracker.peak(t.trackId);
10302
+ let endImportance;
10303
+ let endImportanceReason;
10304
+ let endBestEventId;
7862
10305
  try {
7863
10306
  const peak = await this.eventStore?.peakForTrack(t.trackId);
7864
10307
  if (peak) {
10308
+ endBestEventId = peak.bestEventId;
7865
10309
  const { importance, reason } = computeImportance({
7866
- peakConfidence: this.bestFrameTracker.peak(t.trackId)?.confidence ?? peak.peakConfidence,
10310
+ peakConfidence: trackerPeak?.confidence ?? peak.peakConfidence,
7867
10311
  className: t.className,
7868
10312
  durationMs: duration,
7869
10313
  peakBboxAreaFrac: peak.peakBboxAreaFrac,
@@ -7871,6 +10315,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
7871
10315
  zonesVisited: t.zonesVisited,
7872
10316
  ...t.label !== void 0 ? { label: t.label } : {}
7873
10317
  });
10318
+ endImportance = importance;
10319
+ endImportanceReason = reason;
7874
10320
  await this.trackStore?.setImportance(t.trackId, importance, reason, peak.bestEventId);
7875
10321
  if (peak.bestEventId !== void 0) await this.eventStore?.setImportanceForTrack(t.trackId, importance);
7876
10322
  }
@@ -7882,6 +10328,19 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
7882
10328
  }
7883
10329
  this.bestFrameTracker.delete(t.trackId);
7884
10330
  this.objectEmbeddingBestSelector.delete(t.trackId);
10331
+ this.detailDispatcher?.onTrackEnded(t.deviceId, t.trackId);
10332
+ this.overlayState.onTrackEnded(t.deviceId, t.trackId);
10333
+ if ((this.trackStore?.getActive(t.deviceId).length ?? 0) === 0) {
10334
+ const captureAgg = this.mediaCaptureLog.flush(t.deviceId, Date.now());
10335
+ if (captureAgg !== null) this.ctx.logger.info("media capture (window)", {
10336
+ tags: { deviceId: t.deviceId },
10337
+ meta: {
10338
+ windowSec: Math.round(captureAgg.windowMs / 1e3),
10339
+ frames: captureAgg.frames,
10340
+ ...captureAgg.sums
10341
+ }
10342
+ });
10343
+ }
7885
10344
  this.ctx.eventBus.emit({
7886
10345
  id: `pa-end-${t.trackId}`,
7887
10346
  timestamp: new Date(t.lastSeen),
@@ -7898,6 +10357,26 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
7898
10357
  durationMs: duration
7899
10358
  }
7900
10359
  });
10360
+ const endPayload = buildTrackLifecyclePayload({
10361
+ deviceId: t.deviceId,
10362
+ trackId: t.trackId,
10363
+ phase: "end",
10364
+ classes: t.classes ?? [t.className],
10365
+ bestClassName: t.className,
10366
+ bestConfidence: trackerPeak?.confidence ?? 0,
10367
+ firstSeen: t.firstSeen,
10368
+ lastSeen: t.lastSeen,
10369
+ ...t.label !== void 0 ? { label: t.label } : {},
10370
+ zonesVisited: t.zonesVisited,
10371
+ totalDistance: t.totalDistance,
10372
+ positionsCount: t.positions.length,
10373
+ ...endImportance !== void 0 ? { importance: endImportance } : {},
10374
+ ...endImportanceReason !== void 0 ? { importanceReason: endImportanceReason } : {},
10375
+ ...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {},
10376
+ ...endBestEventId !== void 0 ? { bestEventId: endBestEventId } : {}
10377
+ });
10378
+ this.emitTrackLifecycle(endPayload, t.lastSeen);
10379
+ this.trackLifecycleUpdateMem.delete(t.trackId);
7901
10380
  }
7902
10381
  } catch (err) {
7903
10382
  if (this.shuttingDown) return;
@@ -7989,6 +10468,62 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
7989
10468
  if (!frame) return;
7990
10469
  await this.processFrame(payload.cameraId, frame, "onboard");
7991
10470
  }
10471
+ /**
10472
+ * Best-effort bus notification for a gallery face-row change (buffer /
10473
+ * assign / unassign / delete). Passed into the `FaceRecognizer` and
10474
+ * `FaceGalleryProvider` as their `emitFaceGalleryChanged` dep. Telemetry
10475
+ * (D8): a lost/failed emit only delays the admin-ui live-refresh — never
10476
+ * allowed to fail the caller's mutation.
10477
+ */
10478
+ emitFaceGalleryChanged(payload) {
10479
+ try {
10480
+ this.ctx.eventBus.emit({
10481
+ id: `pa-${randomUUID()}`,
10482
+ timestamp: /* @__PURE__ */ new Date(),
10483
+ source: {
10484
+ type: "addon",
10485
+ id: "pipeline-analytics",
10486
+ addonId: "pipeline-analytics"
10487
+ },
10488
+ category: EventCategory.PipelineAnalyticsFaceGalleryChanged,
10489
+ data: payload
10490
+ });
10491
+ } catch (err) {
10492
+ this.ctx.logger.debug("face-gallery-changed emit failed", { meta: {
10493
+ deviceId: payload.deviceId,
10494
+ faceId: payload.faceId,
10495
+ error: String(err)
10496
+ } });
10497
+ }
10498
+ }
10499
+ /**
10500
+ * Best-effort bus notification for a gallery plate-row change (buffer /
10501
+ * assign / unassign / delete). Passed into the `PlateRecognizer` and
10502
+ * `PlateGalleryProvider` as their `emitPlateGalleryChanged` dep. Telemetry
10503
+ * (D8): a lost/failed emit only delays the admin-ui live-refresh — never
10504
+ * allowed to fail the caller's mutation.
10505
+ */
10506
+ emitPlateGalleryChanged(payload) {
10507
+ try {
10508
+ this.ctx.eventBus.emit({
10509
+ id: `pa-${randomUUID()}`,
10510
+ timestamp: /* @__PURE__ */ new Date(),
10511
+ source: {
10512
+ type: "addon",
10513
+ id: "pipeline-analytics",
10514
+ addonId: "pipeline-analytics"
10515
+ },
10516
+ category: EventCategory.PipelineAnalyticsPlateGalleryChanged,
10517
+ data: payload
10518
+ });
10519
+ } catch (err) {
10520
+ this.ctx.logger.debug("plate-gallery-changed emit failed", { meta: {
10521
+ deviceId: payload.deviceId,
10522
+ plateId: payload.plateId,
10523
+ error: String(err)
10524
+ } });
10525
+ }
10526
+ }
7992
10527
  /** Composite key for the per-(device, source) processor + track maps. */
7993
10528
  procKey(deviceId, source) {
7994
10529
  return `${deviceId}:${source}`;
@@ -8088,6 +10623,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
8088
10623
  }
8089
10624
  async clearTracks(input) {
8090
10625
  this.trackStore?.clearDevice(input.deviceId);
10626
+ this.overlayState.clearDevice(input.deviceId);
10627
+ this.overlaySynthesisWarnAt.delete(input.deviceId);
8091
10628
  const prefix = `${input.deviceId}:`;
8092
10629
  for (const k of this.lastActiveTrackIds.keys()) if (k.startsWith(prefix)) this.lastActiveTrackIds.delete(k);
8093
10630
  }
@@ -8264,6 +10801,168 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
8264
10801
  return counts;
8265
10802
  }
8266
10803
  /**
10804
+ * The widened, extensible track-deletion cascade registry (design §3): every
10805
+ * leaf store that holds track-scoped rows, ORDERED leaves-first (the track
10806
+ * root is deleted internally, last, by `cascadeDeleteTracks`). eventStore
10807
+ * folds in its event-owned media; mediaStore removes track/face/plate crops
10808
+ * (never identity); faceStore removes unassigned (non-enrolled) reads;
10809
+ * objectEmbeddingStore removes the per-track CLIP vector. The plate leg is
10810
+ * deferred until the plate `assigned` enrollment model lands (design §11.3).
10811
+ * Returns null when the required stores aren't ready.
10812
+ */
10813
+ buildTrackCascadeRegistry() {
10814
+ const eventStore = this.eventStore;
10815
+ const mediaStore = this.mediaStore;
10816
+ const trackStore = this.trackStore;
10817
+ if (!eventStore || !mediaStore || !trackStore) return null;
10818
+ const registry = [eventStore, mediaStore];
10819
+ if (this.faceStore) registry.push(this.faceStore);
10820
+ if (this.plateStore) registry.push(this.plateStore);
10821
+ if (this.objectEmbeddingStore) registry.push(this.objectEmbeddingStore);
10822
+ return {
10823
+ registry,
10824
+ trackStore
10825
+ };
10826
+ }
10827
+ /**
10828
+ * Clear the addon-level per-track in-memory scheduler/overlay state that a
10829
+ * natural track-end (see `sweepExpiredTracks`) clears — otherwise a pruned
10830
+ * track keeps drawing overlay boxes and the dispatcher keeps scheduling detail
10831
+ * work until it ends on its own. Fired per successfully-cascaded track by all
10832
+ * three retention entry points (design §5).
10833
+ */
10834
+ clearLiveTrackState(deviceId, trackId) {
10835
+ this.detailDispatcher?.onTrackEnded(deviceId, trackId);
10836
+ this.overlayState.onTrackEnded(deviceId, trackId);
10837
+ }
10838
+ async deleteTracks(input) {
10839
+ const cascade = this.buildTrackCascadeRegistry();
10840
+ if (!cascade) return {
10841
+ deleted: 0,
10842
+ failed: [...input.trackIds]
10843
+ };
10844
+ const { deleted, failed } = await runTrackCascadeBatch({
10845
+ registry: cascade.registry,
10846
+ trackStore: cascade.trackStore,
10847
+ deviceId: input.deviceId,
10848
+ onTrackCleanup: (deviceId, trackId) => this.clearLiveTrackState(deviceId, trackId),
10849
+ onFailure: (trackId, err) => {
10850
+ this.ctx.logger.debug("deleteTracks failed", { meta: {
10851
+ trackId,
10852
+ error: String(err)
10853
+ } });
10854
+ }
10855
+ }, input.trackIds);
10856
+ if (deleted > 0) this.ctx.logger.info("analytics tracks deleted", {
10857
+ tags: { deviceId: input.deviceId },
10858
+ meta: {
10859
+ deviceId: input.deviceId,
10860
+ deleted,
10861
+ failed: failed.length
10862
+ }
10863
+ });
10864
+ return {
10865
+ deleted,
10866
+ failed
10867
+ };
10868
+ }
10869
+ /**
10870
+ * Track-centric time-based retention (design §5.1). Drains every persisted
10871
+ * track for the device whose `lastSeen < cutoffMs`, page by page, through the
10872
+ * widened cascade — enrolled faces/plates + identity media are exempt (design
10873
+ * §4). `tracks` is the authoritative count; leaf families are best-effort 0
10874
+ * under the void `TrackScopedStore` contract (see cap doc).
10875
+ */
10876
+ async pruneTracksBefore(input) {
10877
+ const empty = {
10878
+ tracks: 0,
10879
+ events: 0,
10880
+ media: 0,
10881
+ faces: 0,
10882
+ plates: 0,
10883
+ embeddings: 0
10884
+ };
10885
+ const cascade = this.buildTrackCascadeRegistry();
10886
+ if (!cascade) return empty;
10887
+ const PAGE = 200;
10888
+ let totalDeleted = 0;
10889
+ for (;;) {
10890
+ if (this.shuttingDown) break;
10891
+ const ids = await cascade.trackStore.listIdsBefore(input.deviceId, input.cutoffMs, PAGE);
10892
+ if (ids.length === 0) break;
10893
+ const { deleted } = await runTrackCascadeBatch({
10894
+ registry: cascade.registry,
10895
+ trackStore: cascade.trackStore,
10896
+ deviceId: input.deviceId,
10897
+ onTrackCleanup: (deviceId, trackId) => this.clearLiveTrackState(deviceId, trackId),
10898
+ onFailure: (trackId, err) => {
10899
+ this.ctx.logger.debug("pruneTracksBefore track failed", { meta: {
10900
+ trackId,
10901
+ error: String(err)
10902
+ } });
10903
+ }
10904
+ }, ids);
10905
+ totalDeleted += deleted;
10906
+ if (deleted === 0) break;
10907
+ }
10908
+ if (totalDeleted > 0) this.ctx.logger.info("analytics track retention prune", {
10909
+ tags: { deviceId: input.deviceId },
10910
+ meta: {
10911
+ deviceId: input.deviceId,
10912
+ cutoffMs: input.cutoffMs,
10913
+ tracks: totalDeleted
10914
+ }
10915
+ });
10916
+ return {
10917
+ ...empty,
10918
+ tracks: totalDeleted
10919
+ };
10920
+ }
10921
+ /**
10922
+ * Operator "clean slate" for a device (design §5.3): prune EVERY persisted
10923
+ * track via the same cascade as `pruneTracksBefore` with `cutoffMs = now`.
10924
+ * Enrolled gallery + identity media are exempt.
10925
+ */
10926
+ async wipeAllAnalytics(input) {
10927
+ return this.pruneTracksBefore({
10928
+ deviceId: input.deviceId,
10929
+ cutoffMs: Date.now()
10930
+ });
10931
+ }
10932
+ /**
10933
+ * Periodic per-device track retention sweep (design §6): for every device
10934
+ * that has persisted tracks, prune those older than its `trackRetentionDays`
10935
+ * window (default 7 days; 0 = keep forever → skipped). Runs on the shared
10936
+ * retention interval. Enrolled gallery is exempt by the cascade store layer.
10937
+ */
10938
+ async runTrackRetentionSweep() {
10939
+ if (this.shuttingDown || !this.trackStore) return;
10940
+ const trackStore = this.trackStore;
10941
+ try {
10942
+ const total = await sweepTrackRetention({
10943
+ listDeviceIds: () => trackStore.listDeviceIds(),
10944
+ resolveRetentionDays: async (deviceId) => {
10945
+ return resolveTrackRetentionDays(await this.ctx?.settings?.readDeviceStore(deviceId) ?? {});
10946
+ },
10947
+ pruneTracksBefore: (deviceId, cutoffMs) => this.pruneTracksBefore({
10948
+ deviceId,
10949
+ cutoffMs
10950
+ }),
10951
+ now: () => Date.now(),
10952
+ onError: (deviceId, err) => {
10953
+ this.ctx.logger.debug("track retention sweep (device) failed", { meta: {
10954
+ deviceId,
10955
+ error: String(err)
10956
+ } });
10957
+ }
10958
+ });
10959
+ if (total > 0) this.ctx.logger.info("analytics track retention sweep", { meta: { tracks: total } });
10960
+ } catch (err) {
10961
+ if (this.shuttingDown) return;
10962
+ this.ctx.logger.debug("runTrackRetentionSweep failed", { meta: { error: String(err) } });
10963
+ }
10964
+ }
10965
+ /**
8267
10966
  * Decode a stored event image into `EventMedia` for the data-plane handler.
8268
10967
  * Preference order: `crop` (square-safe 16:9 preview) → `fullFrameBoxed`
8269
10968
  * (native-res boxed frame) → any available file. Returns `null` if the
@@ -8324,6 +11023,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
8324
11023
  label: "Post-processing node",
8325
11024
  description: "Node id that persists events + media (e.g. \"hub\"). Leave \"hub\" unless you intentionally move post-processing to an agent. Takes effect on addon restart.",
8326
11025
  default: POST_PROCESSING_NODE_DEFAULT
11026
+ }, {
11027
+ type: "boolean",
11028
+ key: "embeddingEnabled",
11029
+ label: "Object/face embeddings",
11030
+ description: "Compute object/face embeddings from detection crops (feeds similarity search + face recognition). Runs on the post-processing node. Takes effect on addon restart.",
11031
+ default: true
8327
11032
  }]
8328
11033
  },
8329
11034
  {
@@ -8430,9 +11135,20 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
8430
11135
  {
8431
11136
  id: "retention",
8432
11137
  title: "Retention",
8433
- description: "How long each event kind is kept in the SQL store. Media files follow the minimum of these.",
11138
+ description: "How long analytics history is kept in the SQL store. Media files follow the minimum of these.",
8434
11139
  columns: 3,
8435
11140
  fields: [
11141
+ {
11142
+ type: "number",
11143
+ key: "trackRetentionDays",
11144
+ label: "Tracks",
11145
+ description: "Age out persisted tracks and everything derived from them (events, media, faces, embeddings) beyond this window. 0 = keep forever. Enrolled faces/plates are never pruned.",
11146
+ min: 0,
11147
+ max: 365,
11148
+ step: 1,
11149
+ default: TRACK_RETENTION_DEFAULT_DAYS,
11150
+ unit: "days"
11151
+ },
8436
11152
  {
8437
11153
  type: "number",
8438
11154
  key: "retentionMotionDays",