@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.
@@ -2,12 +2,11 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-CtnFKuWh.js");
6
- const require_resolve_frame = require("../resolve-frame-BAdpVnlx.js");
7
- let _camstack_shm_ring = require("@camstack/shm-ring");
5
+ const require_dist = require("../dist-Bnb58pyL.js");
6
+ let node_crypto = require("node:crypto");
8
7
  let sharp = require("sharp");
9
8
  sharp = require_dist.__toESM(sharp);
10
- let node_crypto = require("node:crypto");
9
+ let _camstack_shm_ring = require("@camstack/shm-ring");
11
10
  //#region src/pipeline-analytics/videoclips-provider.ts
12
11
  var SOURCE = "analytics";
13
12
  function clipIdFor(eventId, startMs, endMs) {
@@ -266,6 +265,7 @@ var FaceGalleryProvider = class {
266
265
  trackStore;
267
266
  eventStore;
268
267
  refreshGallery;
268
+ emitFaceGalleryChanged;
269
269
  constructor(deps) {
270
270
  this.identityStore = deps.identityStore;
271
271
  this.faceStore = deps.faceStore;
@@ -273,6 +273,13 @@ var FaceGalleryProvider = class {
273
273
  this.trackStore = deps.trackStore;
274
274
  this.eventStore = deps.eventStore;
275
275
  this.refreshGallery = deps.refreshGallery;
276
+ this.emitFaceGalleryChanged = deps.emitFaceGalleryChanged;
277
+ }
278
+ /** Best-effort emit — never allowed to fail the mutation it follows. */
279
+ safeEmitFaceGalleryChanged(payload) {
280
+ try {
281
+ this.emitFaceGalleryChanged(payload);
282
+ } catch {}
276
283
  }
277
284
  async listIdentities() {
278
285
  const rows = await this.identityStore.listIdentities();
@@ -491,6 +498,11 @@ var FaceGalleryProvider = class {
491
498
  }, currentFace.trackId);
492
499
  }
493
500
  this.refreshGallery();
501
+ this.safeEmitFaceGalleryChanged({
502
+ deviceId: currentFace.deviceId,
503
+ faceId,
504
+ kind: "assigned"
505
+ });
494
506
  }
495
507
  /**
496
508
  * Unassign a buffered face from its identity:
@@ -521,6 +533,11 @@ var FaceGalleryProvider = class {
521
533
  await this.eventStore.clearLabelForTrack(face.trackId);
522
534
  await this.trackStore.clearLabel(face.trackId);
523
535
  this.refreshGallery();
536
+ this.safeEmitFaceGalleryChanged({
537
+ deviceId: face.deviceId,
538
+ faceId,
539
+ kind: "unassigned"
540
+ });
524
541
  }
525
542
  /**
526
543
  * Delete a buffered face entirely (row + its crop media). If the face is
@@ -534,6 +551,11 @@ var FaceGalleryProvider = class {
534
551
  await this.mediaStore.deleteForOwner("face", [input.faceId]);
535
552
  await this.faceStore.delete(input.faceId);
536
553
  this.refreshGallery();
554
+ this.safeEmitFaceGalleryChanged({
555
+ deviceId: face.deviceId,
556
+ faceId: input.faceId,
557
+ kind: "deleted"
558
+ });
537
559
  }
538
560
  /** Batch-assign many faces to one identity. Per-face failures are collected,
539
561
  * not thrown, so one bad face doesn't abort the rest. */
@@ -691,10 +713,26 @@ function clusterByText(items, maxDistance = 1) {
691
713
  //#region src/pipeline-analytics/plate-gallery-provider.ts
692
714
  var PlateGalleryProvider = class {
693
715
  plateStore;
716
+ vehicleStore;
694
717
  mediaStore;
718
+ trackStore;
719
+ eventStore;
720
+ refreshGallery;
721
+ emitPlateGalleryChanged;
695
722
  constructor(deps) {
696
723
  this.plateStore = deps.plateStore;
724
+ this.vehicleStore = deps.vehicleStore;
697
725
  this.mediaStore = deps.mediaStore;
726
+ this.trackStore = deps.trackStore;
727
+ this.eventStore = deps.eventStore;
728
+ this.refreshGallery = deps.refreshGallery;
729
+ this.emitPlateGalleryChanged = deps.emitPlateGalleryChanged;
730
+ }
731
+ /** Best-effort emit — never allowed to fail the mutation it follows. */
732
+ safeEmitPlateGalleryChanged(payload) {
733
+ try {
734
+ this.emitPlateGalleryChanged(payload);
735
+ } catch {}
698
736
  }
699
737
  async plateCropBase64(plate) {
700
738
  if (plate.mediaKey) {
@@ -704,8 +742,9 @@ var PlateGalleryProvider = class {
704
742
  const media = await this.mediaStore.listByOwner("plate", plate.id);
705
743
  return media.length > 0 ? media[0].base64 : void 0;
706
744
  }
707
- async toPlateInfo(plate) {
745
+ async toPlateInfo(plate, nameMap) {
708
746
  const base64 = await this.plateCropBase64(plate);
747
+ const vehicleName = plate.recognizedVehicleId != null ? nameMap?.get(plate.recognizedVehicleId) : void 0;
709
748
  return {
710
749
  plateId: plate.id,
711
750
  deviceId: plate.deviceId,
@@ -714,20 +753,31 @@ var PlateGalleryProvider = class {
714
753
  text: plate.text,
715
754
  score: plate.score,
716
755
  corrected: plate.corrected,
756
+ assigned: plate.assigned,
757
+ ...plate.recognizedVehicleId != null ? { recognizedVehicleId: plate.recognizedVehicleId } : {},
758
+ ...vehicleName !== void 0 ? { vehicleName } : {},
759
+ ...plate.plateBbox !== void 0 ? { plateBbox: plate.plateBbox } : {},
760
+ ...plate.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: plate.keyFrameMediaKey } : {},
717
761
  ...base64 !== void 0 ? { base64 } : {}
718
762
  };
719
763
  }
764
+ async vehicleNameMap() {
765
+ const vehicles = await this.vehicleStore.listVehicles();
766
+ return new Map(vehicles.map((v) => [v.id, v.name]));
767
+ }
720
768
  async listPlates(input) {
721
769
  const rows = await this.plateStore.listRecentPlates({
722
770
  ...input?.deviceId !== void 0 ? { deviceId: input.deviceId } : {},
723
771
  ...input?.limit !== void 0 ? { limit: input.limit } : {}
724
772
  });
725
- return Promise.all(rows.map((p) => this.toPlateInfo(p)));
773
+ const nameMap = await this.vehicleNameMap();
774
+ return Promise.all(rows.map((p) => this.toPlateInfo(p, nameMap)));
726
775
  }
727
776
  async getPlateByTrack(input) {
728
777
  const plate = await this.plateStore.get(`plate-${input.trackId}`);
729
778
  if (!plate || plate.deviceId !== input.deviceId) return null;
730
- return this.toPlateInfo(plate);
779
+ const nameMap = await this.vehicleNameMap();
780
+ return this.toPlateInfo(plate, nameMap);
731
781
  }
732
782
  async getPlateMedia(input) {
733
783
  return (await this.mediaStore.listByOwner("plate", input.plateId)).map((m) => ({
@@ -745,7 +795,8 @@ var PlateGalleryProvider = class {
745
795
  dist: plateDistance(input.text, p.text)
746
796
  })).filter((x) => x.dist <= maxDistance).toSorted((a, b) => a.dist - b.dist || b.p.timestamp - a.p.timestamp);
747
797
  const limited = input.limit !== void 0 ? scored.slice(0, input.limit) : scored;
748
- return Promise.all(limited.map((x) => this.toPlateInfo(x.p)));
798
+ const nameMap = await this.vehicleNameMap();
799
+ return Promise.all(limited.map((x) => this.toPlateInfo(x.p, nameMap)));
749
800
  }
750
801
  async suggestPlateClusters(input) {
751
802
  const maxDistance = input?.maxDistance ?? 1;
@@ -764,8 +815,203 @@ var PlateGalleryProvider = class {
764
815
  });
765
816
  }
766
817
  async deletePlate(input) {
818
+ const plate = await this.plateStore.get(input.plateId);
819
+ if (plate?.assigned) await this.unassignPlate({ plateId: input.plateId });
767
820
  await this.plateStore.delete(input.plateId);
768
821
  await this.mediaStore.deleteForOwner("plate", [input.plateId]);
822
+ this.refreshGallery();
823
+ if (plate) this.safeEmitPlateGalleryChanged({
824
+ deviceId: plate.deviceId,
825
+ plateId: input.plateId,
826
+ kind: "deleted"
827
+ });
828
+ }
829
+ async listVehicles() {
830
+ const rows = await this.vehicleStore.listVehicles();
831
+ const result = [];
832
+ for (const r of rows) {
833
+ let coverKey = r.coverMediaKey ?? void 0;
834
+ if (!coverKey) coverKey = (await this.vehicleStore.listSamples(r.id)).find((s) => s.mediaKey)?.mediaKey;
835
+ let coverBase64;
836
+ if (coverKey) {
837
+ const media = await this.mediaStore.getByKey(coverKey);
838
+ if (media) coverBase64 = media.base64;
839
+ }
840
+ result.push({
841
+ id: r.id,
842
+ name: r.name,
843
+ sampleCount: r.sampleCount,
844
+ ...coverKey != null ? { coverMediaKey: coverKey } : {},
845
+ ...coverBase64 !== void 0 ? { coverBase64 } : {}
846
+ });
847
+ }
848
+ return result;
849
+ }
850
+ async createVehicle(input) {
851
+ const raw = await this.vehicleStore.createVehicle({ name: input.name });
852
+ this.refreshGallery();
853
+ return {
854
+ id: raw.id,
855
+ name: raw.name,
856
+ sampleCount: raw.sampleCount,
857
+ ...raw.coverMediaKey !== void 0 ? { coverMediaKey: raw.coverMediaKey } : {}
858
+ };
859
+ }
860
+ async renameVehicle(input) {
861
+ await this.vehicleStore.renameVehicle(input.id, input.name);
862
+ }
863
+ async deleteVehicle(input) {
864
+ const plates = await this.plateStore.listAllRecentPlates({});
865
+ for (const p of plates) if (p.recognizedVehicleId === input.id) await this.unassignPlate({ plateId: p.id });
866
+ await this.mediaStore.deleteForOwner("vehicle", [input.id]);
867
+ await this.vehicleStore.deleteVehicle(input.id);
868
+ this.refreshGallery();
869
+ }
870
+ async listVehicleSamples(input) {
871
+ const rows = await this.vehicleStore.listSamples(input.vehicleId);
872
+ const result = [];
873
+ for (const row of rows) {
874
+ let base64;
875
+ if (row.mediaKey) {
876
+ const media = await this.mediaStore.getByKey(row.mediaKey);
877
+ if (media) base64 = media.base64;
878
+ }
879
+ result.push({
880
+ id: row.id,
881
+ text: row.text,
882
+ score: row.score,
883
+ addedAt: row.addedAt,
884
+ ...row.deviceId !== void 0 ? { deviceId: row.deviceId } : {},
885
+ ...base64 !== void 0 ? { base64 } : {}
886
+ });
887
+ }
888
+ return result;
889
+ }
890
+ async removeVehicleSample(input) {
891
+ const sample = (await this.vehicleStore.listSamples(input.vehicleId)).find((s) => s.id === input.sampleId);
892
+ if (sample?.mediaKey) await this.mediaStore.deleteByKey(sample.mediaKey);
893
+ await this.vehicleStore.removeSample(input.vehicleId, input.sampleId);
894
+ }
895
+ async assignPlate(input) {
896
+ const { plateId, vehicleId } = input;
897
+ const plate = await this.plateStore.get(plateId);
898
+ if (!plate) throw new Error(`PlateGalleryProvider.assignPlate: plate not found: ${plateId}`);
899
+ if (plate.assignedSampleId !== void 0 && plate.recognizedVehicleId !== void 0) {
900
+ await this.removeVehicleSample({
901
+ vehicleId: plate.recognizedVehicleId,
902
+ sampleId: plate.assignedSampleId
903
+ });
904
+ await this.plateStore.clearAssignment(plateId);
905
+ await this.plateStore.update(plateId, { mediaKey: null });
906
+ }
907
+ const current = await this.plateStore.get(plateId);
908
+ if (!current) throw new Error(`PlateGalleryProvider.assignPlate: plate disappeared: ${plateId}`);
909
+ let newKey;
910
+ if (current.mediaKey) {
911
+ newKey = await this.mediaStore.reown({
912
+ fromOwnerKind: "plate",
913
+ fromOwnerId: plateId,
914
+ toOwnerKind: "vehicle",
915
+ toOwnerId: vehicleId,
916
+ key: current.mediaKey
917
+ });
918
+ await this.plateStore.update(plateId, { mediaKey: newKey });
919
+ }
920
+ let sampleId;
921
+ try {
922
+ sampleId = (await this.vehicleStore.addSample({
923
+ vehicleId,
924
+ text: current.text,
925
+ score: current.score,
926
+ sourcePlateId: plateId,
927
+ ...newKey !== void 0 ? { mediaKey: newKey } : {},
928
+ ...current.deviceId !== void 0 ? { deviceId: current.deviceId } : {}
929
+ })).id;
930
+ await this.plateStore.markAssigned(plateId, vehicleId, sampleId);
931
+ } catch (err) {
932
+ if (newKey !== void 0) try {
933
+ await this.mediaStore.reown({
934
+ fromOwnerKind: "vehicle",
935
+ fromOwnerId: vehicleId,
936
+ toOwnerKind: "plate",
937
+ toOwnerId: plateId,
938
+ key: newKey
939
+ });
940
+ } catch {}
941
+ throw err;
942
+ }
943
+ const vehicle = (await this.vehicleStore.listVehicles()).find((v) => v.id === vehicleId);
944
+ if (vehicle?.name !== void 0) {
945
+ await this.trackStore.setLabel(current.trackId, vehicle.name);
946
+ await this.eventStore.setLabelForTrack(current.trackId, vehicle.name);
947
+ await recomputeTrackImportance({
948
+ trackStore: this.trackStore,
949
+ eventStore: this.eventStore
950
+ }, current.trackId);
951
+ }
952
+ this.refreshGallery();
953
+ this.safeEmitPlateGalleryChanged({
954
+ deviceId: current.deviceId,
955
+ plateId,
956
+ kind: "assigned"
957
+ });
958
+ }
959
+ async unassignPlate(input) {
960
+ const { plateId } = input;
961
+ const plate = await this.plateStore.get(plateId);
962
+ if (!plate) throw new Error(`PlateGalleryProvider.unassignPlate: plate not found: ${plateId}`);
963
+ const owningVehicleId = plate.recognizedVehicleId;
964
+ if (plate.mediaKey && owningVehicleId !== void 0) try {
965
+ const restoredKey = await this.mediaStore.reown({
966
+ fromOwnerKind: "vehicle",
967
+ fromOwnerId: owningVehicleId,
968
+ toOwnerKind: "plate",
969
+ toOwnerId: plateId,
970
+ key: plate.mediaKey
971
+ });
972
+ await this.plateStore.update(plateId, { mediaKey: restoredKey });
973
+ } catch {}
974
+ if (plate.assignedSampleId !== void 0 && owningVehicleId !== void 0) await this.vehicleStore.removeSample(owningVehicleId, plate.assignedSampleId);
975
+ await this.plateStore.clearAssignment(plateId);
976
+ await this.eventStore.clearLabelForTrack(plate.trackId);
977
+ await this.trackStore.clearLabel(plate.trackId);
978
+ this.refreshGallery();
979
+ this.safeEmitPlateGalleryChanged({
980
+ deviceId: plate.deviceId,
981
+ plateId,
982
+ kind: "unassigned"
983
+ });
984
+ }
985
+ async assignPlates(input) {
986
+ let assigned = 0;
987
+ const failed = [];
988
+ for (const plateId of input.plateIds) try {
989
+ await this.assignPlate({
990
+ plateId,
991
+ vehicleId: input.vehicleId
992
+ });
993
+ assigned += 1;
994
+ } catch {
995
+ failed.push(plateId);
996
+ }
997
+ return {
998
+ assigned,
999
+ failed
1000
+ };
1001
+ }
1002
+ async unassignPlates(input) {
1003
+ let unassigned = 0;
1004
+ const failed = [];
1005
+ for (const plateId of input.plateIds) try {
1006
+ await this.unassignPlate({ plateId });
1007
+ unassigned += 1;
1008
+ } catch {
1009
+ failed.push(plateId);
1010
+ }
1011
+ return {
1012
+ unassigned,
1013
+ failed
1014
+ };
769
1015
  }
770
1016
  };
771
1017
  //#endregion
@@ -853,7 +1099,6 @@ var DEFAULT_TRACKER_CONFIG = {
853
1099
  stationarySpeedPx: 2
854
1100
  };
855
1101
  var MAX_PATH_LENGTH = 300;
856
- var nextTrackId = 1;
857
1102
  function clamp(value, min, max) {
858
1103
  return Math.max(min, Math.min(max, value));
859
1104
  }
@@ -1060,7 +1305,7 @@ var SortTracker = class {
1060
1305
  const det = detections[di];
1061
1306
  if (this.config.occlusionEnabled && occluderBoxes.some((ob) => containment(det.bbox, ob) >= this.config.occlusionContainment)) continue;
1062
1307
  surviving.push({
1063
- id: `track-${nextTrackId++}`,
1308
+ id: (0, node_crypto.randomUUID)(),
1064
1309
  bbox: det.bbox,
1065
1310
  class: det.class,
1066
1311
  originalClass: det.originalClass,
@@ -1526,6 +1771,7 @@ var FrameProcessor = class {
1526
1771
  const labelsByBbox = /* @__PURE__ */ new Map();
1527
1772
  const embeddingByBbox = /* @__PURE__ */ new Map();
1528
1773
  const firstLevelBboxById = /* @__PURE__ */ new Map();
1774
+ const sourceIdByBbox = /* @__PURE__ */ new Map();
1529
1775
  const faceBboxByBbox = /* @__PURE__ */ new Map();
1530
1776
  const faceAlignedCropByBbox = /* @__PURE__ */ new Map();
1531
1777
  const plateByBbox = /* @__PURE__ */ new Map();
@@ -1554,6 +1800,7 @@ var FrameProcessor = class {
1554
1800
  height: det.maskHeight
1555
1801
  });
1556
1802
  firstLevelBboxById.set(det.id, bbox);
1803
+ sourceIdByBbox.set(bbox, det.id);
1557
1804
  return {
1558
1805
  ...bbox,
1559
1806
  detection,
@@ -1615,8 +1862,10 @@ var FrameProcessor = class {
1615
1862
  const faceBbox = faceBboxByBbox.get(td.bbox);
1616
1863
  const faceAlignedCrop = faceAlignedCropByBbox.get(td.bbox);
1617
1864
  const plate = plateByBbox.get(td.bbox);
1865
+ const sourceDetectionId = sourceIdByBbox.get(td.bbox);
1618
1866
  return {
1619
1867
  trackId: td.trackId,
1868
+ ...sourceDetectionId !== void 0 ? { sourceDetectionId } : {},
1620
1869
  className: td.class,
1621
1870
  confidence: td.score,
1622
1871
  bbox: { ...td.bbox },
@@ -1679,6 +1928,84 @@ var FrameProcessor = class {
1679
1928
  };
1680
1929
  }
1681
1930
  };
1931
+ var DEFAULT_UPDATE_GATE_CONFIG = {
1932
+ minConfDelta: .1,
1933
+ minIntervalMs: 4e3,
1934
+ minCropAreaGrowth: .15
1935
+ };
1936
+ /**
1937
+ * Decide whether a `phase:'update'` event should fire this frame.
1938
+ *
1939
+ * Priority of triggers: newly-resolved identity/plate > confidence
1940
+ * improvement > materially larger crop. All triggers are debounced by
1941
+ * `minIntervalMs` relative to the last emit. Deltas are measured against
1942
+ * the LAST-EMITTED memory, so a run of sub-threshold new-bests accumulates
1943
+ * and fires once it crosses `minConfDelta`.
1944
+ */
1945
+ function evaluateTrackLifecycleUpdate(signal, memory, config) {
1946
+ const identityTrigger = signal.label !== void 0 && signal.label !== "" && (memory === void 0 || memory.lastLabel !== signal.label);
1947
+ const confidenceTrigger = signal.isNewBest && (memory === void 0 || signal.confidence - memory.lastConfidence >= config.minConfDelta);
1948
+ const cropTrigger = signal.bboxArea !== void 0 && memory?.lastBboxArea !== void 0 && memory.lastBboxArea > 0 && signal.bboxArea >= memory.lastBboxArea * (1 + config.minCropAreaGrowth);
1949
+ const reason = identityTrigger ? "identity" : confidenceTrigger ? "confidence" : cropTrigger ? "crop" : void 0;
1950
+ const keptMemory = memory ?? {
1951
+ lastConfidence: signal.confidence,
1952
+ lastEmitAt: signal.now
1953
+ };
1954
+ if (reason === void 0) return {
1955
+ emit: false,
1956
+ memory: keptMemory
1957
+ };
1958
+ if (memory !== void 0 && signal.now - memory.lastEmitAt < config.minIntervalMs) return {
1959
+ emit: false,
1960
+ memory
1961
+ };
1962
+ const nextLabel = signal.label ?? memory?.lastLabel;
1963
+ const nextBboxArea = signal.bboxArea ?? memory?.lastBboxArea;
1964
+ return {
1965
+ emit: true,
1966
+ reason,
1967
+ memory: {
1968
+ lastConfidence: memory === void 0 ? signal.confidence : Math.max(memory.lastConfidence, signal.confidence),
1969
+ lastEmitAt: signal.now,
1970
+ ...nextLabel !== void 0 ? { lastLabel: nextLabel } : {},
1971
+ ...nextBboxArea !== void 0 ? { lastBboxArea: nextBboxArea } : {}
1972
+ }
1973
+ };
1974
+ }
1975
+ /**
1976
+ * Assemble the typed lifecycle payload. Optional fields (and the whole
1977
+ * `media` object) are omitted when absent so consumers get a clean shape.
1978
+ */
1979
+ function buildTrackLifecyclePayload(input) {
1980
+ const media = {
1981
+ ...input.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: input.keyFrameMediaKey } : {},
1982
+ ...input.bestCropMediaKey !== void 0 ? { bestCropMediaKey: input.bestCropMediaKey } : {},
1983
+ ...input.bestEventId !== void 0 ? { bestEventId: input.bestEventId } : {}
1984
+ };
1985
+ const hasMedia = Object.keys(media).length > 0;
1986
+ return {
1987
+ deviceId: input.deviceId,
1988
+ trackId: input.trackId,
1989
+ phase: input.phase,
1990
+ classes: [...input.classes],
1991
+ bestClassName: input.bestClassName,
1992
+ bestConfidence: input.bestConfidence,
1993
+ firstSeen: input.firstSeen,
1994
+ lastSeen: input.lastSeen,
1995
+ durationMs: Math.max(0, input.lastSeen - input.firstSeen),
1996
+ ...input.label !== void 0 ? { label: input.label } : {},
1997
+ ...input.identityId !== void 0 ? { identityId: input.identityId } : {},
1998
+ ...input.plateText !== void 0 ? { plateText: input.plateText } : {},
1999
+ ...input.zonesVisited !== void 0 ? { zonesVisited: [...input.zonesVisited] } : {},
2000
+ ...input.totalDistance !== void 0 ? { totalDistance: input.totalDistance } : {},
2001
+ ...input.positionsCount !== void 0 ? { positionsCount: input.positionsCount } : {},
2002
+ ...input.importance !== void 0 ? { importance: input.importance } : {},
2003
+ ...input.importanceReason !== void 0 ? { importanceReason: input.importanceReason } : {},
2004
+ ...input.embeddingId !== void 0 ? { embeddingId: input.embeddingId } : {},
2005
+ ...input.embeddingModelId !== void 0 ? { embeddingModelId: input.embeddingModelId } : {},
2006
+ ...hasMedia ? { media } : {}
2007
+ };
2008
+ }
1682
2009
  //#endregion
1683
2010
  //#region src/pipeline-analytics/pipeline/best-detection-tracker.ts
1684
2011
  var BestDetectionTracker = class {
@@ -1883,6 +2210,50 @@ function nativeDetectionsToFrame(input) {
1883
2210
  };
1884
2211
  }
1885
2212
  //#endregion
2213
+ //#region src/pipeline-analytics/pipeline/delete-track-cascade.ts
2214
+ /**
2215
+ * Extensible whole-track deletion cascade. Runs every registered
2216
+ * `TrackScopedStore` (each removes its ephemeral rows + owned media for the
2217
+ * tracks), THEN deletes the track ROOT rows LAST.
2218
+ *
2219
+ * ORDER MATTERS: every leaf store first, the track row last. The intended
2220
+ * production registry (leaves first, root last):
2221
+ *
2222
+ * [ eventStore, // object events + their event-owned media
2223
+ * mediaStore, // track + unassigned face/plate crops (NEVER identity/vehicle)
2224
+ * faceStore, // unassigned face rows (assigned/enrolled exempt)
2225
+ * plateStore, // unassigned plate reads (assigned/enrolled exempt)
2226
+ * objectEmbeddingStore, // per-track CLIP search vectors
2227
+ * ]
2228
+ */
2229
+ async function cascadeDeleteTracks(registry, trackStore, trackIds) {
2230
+ for (const store of registry) await store.deleteByTracks(trackIds);
2231
+ for (const trackId of trackIds) await trackStore.deletePersisted(trackId);
2232
+ }
2233
+ /**
2234
+ * Widened batch whole-track deletion — the ONE engine behind the three §5 entry
2235
+ * points. Runs the FULL registry cascade per track (via `cascadeDeleteTracks`
2236
+ * with a single-id list so the track root is deleted last), fires cleanup on
2237
+ * success only, and collects the ids whose cascade threw. Per-track ISOLATED —
2238
+ * one bad id never aborts the rest of the batch.
2239
+ */
2240
+ async function runTrackCascadeBatch(deps, trackIds) {
2241
+ let deleted = 0;
2242
+ const failed = [];
2243
+ for (const trackId of trackIds) try {
2244
+ await cascadeDeleteTracks(deps.registry, deps.trackStore, [trackId]);
2245
+ deps.onTrackCleanup(deps.deviceId, trackId);
2246
+ deleted += 1;
2247
+ } catch (err) {
2248
+ deps.onFailure?.(trackId, err);
2249
+ failed.push(trackId);
2250
+ }
2251
+ return {
2252
+ deleted,
2253
+ failed
2254
+ };
2255
+ }
2256
+ //#endregion
1886
2257
  //#region src/pipeline-analytics/runtime/binding-cache.ts
1887
2258
  var BindingCache = class {
1888
2259
  api;
@@ -1988,6 +2359,10 @@ var TRACKS_COLUMNS = [
1988
2359
  name: "zonesVisited",
1989
2360
  type: "JSON"
1990
2361
  },
2362
+ {
2363
+ name: "classes",
2364
+ type: "JSON"
2365
+ },
1991
2366
  {
1992
2367
  name: "totalDistance",
1993
2368
  type: "REAL"
@@ -2036,6 +2411,7 @@ function cloneTrack(t) {
2036
2411
  }
2037
2412
  })),
2038
2413
  zonesVisited: [...t.zonesVisited],
2414
+ classes: [...t.classes],
2039
2415
  totalDistance: t.totalDistance,
2040
2416
  state: t.state,
2041
2417
  active: t.active,
@@ -2080,6 +2456,7 @@ var TrackStore = class {
2080
2456
  existing.positions.push(params.position);
2081
2457
  } else existing.positions.push(params.position);
2082
2458
  for (const z of params.zones) if (!existing.zonesVisited.includes(z)) existing.zonesVisited.push(z);
2459
+ if (!existing.classes.includes(params.className)) existing.classes.push(params.className);
2083
2460
  return existing;
2084
2461
  }
2085
2462
  const fresh = {
@@ -2092,6 +2469,7 @@ var TrackStore = class {
2092
2469
  positions: [params.position],
2093
2470
  snapshots: [],
2094
2471
  zonesVisited: [...params.zones],
2472
+ classes: [params.className],
2095
2473
  totalDistance: 0,
2096
2474
  state: params.state,
2097
2475
  active: true,
@@ -2234,6 +2612,92 @@ var TrackStore = class {
2234
2612
  clearAll() {
2235
2613
  this.active.clear();
2236
2614
  }
2615
+ /** Delete the persisted track row (keyed by trackId) and drop the in-RAM
2616
+ * active entry if present. Used by the whole-track deletion cascade. */
2617
+ async deletePersisted(trackId) {
2618
+ await this.store.delete.mutate({
2619
+ collection: TRACKS_COLLECTION,
2620
+ key: trackId
2621
+ });
2622
+ this.active.delete(trackId);
2623
+ }
2624
+ /**
2625
+ * A page of persisted track ids for a device whose `lastSeen < cutoffMs`
2626
+ * (exclusive), oldest first. Feeds the `pruneTracksBefore` drain loop (design
2627
+ * §5.1): the caller cascades the returned ids (which deletes their rows) and
2628
+ * calls again until an empty page. Backed by `idx_tracks_device_lastSeen`.
2629
+ *
2630
+ * Filter shape mirrors `EventStore.pruneBefore`: inclusive `[0, cutoffMs - 1]`
2631
+ * → strictly `< cutoffMs`. Best-effort — a query failure yields [].
2632
+ */
2633
+ async listIdsBefore(deviceId, cutoffMs, limit) {
2634
+ try {
2635
+ return (await this.store.query.query({
2636
+ collection: TRACKS_COLLECTION,
2637
+ filter: {
2638
+ where: { deviceId },
2639
+ whereBetween: { lastSeen: [0, cutoffMs - 1] },
2640
+ orderBy: {
2641
+ field: "lastSeen",
2642
+ direction: "asc"
2643
+ },
2644
+ limit
2645
+ }
2646
+ })).map((r) => r.id).filter((id) => typeof id === "string");
2647
+ } catch (err) {
2648
+ this.logger.warn("TrackStore.listIdsBefore failed", { meta: {
2649
+ deviceId,
2650
+ cutoffMs,
2651
+ error: String(err)
2652
+ } });
2653
+ return [];
2654
+ }
2655
+ }
2656
+ /**
2657
+ * The distinct set of deviceIds that currently have persisted tracks. Feeds
2658
+ * the per-device retention sweep (design §6) so a device that stopped
2659
+ * producing frames still has its aged track debris pruned. Paged scan +
2660
+ * in-memory dedup (the query cap has no DISTINCT); best-effort.
2661
+ */
2662
+ async listDeviceIds() {
2663
+ const seenDevices = /* @__PURE__ */ new Set();
2664
+ const seenIds = /* @__PURE__ */ new Set();
2665
+ const PAGE = 500;
2666
+ let cursor = 0;
2667
+ try {
2668
+ for (;;) {
2669
+ const rows = await this.store.query.query({
2670
+ collection: TRACKS_COLLECTION,
2671
+ filter: {
2672
+ whereBetween: { lastSeen: [cursor, Number.MAX_SAFE_INTEGER] },
2673
+ orderBy: {
2674
+ field: "lastSeen",
2675
+ direction: "asc"
2676
+ },
2677
+ limit: PAGE
2678
+ }
2679
+ });
2680
+ if (rows.length === 0) break;
2681
+ let newInPage = 0;
2682
+ let maxLastSeen = cursor;
2683
+ for (const r of rows) {
2684
+ if (typeof r.id === "string" && !seenIds.has(r.id)) {
2685
+ seenIds.add(r.id);
2686
+ newInPage++;
2687
+ }
2688
+ const ls = Number(r.data["lastSeen"]);
2689
+ if (Number.isFinite(ls) && ls > maxLastSeen) maxLastSeen = ls;
2690
+ const deviceId = Number(r.data["deviceId"]);
2691
+ if (Number.isFinite(deviceId)) seenDevices.add(deviceId);
2692
+ }
2693
+ if (rows.length < PAGE || newInPage === 0) break;
2694
+ cursor = maxLastSeen;
2695
+ }
2696
+ } catch (err) {
2697
+ this.logger.warn("TrackStore.listDeviceIds failed", { meta: { error: String(err) } });
2698
+ }
2699
+ return [...seenDevices];
2700
+ }
2237
2701
  /** Historical query — hits the persisted collection. */
2238
2702
  async queryHistorical(params) {
2239
2703
  const filter = { where: { deviceId: params.deviceId } };
@@ -2275,6 +2739,7 @@ var TrackStore = class {
2275
2739
  positions: [...t.positions],
2276
2740
  snapshots: [...t.snapshots],
2277
2741
  zonesVisited: [...t.zonesVisited],
2742
+ ...t.classes !== void 0 ? { classes: [...t.classes] } : {},
2278
2743
  totalDistance: t.totalDistance,
2279
2744
  state: t.state,
2280
2745
  ...t.importance !== void 0 ? { importance: t.importance } : {},
@@ -2287,6 +2752,7 @@ var TrackStore = class {
2287
2752
  const positions = data["positions"] ?? [];
2288
2753
  const snapshots = data["snapshots"] ?? [];
2289
2754
  const zones = data["zonesVisited"] ?? [];
2755
+ const classes = data["classes"];
2290
2756
  const label = data["label"];
2291
2757
  const importance = data["importance"];
2292
2758
  const bestEventId = data["bestEventId"];
@@ -2301,6 +2767,7 @@ var TrackStore = class {
2301
2767
  positions,
2302
2768
  snapshots,
2303
2769
  zonesVisited: zones,
2770
+ ...classes !== null ? { classes } : {},
2304
2771
  totalDistance: Number(data["totalDistance"] ?? 0),
2305
2772
  state: data["state"] ?? "idle",
2306
2773
  active: false,
@@ -2313,6 +2780,18 @@ var TrackStore = class {
2313
2780
  //#endregion
2314
2781
  //#region src/pipeline-analytics/store/media-store.ts
2315
2782
  var MEDIA_COLLECTION = "pipeline-analytics:media";
2783
+ /** Owner-id prefix for a track's buffered FACE crop. Invariant established by
2784
+ * the face recognizer (`face-recognizer.ts`): `faceId === 'face-' + trackId`
2785
+ * (one buffered face per track), so a track's face crops are owned by
2786
+ * `face-<trackId>`. Used by `deleteByTracks` to derive the face crop owners of
2787
+ * a set of tracks without a `trackId` column on media rows. */
2788
+ var FACE_MEDIA_OWNER_PREFIX = "face-";
2789
+ /** Owner-id prefix for a track's buffered PLATE crop. Invariant established by
2790
+ * the plate recognizer (`plate-recognizer.ts`): `plateId === 'plate-' + trackId`
2791
+ * (one buffered plate per track), so a track's plate crops are owned by
2792
+ * `plate-<trackId>`. Used by `deleteByTracks` to derive the plate crop owners
2793
+ * of a set of tracks without a `trackId` column on media rows. */
2794
+ var PLATE_MEDIA_OWNER_PREFIX = "plate-";
2316
2795
  var MEDIA_COLUMNS = [
2317
2796
  {
2318
2797
  name: "id",
@@ -2677,6 +3156,28 @@ var MediaStore = class {
2677
3156
  async deleteForEvents(eventIds) {
2678
3157
  return this.deleteForOwner("event", eventIds);
2679
3158
  }
3159
+ /** Delete all media owned by these tracks (keyFrame/snapshot/thumbnail). */
3160
+ async deleteForTracks(trackIds) {
3161
+ return this.deleteForOwner("track", trackIds);
3162
+ }
3163
+ /**
3164
+ * `TrackScopedStore` contract for the retention cascade: delete all media
3165
+ * owned by the given tracks — the tracks' OWN media (`ownerKind:'track'`) plus
3166
+ * the UNassigned face crops (`ownerKind:'face'`, owner `face-<trackId>`).
3167
+ *
3168
+ * NEVER deletes `ownerKind:'identity'` or `ownerKind:'vehicle'` media: an
3169
+ * ENROLLED face/plate has its crop REOWNED to `identity`/`vehicle` at
3170
+ * assignment, so deleting only the `'face'`/`'plate'` owner removes unassigned
3171
+ * crops and leaves the enrolled gallery intact. Event-owned media
3172
+ * (`ownerKind:'event'`) is handled by `EventStore` (which holds the eventId
3173
+ * set). `deleteForOwner` is best-effort per row, so one bad row can't abort the
3174
+ * batch.
3175
+ */
3176
+ async deleteByTracks(trackIds) {
3177
+ await this.deleteForOwner("track", trackIds);
3178
+ await this.deleteForOwner("face", trackIds.map((id) => FACE_MEDIA_OWNER_PREFIX + id));
3179
+ await this.deleteForOwner("plate", trackIds.map((id) => PLATE_MEDIA_OWNER_PREFIX + id));
3180
+ }
2680
3181
  /** Retention sweep: delete any media row + blob older than cutoff.
2681
3182
  * Returns number of entries removed. */
2682
3183
  async evictBefore(cutoffMs) {
@@ -2839,9 +3340,11 @@ var COMMON_INDEXES = (prefix) => [{
2839
3340
  var EventStore = class {
2840
3341
  store;
2841
3342
  logger;
3343
+ media;
2842
3344
  constructor(deps) {
2843
3345
  this.store = deps.store;
2844
3346
  this.logger = deps.logger;
3347
+ this.media = deps.media;
2845
3348
  }
2846
3349
  static async declare(store) {
2847
3350
  await store.declareCollection.mutate({
@@ -3054,6 +3557,12 @@ var EventStore = class {
3054
3557
  let bestEventId;
3055
3558
  let peakBboxAreaFrac = 0;
3056
3559
  for (const row of rows) {
3560
+ const bbox = row.data["bbox"];
3561
+ if (bbox !== null && typeof bbox === "object") {
3562
+ const bw = "w" in bbox && typeof bbox.w === "number" ? bbox.w : 0;
3563
+ const bh = "h" in bbox && typeof bbox.h === "number" ? bbox.h : 0;
3564
+ if (bw <= 0 || bh <= 0) continue;
3565
+ }
3057
3566
  const conf = typeof row.data["confidence"] === "number" ? row.data["confidence"] : 0;
3058
3567
  if (conf <= bestConf) continue;
3059
3568
  bestConf = conf;
@@ -3243,6 +3752,72 @@ var EventStore = class {
3243
3752
  ]
3244
3753
  };
3245
3754
  }
3755
+ /**
3756
+ * Delete every OBJECT_EVENTS_COLLECTION row for a single track and RETURN the
3757
+ * deleted event ids so the caller can delete each event's media in lockstep
3758
+ * (the whole-track delete cascade). Motion/audio collections have no
3759
+ * `trackId` column and are never touched.
3760
+ *
3761
+ * The underlying settings-store `query` cap applies a default row limit
3762
+ * (~500). This drains a page at a time — repeatedly querying up to
3763
+ * `PRUNE_PAGE_SIZE` rows for the track and deleting them — until a page
3764
+ * returns 0 rows, so ALL of the track's events are removed regardless of
3765
+ * count. Mirrors the drain loop in `pruneBefore`, including the
3766
+ * infinite-loop guard (a non-empty page that deletes nothing stops the loop).
3767
+ */
3768
+ async deleteByTrack(trackId) {
3769
+ const ids = [];
3770
+ for (;;) {
3771
+ const rows = await this.store.query.query({
3772
+ collection: OBJECT_EVENTS_COLLECTION,
3773
+ filter: {
3774
+ where: { trackId },
3775
+ limit: 500
3776
+ }
3777
+ });
3778
+ if (rows.length === 0) break;
3779
+ let deletedInPage = 0;
3780
+ for (const row of rows) {
3781
+ const id = row.id;
3782
+ if (typeof id !== "string") continue;
3783
+ try {
3784
+ await this.store.delete.mutate({
3785
+ collection: OBJECT_EVENTS_COLLECTION,
3786
+ key: id
3787
+ });
3788
+ ids.push(id);
3789
+ deletedInPage++;
3790
+ } catch {}
3791
+ }
3792
+ if (deletedInPage === 0) break;
3793
+ }
3794
+ return ids;
3795
+ }
3796
+ /**
3797
+ * `TrackScopedStore` contract for the retention cascade: delete every object
3798
+ * event of the given tracks AND (folded in) the event-owned media of those
3799
+ * events, so a caller can never skip the media wipe. Motion/audio collections
3800
+ * carry no `trackId` and are untouched. Reuses the paged `deleteByTrack` drain
3801
+ * per track and is per-track ISOLATED — a failure on one track never aborts
3802
+ * the others (mirrors the best-effort contract in §4 of the design).
3803
+ */
3804
+ async deleteByTracks(trackIds) {
3805
+ const eventIds = [];
3806
+ for (const trackId of trackIds) try {
3807
+ const ids = await this.deleteByTrack(trackId);
3808
+ eventIds.push(...ids);
3809
+ } catch (err) {
3810
+ this.logger.warn("EventStore.deleteByTracks: track failed", { meta: {
3811
+ trackId,
3812
+ error: String(err)
3813
+ } });
3814
+ }
3815
+ if (eventIds.length > 0 && this.media !== void 0) try {
3816
+ await this.media.deleteForEvents(eventIds);
3817
+ } catch (err) {
3818
+ this.logger.warn("EventStore.deleteByTracks: event media delete failed", { meta: { error: String(err) } });
3819
+ }
3820
+ }
3246
3821
  };
3247
3822
  function slimMotion(id, data) {
3248
3823
  return {
@@ -3309,6 +3884,18 @@ function stripNulls(data) {
3309
3884
  return out;
3310
3885
  }
3311
3886
  //#endregion
3887
+ //#region src/shared/frame/resolve-frame.ts
3888
+ /**
3889
+ * Resolve the pixels a `FrameHandle` refers to. Local shm read when the
3890
+ * handle's `nodeId` matches `deps.ownNodeId`, else routed via
3891
+ * `deps.getRemoteFrame`. Returns `null` when the frame is no longer
3892
+ * available (slot recycled locally, or the remote node reports no frame).
3893
+ */
3894
+ async function resolveFrame(handle, deps) {
3895
+ if (handle.nodeId === deps.ownNodeId) return deps.readers.read(handle);
3896
+ return deps.getRemoteFrame(handle);
3897
+ }
3898
+ //#endregion
3312
3899
  //#region src/shared/frame/square-safe-crop.ts
3313
3900
  /**
3314
3901
  * Compute a square-safe 16:9 crop region in pixel space.
@@ -3431,6 +4018,34 @@ function caption(className, confidence, label) {
3431
4018
  return typeof confidence === "number" ? `${base} ${Math.round(confidence * 100)}%` : base;
3432
4019
  }
3433
4020
  /**
4021
+ * True when a packed-RGB frame is (near-)uniform in every channel — the
4022
+ * signature of a blank / hwaccel-unmapped decode frame. Samples a strided set
4023
+ * of pixels and checks each channel's min→max spread stays within a small
4024
+ * epsilon; a real image always has spread in at least one channel. Cheap
4025
+ * (~512 samples) so it runs on every capture.
4026
+ */
4027
+ function isUniformRgbFrame(data, width, height) {
4028
+ const pixels = width * height;
4029
+ if (pixels === 0 || data.length < pixels * 3) return false;
4030
+ const step = Math.max(1, Math.floor(pixels / 512)) * 3;
4031
+ const mins = [
4032
+ 255,
4033
+ 255,
4034
+ 255
4035
+ ];
4036
+ const maxs = [
4037
+ 0,
4038
+ 0,
4039
+ 0
4040
+ ];
4041
+ for (let i = 0; i + 2 < data.length; i += step) for (let c = 0; c < 3; c++) {
4042
+ const v = data[i + c];
4043
+ mins[c] = Math.min(mins[c], v);
4044
+ maxs[c] = Math.max(maxs[c], v);
4045
+ }
4046
+ return maxs.every((mx, c) => mx - mins[c] <= 6);
4047
+ }
4048
+ /**
3434
4049
  * Generates object-event + track media FROM the decoded detection-pipeline
3435
4050
  * frame (the `frameHandle`/shm frame the detector ran on), NEVER the device
3436
4051
  * snapshot cap. Per object event it writes: a `crop` (square-safe 16:9
@@ -3454,7 +4069,7 @@ var EventMediaDispatcher = class {
3454
4069
  if (events.length === 0 && trackFrames.length === 0 && snapshots.length === 0) return empty;
3455
4070
  let decoded;
3456
4071
  try {
3457
- decoded = await require_resolve_frame.resolveFrame(frameHandle, {
4072
+ decoded = await resolveFrame(frameHandle, {
3458
4073
  ownNodeId: this.deps.ownNodeId,
3459
4074
  readers: this.deps.readers,
3460
4075
  getRemoteFrame: this.deps.getRemoteFrame
@@ -3490,9 +4105,21 @@ var EventMediaDispatcher = class {
3490
4105
  });
3491
4106
  return empty;
3492
4107
  }
3493
- const frameData = Buffer.isBuffer(decoded.data) ? decoded.data : Buffer.from(decoded.data);
4108
+ const frameData = Buffer.from(decoded.data);
3494
4109
  const fw = decoded.width;
3495
4110
  const fh = decoded.height;
4111
+ if (isUniformRgbFrame(frameData, fw, fh)) {
4112
+ this.deps.logger.debug("event media: resolved frame uniform (blank/hwaccel) — skipping crop", {
4113
+ tags: { deviceId },
4114
+ meta: {
4115
+ deviceId,
4116
+ shmId: frameHandle.shmId,
4117
+ width: fw,
4118
+ height: fh
4119
+ }
4120
+ });
4121
+ return empty;
4122
+ }
3496
4123
  for (const ev of events) await this.writeEventMedia(deviceId, frameData, fw, fh, ev, input.cropPadding);
3497
4124
  for (const tf of trackFrames) await this.writeTrackFrame(deviceId, frameData, fw, fh, tf);
3498
4125
  const storedSnapshots = [];
@@ -3718,8 +4345,264 @@ var EventMediaDispatcher = class {
3718
4345
  }
3719
4346
  };
3720
4347
  //#endregion
3721
- //#region src/pipeline-analytics/runtime/slice-throttler.ts
3722
- var SliceThrottler = class {
4348
+ //#region src/shared/frame/crop-extractor.ts
4349
+ /**
4350
+ * Extracts a JPEG-encoded crop from a raw frame buffer using a normalized bounding box.
4351
+ * Coordinates are clamped to frame bounds to avoid out-of-range errors.
4352
+ */
4353
+ async function extractCrop(frameData, frameWidth, frameHeight, bbox) {
4354
+ const rawLeft = Math.round(bbox.x * frameWidth);
4355
+ const rawTop = Math.round(bbox.y * frameHeight);
4356
+ const rawWidth = Math.round(bbox.w * frameWidth);
4357
+ const rawHeight = Math.round(bbox.h * frameHeight);
4358
+ const left = Math.max(0, Math.min(rawLeft, frameWidth - 1));
4359
+ const top = Math.max(0, Math.min(rawTop, frameHeight - 1));
4360
+ const width = Math.max(1, Math.min(rawWidth, frameWidth - left));
4361
+ const height = Math.max(1, Math.min(rawHeight, frameHeight - top));
4362
+ return {
4363
+ crop: await (0, sharp.default)(frameData, { raw: {
4364
+ width: frameWidth,
4365
+ height: frameHeight,
4366
+ channels: 3
4367
+ } }).extract({
4368
+ left,
4369
+ top,
4370
+ width,
4371
+ height
4372
+ }).jpeg({ quality: 90 }).toBuffer(),
4373
+ width,
4374
+ height
4375
+ };
4376
+ }
4377
+ //#endregion
4378
+ //#region src/pipeline-analytics/embedding/embedding-dispatcher.ts
4379
+ function selectCropBbox(detection) {
4380
+ return detection.refinedBbox ?? detection.bbox;
4381
+ }
4382
+ var EmbeddingDispatcher = class {
4383
+ config;
4384
+ encoder;
4385
+ eventBus;
4386
+ logger;
4387
+ ownNodeId;
4388
+ readers;
4389
+ getRemoteFrame;
4390
+ lastEmbedTime = /* @__PURE__ */ new Map();
4391
+ pendingCrops = /* @__PURE__ */ new Map();
4392
+ flushTimer = null;
4393
+ unsubscribe = null;
4394
+ _processedCount = 0;
4395
+ _totalInferenceMs = 0;
4396
+ constructor(deps) {
4397
+ this.config = deps.config;
4398
+ this.encoder = deps.encoder;
4399
+ this.eventBus = deps.eventBus;
4400
+ this.logger = deps.logger;
4401
+ this.ownNodeId = deps.ownNodeId;
4402
+ this.readers = deps.readers;
4403
+ this.getRemoteFrame = deps.getRemoteFrame;
4404
+ }
4405
+ async start() {
4406
+ if (!this.config.enabled) {
4407
+ this.logger.info("EmbeddingDispatcher disabled");
4408
+ return;
4409
+ }
4410
+ this.unsubscribe = this.eventBus.subscribe({ category: require_dist.EventCategory.DetectionResult }, (event) => {
4411
+ this.handleDetectionResult(event);
4412
+ });
4413
+ if (this.config.cropStrategy === "best-confidence") this.flushTimer = setInterval(() => {
4414
+ this.flushPending();
4415
+ }, 1e3);
4416
+ this.logger.info("EmbeddingDispatcher started", { meta: {
4417
+ strategy: this.config.cropStrategy,
4418
+ maxPerSec: this.config.maxPerSecPerCamera
4419
+ } });
4420
+ }
4421
+ async stop() {
4422
+ this.unsubscribe?.();
4423
+ this.unsubscribe = null;
4424
+ if (this.flushTimer) {
4425
+ clearInterval(this.flushTimer);
4426
+ this.flushTimer = null;
4427
+ }
4428
+ await this.flushPending();
4429
+ }
4430
+ get processedCount() {
4431
+ return this._processedCount;
4432
+ }
4433
+ get avgInferenceMs() {
4434
+ return this._processedCount > 0 ? this._totalInferenceMs / this._processedCount : 0;
4435
+ }
4436
+ get queueDepth() {
4437
+ return this.pendingCrops.size;
4438
+ }
4439
+ async handleDetectionResult(event) {
4440
+ const data = event.data;
4441
+ const deviceId = event.source.id !== void 0 ? String(event.source.id) : "";
4442
+ if (!deviceId) return;
4443
+ const detections = data.analysisResults ?? [];
4444
+ const handle = data.frameHandle;
4445
+ if (!handle) {
4446
+ this.logger.debug("skip: no frameHandle on DetectionResult", {
4447
+ tags: { deviceId: Number(deviceId) },
4448
+ meta: { deviceId }
4449
+ });
4450
+ return;
4451
+ }
4452
+ let decoded;
4453
+ try {
4454
+ decoded = await resolveFrame(handle, {
4455
+ ownNodeId: this.ownNodeId,
4456
+ readers: this.readers,
4457
+ getRemoteFrame: this.getRemoteFrame
4458
+ });
4459
+ } catch (err) {
4460
+ this.logger.debug("skip: resolveFrame threw", {
4461
+ tags: { deviceId: Number(deviceId) },
4462
+ meta: {
4463
+ deviceId,
4464
+ shmId: handle.shmId,
4465
+ error: String(err)
4466
+ }
4467
+ });
4468
+ return;
4469
+ }
4470
+ if (!decoded) {
4471
+ this.logger.debug("skip: frame recycled before resolve", {
4472
+ tags: { deviceId: Number(deviceId) },
4473
+ meta: {
4474
+ deviceId,
4475
+ shmId: handle.shmId
4476
+ }
4477
+ });
4478
+ return;
4479
+ }
4480
+ if (decoded.format !== "rgb") {
4481
+ this.logger.debug("skip: resolved frame is not RGB", {
4482
+ tags: { deviceId: Number(deviceId) },
4483
+ meta: {
4484
+ deviceId,
4485
+ format: decoded.format
4486
+ }
4487
+ });
4488
+ return;
4489
+ }
4490
+ const frameData = Buffer.isBuffer(decoded.data) ? decoded.data : Buffer.from(decoded.data);
4491
+ const frameWidth = decoded.width;
4492
+ const frameHeight = decoded.height;
4493
+ for (const det of detections) {
4494
+ const detection = det.detection;
4495
+ if (!detection) continue;
4496
+ if (this.config.classes.length > 0 && !this.config.classes.includes(detection.class)) continue;
4497
+ if (detection.score < this.config.minConfidence) continue;
4498
+ const now = Date.now();
4499
+ const minInterval = 1e3 / this.config.maxPerSecPerCamera;
4500
+ if (now - (this.lastEmbedTime.get(deviceId) ?? 0) < minInterval) continue;
4501
+ const trackId = detection.trackId ?? `${deviceId}-${now}`;
4502
+ const pending = {
4503
+ trackId,
4504
+ deviceId,
4505
+ class: detection.class,
4506
+ confidence: detection.score,
4507
+ frameData,
4508
+ frameWidth,
4509
+ frameHeight,
4510
+ bbox: selectCropBbox(detection),
4511
+ receivedAt: now
4512
+ };
4513
+ switch (this.config.cropStrategy) {
4514
+ case "first":
4515
+ if (!this.pendingCrops.has(trackId)) {
4516
+ this.pendingCrops.set(trackId, pending);
4517
+ this.processOne(pending);
4518
+ }
4519
+ break;
4520
+ case "best-confidence": {
4521
+ const existing = this.pendingCrops.get(trackId);
4522
+ if (!existing || pending.confidence > existing.confidence) this.pendingCrops.set(trackId, pending);
4523
+ break;
4524
+ }
4525
+ case "track-end":
4526
+ if (det.objectState?.state === "leaving") {
4527
+ this.pendingCrops.set(trackId, pending);
4528
+ this.processOne(pending);
4529
+ } else {
4530
+ const existing = this.pendingCrops.get(trackId);
4531
+ if (!existing || pending.confidence > existing.confidence) this.pendingCrops.set(trackId, pending);
4532
+ }
4533
+ break;
4534
+ }
4535
+ }
4536
+ }
4537
+ async flushPending() {
4538
+ const now = Date.now();
4539
+ const toFlush = [];
4540
+ for (const [trackId, pending] of this.pendingCrops) if (now - pending.receivedAt > 3e3) {
4541
+ toFlush.push(pending);
4542
+ this.pendingCrops.delete(trackId);
4543
+ }
4544
+ await Promise.all(toFlush.map((p) => this.processOne(p)));
4545
+ }
4546
+ async processOne(pending) {
4547
+ try {
4548
+ const { crop, width, height } = await extractCrop(pending.frameData, pending.frameWidth, pending.frameHeight, pending.bbox);
4549
+ const { embedding: _embedding, inferenceMs } = await this.encoder.encode(crop, width, height);
4550
+ const info = await this.encoder.getInfo();
4551
+ this._processedCount++;
4552
+ this._totalInferenceMs += inferenceMs;
4553
+ this.lastEmbedTime.set(pending.deviceId, Date.now());
4554
+ this.pendingCrops.delete(pending.trackId);
4555
+ const embeddingId = `${pending.deviceId}/${pending.trackId}/${Date.now()}`;
4556
+ const payload = {
4557
+ deviceId: Number(pending.deviceId),
4558
+ trackId: pending.trackId,
4559
+ class: pending.class,
4560
+ embeddingId,
4561
+ modelId: info.modelId,
4562
+ embeddingDim: info.embeddingDim,
4563
+ inferenceMs,
4564
+ timestamp: Date.now()
4565
+ };
4566
+ this.eventBus.emit(require_dist.createEvent(require_dist.EventCategory.EnrichmentEmbeddingStored, {
4567
+ type: "addon",
4568
+ id: "pipeline-analytics"
4569
+ }, payload));
4570
+ this.logger.debug("Embedded track", {
4571
+ tags: { deviceId: Number(pending.deviceId) },
4572
+ meta: {
4573
+ class: pending.class,
4574
+ trackId: pending.trackId,
4575
+ inferenceMs: Number(inferenceMs.toFixed(1))
4576
+ }
4577
+ });
4578
+ } catch (err) {
4579
+ this.logger.warn("Failed to embed track", {
4580
+ tags: { deviceId: Number(pending.deviceId) },
4581
+ meta: {
4582
+ trackId: pending.trackId,
4583
+ error: String(err)
4584
+ }
4585
+ });
4586
+ }
4587
+ }
4588
+ };
4589
+ //#endregion
4590
+ //#region src/pipeline-analytics/embedding/embedding-config.ts
4591
+ var DEFAULT_EMBEDDING_CONFIG = {
4592
+ enabled: true,
4593
+ modelId: "clip-vit-b32",
4594
+ agentId: "local",
4595
+ runtime: "node",
4596
+ backend: "cpu",
4597
+ classes: [],
4598
+ minConfidence: .5,
4599
+ maxPerSecPerCamera: 1,
4600
+ cropStrategy: "first",
4601
+ retentionDays: 30
4602
+ };
4603
+ //#endregion
4604
+ //#region src/pipeline-analytics/runtime/slice-throttler.ts
4605
+ var SliceThrottler = class {
3723
4606
  opts;
3724
4607
  lastWrittenAt = /* @__PURE__ */ new Map();
3725
4608
  lastWritten = /* @__PURE__ */ new Map();
@@ -4594,6 +5477,56 @@ function resolveMediaSettings(raw) {
4594
5477
  };
4595
5478
  }
4596
5479
  //#endregion
5480
+ //#region src/pipeline-analytics/track-retention-sweep.ts
5481
+ /**
5482
+ * Periodic track retention sweep (design §6) — the piece that makes retention
5483
+ * actually BOUNDED. Without it, `pruneTracksBefore` only runs when something
5484
+ * calls it; this sweep ages persisted tracks out on the provider's existing
5485
+ * retention interval.
5486
+ *
5487
+ * `retentionMs` is a PER-DEVICE setting (`trackRetentionDays`, default 7 days;
5488
+ * `0` = keep forever → the device is skipped). Enrolled gallery is exempt by the
5489
+ * cascade store layer (design §4), not here.
5490
+ *
5491
+ * Kept as a pure, dependency-injected function so the loop is unit-testable
5492
+ * without booting the addon.
5493
+ */
5494
+ var DAY_MS = 1440 * 60 * 1e3;
5495
+ /** Per-device track retention setting. Default 7 days; 0 = keep forever. */
5496
+ var TrackRetentionSettingsSchema = require_dist.object({ trackRetentionDays: require_dist.number().min(0).default(7) });
5497
+ var TRACK_RETENTION_DEFAULT_DAYS = TrackRetentionSettingsSchema.parse({}).trackRetentionDays;
5498
+ /** Resolve `trackRetentionDays` from a raw per-device store blob — an invalid or
5499
+ * missing value falls back to the default (parse never throws). */
5500
+ function resolveTrackRetentionDays(raw) {
5501
+ const parsed = TrackRetentionSettingsSchema.shape.trackRetentionDays.safeParse(raw["trackRetentionDays"]);
5502
+ return parsed.success ? parsed.data : TRACK_RETENTION_DEFAULT_DAYS;
5503
+ }
5504
+ /** Cutoff timestamp for a retention window. `null` when retention is disabled
5505
+ * (`retentionDays <= 0` → keep forever, the sweep skips the device). */
5506
+ function trackRetentionCutoff(nowMs, retentionDays) {
5507
+ if (retentionDays <= 0) return null;
5508
+ return nowMs - retentionDays * DAY_MS;
5509
+ }
5510
+ /**
5511
+ * Sweep every device with persisted tracks: skip retention-disabled devices,
5512
+ * prune the rest at `now − retentionMs`. Per-device ISOLATED — one bad device
5513
+ * never aborts the sweep. Returns the total tracks pruned across devices.
5514
+ */
5515
+ async function sweepTrackRetention(deps) {
5516
+ const devices = await deps.listDeviceIds();
5517
+ const nowMs = deps.now();
5518
+ let totalTracks = 0;
5519
+ for (const deviceId of devices) try {
5520
+ const cutoffMs = trackRetentionCutoff(nowMs, await deps.resolveRetentionDays(deviceId));
5521
+ if (cutoffMs === null) continue;
5522
+ const counts = await deps.pruneTracksBefore(deviceId, cutoffMs);
5523
+ totalTracks += counts.tracks;
5524
+ } catch (err) {
5525
+ deps.onError?.(deviceId, err);
5526
+ }
5527
+ return totalTracks;
5528
+ }
5529
+ //#endregion
4597
5530
  //#region src/pipeline-analytics/store/identity-store.ts
4598
5531
  /**
4599
5532
  * IdentityStore — per-person identity registry for face recognition.
@@ -4643,7 +5576,7 @@ var IDENTITY_COLUMNS = [
4643
5576
  type: "TEXT"
4644
5577
  }
4645
5578
  ];
4646
- var SAMPLE_COLUMNS = [
5579
+ var SAMPLE_COLUMNS$1 = [
4647
5580
  {
4648
5581
  name: "id",
4649
5582
  type: "TEXT",
@@ -4704,7 +5637,7 @@ var IdentityStore = class {
4704
5637
  });
4705
5638
  await store.declareCollection.mutate({
4706
5639
  collection: IDENTITY_SAMPLES_COLLECTION,
4707
- columns: [...SAMPLE_COLUMNS],
5640
+ columns: [...SAMPLE_COLUMNS$1],
4708
5641
  indexes: [{
4709
5642
  name: "idx_sample_identity",
4710
5643
  columns: ["identityId"]
@@ -5139,6 +6072,38 @@ var FaceStore = class {
5139
6072
  } });
5140
6073
  }
5141
6074
  }
6075
+ /**
6076
+ * `TrackScopedStore` contract for the retention cascade: delete the buffered
6077
+ * face rows of the given tracks that are NOT assigned. Reuses the EXACT
6078
+ * `assigned` exemption that `prune`/`pruneAll` apply (see the
6079
+ * `!(r.data.assigned)` predicate above) so ENROLLED faces SURVIVE retention.
6080
+ * Per-track ISOLATED + best-effort per row — one bad track or row never
6081
+ * aborts the batch. (Crop media of these rows is removed by `MediaStore`.)
6082
+ */
6083
+ async deleteByTracks(trackIds) {
6084
+ for (const trackId of trackIds) try {
6085
+ const eligible = (await this.store.query.query({
6086
+ collection: FACES_COLLECTION,
6087
+ filter: { where: { trackId } }
6088
+ })).filter((r) => !r.data.assigned);
6089
+ for (const row of eligible) try {
6090
+ await this.store.delete.mutate({
6091
+ collection: FACES_COLLECTION,
6092
+ key: row.id
6093
+ });
6094
+ } catch (err) {
6095
+ this.logger.warn("FaceStore.deleteByTracks delete failed", { meta: {
6096
+ faceId: row.id,
6097
+ error: String(err)
6098
+ } });
6099
+ }
6100
+ } catch (err) {
6101
+ this.logger.warn("FaceStore.deleteByTracks query failed", { meta: {
6102
+ trackId,
6103
+ error: String(err)
6104
+ } });
6105
+ }
6106
+ }
5142
6107
  /** Delete a single buffered face row by id (its crop media is removed by the
5143
6108
  * caller — the FaceStore owns rows, not blobs). Best-effort. */
5144
6109
  async delete(faceId) {
@@ -5410,6 +6375,27 @@ var ObjectEmbeddingStore = class {
5410
6375
  }
5411
6376
  return ids;
5412
6377
  }
6378
+ /**
6379
+ * `TrackScopedStore` contract for the retention cascade: delete the per-track
6380
+ * CLIP search vector for the given tracks. The row id IS the trackId (one row
6381
+ * per track), so this deletes by key directly. Pure EPHEMERAL track state —
6382
+ * the identity MATCHING embeddings (ArcFace) live in a SEPARATE durable store
6383
+ * (`identity-samples`) and are never touched here, so pruning this never
6384
+ * breaks recognition (design §2/§4). Per-track ISOLATED + best-effort.
6385
+ */
6386
+ async deleteByTracks(trackIds) {
6387
+ for (const trackId of trackIds) try {
6388
+ await this.store.delete.mutate({
6389
+ collection: OBJECT_EMBEDDINGS_COLLECTION,
6390
+ key: trackId
6391
+ });
6392
+ } catch (err) {
6393
+ this.logger.warn("ObjectEmbeddingStore.deleteByTracks failed", { meta: {
6394
+ trackId,
6395
+ error: String(err)
6396
+ } });
6397
+ }
6398
+ }
5413
6399
  };
5414
6400
  //#endregion
5415
6401
  //#region src/pipeline-analytics/pipeline/face-matcher.ts
@@ -5788,6 +6774,24 @@ var FaceRecognizer = class {
5788
6774
  score: held.score
5789
6775
  }
5790
6776
  });
6777
+ try {
6778
+ this.deps.emitFaceGalleryChanged?.({
6779
+ deviceId,
6780
+ faceId,
6781
+ kind: "buffered"
6782
+ });
6783
+ } catch (err) {
6784
+ this.deps.logger.debug("face-gallery-changed emit failed", {
6785
+ tags: {
6786
+ deviceId,
6787
+ trackId
6788
+ },
6789
+ meta: {
6790
+ faceId,
6791
+ error: String(err)
6792
+ }
6793
+ });
6794
+ }
5791
6795
  } catch (err) {
5792
6796
  this.deps.logger.warn("FaceRecognizer faceStore insert failed", {
5793
6797
  tags: { deviceId },
@@ -5937,6 +6941,25 @@ var DetailScheduler = class {
5937
6941
  };
5938
6942
  //#endregion
5939
6943
  //#region src/pipeline-analytics/detail-dispatcher.ts
6944
+ /**
6945
+ * Compose the `steps` list sent to `runDetailSubtree` for one request —
6946
+ * identity-aware for the face chain.
6947
+ *
6948
+ * A `face-detection` request ALSO includes `'face-embedding'` (the full
6949
+ * detect→recognize chain) EXCEPT when it is a PERIODIC geometry refresh on a
6950
+ * track that already carries an identity label: re-embedding a known face every
6951
+ * second is wasted ArcFace inference (250-1900ms per call on N100 agents), so
6952
+ * that case runs the detector geometry ALONE. Every recognition-bearing reason
6953
+ * (new-track / improve / retry) keeps the embedding regardless of the label.
6954
+ *
6955
+ * Non-face steps are unchanged (`[req.stepId]`). Pairs with the pipeline's
6956
+ * strict-`steps` pruning (`pruneChildStepsToRequested`) — including
6957
+ * `'face-embedding'` here is what keeps the nested child in the executed chain.
6958
+ */
6959
+ function composeDetailSteps(req, hasTrackLabel) {
6960
+ if (req.stepId !== "face-detection") return [req.stepId];
6961
+ return req.reason === "periodic" && hasTrackLabel(req.trackId) ? ["face-detection"] : ["face-detection", "face-embedding"];
6962
+ }
5940
6963
  /** Throttle for the per-device "detail call failed" warn — one line / minute. */
5941
6964
  var FAIL_WARN_THROTTLE_MS = 6e4;
5942
6965
  var TrackDetailDispatcher = class {
@@ -6083,12 +7106,13 @@ var TrackDetailDispatcher = class {
6083
7106
  bbox: { ...frame.bbox },
6084
7107
  className: frame.className
6085
7108
  };
7109
+ const steps = composeDetailSteps(req, (trackId) => this.deps.hasTrackLabel?.(trackId) ?? false);
6086
7110
  if (frame.frameHandle !== void 0) try {
6087
7111
  const primary = await this.deps.runDetailSubtree({
6088
7112
  deviceId,
6089
7113
  frameHandle: frame.frameHandle,
6090
7114
  parent,
6091
- steps: [req.stepId]
7115
+ steps
6092
7116
  }, frame.nodeId);
6093
7117
  if (primary !== null) return primary.details;
6094
7118
  } catch (err) {
@@ -6108,7 +7132,7 @@ var TrackDetailDispatcher = class {
6108
7132
  deviceId,
6109
7133
  cropJpeg,
6110
7134
  parent,
6111
- steps: [req.stepId]
7135
+ steps
6112
7136
  }, frame.nodeId);
6113
7137
  if (retry !== null) return retry.details;
6114
7138
  }
@@ -6140,6 +7164,184 @@ var TrackDetailDispatcher = class {
6140
7164
  }
6141
7165
  };
6142
7166
  //#endregion
7167
+ //#region src/pipeline-analytics/overlay-state.ts
7168
+ function key(deviceId, trackId) {
7169
+ return `${deviceId}:${trackId}`;
7170
+ }
7171
+ var OverlayDetailStateStore = class {
7172
+ byTrack = /* @__PURE__ */ new Map();
7173
+ noteFaceDetail(deviceId, trackId, faceBbox, score, capturedAt) {
7174
+ const k = key(deviceId, trackId);
7175
+ const prev = this.byTrack.get(k);
7176
+ this.byTrack.set(k, {
7177
+ ...prev,
7178
+ face: {
7179
+ bbox: faceBbox,
7180
+ score,
7181
+ capturedAt
7182
+ }
7183
+ });
7184
+ }
7185
+ notePlateDetail(deviceId, trackId, plateBbox, score, text, capturedAt) {
7186
+ const k = key(deviceId, trackId);
7187
+ const prev = this.byTrack.get(k);
7188
+ this.byTrack.set(k, {
7189
+ ...prev,
7190
+ plate: {
7191
+ bbox: plateBbox,
7192
+ score,
7193
+ capturedAt,
7194
+ text
7195
+ }
7196
+ });
7197
+ }
7198
+ get(deviceId, trackId) {
7199
+ return this.byTrack.get(key(deviceId, trackId));
7200
+ }
7201
+ onTrackEnded(deviceId, trackId) {
7202
+ this.byTrack.delete(key(deviceId, trackId));
7203
+ }
7204
+ clearDevice(deviceId) {
7205
+ const prefix = `${deviceId}:`;
7206
+ for (const k of this.byTrack.keys()) if (k.startsWith(prefix)) this.byTrack.delete(k);
7207
+ }
7208
+ clear() {
7209
+ this.byTrack.clear();
7210
+ }
7211
+ };
7212
+ function toDetectionBbox(bbox) {
7213
+ return {
7214
+ x: bbox.x,
7215
+ y: bbox.y,
7216
+ width: bbox.w,
7217
+ height: bbox.h
7218
+ };
7219
+ }
7220
+ function buildOverlayDetections(input) {
7221
+ const trackedBySourceId = /* @__PURE__ */ new Map();
7222
+ for (const t of input.tracked) if (t.sourceDetectionId !== void 0) trackedBySourceId.set(t.sourceDetectionId, t);
7223
+ const realDetailKindsByParent = /* @__PURE__ */ new Map();
7224
+ for (const d of input.frameDetections) {
7225
+ if (d.kind !== "detail" || d.parentId === void 0) continue;
7226
+ const set = realDetailKindsByParent.get(d.parentId) ?? /* @__PURE__ */ new Set();
7227
+ set.add(d.macroClass);
7228
+ realDetailKindsByParent.set(d.parentId, set);
7229
+ }
7230
+ const out = [];
7231
+ const synthesized = [];
7232
+ for (const det of input.frameDetections) {
7233
+ const t = det.kind === "first-level" ? trackedBySourceId.get(det.id) : void 0;
7234
+ if (t === void 0) {
7235
+ out.push(det);
7236
+ continue;
7237
+ }
7238
+ const name = input.labelFor(t.trackId) ?? "";
7239
+ const hasName = name.length > 0;
7240
+ const alreadyLabeled = hasName && det.labels.some((l) => l.label === name);
7241
+ out.push({
7242
+ ...det,
7243
+ track: {
7244
+ trackId: t.trackId,
7245
+ zones: t.zones
7246
+ },
7247
+ labels: hasName && !alreadyLabeled ? [...det.labels, {
7248
+ label: name,
7249
+ score: 1
7250
+ }] : det.labels
7251
+ });
7252
+ const overlay = input.overlayFor(t.trackId);
7253
+ if (overlay === void 0) continue;
7254
+ const realKinds = realDetailKindsByParent.get(det.id);
7255
+ if (overlay.face !== void 0 && input.nowMs - overlay.face.capturedAt <= 2500 && overlay.face !== void 0 && realKinds?.has("face") !== true) synthesized.push({
7256
+ id: `detail-${t.trackId}-face`,
7257
+ kind: "detail",
7258
+ macroClass: "face",
7259
+ score: overlay.face.score,
7260
+ bbox: toDetectionBbox(overlay.face.bbox),
7261
+ parentId: det.id,
7262
+ labels: hasName ? [{
7263
+ label: name,
7264
+ score: overlay.face.score
7265
+ }] : []
7266
+ });
7267
+ if (overlay.plate !== void 0 && input.nowMs - overlay.plate.capturedAt <= 2500 && overlay.plate !== void 0 && realKinds?.has("plate") !== true) synthesized.push({
7268
+ id: `detail-${t.trackId}-plate`,
7269
+ kind: "detail",
7270
+ macroClass: "plate",
7271
+ score: overlay.plate.score,
7272
+ bbox: toDetectionBbox(overlay.plate.bbox),
7273
+ parentId: det.id,
7274
+ labels: [{
7275
+ label: overlay.plate.text,
7276
+ score: overlay.plate.score
7277
+ }]
7278
+ });
7279
+ }
7280
+ return [...out, ...synthesized];
7281
+ }
7282
+ //#endregion
7283
+ //#region src/pipeline-analytics/media-capture-log.ts
7284
+ var DEFAULT_FLUSH_INTERVAL_MS = 6e4;
7285
+ var MediaCaptureLogAggregator = class {
7286
+ flushIntervalMs;
7287
+ byDevice = /* @__PURE__ */ new Map();
7288
+ constructor(flushIntervalMs = DEFAULT_FLUSH_INTERVAL_MS) {
7289
+ this.flushIntervalMs = flushIntervalMs;
7290
+ }
7291
+ /**
7292
+ * Record one frame's capture counts. Returns the window aggregate (and
7293
+ * resets the window) once `flushIntervalMs` has elapsed since the window
7294
+ * opened; `null` while still accumulating.
7295
+ */
7296
+ note(deviceId, counts, nowMs) {
7297
+ const w = this.byDevice.get(deviceId);
7298
+ if (w === void 0) {
7299
+ this.byDevice.set(deviceId, {
7300
+ sinceMs: nowMs,
7301
+ frames: 1,
7302
+ events: counts.events,
7303
+ trackFrames: counts.trackFrames,
7304
+ snapshots: counts.snapshots,
7305
+ faceCrops: counts.faceCrops,
7306
+ plateCrops: counts.plateCrops
7307
+ });
7308
+ return null;
7309
+ }
7310
+ w.frames += 1;
7311
+ w.events += counts.events;
7312
+ w.trackFrames += counts.trackFrames;
7313
+ w.snapshots += counts.snapshots;
7314
+ w.faceCrops += counts.faceCrops;
7315
+ w.plateCrops += counts.plateCrops;
7316
+ if (nowMs - w.sinceMs < this.flushIntervalMs) return null;
7317
+ return this.drain(deviceId, w, nowMs);
7318
+ }
7319
+ /**
7320
+ * Drain a device's pending window (e.g. when its last live track ends),
7321
+ * so short activity bursts still surface an aggregate. Returns `null`
7322
+ * when nothing is pending.
7323
+ */
7324
+ flush(deviceId, nowMs) {
7325
+ const w = this.byDevice.get(deviceId);
7326
+ if (w === void 0) return null;
7327
+ return this.drain(deviceId, w, nowMs);
7328
+ }
7329
+ drain(deviceId, w, nowMs) {
7330
+ this.byDevice.delete(deviceId);
7331
+ return {
7332
+ frames: w.frames,
7333
+ windowMs: nowMs - w.sinceMs,
7334
+ sums: {
7335
+ events: w.events,
7336
+ trackFrames: w.trackFrames,
7337
+ snapshots: w.snapshots,
7338
+ faceCrops: w.faceCrops,
7339
+ plateCrops: w.plateCrops
7340
+ }
7341
+ };
7342
+ }
7343
+ };
7344
+ //#endregion
6143
7345
  //#region src/pipeline-analytics/store/plate-store.ts
6144
7346
  var PLATES_COLLECTION = "pipeline-analytics:plates";
6145
7347
  var PLATE_COLUMNS = [
@@ -6182,6 +7384,27 @@ var PLATE_COLUMNS = [
6182
7384
  name: "corrected",
6183
7385
  type: "BOOLEAN",
6184
7386
  notNull: true
7387
+ },
7388
+ {
7389
+ name: "recognizedVehicleId",
7390
+ type: "TEXT"
7391
+ },
7392
+ {
7393
+ name: "assigned",
7394
+ type: "BOOLEAN",
7395
+ notNull: true
7396
+ },
7397
+ {
7398
+ name: "assignedSampleId",
7399
+ type: "TEXT"
7400
+ },
7401
+ {
7402
+ name: "keyFrameMediaKey",
7403
+ type: "TEXT"
7404
+ },
7405
+ {
7406
+ name: "plateBbox",
7407
+ type: "JSON"
6185
7408
  }
6186
7409
  ];
6187
7410
  var PLATE_INDEXES = [{
@@ -6231,7 +7454,12 @@ var PlateStore = class {
6231
7454
  ...data,
6232
7455
  score: Number(r.data.score ?? 0),
6233
7456
  corrected: Boolean(r.data.corrected),
6234
- mediaKey: data.mediaKey ?? void 0
7457
+ assigned: Boolean(r.data.assigned),
7458
+ mediaKey: data.mediaKey ?? void 0,
7459
+ recognizedVehicleId: data.recognizedVehicleId ?? void 0,
7460
+ assignedSampleId: data.assignedSampleId ?? void 0,
7461
+ keyFrameMediaKey: data.keyFrameMediaKey ?? void 0,
7462
+ plateBbox: data.plateBbox ?? void 0
6235
7463
  };
6236
7464
  }
6237
7465
  /** Recent plate reads for one device (or all when deviceId omitted), newest first. */
@@ -6295,63 +7523,443 @@ var PlateStore = class {
6295
7523
  collection: PLATES_COLLECTION,
6296
7524
  key: plateId
6297
7525
  });
6298
- } catch (err) {
6299
- this.logger.warn("PlateStore.delete failed", { meta: {
6300
- plateId,
6301
- error: String(err)
6302
- } });
7526
+ } catch (err) {
7527
+ this.logger.warn("PlateStore.delete failed", { meta: {
7528
+ plateId,
7529
+ error: String(err)
7530
+ } });
7531
+ }
7532
+ }
7533
+ /** Flip `assigned` to true and record the vehicle + enrolled sample. */
7534
+ async markAssigned(plateId, vehicleId, sampleId) {
7535
+ const data = {
7536
+ assigned: true,
7537
+ recognizedVehicleId: vehicleId
7538
+ };
7539
+ if (sampleId !== void 0) data["assignedSampleId"] = sampleId;
7540
+ try {
7541
+ await this.store.update.mutate({
7542
+ collection: PLATES_COLLECTION,
7543
+ id: plateId,
7544
+ data
7545
+ });
7546
+ } catch (err) {
7547
+ this.logger.warn("PlateStore.markAssigned failed", { meta: {
7548
+ plateId,
7549
+ error: String(err)
7550
+ } });
7551
+ }
7552
+ }
7553
+ /** Clear the assignment (assigned=false, null recognizedVehicleId + assignedSampleId). */
7554
+ async clearAssignment(plateId) {
7555
+ try {
7556
+ await this.store.update.mutate({
7557
+ collection: PLATES_COLLECTION,
7558
+ id: plateId,
7559
+ data: {
7560
+ assigned: false,
7561
+ recognizedVehicleId: null,
7562
+ assignedSampleId: null
7563
+ }
7564
+ });
7565
+ } catch (err) {
7566
+ this.logger.warn("PlateStore.clearAssignment failed", { meta: {
7567
+ plateId,
7568
+ error: String(err)
7569
+ } });
7570
+ }
7571
+ }
7572
+ /**
7573
+ * `TrackScopedStore` contract for the retention cascade: delete the buffered
7574
+ * plate rows of the given tracks that are NOT assigned. Mirrors
7575
+ * `FaceStore.deleteByTracks` — ENROLLED (`assigned`) reads SURVIVE retention.
7576
+ * Per-track ISOLATED + best-effort per row — one bad track or row never aborts
7577
+ * the batch. (Crop media of these rows is removed by `MediaStore`.)
7578
+ */
7579
+ async deleteByTracks(trackIds) {
7580
+ for (const trackId of trackIds) try {
7581
+ const eligible = (await this.store.query.query({
7582
+ collection: PLATES_COLLECTION,
7583
+ filter: { where: { trackId } }
7584
+ })).filter((r) => !r.data.assigned);
7585
+ for (const row of eligible) try {
7586
+ await this.store.delete.mutate({
7587
+ collection: PLATES_COLLECTION,
7588
+ key: row.id
7589
+ });
7590
+ } catch (err) {
7591
+ this.logger.warn("PlateStore.deleteByTracks delete failed", { meta: {
7592
+ plateId: row.id,
7593
+ error: String(err)
7594
+ } });
7595
+ }
7596
+ } catch (err) {
7597
+ this.logger.warn("PlateStore.deleteByTracks query failed", { meta: {
7598
+ trackId,
7599
+ error: String(err)
7600
+ } });
7601
+ }
7602
+ }
7603
+ /**
7604
+ * Retention across ALL devices: delete reads older than `cutoffMs` AND any
7605
+ * beyond the newest `maxPerDevice` per device. Corrected reads are exempt
7606
+ * (human-confirmed). Returns deleted ids so the caller removes their crops.
7607
+ */
7608
+ async pruneAll(input) {
7609
+ const rows = await this.store.query.query({
7610
+ collection: PLATES_COLLECTION,
7611
+ filter: { orderBy: {
7612
+ field: "timestamp",
7613
+ direction: "desc"
7614
+ } }
7615
+ });
7616
+ const byDevice = /* @__PURE__ */ new Map();
7617
+ for (const r of rows) {
7618
+ const data = r.data;
7619
+ if (data.corrected || data.assigned) continue;
7620
+ const deviceId = Number(data.deviceId ?? -1);
7621
+ const list = byDevice.get(deviceId) ?? [];
7622
+ list.push({
7623
+ id: r.id,
7624
+ timestamp: Number(data.timestamp ?? 0)
7625
+ });
7626
+ byDevice.set(deviceId, list);
7627
+ }
7628
+ const toDelete = /* @__PURE__ */ new Set();
7629
+ for (const list of byDevice.values()) {
7630
+ list.sort((a, b) => b.timestamp - a.timestamp);
7631
+ list.forEach((row, idx) => {
7632
+ if (row.timestamp < input.cutoffMs) toDelete.add(row.id);
7633
+ if (idx >= input.maxPerDevice) toDelete.add(row.id);
7634
+ });
7635
+ }
7636
+ const deleted = [];
7637
+ for (const id of toDelete) try {
7638
+ await this.store.delete.mutate({
7639
+ collection: PLATES_COLLECTION,
7640
+ key: id
7641
+ });
7642
+ deleted.push(id);
7643
+ } catch (err) {
7644
+ this.logger.warn("PlateStore.pruneAll delete failed", { meta: {
7645
+ plateId: id,
7646
+ error: String(err)
7647
+ } });
7648
+ }
7649
+ return deleted;
7650
+ }
7651
+ };
7652
+ //#endregion
7653
+ //#region src/pipeline-analytics/store/vehicle-store.ts
7654
+ /**
7655
+ * VehicleStore — per-vehicle identity registry for license-plate recognition.
7656
+ *
7657
+ * Two SQL-backed collections (parallel to IdentityStore, text-not-embedding):
7658
+ * - pipeline-analytics:vehicles — named vehicle records
7659
+ * - pipeline-analytics:vehicle-samples — enrolled plate reads (text + score)
7660
+ *
7661
+ * A plate is self-labeling (the OCR text IS the key), so a sample carries
7662
+ * `text` + `score` instead of an embedding + modelId + dim — no model-version
7663
+ * gate is needed. deleteVehicle cascades: removes all sample rows first.
7664
+ */
7665
+ var VEHICLES_COLLECTION = "pipeline-analytics:vehicles";
7666
+ var VEHICLE_SAMPLES_COLLECTION = "pipeline-analytics:vehicle-samples";
7667
+ var VEHICLE_COLUMNS = [
7668
+ {
7669
+ name: "id",
7670
+ type: "TEXT",
7671
+ primaryKey: true,
7672
+ notNull: true
7673
+ },
7674
+ {
7675
+ name: "name",
7676
+ type: "TEXT",
7677
+ notNull: true
7678
+ },
7679
+ {
7680
+ name: "createdAt",
7681
+ type: "INTEGER",
7682
+ notNull: true
7683
+ },
7684
+ {
7685
+ name: "updatedAt",
7686
+ type: "INTEGER",
7687
+ notNull: true
7688
+ },
7689
+ {
7690
+ name: "sampleCount",
7691
+ type: "INTEGER",
7692
+ notNull: true
7693
+ },
7694
+ {
7695
+ name: "coverMediaKey",
7696
+ type: "TEXT"
7697
+ }
7698
+ ];
7699
+ var SAMPLE_COLUMNS = [
7700
+ {
7701
+ name: "id",
7702
+ type: "TEXT",
7703
+ primaryKey: true,
7704
+ notNull: true
7705
+ },
7706
+ {
7707
+ name: "vehicleId",
7708
+ type: "TEXT",
7709
+ notNull: true
7710
+ },
7711
+ {
7712
+ name: "text",
7713
+ type: "TEXT",
7714
+ notNull: true
7715
+ },
7716
+ {
7717
+ name: "score",
7718
+ type: "REAL",
7719
+ notNull: true
7720
+ },
7721
+ {
7722
+ name: "sourcePlateId",
7723
+ type: "TEXT"
7724
+ },
7725
+ {
7726
+ name: "mediaKey",
7727
+ type: "TEXT"
7728
+ },
7729
+ {
7730
+ name: "deviceId",
7731
+ type: "INTEGER"
7732
+ },
7733
+ {
7734
+ name: "addedAt",
7735
+ type: "INTEGER",
7736
+ notNull: true
7737
+ }
7738
+ ];
7739
+ var VehicleStore = class {
7740
+ store;
7741
+ logger;
7742
+ constructor(deps) {
7743
+ this.store = deps.store;
7744
+ this.logger = deps.logger;
7745
+ }
7746
+ static async declare(store) {
7747
+ await store.declareCollection.mutate({
7748
+ collection: VEHICLES_COLLECTION,
7749
+ columns: [...VEHICLE_COLUMNS],
7750
+ indexes: []
7751
+ });
7752
+ await store.declareCollection.mutate({
7753
+ collection: VEHICLE_SAMPLES_COLLECTION,
7754
+ columns: [...SAMPLE_COLUMNS],
7755
+ indexes: [{
7756
+ name: "idx_vehicle_sample",
7757
+ columns: ["vehicleId"]
7758
+ }]
7759
+ });
7760
+ }
7761
+ async createVehicle(input) {
7762
+ const now = Date.now();
7763
+ const vehicle = {
7764
+ id: (0, node_crypto.randomUUID)(),
7765
+ name: input.name,
7766
+ createdAt: now,
7767
+ updatedAt: now,
7768
+ sampleCount: 0
7769
+ };
7770
+ const { id, ...rest } = vehicle;
7771
+ await this.store.insert.mutate({
7772
+ collection: VEHICLES_COLLECTION,
7773
+ record: {
7774
+ id,
7775
+ data: rest
7776
+ }
7777
+ });
7778
+ return vehicle;
7779
+ }
7780
+ async renameVehicle(id, name) {
7781
+ await this.store.update.mutate({
7782
+ collection: VEHICLES_COLLECTION,
7783
+ id,
7784
+ data: {
7785
+ name,
7786
+ updatedAt: Date.now()
7787
+ }
7788
+ });
7789
+ }
7790
+ async deleteVehicle(id) {
7791
+ const samples = await this.store.query.query({
7792
+ collection: VEHICLE_SAMPLES_COLLECTION,
7793
+ filter: { where: { vehicleId: id } }
7794
+ });
7795
+ for (const s of samples) try {
7796
+ await this.store.delete.mutate({
7797
+ collection: VEHICLE_SAMPLES_COLLECTION,
7798
+ key: s.id
7799
+ });
7800
+ } catch (err) {
7801
+ this.logger.warn("deleteVehicle sample delete failed", { meta: {
7802
+ vehicleId: id,
7803
+ sampleId: s.id,
7804
+ error: String(err)
7805
+ } });
7806
+ }
7807
+ await this.store.delete.mutate({
7808
+ collection: VEHICLES_COLLECTION,
7809
+ key: id
7810
+ });
7811
+ }
7812
+ async listVehicles() {
7813
+ return (await this.store.query.query({
7814
+ collection: VEHICLES_COLLECTION,
7815
+ filter: { orderBy: {
7816
+ field: "createdAt",
7817
+ direction: "asc"
7818
+ } }
7819
+ })).map((r) => {
7820
+ const data = r.data;
7821
+ return {
7822
+ id: r.id,
7823
+ ...data,
7824
+ coverMediaKey: data.coverMediaKey ?? void 0
7825
+ };
7826
+ });
7827
+ }
7828
+ async addSample(input) {
7829
+ const sample = {
7830
+ id: (0, node_crypto.randomUUID)(),
7831
+ vehicleId: input.vehicleId,
7832
+ text: input.text,
7833
+ score: input.score,
7834
+ ...input.sourcePlateId !== void 0 ? { sourcePlateId: input.sourcePlateId } : {},
7835
+ ...input.mediaKey !== void 0 ? { mediaKey: input.mediaKey } : {},
7836
+ ...input.deviceId !== void 0 ? { deviceId: input.deviceId } : {},
7837
+ addedAt: Date.now()
7838
+ };
7839
+ const { id, ...rest } = sample;
7840
+ await this.store.insert.mutate({
7841
+ collection: VEHICLE_SAMPLES_COLLECTION,
7842
+ record: {
7843
+ id,
7844
+ data: rest
7845
+ }
7846
+ });
7847
+ const vehicle = (await this.listVehicles()).find((v) => v.id === input.vehicleId);
7848
+ if (vehicle) {
7849
+ const patch = {
7850
+ sampleCount: vehicle.sampleCount + 1,
7851
+ updatedAt: Date.now()
7852
+ };
7853
+ if (vehicle.coverMediaKey == null && input.mediaKey !== void 0) patch["coverMediaKey"] = input.mediaKey;
7854
+ await this.store.update.mutate({
7855
+ collection: VEHICLES_COLLECTION,
7856
+ id: input.vehicleId,
7857
+ data: patch
7858
+ });
6303
7859
  }
7860
+ return sample;
6304
7861
  }
6305
- /**
6306
- * Retention across ALL devices: delete reads older than `cutoffMs` AND any
6307
- * beyond the newest `maxPerDevice` per device. Corrected reads are exempt
6308
- * (human-confirmed). Returns deleted ids so the caller removes their crops.
6309
- */
6310
- async pruneAll(input) {
7862
+ async listSamples(vehicleId) {
7863
+ return (await this.store.query.query({
7864
+ collection: VEHICLE_SAMPLES_COLLECTION,
7865
+ filter: {
7866
+ where: { vehicleId },
7867
+ orderBy: {
7868
+ field: "addedAt",
7869
+ direction: "asc"
7870
+ }
7871
+ }
7872
+ })).map((r) => {
7873
+ const d = r.data;
7874
+ return {
7875
+ id: r.id,
7876
+ text: String(d.text ?? ""),
7877
+ score: Number(d.score ?? 0),
7878
+ addedAt: Number(d.addedAt ?? 0),
7879
+ ...d.mediaKey != null ? { mediaKey: d.mediaKey } : {},
7880
+ ...d.deviceId != null ? { deviceId: d.deviceId } : {}
7881
+ };
7882
+ });
7883
+ }
7884
+ async removeSample(vehicleId, sampleId) {
7885
+ await this.store.delete.mutate({
7886
+ collection: VEHICLE_SAMPLES_COLLECTION,
7887
+ key: sampleId
7888
+ });
7889
+ const vehicle = (await this.listVehicles()).find((v) => v.id === vehicleId);
7890
+ if (vehicle) await this.store.update.mutate({
7891
+ collection: VEHICLES_COLLECTION,
7892
+ id: vehicleId,
7893
+ data: {
7894
+ sampleCount: Math.max(0, vehicle.sampleCount - 1),
7895
+ updatedAt: Date.now()
7896
+ }
7897
+ });
7898
+ }
7899
+ async loadGallery() {
6311
7900
  const rows = await this.store.query.query({
6312
- collection: PLATES_COLLECTION,
6313
- filter: { orderBy: {
6314
- field: "timestamp",
6315
- direction: "desc"
6316
- } }
7901
+ collection: VEHICLE_SAMPLES_COLLECTION,
7902
+ filter: {}
6317
7903
  });
6318
- const byDevice = /* @__PURE__ */ new Map();
7904
+ const gallery = [];
6319
7905
  for (const r of rows) {
6320
- const data = r.data;
6321
- if (data.corrected) continue;
6322
- const deviceId = Number(data.deviceId ?? -1);
6323
- const list = byDevice.get(deviceId) ?? [];
6324
- list.push({
6325
- id: r.id,
6326
- timestamp: Number(data.timestamp ?? 0)
6327
- });
6328
- byDevice.set(deviceId, list);
6329
- }
6330
- const toDelete = /* @__PURE__ */ new Set();
6331
- for (const list of byDevice.values()) {
6332
- list.sort((a, b) => b.timestamp - a.timestamp);
6333
- list.forEach((row, idx) => {
6334
- if (row.timestamp < input.cutoffMs) toDelete.add(row.id);
6335
- if (idx >= input.maxPerDevice) toDelete.add(row.id);
6336
- });
6337
- }
6338
- const deleted = [];
6339
- for (const id of toDelete) try {
6340
- await this.store.delete.mutate({
6341
- collection: PLATES_COLLECTION,
6342
- key: id
7906
+ const d = r.data;
7907
+ if (typeof d.text !== "string" || typeof d.vehicleId !== "string") continue;
7908
+ gallery.push({
7909
+ vehicleId: d.vehicleId,
7910
+ text: d.text
6343
7911
  });
6344
- deleted.push(id);
6345
- } catch (err) {
6346
- this.logger.warn("PlateStore.pruneAll delete failed", { meta: {
6347
- plateId: id,
6348
- error: String(err)
6349
- } });
6350
7912
  }
6351
- return deleted;
7913
+ return gallery;
6352
7914
  }
6353
7915
  };
6354
7916
  //#endregion
7917
+ //#region src/pipeline-analytics/pipeline/plate-matcher.ts
7918
+ /**
7919
+ * plate-matcher — name a plate read against the vehicle gallery by text vicinity.
7920
+ *
7921
+ * Parallel to face-matcher's `matchEmbedding`, but a plate is self-labeling:
7922
+ * distance is `plateDistance` (OCR-confusion-folded Levenshtein), not cosine.
7923
+ * There is no model-version gate (text has no embedding drift) and no ambiguity
7924
+ * `margin` (Levenshtein has no comparable normalized gap) — a tie is resolved
7925
+ * conservatively to NO match. The read's `score` weights how far it may match:
7926
+ * a high-confidence read (`score >= lowScoreFloor`) may match at `maxDistance`;
7927
+ * a low-confidence read is held to `lowScoreMaxDistance` (exact fold only).
7928
+ * "Prefer unassigned over wrong vehicle."
7929
+ */
7930
+ /** Calibration placeholders — re-tune against real OCR score distributions. */
7931
+ var DEFAULT_PLATE_MATCH_OPTS = {
7932
+ maxDistance: 1,
7933
+ lowScoreFloor: .5,
7934
+ lowScoreMaxDistance: 0
7935
+ };
7936
+ function matchPlateText(probe, gallery, opts) {
7937
+ const allowed = probe.score >= opts.lowScoreFloor ? opts.maxDistance : opts.lowScoreMaxDistance;
7938
+ const bestByVehicle = /* @__PURE__ */ new Map();
7939
+ for (const s of gallery) {
7940
+ const dist = plateDistance(probe.text, s.text);
7941
+ const prev = bestByVehicle.get(s.vehicleId);
7942
+ if (prev === void 0 || dist < prev) bestByVehicle.set(s.vehicleId, dist);
7943
+ }
7944
+ if (bestByVehicle.size === 0) return null;
7945
+ let bestId = null;
7946
+ let bestDist = Infinity;
7947
+ let tie = false;
7948
+ for (const [vehicleId, dist] of bestByVehicle) {
7949
+ if (dist > allowed) continue;
7950
+ if (dist < bestDist) {
7951
+ bestDist = dist;
7952
+ bestId = vehicleId;
7953
+ tie = false;
7954
+ } else if (dist === bestDist) tie = true;
7955
+ }
7956
+ if (bestId === null || tie) return null;
7957
+ return {
7958
+ vehicleId: bestId,
7959
+ distance: bestDist
7960
+ };
7961
+ }
7962
+ //#endregion
6355
7963
  //#region src/pipeline-analytics/plate-recognizer.ts
6356
7964
  /**
6357
7965
  * PlateRecognizer — buffers the best license-plate OCR read per vehicle track
@@ -6363,8 +7971,38 @@ var PlateStore = class {
6363
7971
  var PlateRecognizer = class {
6364
7972
  deps;
6365
7973
  bestPlate = /* @__PURE__ */ new Map();
7974
+ gallery = [];
7975
+ nameById = /* @__PURE__ */ new Map();
7976
+ matchOpts;
6366
7977
  constructor(deps) {
6367
7978
  this.deps = deps;
7979
+ this.matchOpts = deps.matchOpts ?? DEFAULT_PLATE_MATCH_OPTS;
7980
+ }
7981
+ /** Reload the in-memory vehicle gallery + name map (after a gallery mutation). */
7982
+ async refreshGallery() {
7983
+ try {
7984
+ this.gallery = await this.deps.vehicleStore.loadGallery();
7985
+ this.nameById = new Map((await this.deps.vehicleStore.listVehicles()).map((v) => [v.id, v.name]));
7986
+ } catch (err) {
7987
+ this.deps.logger.warn("PlateRecognizer.refreshGallery failed", { meta: { error: String(err) } });
7988
+ }
7989
+ }
7990
+ matchVehicle(text, score) {
7991
+ const m = matchPlateText({
7992
+ text: normalizePlate(text),
7993
+ score
7994
+ }, this.gallery, this.matchOpts);
7995
+ if (m === null) return null;
7996
+ const name = this.nameById.get(m.vehicleId);
7997
+ return name !== void 0 ? {
7998
+ vehicleId: m.vehicleId,
7999
+ name
8000
+ } : null;
8001
+ }
8002
+ /** Live label for a plate read: the recognized vehicle NAME when matched, else
8003
+ * the raw OCR text (today's behavior). Used by the ingest label path. */
8004
+ resolveLabel(text, score) {
8005
+ return this.matchVehicle(text, score)?.name ?? text;
6368
8006
  }
6369
8007
  async processFrame(input) {
6370
8008
  const minConfidence = input.minConfidence ?? 0;
@@ -6420,6 +8058,8 @@ var PlateRecognizer = class {
6420
8058
  }
6421
8059
  });
6422
8060
  }
8061
+ const match = this.matchVehicle(held.text, held.score);
8062
+ const keyFrameMediaKey = this.deps.getKeyFrameMediaKey?.(trackId);
6423
8063
  try {
6424
8064
  await this.deps.plateStore.insert({
6425
8065
  id: plateId,
@@ -6429,7 +8069,16 @@ var PlateRecognizer = class {
6429
8069
  text: normalizePlate(held.text),
6430
8070
  score: held.score,
6431
8071
  ...mediaKey !== void 0 ? { mediaKey } : {},
6432
- corrected: false
8072
+ corrected: false,
8073
+ assigned: false,
8074
+ ...match !== null ? { recognizedVehicleId: match.vehicleId } : {},
8075
+ ...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {},
8076
+ plateBbox: held.bbox
8077
+ });
8078
+ this.deps.emitPlateGalleryChanged?.({
8079
+ deviceId,
8080
+ plateId,
8081
+ kind: "buffered"
6433
8082
  });
6434
8083
  this.deps.logger.info("plate: buffered to gallery", {
6435
8084
  tags: {
@@ -6440,7 +8089,8 @@ var PlateRecognizer = class {
6440
8089
  plateId,
6441
8090
  text: normalizePlate(held.text),
6442
8091
  score: held.score,
6443
- hasCrop: mediaKey !== void 0
8092
+ hasCrop: mediaKey !== void 0,
8093
+ recognizedVehicleId: match?.vehicleId ?? null
6444
8094
  }
6445
8095
  });
6446
8096
  } catch (err) {
@@ -6817,8 +8467,11 @@ var POST_PROCESSING_NODE_DEFAULT = "hub";
6817
8467
  * Absent / empty / non-string all fall back to the hub default — the exact
6818
8468
  * narrowing the old raw read applied inline. */
6819
8469
  var PostProcessingNodeIdSchema = require_dist.string().min(1);
8470
+ var EmbeddingEnabledSchema = require_dist.boolean();
6820
8471
  var SILENCE_FLOOR_DBFS = -55;
6821
8472
  var RETENTION_SWEEP_INTERVAL_MS = 5 * 6e4;
8473
+ /** Throttle for the "overlay synthesis failed" warn — one line / minute / device. */
8474
+ var OVERLAY_SYNTHESIS_WARN_THROTTLE_MS = 6e4;
6822
8475
  var AUDIO_EVENT_HEARTBEAT_MS = 5e3;
6823
8476
  var MOTION_EVENT_HEARTBEAT_MS = 5e3;
6824
8477
  /**
@@ -6878,12 +8531,17 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
6878
8531
  faceStore = null;
6879
8532
  faceRecognizer = null;
6880
8533
  plateStore = null;
8534
+ vehicleStore = null;
6881
8535
  plateRecognizer = null;
6882
8536
  objectEmbeddingStore = null;
6883
8537
  /** Two-plane detail scheduler/dispatcher: per-track on-demand face/clip/
6884
8538
  * classifier/plate enrichment via `pipelineRunner.runDetailSubtree`. Replaces
6885
8539
  * the per-frame child consumption the executor no longer emits. */
6886
8540
  detailDispatcher = null;
8541
+ /** Per-track detail geometry for the live-overlay synthesis (two-plane). */
8542
+ overlayState = new OverlayDetailStateStore();
8543
+ /** Throttle state for the "overlay synthesis failed" warn, one per device. */
8544
+ overlaySynthesisWarnAt = /* @__PURE__ */ new Map();
6887
8545
  /** Active CLIP image-embedding model id (== the text encoder's), TTL-cached.
6888
8546
  * Stamped on object-embedding rows from the detail plane so semantic search's
6889
8547
  * same-model gate keeps matching. */
@@ -6895,6 +8553,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
6895
8553
  /** Shared shm-ring reader cache for resolving `frameHandle`s to pixels.
6896
8554
  * Owned here so segments stay open across frames; closed once on shutdown. */
6897
8555
  frameReaders = null;
8556
+ /** Object/face embedding dispatcher — migrated from the retired
8557
+ * enrichment-engine addon. Runs ONLY on the post-processing node; on each
8558
+ * detection it resolves the frame, crops the ROI, and calls the
8559
+ * embedding-encoder cap (which persists the vector for the search/face
8560
+ * path). Null on non-post-processing nodes or when disabled. */
8561
+ embeddingDispatcher = null;
6898
8562
  bindingCache = null;
6899
8563
  zoneAnalytics = null;
6900
8564
  audioMetrics = null;
@@ -6945,6 +8609,10 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
6945
8609
  hysteresis: BEST_FRAME_HYSTERESIS,
6946
8610
  minGapMs: BEST_FRAME_MIN_GAP_MS
6947
8611
  });
8612
+ /** Windowed per-device aggregation of the "media capture" diagnostic — the
8613
+ * per-capture line is debug; a 60s per-counter SUM lands at info (~1
8614
+ * line/min/device instead of one per §5 cadence tick, see media-capture-log.ts). */
8615
+ mediaCaptureLog = new MediaCaptureLogAggregator();
6948
8616
  /** Best (highest-confidence) CLIP-object detection per track — drives ONE
6949
8617
  * tight object-crop capture whose media key is written onto the embedding
6950
8618
  * row (so a semantic-search hit's thumbnail IS the embedded crop). Shares the
@@ -6956,6 +8624,11 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
6956
8624
  * at track end so a face row links to the SAME single key frame. Cleared on
6957
8625
  * track end. */
6958
8626
  keyFrameKeyByTrackId = /* @__PURE__ */ new Map();
8627
+ /** Per-track update-gate memory for `PipelineAnalyticsTrackLifecycle`
8628
+ * `phase:'update'` — the last-emitted best (confidence / label / crop
8629
+ * area) + emit time, so a material improvement is measured against the
8630
+ * last emit and debounced. Seeded at `start`, dropped at `end`. */
8631
+ trackLifecycleUpdateMem = /* @__PURE__ */ new Map();
6959
8632
  /** The shared crop extractor (native-res first, detection-frame fallback),
6960
8633
  * captured in the constructor so `processFrame` can crop object thumbnails in
6961
8634
  * the same live-frame window as the face/plate/event-media captures. The
@@ -6971,6 +8644,15 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
6971
8644
  if (!this._postProcessingNodeState) this._postProcessingNodeState = this.state("postProcessingNodeId", PostProcessingNodeIdSchema, POST_PROCESSING_NODE_DEFAULT);
6972
8645
  return this._postProcessingNodeState;
6973
8646
  }
8647
+ /** Master toggle for the migrated object/face EmbeddingDispatcher (default
8648
+ * ON — the enrichment-engine default was `embeddingEnabled:true`). Read at
8649
+ * init; a change takes effect on addon restart (same convention as
8650
+ * `postProcessingNodeId`). */
8651
+ _embeddingEnabledState = null;
8652
+ get embeddingEnabledState() {
8653
+ if (!this._embeddingEnabledState) this._embeddingEnabledState = this.state("embeddingEnabled", EmbeddingEnabledSchema, true);
8654
+ return this._embeddingEnabledState;
8655
+ }
6974
8656
  /** GLOBAL face-recognition master switch (`enabled` key). Schema +
6975
8657
  * fallback derived from the face-settings source of truth. */
6976
8658
  _faceGlobalEnabledState = null;
@@ -6990,12 +8672,13 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
6990
8672
  await IdentityStore.declare(api.settingsStore);
6991
8673
  await FaceStore.declare(api.settingsStore);
6992
8674
  await PlateStore.declare(api.settingsStore);
8675
+ await VehicleStore.declare(api.settingsStore);
6993
8676
  await ObjectEmbeddingStore.declare(api.settingsStore);
6994
8677
  const logger = this.ctx.logger;
6995
8678
  let storage = this.ctx.kernel.storage;
6996
8679
  const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
6997
8680
  if (mediaRoot) {
6998
- const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-BN31iDiA.js"));
8681
+ const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-Cvhwrf43.js"));
6999
8682
  storage = new FilesystemStorageProvider(mediaRoot);
7000
8683
  logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
7001
8684
  }
@@ -7011,7 +8694,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
7011
8694
  });
7012
8695
  this.eventStore = new EventStore({
7013
8696
  store: api.settingsStore,
7014
- logger: logger.child("EventStore")
8697
+ logger: logger.child("EventStore"),
8698
+ media: this.mediaStore
7015
8699
  });
7016
8700
  this.identityStore = new IdentityStore({
7017
8701
  store: api.settingsStore,
@@ -7025,6 +8709,10 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
7025
8709
  store: api.settingsStore,
7026
8710
  logger: logger.child("PlateStore")
7027
8711
  });
8712
+ this.vehicleStore = new VehicleStore({
8713
+ store: api.settingsStore,
8714
+ logger: logger.child("VehicleStore")
8715
+ });
7028
8716
  const rawNodeId = this.ctx.kernel.localNodeId ?? this.ctx.id;
7029
8717
  const ownNodeId = rawNodeId.includes("/") ? rawNodeId.split("/")[0] : rawNodeId;
7030
8718
  {
@@ -7093,7 +8781,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
7093
8781
  return null;
7094
8782
  }
7095
8783
  };
7096
- const resolveFrameShared = createSharedFrameResolver((frameHandle) => require_resolve_frame.resolveFrame(frameHandle, {
8784
+ const resolveFrameShared = createSharedFrameResolver((frameHandle) => resolveFrame(frameHandle, {
7097
8785
  ownNodeId: ownNodeIdForFaces,
7098
8786
  readers: frameReadersForFaces,
7099
8787
  getRemoteFrame
@@ -7113,7 +8801,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
7113
8801
  bumpCropMetric(false);
7114
8802
  const decoded = await resolveFrameShared(frameHandle);
7115
8803
  if (!decoded || decoded.format !== "rgb") return null;
7116
- const { crop } = await require_resolve_frame.extractCrop(Buffer.isBuffer(decoded.data) ? decoded.data : Buffer.from(decoded.data), decoded.width, decoded.height, paddedNorm);
8804
+ const { crop } = await extractCrop(Buffer.isBuffer(decoded.data) ? decoded.data : Buffer.from(decoded.data), decoded.width, decoded.height, paddedNorm);
7117
8805
  return crop;
7118
8806
  };
7119
8807
  this.captureCrop = captureCrop;
@@ -7134,15 +8822,20 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
7134
8822
  }, trackId);
7135
8823
  },
7136
8824
  getKeyFrameMediaKey: (trackId) => this.keyFrameKeyByTrackId.get(trackId),
8825
+ emitFaceGalleryChanged: (payload) => this.emitFaceGalleryChanged(payload),
7137
8826
  logger: logger.child("FaceRecognizer")
7138
8827
  });
7139
8828
  this.faceRecognizer.refreshGallery();
7140
8829
  this.plateRecognizer = new PlateRecognizer({
7141
8830
  plateStore: this.plateStore,
8831
+ vehicleStore: this.vehicleStore,
7142
8832
  mediaStore: this.mediaStore,
7143
8833
  captureCrop,
8834
+ getKeyFrameMediaKey: (trackId) => this.keyFrameKeyByTrackId.get(trackId),
8835
+ emitPlateGalleryChanged: (payload) => this.emitPlateGalleryChanged(payload),
7144
8836
  logger: logger.child("PlateRecognizer")
7145
8837
  });
8838
+ this.plateRecognizer.refreshGallery();
7146
8839
  this.objectEmbeddingStore = new ObjectEmbeddingStore({
7147
8840
  store: api.settingsStore,
7148
8841
  logger: logger.child("ObjectEmbeddingStore")
@@ -7160,6 +8853,10 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
7160
8853
  if (frame.frameHandle === void 0 || !this.captureCrop) return null;
7161
8854
  const buf = await this.captureCrop(frame.frameHandle, { ...frame.bbox }, frame.frameWidth, frame.frameHeight, DETAIL_FALLBACK_CROP_PADDING);
7162
8855
  return buf ? buf.toString("base64") : null;
8856
+ },
8857
+ hasTrackLabel: (trackId) => {
8858
+ const label = this.trackStore?.getActiveByTrack(trackId)?.label;
8859
+ return label !== void 0 && label.length > 0;
7163
8860
  }
7164
8861
  });
7165
8862
  this.bindingCache = new BindingCache({
@@ -7227,12 +8924,37 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
7227
8924
  const data = ev.data;
7228
8925
  this.handleNativeDetection(data);
7229
8926
  });
8927
+ if (await this.embeddingEnabledState.get()) {
8928
+ const encoderClient = {
8929
+ encode: (crop, width, height) => this.ctx.api.embeddingEncoder.encode.query({
8930
+ crop: new Uint8Array(crop),
8931
+ width,
8932
+ height
8933
+ }),
8934
+ getInfo: () => this.ctx.api.embeddingEncoder.getInfo.query()
8935
+ };
8936
+ this.embeddingDispatcher = new EmbeddingDispatcher({
8937
+ config: {
8938
+ ...DEFAULT_EMBEDDING_CONFIG,
8939
+ enabled: true
8940
+ },
8941
+ encoder: encoderClient,
8942
+ eventBus: this.ctx.eventBus,
8943
+ logger: logger.child("EmbeddingDispatcher"),
8944
+ ownNodeId,
8945
+ readers: frameReadersForFaces,
8946
+ getRemoteFrame
8947
+ });
8948
+ await this.embeddingDispatcher.start();
8949
+ } else logger.info("EmbeddingDispatcher disabled via settings (embeddingEnabled=false)");
7230
8950
  }
7231
8951
  this.unsubBindings = this.ctx.eventBus.subscribe({ category: require_dist.EventCategory.DeviceBindingsChanged }, (ev) => {
7232
8952
  const data = ev.data;
7233
8953
  this.bindingCache?.onBindingsChanged(data);
7234
8954
  if (data.capName === "pipeline-analytics" && data.reason === "wrapper-deactivated") {
7235
8955
  this.trackStore?.clearDevice(data.deviceId);
8956
+ this.overlayState.clearDevice(data.deviceId);
8957
+ this.overlaySynthesisWarnAt.delete(data.deviceId);
7236
8958
  this.forgetDeviceProcessors(data.deviceId);
7237
8959
  this.levelStateByDevice.delete(data.deviceId);
7238
8960
  this.settingsCacheByDevice.delete(data.deviceId);
@@ -7245,6 +8967,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
7245
8967
  this.unsubDeviceUnreg = this.ctx.eventBus.subscribe({ category: require_dist.EventCategory.DeviceUnregistered }, (ev) => {
7246
8968
  const { deviceId } = ev.data;
7247
8969
  this.trackStore?.clearDevice(deviceId);
8970
+ this.overlayState.clearDevice(deviceId);
8971
+ this.overlaySynthesisWarnAt.delete(deviceId);
7248
8972
  this.forgetDeviceProcessors(deviceId);
7249
8973
  this.levelStateByDevice.delete(deviceId);
7250
8974
  this.settingsCacheByDevice.delete(deviceId);
@@ -7262,6 +8986,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
7262
8986
  }, TTL_SWEEP_INTERVAL_MS);
7263
8987
  this.retentionSweepTimer = setInterval(() => {
7264
8988
  this.sweepRetention();
8989
+ this.runTrackRetentionSweep();
7265
8990
  }, RETENTION_SWEEP_INTERVAL_MS);
7266
8991
  this.ctx.logger.info("pipeline-analytics subscribers installed");
7267
8992
  const widgetsProvider = { listWidgets: async () => [
@@ -7502,11 +9227,19 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
7502
9227
  mediaStore: this.mediaStore,
7503
9228
  trackStore: this.trackStore,
7504
9229
  eventStore: this.eventStore,
7505
- refreshGallery: () => this.faceRecognizer?.refreshGallery()
9230
+ refreshGallery: () => this.faceRecognizer?.refreshGallery(),
9231
+ emitFaceGalleryChanged: (payload) => this.emitFaceGalleryChanged(payload)
7506
9232
  });
7507
9233
  const plateGallery = new PlateGalleryProvider({
7508
9234
  plateStore: this.plateStore,
7509
- mediaStore: this.mediaStore
9235
+ vehicleStore: this.vehicleStore,
9236
+ mediaStore: this.mediaStore,
9237
+ trackStore: this.trackStore,
9238
+ eventStore: this.eventStore,
9239
+ refreshGallery: () => {
9240
+ this.plateRecognizer?.refreshGallery();
9241
+ },
9242
+ emitPlateGalleryChanged: (payload) => this.emitPlateGalleryChanged(payload)
7510
9243
  });
7511
9244
  return [
7512
9245
  {
@@ -7555,6 +9288,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
7555
9288
  this.unsubBindings = null;
7556
9289
  this.unsubDeviceUnreg?.();
7557
9290
  this.unsubDeviceUnreg = null;
9291
+ await this.embeddingDispatcher?.stop();
9292
+ this.embeddingDispatcher = null;
7558
9293
  for (const id of this.proxies.keys()) this.releaseProxy(id);
7559
9294
  if (this.ttlSweepTimer) {
7560
9295
  clearInterval(this.ttlSweepTimer);
@@ -7568,10 +9303,13 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
7568
9303
  this.audioMetrics?.destroy();
7569
9304
  this.detailDispatcher?.dispose();
7570
9305
  this.detailDispatcher = null;
9306
+ this.overlayState.clear();
9307
+ this.overlaySynthesisWarnAt.clear();
7571
9308
  this.processors.clear();
7572
9309
  this.lastActiveTrackIds.clear();
7573
9310
  this.dropoutSkipsByKey.clear();
7574
9311
  this.bestFrameTracker.clear();
9312
+ this.trackLifecycleUpdateMem.clear();
7575
9313
  this.objectEmbeddingBestSelector.clear();
7576
9314
  this.levelStateByDevice.clear();
7577
9315
  this.settingsCacheByDevice.clear();
@@ -7703,6 +9441,24 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
7703
9441
  className: t.className
7704
9442
  }
7705
9443
  });
9444
+ const startPayload = buildTrackLifecyclePayload({
9445
+ deviceId,
9446
+ trackId: id,
9447
+ phase: "start",
9448
+ classes: [t.className],
9449
+ bestClassName: t.className,
9450
+ bestConfidence: t.confidence,
9451
+ firstSeen: result.timestamp,
9452
+ lastSeen: result.timestamp,
9453
+ ...t.label !== void 0 ? { label: t.label } : {}
9454
+ });
9455
+ this.emitTrackLifecycle(startPayload, result.timestamp);
9456
+ this.trackLifecycleUpdateMem.set(id, {
9457
+ lastConfidence: t.confidence,
9458
+ lastEmitAt: result.timestamp,
9459
+ ...t.label !== void 0 ? { lastLabel: t.label } : {},
9460
+ lastBboxArea: t.bbox.w * t.bbox.h
9461
+ });
7706
9462
  }
7707
9463
  }
7708
9464
  let lostTrackCount = 0;
@@ -7766,7 +9522,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
7766
9522
  const mediaSettings = await this.resolveDeviceMediaSettings(deviceId);
7767
9523
  if (this.eventMediaDispatcher && frameHandle) {
7768
9524
  const childCropsByEvent = buildEventChildCrops(result.objectEvents, frame.detections);
7769
- const eventTargets = result.objectEvents.filter((e) => e.bbox).map((e) => {
9525
+ const eventTargets = result.objectEvents.filter((e) => e.bbox !== void 0 && e.bbox.w > 0 && e.bbox.h > 0).map((e) => {
7770
9526
  const childCrops = childCropsByEvent.get(e.id);
7771
9527
  return {
7772
9528
  eventId: e.id,
@@ -7782,15 +9538,25 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
7782
9538
  let plateCrops = 0;
7783
9539
  for (const crops of childCropsByEvent.values()) for (const c of crops) if (c.kind === "faceCrop") faceCrops += 1;
7784
9540
  else plateCrops += 1;
7785
- const snapshotTargets = this.buildSnapshotTargets(result.tracked, result.timestamp, mediaSettings);
9541
+ const snapshotTargets = this.buildSnapshotTargets(deviceId, result.tracked, result.timestamp, mediaSettings);
7786
9542
  if (eventTargets.length > 0 || firstFrameTargets.length > 0 || snapshotTargets.length > 0) {
7787
- log.info("media capture", { meta: {
7788
- source,
9543
+ const captureCounts = {
7789
9544
  events: eventTargets.length,
7790
9545
  trackFrames: firstFrameTargets.length,
7791
9546
  snapshots: snapshotTargets.length,
7792
9547
  faceCrops,
7793
9548
  plateCrops
9549
+ };
9550
+ log.debug("media capture", { meta: {
9551
+ source,
9552
+ ...captureCounts
9553
+ } });
9554
+ const captureAgg = this.mediaCaptureLog.note(deviceId, captureCounts, Date.now());
9555
+ if (captureAgg !== null) log.info("media capture (window)", { meta: {
9556
+ source,
9557
+ windowSec: Math.round(captureAgg.windowMs / 1e3),
9558
+ frames: captureAgg.frames,
9559
+ ...captureAgg.sums
7794
9560
  } });
7795
9561
  this.eventMediaDispatcher.captureForFrame({
7796
9562
  deviceId,
@@ -7853,6 +9619,26 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
7853
9619
  zones: e.zones ?? []
7854
9620
  }
7855
9621
  });
9622
+ let overlayDetections;
9623
+ try {
9624
+ overlayDetections = buildOverlayDetections({
9625
+ frameDetections: frame.detections,
9626
+ tracked: result.tracked,
9627
+ overlayFor: (trackId) => this.overlayState.get(deviceId, trackId),
9628
+ labelFor: (trackId) => this.trackStore?.getActiveByTrack(trackId)?.label,
9629
+ nowMs: Date.now()
9630
+ });
9631
+ } catch (err) {
9632
+ const now = Date.now();
9633
+ if (now - (this.overlaySynthesisWarnAt.get(deviceId) ?? 0) >= OVERLAY_SYNTHESIS_WARN_THROTTLE_MS) {
9634
+ this.overlaySynthesisWarnAt.set(deviceId, now);
9635
+ this.ctx.logger.warn("overlay synthesis failed — emitting unenriched frame detections", {
9636
+ tags: { deviceId },
9637
+ meta: { error: String(err) }
9638
+ });
9639
+ }
9640
+ overlayDetections = frame.detections;
9641
+ }
7856
9642
  this.ctx.eventBus.emit({
7857
9643
  id: `pa-${(0, node_crypto.randomUUID)()}`,
7858
9644
  timestamp: new Date(result.timestamp),
@@ -7867,7 +9653,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
7867
9653
  timestamp: result.timestamp,
7868
9654
  frameWidth: result.frameWidth,
7869
9655
  frameHeight: result.frameHeight,
7870
- detections: result.tracked
9656
+ detections: overlayDetections
7871
9657
  }
7872
9658
  });
7873
9659
  }
@@ -7957,9 +9743,25 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
7957
9743
  */
7958
9744
  async routeDetailResults(deviceId, trackId, details, frame) {
7959
9745
  for (const d of details) try {
7960
- if (d.className === "face" && d.embedding !== void 0) await this.routeFaceDetail(deviceId, trackId, d, frame);
9746
+ const isFaceDetail = d.className === "face";
9747
+ if (isFaceDetail && d.bbox !== void 0) this.overlayState.noteFaceDetail(deviceId, trackId, {
9748
+ x: d.bbox.x,
9749
+ y: d.bbox.y,
9750
+ w: d.bbox.w,
9751
+ h: d.bbox.h
9752
+ }, d.score, frame.timestamp);
9753
+ if (isFaceDetail && d.embedding !== void 0) await this.routeFaceDetail(deviceId, trackId, d, frame);
7961
9754
  else if (d.embedding !== void 0) await this.routeClipDetail(deviceId, trackId, d, frame.timestamp);
7962
- else if (d.label !== void 0 && d.label.length > 0) await this.applyTrackEnrichmentLabel(deviceId, trackId, d.label);
9755
+ else if (d.label !== void 0 && d.label.length > 0) {
9756
+ if (d.className === "plate" && d.bbox !== void 0) this.overlayState.notePlateDetail(deviceId, trackId, {
9757
+ x: d.bbox.x,
9758
+ y: d.bbox.y,
9759
+ w: d.bbox.w,
9760
+ h: d.bbox.h
9761
+ }, d.score, d.label, frame.timestamp);
9762
+ const label = d.className === "plate" ? this.plateRecognizer?.resolveLabel(d.label, d.score) ?? d.label : d.label;
9763
+ await this.applyTrackEnrichmentLabel(deviceId, trackId, label);
9764
+ }
7963
9765
  } catch (err) {
7964
9766
  this.ctx.logger.warn("detail result route failed", {
7965
9767
  tags: { deviceId },
@@ -8167,12 +9969,67 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
8167
9969
  });
8168
9970
  }));
8169
9971
  }
8170
- buildSnapshotTargets(tracked, timestamp, media) {
9972
+ /** Emit a `PipelineAnalyticsTrackLifecycle` event (start / update / end). */
9973
+ emitTrackLifecycle(payload, timestamp) {
9974
+ this.ctx.eventBus.emit({
9975
+ id: `pa-life-${(0, node_crypto.randomUUID)()}`,
9976
+ timestamp: new Date(timestamp),
9977
+ source: {
9978
+ type: "addon",
9979
+ id: "pipeline-analytics",
9980
+ addonId: "pipeline-analytics"
9981
+ },
9982
+ category: require_dist.EventCategory.PipelineAnalyticsTrackLifecycle,
9983
+ data: payload
9984
+ });
9985
+ }
9986
+ /**
9987
+ * Fire a `phase:'update'` lifecycle event when this frame's observation is
9988
+ * a MATERIAL improvement over the last emit (confidence delta, a
9989
+ * newly-resolved identity/plate label, or a materially larger crop),
9990
+ * debounced per `DEFAULT_UPDATE_GATE_CONFIG`. Reuses the already-computed
9991
+ * `isNewBest`; the gate is pure and the per-track memory is advanced in
9992
+ * place. Carries the CURRENT best incl. the improved key-frame media key.
9993
+ */
9994
+ maybeEmitTrackLifecycleUpdate(deviceId, t, timestamp, isNewBest) {
9995
+ const memory = this.trackLifecycleUpdateMem.get(t.trackId);
9996
+ const decision = evaluateTrackLifecycleUpdate({
9997
+ isNewBest,
9998
+ confidence: t.confidence,
9999
+ now: timestamp,
10000
+ ...t.label !== void 0 ? { label: t.label } : {},
10001
+ bboxArea: t.bbox.w * t.bbox.h
10002
+ }, memory, DEFAULT_UPDATE_GATE_CONFIG);
10003
+ this.trackLifecycleUpdateMem.set(t.trackId, decision.memory);
10004
+ if (!decision.emit) return;
10005
+ const track = this.trackStore?.getActiveByTrack(t.trackId);
10006
+ const keyFrameMediaKey = this.keyFrameKeyByTrackId.get(t.trackId);
10007
+ const payload = buildTrackLifecyclePayload({
10008
+ deviceId,
10009
+ trackId: t.trackId,
10010
+ phase: "update",
10011
+ classes: track?.classes ?? [t.className],
10012
+ bestClassName: t.className,
10013
+ bestConfidence: t.confidence,
10014
+ firstSeen: track?.firstSeen ?? timestamp,
10015
+ lastSeen: track?.lastSeen ?? timestamp,
10016
+ ...t.label !== void 0 ? { label: t.label } : {},
10017
+ ...t.plateText !== void 0 ? { plateText: t.plateText } : {},
10018
+ ...track?.zonesVisited !== void 0 ? { zonesVisited: track.zonesVisited } : {},
10019
+ ...track?.totalDistance !== void 0 ? { totalDistance: track.totalDistance } : {},
10020
+ ...track?.positions !== void 0 ? { positionsCount: track.positions.length } : {},
10021
+ ...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {},
10022
+ ...t.embeddingModelId !== void 0 ? { embeddingModelId: t.embeddingModelId } : {}
10023
+ });
10024
+ this.emitTrackLifecycle(payload, timestamp);
10025
+ }
10026
+ buildSnapshotTargets(deviceId, tracked, timestamp, media) {
8171
10027
  const targets = [];
8172
10028
  for (const t of tracked) {
8173
10029
  const lastSnap = this.trackStore.lastSnapshotAt(t.trackId);
8174
10030
  const dueSnapshot = media.saveThumbnails && lastSnap > 0 && timestamp - lastSnap >= media.snapshotIntervalMs;
8175
10031
  const isNewBest = this.bestFrameTracker.observe(t.trackId, t.confidence, timestamp);
10032
+ this.maybeEmitTrackLifecycleUpdate(deviceId, t, timestamp, isNewBest);
8176
10033
  if (!dueSnapshot && !isNewBest) continue;
8177
10034
  targets.push({
8178
10035
  trackId: t.trackId,
@@ -8438,6 +10295,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
8438
10295
  positions: t.positions.length
8439
10296
  }
8440
10297
  });
10298
+ const keyFrameMediaKey = this.keyFrameKeyByTrackId.get(t.trackId);
8441
10299
  const faceEnd = this.faceRecognizer?.onTrackEnd(t.deviceId, t.trackId);
8442
10300
  const dropKeyFrameKey = () => {
8443
10301
  this.keyFrameKeyByTrackId.delete(t.trackId);
@@ -8445,11 +10303,16 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
8445
10303
  if (faceEnd) faceEnd.finally(dropKeyFrameKey);
8446
10304
  else dropKeyFrameKey();
8447
10305
  this.plateRecognizer?.onTrackEnd(t.deviceId, t.trackId);
10306
+ const trackerPeak = this.bestFrameTracker.peak(t.trackId);
10307
+ let endImportance;
10308
+ let endImportanceReason;
10309
+ let endBestEventId;
8448
10310
  try {
8449
10311
  const peak = await this.eventStore?.peakForTrack(t.trackId);
8450
10312
  if (peak) {
10313
+ endBestEventId = peak.bestEventId;
8451
10314
  const { importance, reason } = computeImportance({
8452
- peakConfidence: this.bestFrameTracker.peak(t.trackId)?.confidence ?? peak.peakConfidence,
10315
+ peakConfidence: trackerPeak?.confidence ?? peak.peakConfidence,
8453
10316
  className: t.className,
8454
10317
  durationMs: duration,
8455
10318
  peakBboxAreaFrac: peak.peakBboxAreaFrac,
@@ -8457,6 +10320,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
8457
10320
  zonesVisited: t.zonesVisited,
8458
10321
  ...t.label !== void 0 ? { label: t.label } : {}
8459
10322
  });
10323
+ endImportance = importance;
10324
+ endImportanceReason = reason;
8460
10325
  await this.trackStore?.setImportance(t.trackId, importance, reason, peak.bestEventId);
8461
10326
  if (peak.bestEventId !== void 0) await this.eventStore?.setImportanceForTrack(t.trackId, importance);
8462
10327
  }
@@ -8469,6 +10334,18 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
8469
10334
  this.bestFrameTracker.delete(t.trackId);
8470
10335
  this.objectEmbeddingBestSelector.delete(t.trackId);
8471
10336
  this.detailDispatcher?.onTrackEnded(t.deviceId, t.trackId);
10337
+ this.overlayState.onTrackEnded(t.deviceId, t.trackId);
10338
+ if ((this.trackStore?.getActive(t.deviceId).length ?? 0) === 0) {
10339
+ const captureAgg = this.mediaCaptureLog.flush(t.deviceId, Date.now());
10340
+ if (captureAgg !== null) this.ctx.logger.info("media capture (window)", {
10341
+ tags: { deviceId: t.deviceId },
10342
+ meta: {
10343
+ windowSec: Math.round(captureAgg.windowMs / 1e3),
10344
+ frames: captureAgg.frames,
10345
+ ...captureAgg.sums
10346
+ }
10347
+ });
10348
+ }
8472
10349
  this.ctx.eventBus.emit({
8473
10350
  id: `pa-end-${t.trackId}`,
8474
10351
  timestamp: new Date(t.lastSeen),
@@ -8485,6 +10362,26 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
8485
10362
  durationMs: duration
8486
10363
  }
8487
10364
  });
10365
+ const endPayload = buildTrackLifecyclePayload({
10366
+ deviceId: t.deviceId,
10367
+ trackId: t.trackId,
10368
+ phase: "end",
10369
+ classes: t.classes ?? [t.className],
10370
+ bestClassName: t.className,
10371
+ bestConfidence: trackerPeak?.confidence ?? 0,
10372
+ firstSeen: t.firstSeen,
10373
+ lastSeen: t.lastSeen,
10374
+ ...t.label !== void 0 ? { label: t.label } : {},
10375
+ zonesVisited: t.zonesVisited,
10376
+ totalDistance: t.totalDistance,
10377
+ positionsCount: t.positions.length,
10378
+ ...endImportance !== void 0 ? { importance: endImportance } : {},
10379
+ ...endImportanceReason !== void 0 ? { importanceReason: endImportanceReason } : {},
10380
+ ...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {},
10381
+ ...endBestEventId !== void 0 ? { bestEventId: endBestEventId } : {}
10382
+ });
10383
+ this.emitTrackLifecycle(endPayload, t.lastSeen);
10384
+ this.trackLifecycleUpdateMem.delete(t.trackId);
8488
10385
  }
8489
10386
  } catch (err) {
8490
10387
  if (this.shuttingDown) return;
@@ -8576,6 +10473,62 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
8576
10473
  if (!frame) return;
8577
10474
  await this.processFrame(payload.cameraId, frame, "onboard");
8578
10475
  }
10476
+ /**
10477
+ * Best-effort bus notification for a gallery face-row change (buffer /
10478
+ * assign / unassign / delete). Passed into the `FaceRecognizer` and
10479
+ * `FaceGalleryProvider` as their `emitFaceGalleryChanged` dep. Telemetry
10480
+ * (D8): a lost/failed emit only delays the admin-ui live-refresh — never
10481
+ * allowed to fail the caller's mutation.
10482
+ */
10483
+ emitFaceGalleryChanged(payload) {
10484
+ try {
10485
+ this.ctx.eventBus.emit({
10486
+ id: `pa-${(0, node_crypto.randomUUID)()}`,
10487
+ timestamp: /* @__PURE__ */ new Date(),
10488
+ source: {
10489
+ type: "addon",
10490
+ id: "pipeline-analytics",
10491
+ addonId: "pipeline-analytics"
10492
+ },
10493
+ category: require_dist.EventCategory.PipelineAnalyticsFaceGalleryChanged,
10494
+ data: payload
10495
+ });
10496
+ } catch (err) {
10497
+ this.ctx.logger.debug("face-gallery-changed emit failed", { meta: {
10498
+ deviceId: payload.deviceId,
10499
+ faceId: payload.faceId,
10500
+ error: String(err)
10501
+ } });
10502
+ }
10503
+ }
10504
+ /**
10505
+ * Best-effort bus notification for a gallery plate-row change (buffer /
10506
+ * assign / unassign / delete). Passed into the `PlateRecognizer` and
10507
+ * `PlateGalleryProvider` as their `emitPlateGalleryChanged` dep. Telemetry
10508
+ * (D8): a lost/failed emit only delays the admin-ui live-refresh — never
10509
+ * allowed to fail the caller's mutation.
10510
+ */
10511
+ emitPlateGalleryChanged(payload) {
10512
+ try {
10513
+ this.ctx.eventBus.emit({
10514
+ id: `pa-${(0, node_crypto.randomUUID)()}`,
10515
+ timestamp: /* @__PURE__ */ new Date(),
10516
+ source: {
10517
+ type: "addon",
10518
+ id: "pipeline-analytics",
10519
+ addonId: "pipeline-analytics"
10520
+ },
10521
+ category: require_dist.EventCategory.PipelineAnalyticsPlateGalleryChanged,
10522
+ data: payload
10523
+ });
10524
+ } catch (err) {
10525
+ this.ctx.logger.debug("plate-gallery-changed emit failed", { meta: {
10526
+ deviceId: payload.deviceId,
10527
+ plateId: payload.plateId,
10528
+ error: String(err)
10529
+ } });
10530
+ }
10531
+ }
8579
10532
  /** Composite key for the per-(device, source) processor + track maps. */
8580
10533
  procKey(deviceId, source) {
8581
10534
  return `${deviceId}:${source}`;
@@ -8675,6 +10628,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
8675
10628
  }
8676
10629
  async clearTracks(input) {
8677
10630
  this.trackStore?.clearDevice(input.deviceId);
10631
+ this.overlayState.clearDevice(input.deviceId);
10632
+ this.overlaySynthesisWarnAt.delete(input.deviceId);
8678
10633
  const prefix = `${input.deviceId}:`;
8679
10634
  for (const k of this.lastActiveTrackIds.keys()) if (k.startsWith(prefix)) this.lastActiveTrackIds.delete(k);
8680
10635
  }
@@ -8851,6 +10806,168 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
8851
10806
  return counts;
8852
10807
  }
8853
10808
  /**
10809
+ * The widened, extensible track-deletion cascade registry (design §3): every
10810
+ * leaf store that holds track-scoped rows, ORDERED leaves-first (the track
10811
+ * root is deleted internally, last, by `cascadeDeleteTracks`). eventStore
10812
+ * folds in its event-owned media; mediaStore removes track/face/plate crops
10813
+ * (never identity); faceStore removes unassigned (non-enrolled) reads;
10814
+ * objectEmbeddingStore removes the per-track CLIP vector. The plate leg is
10815
+ * deferred until the plate `assigned` enrollment model lands (design §11.3).
10816
+ * Returns null when the required stores aren't ready.
10817
+ */
10818
+ buildTrackCascadeRegistry() {
10819
+ const eventStore = this.eventStore;
10820
+ const mediaStore = this.mediaStore;
10821
+ const trackStore = this.trackStore;
10822
+ if (!eventStore || !mediaStore || !trackStore) return null;
10823
+ const registry = [eventStore, mediaStore];
10824
+ if (this.faceStore) registry.push(this.faceStore);
10825
+ if (this.plateStore) registry.push(this.plateStore);
10826
+ if (this.objectEmbeddingStore) registry.push(this.objectEmbeddingStore);
10827
+ return {
10828
+ registry,
10829
+ trackStore
10830
+ };
10831
+ }
10832
+ /**
10833
+ * Clear the addon-level per-track in-memory scheduler/overlay state that a
10834
+ * natural track-end (see `sweepExpiredTracks`) clears — otherwise a pruned
10835
+ * track keeps drawing overlay boxes and the dispatcher keeps scheduling detail
10836
+ * work until it ends on its own. Fired per successfully-cascaded track by all
10837
+ * three retention entry points (design §5).
10838
+ */
10839
+ clearLiveTrackState(deviceId, trackId) {
10840
+ this.detailDispatcher?.onTrackEnded(deviceId, trackId);
10841
+ this.overlayState.onTrackEnded(deviceId, trackId);
10842
+ }
10843
+ async deleteTracks(input) {
10844
+ const cascade = this.buildTrackCascadeRegistry();
10845
+ if (!cascade) return {
10846
+ deleted: 0,
10847
+ failed: [...input.trackIds]
10848
+ };
10849
+ const { deleted, failed } = await runTrackCascadeBatch({
10850
+ registry: cascade.registry,
10851
+ trackStore: cascade.trackStore,
10852
+ deviceId: input.deviceId,
10853
+ onTrackCleanup: (deviceId, trackId) => this.clearLiveTrackState(deviceId, trackId),
10854
+ onFailure: (trackId, err) => {
10855
+ this.ctx.logger.debug("deleteTracks failed", { meta: {
10856
+ trackId,
10857
+ error: String(err)
10858
+ } });
10859
+ }
10860
+ }, input.trackIds);
10861
+ if (deleted > 0) this.ctx.logger.info("analytics tracks deleted", {
10862
+ tags: { deviceId: input.deviceId },
10863
+ meta: {
10864
+ deviceId: input.deviceId,
10865
+ deleted,
10866
+ failed: failed.length
10867
+ }
10868
+ });
10869
+ return {
10870
+ deleted,
10871
+ failed
10872
+ };
10873
+ }
10874
+ /**
10875
+ * Track-centric time-based retention (design §5.1). Drains every persisted
10876
+ * track for the device whose `lastSeen < cutoffMs`, page by page, through the
10877
+ * widened cascade — enrolled faces/plates + identity media are exempt (design
10878
+ * §4). `tracks` is the authoritative count; leaf families are best-effort 0
10879
+ * under the void `TrackScopedStore` contract (see cap doc).
10880
+ */
10881
+ async pruneTracksBefore(input) {
10882
+ const empty = {
10883
+ tracks: 0,
10884
+ events: 0,
10885
+ media: 0,
10886
+ faces: 0,
10887
+ plates: 0,
10888
+ embeddings: 0
10889
+ };
10890
+ const cascade = this.buildTrackCascadeRegistry();
10891
+ if (!cascade) return empty;
10892
+ const PAGE = 200;
10893
+ let totalDeleted = 0;
10894
+ for (;;) {
10895
+ if (this.shuttingDown) break;
10896
+ const ids = await cascade.trackStore.listIdsBefore(input.deviceId, input.cutoffMs, PAGE);
10897
+ if (ids.length === 0) break;
10898
+ const { deleted } = await runTrackCascadeBatch({
10899
+ registry: cascade.registry,
10900
+ trackStore: cascade.trackStore,
10901
+ deviceId: input.deviceId,
10902
+ onTrackCleanup: (deviceId, trackId) => this.clearLiveTrackState(deviceId, trackId),
10903
+ onFailure: (trackId, err) => {
10904
+ this.ctx.logger.debug("pruneTracksBefore track failed", { meta: {
10905
+ trackId,
10906
+ error: String(err)
10907
+ } });
10908
+ }
10909
+ }, ids);
10910
+ totalDeleted += deleted;
10911
+ if (deleted === 0) break;
10912
+ }
10913
+ if (totalDeleted > 0) this.ctx.logger.info("analytics track retention prune", {
10914
+ tags: { deviceId: input.deviceId },
10915
+ meta: {
10916
+ deviceId: input.deviceId,
10917
+ cutoffMs: input.cutoffMs,
10918
+ tracks: totalDeleted
10919
+ }
10920
+ });
10921
+ return {
10922
+ ...empty,
10923
+ tracks: totalDeleted
10924
+ };
10925
+ }
10926
+ /**
10927
+ * Operator "clean slate" for a device (design §5.3): prune EVERY persisted
10928
+ * track via the same cascade as `pruneTracksBefore` with `cutoffMs = now`.
10929
+ * Enrolled gallery + identity media are exempt.
10930
+ */
10931
+ async wipeAllAnalytics(input) {
10932
+ return this.pruneTracksBefore({
10933
+ deviceId: input.deviceId,
10934
+ cutoffMs: Date.now()
10935
+ });
10936
+ }
10937
+ /**
10938
+ * Periodic per-device track retention sweep (design §6): for every device
10939
+ * that has persisted tracks, prune those older than its `trackRetentionDays`
10940
+ * window (default 7 days; 0 = keep forever → skipped). Runs on the shared
10941
+ * retention interval. Enrolled gallery is exempt by the cascade store layer.
10942
+ */
10943
+ async runTrackRetentionSweep() {
10944
+ if (this.shuttingDown || !this.trackStore) return;
10945
+ const trackStore = this.trackStore;
10946
+ try {
10947
+ const total = await sweepTrackRetention({
10948
+ listDeviceIds: () => trackStore.listDeviceIds(),
10949
+ resolveRetentionDays: async (deviceId) => {
10950
+ return resolveTrackRetentionDays(await this.ctx?.settings?.readDeviceStore(deviceId) ?? {});
10951
+ },
10952
+ pruneTracksBefore: (deviceId, cutoffMs) => this.pruneTracksBefore({
10953
+ deviceId,
10954
+ cutoffMs
10955
+ }),
10956
+ now: () => Date.now(),
10957
+ onError: (deviceId, err) => {
10958
+ this.ctx.logger.debug("track retention sweep (device) failed", { meta: {
10959
+ deviceId,
10960
+ error: String(err)
10961
+ } });
10962
+ }
10963
+ });
10964
+ if (total > 0) this.ctx.logger.info("analytics track retention sweep", { meta: { tracks: total } });
10965
+ } catch (err) {
10966
+ if (this.shuttingDown) return;
10967
+ this.ctx.logger.debug("runTrackRetentionSweep failed", { meta: { error: String(err) } });
10968
+ }
10969
+ }
10970
+ /**
8854
10971
  * Decode a stored event image into `EventMedia` for the data-plane handler.
8855
10972
  * Preference order: `crop` (square-safe 16:9 preview) → `fullFrameBoxed`
8856
10973
  * (native-res boxed frame) → any available file. Returns `null` if the
@@ -8911,6 +11028,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
8911
11028
  label: "Post-processing node",
8912
11029
  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.",
8913
11030
  default: POST_PROCESSING_NODE_DEFAULT
11031
+ }, {
11032
+ type: "boolean",
11033
+ key: "embeddingEnabled",
11034
+ label: "Object/face embeddings",
11035
+ description: "Compute object/face embeddings from detection crops (feeds similarity search + face recognition). Runs on the post-processing node. Takes effect on addon restart.",
11036
+ default: true
8914
11037
  }]
8915
11038
  },
8916
11039
  {
@@ -9017,9 +11140,20 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
9017
11140
  {
9018
11141
  id: "retention",
9019
11142
  title: "Retention",
9020
- description: "How long each event kind is kept in the SQL store. Media files follow the minimum of these.",
11143
+ description: "How long analytics history is kept in the SQL store. Media files follow the minimum of these.",
9021
11144
  columns: 3,
9022
11145
  fields: [
11146
+ {
11147
+ type: "number",
11148
+ key: "trackRetentionDays",
11149
+ label: "Tracks",
11150
+ 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.",
11151
+ min: 0,
11152
+ max: 365,
11153
+ step: 1,
11154
+ default: TRACK_RETENTION_DEFAULT_DAYS,
11155
+ unit: "days"
11156
+ },
9023
11157
  {
9024
11158
  type: "number",
9025
11159
  key: "retentionMotionDays",