@camstack/addon-post-analysis 1.1.27 → 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-BqOBYSWs.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,6 +1766,7 @@ 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();
1525
1771
  const faceAlignedCropByBbox = /* @__PURE__ */ new Map();
1526
1772
  const plateByBbox = /* @__PURE__ */ new Map();
@@ -1549,6 +1795,7 @@ var FrameProcessor = class {
1549
1795
  height: det.maskHeight
1550
1796
  });
1551
1797
  firstLevelBboxById.set(det.id, bbox);
1798
+ sourceIdByBbox.set(bbox, det.id);
1552
1799
  return {
1553
1800
  ...bbox,
1554
1801
  detection,
@@ -1610,8 +1857,10 @@ var FrameProcessor = class {
1610
1857
  const faceBbox = faceBboxByBbox.get(td.bbox);
1611
1858
  const faceAlignedCrop = faceAlignedCropByBbox.get(td.bbox);
1612
1859
  const plate = plateByBbox.get(td.bbox);
1860
+ const sourceDetectionId = sourceIdByBbox.get(td.bbox);
1613
1861
  return {
1614
1862
  trackId: td.trackId,
1863
+ ...sourceDetectionId !== void 0 ? { sourceDetectionId } : {},
1615
1864
  className: td.class,
1616
1865
  confidence: td.score,
1617
1866
  bbox: { ...td.bbox },
@@ -1674,6 +1923,84 @@ var FrameProcessor = class {
1674
1923
  };
1675
1924
  }
1676
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
+ }
1677
2004
  //#endregion
1678
2005
  //#region src/pipeline-analytics/pipeline/best-detection-tracker.ts
1679
2006
  var BestDetectionTracker = class {
@@ -1878,6 +2205,50 @@ function nativeDetectionsToFrame(input) {
1878
2205
  };
1879
2206
  }
1880
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
1881
2252
  //#region src/pipeline-analytics/runtime/binding-cache.ts
1882
2253
  var BindingCache = class {
1883
2254
  api;
@@ -1983,6 +2354,10 @@ var TRACKS_COLUMNS = [
1983
2354
  name: "zonesVisited",
1984
2355
  type: "JSON"
1985
2356
  },
2357
+ {
2358
+ name: "classes",
2359
+ type: "JSON"
2360
+ },
1986
2361
  {
1987
2362
  name: "totalDistance",
1988
2363
  type: "REAL"
@@ -2031,6 +2406,7 @@ function cloneTrack(t) {
2031
2406
  }
2032
2407
  })),
2033
2408
  zonesVisited: [...t.zonesVisited],
2409
+ classes: [...t.classes],
2034
2410
  totalDistance: t.totalDistance,
2035
2411
  state: t.state,
2036
2412
  active: t.active,
@@ -2075,6 +2451,7 @@ var TrackStore = class {
2075
2451
  existing.positions.push(params.position);
2076
2452
  } else existing.positions.push(params.position);
2077
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);
2078
2455
  return existing;
2079
2456
  }
2080
2457
  const fresh = {
@@ -2087,6 +2464,7 @@ var TrackStore = class {
2087
2464
  positions: [params.position],
2088
2465
  snapshots: [],
2089
2466
  zonesVisited: [...params.zones],
2467
+ classes: [params.className],
2090
2468
  totalDistance: 0,
2091
2469
  state: params.state,
2092
2470
  active: true,
@@ -2229,6 +2607,92 @@ var TrackStore = class {
2229
2607
  clearAll() {
2230
2608
  this.active.clear();
2231
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
+ }
2232
2696
  /** Historical query — hits the persisted collection. */
2233
2697
  async queryHistorical(params) {
2234
2698
  const filter = { where: { deviceId: params.deviceId } };
@@ -2270,6 +2734,7 @@ var TrackStore = class {
2270
2734
  positions: [...t.positions],
2271
2735
  snapshots: [...t.snapshots],
2272
2736
  zonesVisited: [...t.zonesVisited],
2737
+ ...t.classes !== void 0 ? { classes: [...t.classes] } : {},
2273
2738
  totalDistance: t.totalDistance,
2274
2739
  state: t.state,
2275
2740
  ...t.importance !== void 0 ? { importance: t.importance } : {},
@@ -2282,6 +2747,7 @@ var TrackStore = class {
2282
2747
  const positions = data["positions"] ?? [];
2283
2748
  const snapshots = data["snapshots"] ?? [];
2284
2749
  const zones = data["zonesVisited"] ?? [];
2750
+ const classes = data["classes"];
2285
2751
  const label = data["label"];
2286
2752
  const importance = data["importance"];
2287
2753
  const bestEventId = data["bestEventId"];
@@ -2296,6 +2762,7 @@ var TrackStore = class {
2296
2762
  positions,
2297
2763
  snapshots,
2298
2764
  zonesVisited: zones,
2765
+ ...classes !== null ? { classes } : {},
2299
2766
  totalDistance: Number(data["totalDistance"] ?? 0),
2300
2767
  state: data["state"] ?? "idle",
2301
2768
  active: false,
@@ -2308,6 +2775,18 @@ var TrackStore = class {
2308
2775
  //#endregion
2309
2776
  //#region src/pipeline-analytics/store/media-store.ts
2310
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-";
2311
2790
  var MEDIA_COLUMNS = [
2312
2791
  {
2313
2792
  name: "id",
@@ -2672,6 +3151,28 @@ var MediaStore = class {
2672
3151
  async deleteForEvents(eventIds) {
2673
3152
  return this.deleteForOwner("event", eventIds);
2674
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
+ }
2675
3176
  /** Retention sweep: delete any media row + blob older than cutoff.
2676
3177
  * Returns number of entries removed. */
2677
3178
  async evictBefore(cutoffMs) {
@@ -2834,9 +3335,11 @@ var COMMON_INDEXES = (prefix) => [{
2834
3335
  var EventStore = class {
2835
3336
  store;
2836
3337
  logger;
3338
+ media;
2837
3339
  constructor(deps) {
2838
3340
  this.store = deps.store;
2839
3341
  this.logger = deps.logger;
3342
+ this.media = deps.media;
2840
3343
  }
2841
3344
  static async declare(store) {
2842
3345
  await store.declareCollection.mutate({
@@ -3049,6 +3552,12 @@ var EventStore = class {
3049
3552
  let bestEventId;
3050
3553
  let peakBboxAreaFrac = 0;
3051
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
+ }
3052
3561
  const conf = typeof row.data["confidence"] === "number" ? row.data["confidence"] : 0;
3053
3562
  if (conf <= bestConf) continue;
3054
3563
  bestConf = conf;
@@ -3238,6 +3747,72 @@ var EventStore = class {
3238
3747
  ]
3239
3748
  };
3240
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
+ }
3241
3816
  };
3242
3817
  function slimMotion(id, data) {
3243
3818
  return {
@@ -3304,6 +3879,18 @@ function stripNulls(data) {
3304
3879
  return out;
3305
3880
  }
3306
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
3307
3894
  //#region src/shared/frame/square-safe-crop.ts
3308
3895
  /**
3309
3896
  * Compute a square-safe 16:9 crop region in pixel space.
@@ -3426,6 +4013,34 @@ function caption(className, confidence, label) {
3426
4013
  return typeof confidence === "number" ? `${base} ${Math.round(confidence * 100)}%` : base;
3427
4014
  }
3428
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
+ /**
3429
4044
  * Generates object-event + track media FROM the decoded detection-pipeline
3430
4045
  * frame (the `frameHandle`/shm frame the detector ran on), NEVER the device
3431
4046
  * snapshot cap. Per object event it writes: a `crop` (square-safe 16:9
@@ -3485,9 +4100,21 @@ var EventMediaDispatcher = class {
3485
4100
  });
3486
4101
  return empty;
3487
4102
  }
3488
- const frameData = Buffer.isBuffer(decoded.data) ? decoded.data : Buffer.from(decoded.data);
4103
+ const frameData = Buffer.from(decoded.data);
3489
4104
  const fw = decoded.width;
3490
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
+ }
3491
4118
  for (const ev of events) await this.writeEventMedia(deviceId, frameData, fw, fh, ev, input.cropPadding);
3492
4119
  for (const tf of trackFrames) await this.writeTrackFrame(deviceId, frameData, fw, fh, tf);
3493
4120
  const storedSnapshots = [];
@@ -3713,8 +4340,264 @@ var EventMediaDispatcher = class {
3713
4340
  }
3714
4341
  };
3715
4342
  //#endregion
3716
- //#region src/pipeline-analytics/runtime/slice-throttler.ts
3717
- 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 {
3718
4601
  opts;
3719
4602
  lastWrittenAt = /* @__PURE__ */ new Map();
3720
4603
  lastWritten = /* @__PURE__ */ new Map();
@@ -4589,6 +5472,56 @@ function resolveMediaSettings(raw) {
4589
5472
  };
4590
5473
  }
4591
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
4592
5525
  //#region src/pipeline-analytics/store/identity-store.ts
4593
5526
  /**
4594
5527
  * IdentityStore — per-person identity registry for face recognition.
@@ -4638,7 +5571,7 @@ var IDENTITY_COLUMNS = [
4638
5571
  type: "TEXT"
4639
5572
  }
4640
5573
  ];
4641
- var SAMPLE_COLUMNS = [
5574
+ var SAMPLE_COLUMNS$1 = [
4642
5575
  {
4643
5576
  name: "id",
4644
5577
  type: "TEXT",
@@ -4699,7 +5632,7 @@ var IdentityStore = class {
4699
5632
  });
4700
5633
  await store.declareCollection.mutate({
4701
5634
  collection: IDENTITY_SAMPLES_COLLECTION,
4702
- columns: [...SAMPLE_COLUMNS],
5635
+ columns: [...SAMPLE_COLUMNS$1],
4703
5636
  indexes: [{
4704
5637
  name: "idx_sample_identity",
4705
5638
  columns: ["identityId"]
@@ -5134,6 +6067,38 @@ var FaceStore = class {
5134
6067
  } });
5135
6068
  }
5136
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
+ }
5137
6102
  /** Delete a single buffered face row by id (its crop media is removed by the
5138
6103
  * caller — the FaceStore owns rows, not blobs). Best-effort. */
5139
6104
  async delete(faceId) {
@@ -5405,6 +6370,27 @@ var ObjectEmbeddingStore = class {
5405
6370
  }
5406
6371
  return ids;
5407
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
+ }
5408
6394
  };
5409
6395
  //#endregion
5410
6396
  //#region src/pipeline-analytics/pipeline/face-matcher.ts
@@ -5783,6 +6769,24 @@ var FaceRecognizer = class {
5783
6769
  score: held.score
5784
6770
  }
5785
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
+ }
5786
6790
  } catch (err) {
5787
6791
  this.deps.logger.warn("FaceRecognizer faceStore insert failed", {
5788
6792
  tags: { deviceId },
@@ -5932,6 +6936,25 @@ var DetailScheduler = class {
5932
6936
  };
5933
6937
  //#endregion
5934
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
+ }
5935
6958
  /** Throttle for the per-device "detail call failed" warn — one line / minute. */
5936
6959
  var FAIL_WARN_THROTTLE_MS = 6e4;
5937
6960
  var TrackDetailDispatcher = class {
@@ -6078,12 +7101,13 @@ var TrackDetailDispatcher = class {
6078
7101
  bbox: { ...frame.bbox },
6079
7102
  className: frame.className
6080
7103
  };
7104
+ const steps = composeDetailSteps(req, (trackId) => this.deps.hasTrackLabel?.(trackId) ?? false);
6081
7105
  if (frame.frameHandle !== void 0) try {
6082
7106
  const primary = await this.deps.runDetailSubtree({
6083
7107
  deviceId,
6084
7108
  frameHandle: frame.frameHandle,
6085
7109
  parent,
6086
- steps: [req.stepId]
7110
+ steps
6087
7111
  }, frame.nodeId);
6088
7112
  if (primary !== null) return primary.details;
6089
7113
  } catch (err) {
@@ -6103,7 +7127,7 @@ var TrackDetailDispatcher = class {
6103
7127
  deviceId,
6104
7128
  cropJpeg,
6105
7129
  parent,
6106
- steps: [req.stepId]
7130
+ steps
6107
7131
  }, frame.nodeId);
6108
7132
  if (retry !== null) return retry.details;
6109
7133
  }
@@ -6135,6 +7159,184 @@ var TrackDetailDispatcher = class {
6135
7159
  }
6136
7160
  };
6137
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
6138
7340
  //#region src/pipeline-analytics/store/plate-store.ts
6139
7341
  var PLATES_COLLECTION = "pipeline-analytics:plates";
6140
7342
  var PLATE_COLUMNS = [
@@ -6177,6 +7379,27 @@ var PLATE_COLUMNS = [
6177
7379
  name: "corrected",
6178
7380
  type: "BOOLEAN",
6179
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"
6180
7403
  }
6181
7404
  ];
6182
7405
  var PLATE_INDEXES = [{
@@ -6226,7 +7449,12 @@ var PlateStore = class {
6226
7449
  ...data,
6227
7450
  score: Number(r.data.score ?? 0),
6228
7451
  corrected: Boolean(r.data.corrected),
6229
- mediaKey: data.mediaKey ?? void 0
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
6230
7458
  };
6231
7459
  }
6232
7460
  /** Recent plate reads for one device (or all when deviceId omitted), newest first. */
@@ -6290,63 +7518,443 @@ var PlateStore = class {
6290
7518
  collection: PLATES_COLLECTION,
6291
7519
  key: plateId
6292
7520
  });
6293
- } catch (err) {
6294
- this.logger.warn("PlateStore.delete failed", { meta: {
6295
- plateId,
6296
- error: String(err)
6297
- } });
7521
+ } catch (err) {
7522
+ this.logger.warn("PlateStore.delete failed", { meta: {
7523
+ plateId,
7524
+ error: String(err)
7525
+ } });
7526
+ }
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
+ }
7598
+ /**
7599
+ * Retention across ALL devices: delete reads older than `cutoffMs` AND any
7600
+ * beyond the newest `maxPerDevice` per device. Corrected reads are exempt
7601
+ * (human-confirmed). Returns deleted ids so the caller removes their crops.
7602
+ */
7603
+ async pruneAll(input) {
7604
+ const rows = await this.store.query.query({
7605
+ collection: PLATES_COLLECTION,
7606
+ filter: { orderBy: {
7607
+ field: "timestamp",
7608
+ direction: "desc"
7609
+ } }
7610
+ });
7611
+ const byDevice = /* @__PURE__ */ new Map();
7612
+ for (const r of rows) {
7613
+ const data = r.data;
7614
+ if (data.corrected || data.assigned) continue;
7615
+ const deviceId = Number(data.deviceId ?? -1);
7616
+ const list = byDevice.get(deviceId) ?? [];
7617
+ list.push({
7618
+ id: r.id,
7619
+ timestamp: Number(data.timestamp ?? 0)
7620
+ });
7621
+ byDevice.set(deviceId, list);
7622
+ }
7623
+ const toDelete = /* @__PURE__ */ new Set();
7624
+ for (const list of byDevice.values()) {
7625
+ list.sort((a, b) => b.timestamp - a.timestamp);
7626
+ list.forEach((row, idx) => {
7627
+ if (row.timestamp < input.cutoffMs) toDelete.add(row.id);
7628
+ if (idx >= input.maxPerDevice) toDelete.add(row.id);
7629
+ });
7630
+ }
7631
+ const deleted = [];
7632
+ for (const id of toDelete) try {
7633
+ await this.store.delete.mutate({
7634
+ collection: PLATES_COLLECTION,
7635
+ key: id
7636
+ });
7637
+ deleted.push(id);
7638
+ } catch (err) {
7639
+ this.logger.warn("PlateStore.pruneAll delete failed", { meta: {
7640
+ plateId: id,
7641
+ error: String(err)
7642
+ } });
7643
+ }
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
+ });
6298
7854
  }
7855
+ return sample;
6299
7856
  }
6300
- /**
6301
- * Retention across ALL devices: delete reads older than `cutoffMs` AND any
6302
- * beyond the newest `maxPerDevice` per device. Corrected reads are exempt
6303
- * (human-confirmed). Returns deleted ids so the caller removes their crops.
6304
- */
6305
- async pruneAll(input) {
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() {
6306
7895
  const rows = await this.store.query.query({
6307
- collection: PLATES_COLLECTION,
6308
- filter: { orderBy: {
6309
- field: "timestamp",
6310
- direction: "desc"
6311
- } }
7896
+ collection: VEHICLE_SAMPLES_COLLECTION,
7897
+ filter: {}
6312
7898
  });
6313
- const byDevice = /* @__PURE__ */ new Map();
7899
+ const gallery = [];
6314
7900
  for (const r of rows) {
6315
- const data = r.data;
6316
- if (data.corrected) continue;
6317
- const deviceId = Number(data.deviceId ?? -1);
6318
- const list = byDevice.get(deviceId) ?? [];
6319
- list.push({
6320
- id: r.id,
6321
- timestamp: Number(data.timestamp ?? 0)
6322
- });
6323
- byDevice.set(deviceId, list);
6324
- }
6325
- const toDelete = /* @__PURE__ */ new Set();
6326
- for (const list of byDevice.values()) {
6327
- list.sort((a, b) => b.timestamp - a.timestamp);
6328
- list.forEach((row, idx) => {
6329
- if (row.timestamp < input.cutoffMs) toDelete.add(row.id);
6330
- if (idx >= input.maxPerDevice) toDelete.add(row.id);
6331
- });
6332
- }
6333
- const deleted = [];
6334
- for (const id of toDelete) try {
6335
- await this.store.delete.mutate({
6336
- collection: PLATES_COLLECTION,
6337
- key: id
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
6338
7906
  });
6339
- deleted.push(id);
6340
- } catch (err) {
6341
- this.logger.warn("PlateStore.pruneAll delete failed", { meta: {
6342
- plateId: id,
6343
- error: String(err)
6344
- } });
6345
7907
  }
6346
- return deleted;
7908
+ return gallery;
6347
7909
  }
6348
7910
  };
6349
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
6350
7958
  //#region src/pipeline-analytics/plate-recognizer.ts
6351
7959
  /**
6352
7960
  * PlateRecognizer — buffers the best license-plate OCR read per vehicle track
@@ -6358,8 +7966,38 @@ var PlateStore = class {
6358
7966
  var PlateRecognizer = class {
6359
7967
  deps;
6360
7968
  bestPlate = /* @__PURE__ */ new Map();
7969
+ gallery = [];
7970
+ nameById = /* @__PURE__ */ new Map();
7971
+ matchOpts;
6361
7972
  constructor(deps) {
6362
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;
6363
8001
  }
6364
8002
  async processFrame(input) {
6365
8003
  const minConfidence = input.minConfidence ?? 0;
@@ -6415,6 +8053,8 @@ var PlateRecognizer = class {
6415
8053
  }
6416
8054
  });
6417
8055
  }
8056
+ const match = this.matchVehicle(held.text, held.score);
8057
+ const keyFrameMediaKey = this.deps.getKeyFrameMediaKey?.(trackId);
6418
8058
  try {
6419
8059
  await this.deps.plateStore.insert({
6420
8060
  id: plateId,
@@ -6424,7 +8064,16 @@ var PlateRecognizer = class {
6424
8064
  text: normalizePlate(held.text),
6425
8065
  score: held.score,
6426
8066
  ...mediaKey !== void 0 ? { mediaKey } : {},
6427
- 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"
6428
8077
  });
6429
8078
  this.deps.logger.info("plate: buffered to gallery", {
6430
8079
  tags: {
@@ -6435,7 +8084,8 @@ var PlateRecognizer = class {
6435
8084
  plateId,
6436
8085
  text: normalizePlate(held.text),
6437
8086
  score: held.score,
6438
- hasCrop: mediaKey !== void 0
8087
+ hasCrop: mediaKey !== void 0,
8088
+ recognizedVehicleId: match?.vehicleId ?? null
6439
8089
  }
6440
8090
  });
6441
8091
  } catch (err) {
@@ -6812,8 +8462,11 @@ var POST_PROCESSING_NODE_DEFAULT = "hub";
6812
8462
  * Absent / empty / non-string all fall back to the hub default — the exact
6813
8463
  * narrowing the old raw read applied inline. */
6814
8464
  var PostProcessingNodeIdSchema = string().min(1);
8465
+ var EmbeddingEnabledSchema = boolean();
6815
8466
  var SILENCE_FLOOR_DBFS = -55;
6816
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;
6817
8470
  var AUDIO_EVENT_HEARTBEAT_MS = 5e3;
6818
8471
  var MOTION_EVENT_HEARTBEAT_MS = 5e3;
6819
8472
  /**
@@ -6873,12 +8526,17 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
6873
8526
  faceStore = null;
6874
8527
  faceRecognizer = null;
6875
8528
  plateStore = null;
8529
+ vehicleStore = null;
6876
8530
  plateRecognizer = null;
6877
8531
  objectEmbeddingStore = null;
6878
8532
  /** Two-plane detail scheduler/dispatcher: per-track on-demand face/clip/
6879
8533
  * classifier/plate enrichment via `pipelineRunner.runDetailSubtree`. Replaces
6880
8534
  * the per-frame child consumption the executor no longer emits. */
6881
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();
6882
8540
  /** Active CLIP image-embedding model id (== the text encoder's), TTL-cached.
6883
8541
  * Stamped on object-embedding rows from the detail plane so semantic search's
6884
8542
  * same-model gate keeps matching. */
@@ -6890,6 +8548,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
6890
8548
  /** Shared shm-ring reader cache for resolving `frameHandle`s to pixels.
6891
8549
  * Owned here so segments stay open across frames; closed once on shutdown. */
6892
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;
6893
8557
  bindingCache = null;
6894
8558
  zoneAnalytics = null;
6895
8559
  audioMetrics = null;
@@ -6940,6 +8604,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
6940
8604
  hysteresis: BEST_FRAME_HYSTERESIS,
6941
8605
  minGapMs: BEST_FRAME_MIN_GAP_MS
6942
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();
6943
8611
  /** Best (highest-confidence) CLIP-object detection per track — drives ONE
6944
8612
  * tight object-crop capture whose media key is written onto the embedding
6945
8613
  * row (so a semantic-search hit's thumbnail IS the embedded crop). Shares the
@@ -6951,6 +8619,11 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
6951
8619
  * at track end so a face row links to the SAME single key frame. Cleared on
6952
8620
  * track end. */
6953
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();
6954
8627
  /** The shared crop extractor (native-res first, detection-frame fallback),
6955
8628
  * captured in the constructor so `processFrame` can crop object thumbnails in
6956
8629
  * the same live-frame window as the face/plate/event-media captures. The
@@ -6966,6 +8639,15 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
6966
8639
  if (!this._postProcessingNodeState) this._postProcessingNodeState = this.state("postProcessingNodeId", PostProcessingNodeIdSchema, POST_PROCESSING_NODE_DEFAULT);
6967
8640
  return this._postProcessingNodeState;
6968
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
+ }
6969
8651
  /** GLOBAL face-recognition master switch (`enabled` key). Schema +
6970
8652
  * fallback derived from the face-settings source of truth. */
6971
8653
  _faceGlobalEnabledState = null;
@@ -6985,6 +8667,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
6985
8667
  await IdentityStore.declare(api.settingsStore);
6986
8668
  await FaceStore.declare(api.settingsStore);
6987
8669
  await PlateStore.declare(api.settingsStore);
8670
+ await VehicleStore.declare(api.settingsStore);
6988
8671
  await ObjectEmbeddingStore.declare(api.settingsStore);
6989
8672
  const logger = this.ctx.logger;
6990
8673
  let storage = this.ctx.kernel.storage;
@@ -7006,7 +8689,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
7006
8689
  });
7007
8690
  this.eventStore = new EventStore({
7008
8691
  store: api.settingsStore,
7009
- logger: logger.child("EventStore")
8692
+ logger: logger.child("EventStore"),
8693
+ media: this.mediaStore
7010
8694
  });
7011
8695
  this.identityStore = new IdentityStore({
7012
8696
  store: api.settingsStore,
@@ -7020,6 +8704,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
7020
8704
  store: api.settingsStore,
7021
8705
  logger: logger.child("PlateStore")
7022
8706
  });
8707
+ this.vehicleStore = new VehicleStore({
8708
+ store: api.settingsStore,
8709
+ logger: logger.child("VehicleStore")
8710
+ });
7023
8711
  const rawNodeId = this.ctx.kernel.localNodeId ?? this.ctx.id;
7024
8712
  const ownNodeId = rawNodeId.includes("/") ? rawNodeId.split("/")[0] : rawNodeId;
7025
8713
  {
@@ -7129,15 +8817,20 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
7129
8817
  }, trackId);
7130
8818
  },
7131
8819
  getKeyFrameMediaKey: (trackId) => this.keyFrameKeyByTrackId.get(trackId),
8820
+ emitFaceGalleryChanged: (payload) => this.emitFaceGalleryChanged(payload),
7132
8821
  logger: logger.child("FaceRecognizer")
7133
8822
  });
7134
8823
  this.faceRecognizer.refreshGallery();
7135
8824
  this.plateRecognizer = new PlateRecognizer({
7136
8825
  plateStore: this.plateStore,
8826
+ vehicleStore: this.vehicleStore,
7137
8827
  mediaStore: this.mediaStore,
7138
8828
  captureCrop,
8829
+ getKeyFrameMediaKey: (trackId) => this.keyFrameKeyByTrackId.get(trackId),
8830
+ emitPlateGalleryChanged: (payload) => this.emitPlateGalleryChanged(payload),
7139
8831
  logger: logger.child("PlateRecognizer")
7140
8832
  });
8833
+ this.plateRecognizer.refreshGallery();
7141
8834
  this.objectEmbeddingStore = new ObjectEmbeddingStore({
7142
8835
  store: api.settingsStore,
7143
8836
  logger: logger.child("ObjectEmbeddingStore")
@@ -7155,6 +8848,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
7155
8848
  if (frame.frameHandle === void 0 || !this.captureCrop) return null;
7156
8849
  const buf = await this.captureCrop(frame.frameHandle, { ...frame.bbox }, frame.frameWidth, frame.frameHeight, DETAIL_FALLBACK_CROP_PADDING);
7157
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;
7158
8855
  }
7159
8856
  });
7160
8857
  this.bindingCache = new BindingCache({
@@ -7222,12 +8919,37 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
7222
8919
  const data = ev.data;
7223
8920
  this.handleNativeDetection(data);
7224
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)");
7225
8945
  }
7226
8946
  this.unsubBindings = this.ctx.eventBus.subscribe({ category: EventCategory.DeviceBindingsChanged }, (ev) => {
7227
8947
  const data = ev.data;
7228
8948
  this.bindingCache?.onBindingsChanged(data);
7229
8949
  if (data.capName === "pipeline-analytics" && data.reason === "wrapper-deactivated") {
7230
8950
  this.trackStore?.clearDevice(data.deviceId);
8951
+ this.overlayState.clearDevice(data.deviceId);
8952
+ this.overlaySynthesisWarnAt.delete(data.deviceId);
7231
8953
  this.forgetDeviceProcessors(data.deviceId);
7232
8954
  this.levelStateByDevice.delete(data.deviceId);
7233
8955
  this.settingsCacheByDevice.delete(data.deviceId);
@@ -7240,6 +8962,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
7240
8962
  this.unsubDeviceUnreg = this.ctx.eventBus.subscribe({ category: EventCategory.DeviceUnregistered }, (ev) => {
7241
8963
  const { deviceId } = ev.data;
7242
8964
  this.trackStore?.clearDevice(deviceId);
8965
+ this.overlayState.clearDevice(deviceId);
8966
+ this.overlaySynthesisWarnAt.delete(deviceId);
7243
8967
  this.forgetDeviceProcessors(deviceId);
7244
8968
  this.levelStateByDevice.delete(deviceId);
7245
8969
  this.settingsCacheByDevice.delete(deviceId);
@@ -7257,6 +8981,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
7257
8981
  }, TTL_SWEEP_INTERVAL_MS);
7258
8982
  this.retentionSweepTimer = setInterval(() => {
7259
8983
  this.sweepRetention();
8984
+ this.runTrackRetentionSweep();
7260
8985
  }, RETENTION_SWEEP_INTERVAL_MS);
7261
8986
  this.ctx.logger.info("pipeline-analytics subscribers installed");
7262
8987
  const widgetsProvider = { listWidgets: async () => [
@@ -7497,11 +9222,19 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
7497
9222
  mediaStore: this.mediaStore,
7498
9223
  trackStore: this.trackStore,
7499
9224
  eventStore: this.eventStore,
7500
- refreshGallery: () => this.faceRecognizer?.refreshGallery()
9225
+ refreshGallery: () => this.faceRecognizer?.refreshGallery(),
9226
+ emitFaceGalleryChanged: (payload) => this.emitFaceGalleryChanged(payload)
7501
9227
  });
7502
9228
  const plateGallery = new PlateGalleryProvider({
7503
9229
  plateStore: this.plateStore,
7504
- 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)
7505
9238
  });
7506
9239
  return [
7507
9240
  {
@@ -7550,6 +9283,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
7550
9283
  this.unsubBindings = null;
7551
9284
  this.unsubDeviceUnreg?.();
7552
9285
  this.unsubDeviceUnreg = null;
9286
+ await this.embeddingDispatcher?.stop();
9287
+ this.embeddingDispatcher = null;
7553
9288
  for (const id of this.proxies.keys()) this.releaseProxy(id);
7554
9289
  if (this.ttlSweepTimer) {
7555
9290
  clearInterval(this.ttlSweepTimer);
@@ -7563,10 +9298,13 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
7563
9298
  this.audioMetrics?.destroy();
7564
9299
  this.detailDispatcher?.dispose();
7565
9300
  this.detailDispatcher = null;
9301
+ this.overlayState.clear();
9302
+ this.overlaySynthesisWarnAt.clear();
7566
9303
  this.processors.clear();
7567
9304
  this.lastActiveTrackIds.clear();
7568
9305
  this.dropoutSkipsByKey.clear();
7569
9306
  this.bestFrameTracker.clear();
9307
+ this.trackLifecycleUpdateMem.clear();
7570
9308
  this.objectEmbeddingBestSelector.clear();
7571
9309
  this.levelStateByDevice.clear();
7572
9310
  this.settingsCacheByDevice.clear();
@@ -7698,6 +9436,24 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
7698
9436
  className: t.className
7699
9437
  }
7700
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
+ });
7701
9457
  }
7702
9458
  }
7703
9459
  let lostTrackCount = 0;
@@ -7761,7 +9517,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
7761
9517
  const mediaSettings = await this.resolveDeviceMediaSettings(deviceId);
7762
9518
  if (this.eventMediaDispatcher && frameHandle) {
7763
9519
  const childCropsByEvent = buildEventChildCrops(result.objectEvents, frame.detections);
7764
- 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) => {
7765
9521
  const childCrops = childCropsByEvent.get(e.id);
7766
9522
  return {
7767
9523
  eventId: e.id,
@@ -7777,15 +9533,25 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
7777
9533
  let plateCrops = 0;
7778
9534
  for (const crops of childCropsByEvent.values()) for (const c of crops) if (c.kind === "faceCrop") faceCrops += 1;
7779
9535
  else plateCrops += 1;
7780
- const snapshotTargets = this.buildSnapshotTargets(result.tracked, result.timestamp, mediaSettings);
9536
+ const snapshotTargets = this.buildSnapshotTargets(deviceId, result.tracked, result.timestamp, mediaSettings);
7781
9537
  if (eventTargets.length > 0 || firstFrameTargets.length > 0 || snapshotTargets.length > 0) {
7782
- log.info("media capture", { meta: {
7783
- source,
9538
+ const captureCounts = {
7784
9539
  events: eventTargets.length,
7785
9540
  trackFrames: firstFrameTargets.length,
7786
9541
  snapshots: snapshotTargets.length,
7787
9542
  faceCrops,
7788
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
7789
9555
  } });
7790
9556
  this.eventMediaDispatcher.captureForFrame({
7791
9557
  deviceId,
@@ -7848,6 +9614,26 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
7848
9614
  zones: e.zones ?? []
7849
9615
  }
7850
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
+ }
7851
9637
  this.ctx.eventBus.emit({
7852
9638
  id: `pa-${randomUUID()}`,
7853
9639
  timestamp: new Date(result.timestamp),
@@ -7862,7 +9648,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
7862
9648
  timestamp: result.timestamp,
7863
9649
  frameWidth: result.frameWidth,
7864
9650
  frameHeight: result.frameHeight,
7865
- detections: result.tracked
9651
+ detections: overlayDetections
7866
9652
  }
7867
9653
  });
7868
9654
  }
@@ -7952,9 +9738,25 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
7952
9738
  */
7953
9739
  async routeDetailResults(deviceId, trackId, details, frame) {
7954
9740
  for (const d of details) try {
7955
- if (d.className === "face" && d.embedding !== void 0) await this.routeFaceDetail(deviceId, trackId, d, frame);
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);
7956
9749
  else if (d.embedding !== void 0) await this.routeClipDetail(deviceId, trackId, d, frame.timestamp);
7957
- else if (d.label !== void 0 && d.label.length > 0) await this.applyTrackEnrichmentLabel(deviceId, trackId, d.label);
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
+ }
7958
9760
  } catch (err) {
7959
9761
  this.ctx.logger.warn("detail result route failed", {
7960
9762
  tags: { deviceId },
@@ -8162,12 +9964,67 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
8162
9964
  });
8163
9965
  }));
8164
9966
  }
8165
- 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) {
8166
10022
  const targets = [];
8167
10023
  for (const t of tracked) {
8168
10024
  const lastSnap = this.trackStore.lastSnapshotAt(t.trackId);
8169
10025
  const dueSnapshot = media.saveThumbnails && lastSnap > 0 && timestamp - lastSnap >= media.snapshotIntervalMs;
8170
10026
  const isNewBest = this.bestFrameTracker.observe(t.trackId, t.confidence, timestamp);
10027
+ this.maybeEmitTrackLifecycleUpdate(deviceId, t, timestamp, isNewBest);
8171
10028
  if (!dueSnapshot && !isNewBest) continue;
8172
10029
  targets.push({
8173
10030
  trackId: t.trackId,
@@ -8433,6 +10290,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
8433
10290
  positions: t.positions.length
8434
10291
  }
8435
10292
  });
10293
+ const keyFrameMediaKey = this.keyFrameKeyByTrackId.get(t.trackId);
8436
10294
  const faceEnd = this.faceRecognizer?.onTrackEnd(t.deviceId, t.trackId);
8437
10295
  const dropKeyFrameKey = () => {
8438
10296
  this.keyFrameKeyByTrackId.delete(t.trackId);
@@ -8440,11 +10298,16 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
8440
10298
  if (faceEnd) faceEnd.finally(dropKeyFrameKey);
8441
10299
  else dropKeyFrameKey();
8442
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;
8443
10305
  try {
8444
10306
  const peak = await this.eventStore?.peakForTrack(t.trackId);
8445
10307
  if (peak) {
10308
+ endBestEventId = peak.bestEventId;
8446
10309
  const { importance, reason } = computeImportance({
8447
- peakConfidence: this.bestFrameTracker.peak(t.trackId)?.confidence ?? peak.peakConfidence,
10310
+ peakConfidence: trackerPeak?.confidence ?? peak.peakConfidence,
8448
10311
  className: t.className,
8449
10312
  durationMs: duration,
8450
10313
  peakBboxAreaFrac: peak.peakBboxAreaFrac,
@@ -8452,6 +10315,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
8452
10315
  zonesVisited: t.zonesVisited,
8453
10316
  ...t.label !== void 0 ? { label: t.label } : {}
8454
10317
  });
10318
+ endImportance = importance;
10319
+ endImportanceReason = reason;
8455
10320
  await this.trackStore?.setImportance(t.trackId, importance, reason, peak.bestEventId);
8456
10321
  if (peak.bestEventId !== void 0) await this.eventStore?.setImportanceForTrack(t.trackId, importance);
8457
10322
  }
@@ -8464,6 +10329,18 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
8464
10329
  this.bestFrameTracker.delete(t.trackId);
8465
10330
  this.objectEmbeddingBestSelector.delete(t.trackId);
8466
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
+ }
8467
10344
  this.ctx.eventBus.emit({
8468
10345
  id: `pa-end-${t.trackId}`,
8469
10346
  timestamp: new Date(t.lastSeen),
@@ -8480,6 +10357,26 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
8480
10357
  durationMs: duration
8481
10358
  }
8482
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);
8483
10380
  }
8484
10381
  } catch (err) {
8485
10382
  if (this.shuttingDown) return;
@@ -8571,6 +10468,62 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
8571
10468
  if (!frame) return;
8572
10469
  await this.processFrame(payload.cameraId, frame, "onboard");
8573
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
+ }
8574
10527
  /** Composite key for the per-(device, source) processor + track maps. */
8575
10528
  procKey(deviceId, source) {
8576
10529
  return `${deviceId}:${source}`;
@@ -8670,6 +10623,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
8670
10623
  }
8671
10624
  async clearTracks(input) {
8672
10625
  this.trackStore?.clearDevice(input.deviceId);
10626
+ this.overlayState.clearDevice(input.deviceId);
10627
+ this.overlaySynthesisWarnAt.delete(input.deviceId);
8673
10628
  const prefix = `${input.deviceId}:`;
8674
10629
  for (const k of this.lastActiveTrackIds.keys()) if (k.startsWith(prefix)) this.lastActiveTrackIds.delete(k);
8675
10630
  }
@@ -8846,6 +10801,168 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
8846
10801
  return counts;
8847
10802
  }
8848
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
+ /**
8849
10966
  * Decode a stored event image into `EventMedia` for the data-plane handler.
8850
10967
  * Preference order: `crop` (square-safe 16:9 preview) → `fullFrameBoxed`
8851
10968
  * (native-res boxed frame) → any available file. Returns `null` if the
@@ -8906,6 +11023,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
8906
11023
  label: "Post-processing node",
8907
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.",
8908
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
8909
11032
  }]
8910
11033
  },
8911
11034
  {
@@ -9012,9 +11135,20 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
9012
11135
  {
9013
11136
  id: "retention",
9014
11137
  title: "Retention",
9015
- 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.",
9016
11139
  columns: 3,
9017
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
+ },
9018
11152
  {
9019
11153
  type: "number",
9020
11154
  key: "retentionMotionDays",