@camstack/addon-post-analysis 1.1.26 → 1.1.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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-BEx5ST1W.js");
6
- const require_resolve_frame = require("../resolve-frame-Cbm_NFuq.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,7 +1771,9 @@ 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();
1776
+ const faceAlignedCropByBbox = /* @__PURE__ */ new Map();
1530
1777
  const plateByBbox = /* @__PURE__ */ new Map();
1531
1778
  const maskByBbox = /* @__PURE__ */ new Map();
1532
1779
  const flatDetections = frame.detections.filter((d) => d.kind === "first-level").map((det) => {
@@ -1553,6 +1800,7 @@ var FrameProcessor = class {
1553
1800
  height: det.maskHeight
1554
1801
  });
1555
1802
  firstLevelBboxById.set(det.id, bbox);
1803
+ sourceIdByBbox.set(bbox, det.id);
1556
1804
  return {
1557
1805
  ...bbox,
1558
1806
  detection,
@@ -1569,6 +1817,7 @@ var FrameProcessor = class {
1569
1817
  w: det.bbox.width,
1570
1818
  h: det.bbox.height
1571
1819
  });
1820
+ if (det.faceAlignedCrop !== void 0) faceAlignedCropByBbox.set(parentBbox, det.faceAlignedCrop);
1572
1821
  if (det.embedding !== void 0 && !embeddingByBbox.has(parentBbox)) embeddingByBbox.set(parentBbox, {
1573
1822
  embedding: det.embedding,
1574
1823
  ...det.embeddingModelId !== void 0 ? { embeddingModelId: det.embeddingModelId } : {}
@@ -1611,9 +1860,12 @@ var FrameProcessor = class {
1611
1860
  });
1612
1861
  const emb = embeddingByBbox.get(td.bbox);
1613
1862
  const faceBbox = faceBboxByBbox.get(td.bbox);
1863
+ const faceAlignedCrop = faceAlignedCropByBbox.get(td.bbox);
1614
1864
  const plate = plateByBbox.get(td.bbox);
1865
+ const sourceDetectionId = sourceIdByBbox.get(td.bbox);
1615
1866
  return {
1616
1867
  trackId: td.trackId,
1868
+ ...sourceDetectionId !== void 0 ? { sourceDetectionId } : {},
1617
1869
  className: td.class,
1618
1870
  confidence: td.score,
1619
1871
  bbox: { ...td.bbox },
@@ -1625,6 +1877,7 @@ var FrameProcessor = class {
1625
1877
  ...emb.embeddingModelId !== void 0 ? { embeddingModelId: emb.embeddingModelId } : {}
1626
1878
  } : {},
1627
1879
  ...faceBbox !== void 0 ? { faceBbox } : {},
1880
+ ...faceAlignedCrop !== void 0 ? { faceAlignedCrop } : {},
1628
1881
  ...plate !== void 0 ? {
1629
1882
  plateText: plate.text,
1630
1883
  plateScore: plate.score,
@@ -1675,6 +1928,84 @@ var FrameProcessor = class {
1675
1928
  };
1676
1929
  }
1677
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
+ }
1678
2009
  //#endregion
1679
2010
  //#region src/pipeline-analytics/pipeline/best-detection-tracker.ts
1680
2011
  var BestDetectionTracker = class {
@@ -1879,6 +2210,50 @@ function nativeDetectionsToFrame(input) {
1879
2210
  };
1880
2211
  }
1881
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
1882
2257
  //#region src/pipeline-analytics/runtime/binding-cache.ts
1883
2258
  var BindingCache = class {
1884
2259
  api;
@@ -1984,6 +2359,10 @@ var TRACKS_COLUMNS = [
1984
2359
  name: "zonesVisited",
1985
2360
  type: "JSON"
1986
2361
  },
2362
+ {
2363
+ name: "classes",
2364
+ type: "JSON"
2365
+ },
1987
2366
  {
1988
2367
  name: "totalDistance",
1989
2368
  type: "REAL"
@@ -2032,6 +2411,7 @@ function cloneTrack(t) {
2032
2411
  }
2033
2412
  })),
2034
2413
  zonesVisited: [...t.zonesVisited],
2414
+ classes: [...t.classes],
2035
2415
  totalDistance: t.totalDistance,
2036
2416
  state: t.state,
2037
2417
  active: t.active,
@@ -2076,6 +2456,7 @@ var TrackStore = class {
2076
2456
  existing.positions.push(params.position);
2077
2457
  } else existing.positions.push(params.position);
2078
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);
2079
2460
  return existing;
2080
2461
  }
2081
2462
  const fresh = {
@@ -2088,6 +2469,7 @@ var TrackStore = class {
2088
2469
  positions: [params.position],
2089
2470
  snapshots: [],
2090
2471
  zonesVisited: [...params.zones],
2472
+ classes: [params.className],
2091
2473
  totalDistance: 0,
2092
2474
  state: params.state,
2093
2475
  active: true,
@@ -2230,6 +2612,92 @@ var TrackStore = class {
2230
2612
  clearAll() {
2231
2613
  this.active.clear();
2232
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
+ }
2233
2701
  /** Historical query — hits the persisted collection. */
2234
2702
  async queryHistorical(params) {
2235
2703
  const filter = { where: { deviceId: params.deviceId } };
@@ -2271,6 +2739,7 @@ var TrackStore = class {
2271
2739
  positions: [...t.positions],
2272
2740
  snapshots: [...t.snapshots],
2273
2741
  zonesVisited: [...t.zonesVisited],
2742
+ ...t.classes !== void 0 ? { classes: [...t.classes] } : {},
2274
2743
  totalDistance: t.totalDistance,
2275
2744
  state: t.state,
2276
2745
  ...t.importance !== void 0 ? { importance: t.importance } : {},
@@ -2283,6 +2752,7 @@ var TrackStore = class {
2283
2752
  const positions = data["positions"] ?? [];
2284
2753
  const snapshots = data["snapshots"] ?? [];
2285
2754
  const zones = data["zonesVisited"] ?? [];
2755
+ const classes = data["classes"];
2286
2756
  const label = data["label"];
2287
2757
  const importance = data["importance"];
2288
2758
  const bestEventId = data["bestEventId"];
@@ -2297,6 +2767,7 @@ var TrackStore = class {
2297
2767
  positions,
2298
2768
  snapshots,
2299
2769
  zonesVisited: zones,
2770
+ ...classes !== null ? { classes } : {},
2300
2771
  totalDistance: Number(data["totalDistance"] ?? 0),
2301
2772
  state: data["state"] ?? "idle",
2302
2773
  active: false,
@@ -2309,6 +2780,18 @@ var TrackStore = class {
2309
2780
  //#endregion
2310
2781
  //#region src/pipeline-analytics/store/media-store.ts
2311
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-";
2312
2795
  var MEDIA_COLUMNS = [
2313
2796
  {
2314
2797
  name: "id",
@@ -2673,6 +3156,28 @@ var MediaStore = class {
2673
3156
  async deleteForEvents(eventIds) {
2674
3157
  return this.deleteForOwner("event", eventIds);
2675
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
+ }
2676
3181
  /** Retention sweep: delete any media row + blob older than cutoff.
2677
3182
  * Returns number of entries removed. */
2678
3183
  async evictBefore(cutoffMs) {
@@ -2835,9 +3340,11 @@ var COMMON_INDEXES = (prefix) => [{
2835
3340
  var EventStore = class {
2836
3341
  store;
2837
3342
  logger;
3343
+ media;
2838
3344
  constructor(deps) {
2839
3345
  this.store = deps.store;
2840
3346
  this.logger = deps.logger;
3347
+ this.media = deps.media;
2841
3348
  }
2842
3349
  static async declare(store) {
2843
3350
  await store.declareCollection.mutate({
@@ -3050,6 +3557,12 @@ var EventStore = class {
3050
3557
  let bestEventId;
3051
3558
  let peakBboxAreaFrac = 0;
3052
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
+ }
3053
3566
  const conf = typeof row.data["confidence"] === "number" ? row.data["confidence"] : 0;
3054
3567
  if (conf <= bestConf) continue;
3055
3568
  bestConf = conf;
@@ -3239,6 +3752,72 @@ var EventStore = class {
3239
3752
  ]
3240
3753
  };
3241
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
+ }
3242
3821
  };
3243
3822
  function slimMotion(id, data) {
3244
3823
  return {
@@ -3305,6 +3884,18 @@ function stripNulls(data) {
3305
3884
  return out;
3306
3885
  }
3307
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
3308
3899
  //#region src/shared/frame/square-safe-crop.ts
3309
3900
  /**
3310
3901
  * Compute a square-safe 16:9 crop region in pixel space.
@@ -3427,6 +4018,34 @@ function caption(className, confidence, label) {
3427
4018
  return typeof confidence === "number" ? `${base} ${Math.round(confidence * 100)}%` : base;
3428
4019
  }
3429
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
+ /**
3430
4049
  * Generates object-event + track media FROM the decoded detection-pipeline
3431
4050
  * frame (the `frameHandle`/shm frame the detector ran on), NEVER the device
3432
4051
  * snapshot cap. Per object event it writes: a `crop` (square-safe 16:9
@@ -3450,7 +4069,7 @@ var EventMediaDispatcher = class {
3450
4069
  if (events.length === 0 && trackFrames.length === 0 && snapshots.length === 0) return empty;
3451
4070
  let decoded;
3452
4071
  try {
3453
- decoded = await require_resolve_frame.resolveFrame(frameHandle, {
4072
+ decoded = await resolveFrame(frameHandle, {
3454
4073
  ownNodeId: this.deps.ownNodeId,
3455
4074
  readers: this.deps.readers,
3456
4075
  getRemoteFrame: this.deps.getRemoteFrame
@@ -3486,9 +4105,21 @@ var EventMediaDispatcher = class {
3486
4105
  });
3487
4106
  return empty;
3488
4107
  }
3489
- const frameData = Buffer.isBuffer(decoded.data) ? decoded.data : Buffer.from(decoded.data);
4108
+ const frameData = Buffer.from(decoded.data);
3490
4109
  const fw = decoded.width;
3491
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
+ }
3492
4123
  for (const ev of events) await this.writeEventMedia(deviceId, frameData, fw, fh, ev, input.cropPadding);
3493
4124
  for (const tf of trackFrames) await this.writeTrackFrame(deviceId, frameData, fw, fh, tf);
3494
4125
  const storedSnapshots = [];
@@ -3714,8 +4345,264 @@ var EventMediaDispatcher = class {
3714
4345
  }
3715
4346
  };
3716
4347
  //#endregion
3717
- //#region src/pipeline-analytics/runtime/slice-throttler.ts
3718
- 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 {
3719
4606
  opts;
3720
4607
  lastWrittenAt = /* @__PURE__ */ new Map();
3721
4608
  lastWritten = /* @__PURE__ */ new Map();
@@ -4590,6 +5477,56 @@ function resolveMediaSettings(raw) {
4590
5477
  };
4591
5478
  }
4592
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
4593
5530
  //#region src/pipeline-analytics/store/identity-store.ts
4594
5531
  /**
4595
5532
  * IdentityStore — per-person identity registry for face recognition.
@@ -4639,7 +5576,7 @@ var IDENTITY_COLUMNS = [
4639
5576
  type: "TEXT"
4640
5577
  }
4641
5578
  ];
4642
- var SAMPLE_COLUMNS = [
5579
+ var SAMPLE_COLUMNS$1 = [
4643
5580
  {
4644
5581
  name: "id",
4645
5582
  type: "TEXT",
@@ -4700,7 +5637,7 @@ var IdentityStore = class {
4700
5637
  });
4701
5638
  await store.declareCollection.mutate({
4702
5639
  collection: IDENTITY_SAMPLES_COLLECTION,
4703
- columns: [...SAMPLE_COLUMNS],
5640
+ columns: [...SAMPLE_COLUMNS$1],
4704
5641
  indexes: [{
4705
5642
  name: "idx_sample_identity",
4706
5643
  columns: ["identityId"]
@@ -5135,6 +6072,38 @@ var FaceStore = class {
5135
6072
  } });
5136
6073
  }
5137
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
+ }
5138
6107
  /** Delete a single buffered face row by id (its crop media is removed by the
5139
6108
  * caller — the FaceStore owns rows, not blobs). Best-effort. */
5140
6109
  async delete(faceId) {
@@ -5406,6 +6375,27 @@ var ObjectEmbeddingStore = class {
5406
6375
  }
5407
6376
  return ids;
5408
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
+ }
5409
6399
  };
5410
6400
  //#endregion
5411
6401
  //#region src/pipeline-analytics/pipeline/face-matcher.ts
@@ -5500,6 +6490,14 @@ function updateTrackAggregate(prev, match, opts) {
5500
6490
  //#region src/pipeline-analytics/face-recognizer.ts
5501
6491
  /** At most one "dropping imageless track" log per this interval, per recognizer. */
5502
6492
  var FACE_IMAGELESS_LOG_THROTTLE_MS = 6e4;
6493
+ /**
6494
+ * arcface model id stamped on a detail-plane face candidate when the gallery is
6495
+ * empty (collect-only). The detail-subtree result carries no `embeddingModelId`
6496
+ * (the two-plane `DetailResult` schema omits it), so recognition uses the
6497
+ * gallery's own model id (all enrolled samples share one) and this constant is
6498
+ * only a placeholder for the collect-only case where the id is never compared.
6499
+ */
6500
+ var FALLBACK_FACE_MODEL_ID = "arcface";
5503
6501
  var FaceRecognizer = class {
5504
6502
  deps;
5505
6503
  gallery = [];
@@ -5527,6 +6525,38 @@ var FaceRecognizer = class {
5527
6525
  this.deps.logger.warn("FaceRecognizer.refreshGallery failed", { meta: { error: String(err) } });
5528
6526
  }
5529
6527
  }
6528
+ /**
6529
+ * Two-plane detail feed: ingest ONE `runDetailSubtree` face result for a
6530
+ * track and run it through the SAME `processFrame` logic (candidate → best
6531
+ * face → gallery match → crop hold). The result is synthesized into a single
6532
+ * `TrackedDetectionOut` candidate so no recognizer logic changes — only the
6533
+ * input source moves from the per-frame plane to this per-track call.
6534
+ */
6535
+ async ingestFaceDetail(input) {
6536
+ const modelId = this.gallery[0]?.modelId ?? FALLBACK_FACE_MODEL_ID;
6537
+ const candidate = {
6538
+ trackId: input.trackId,
6539
+ className: "face",
6540
+ confidence: input.score,
6541
+ bbox: input.parentBbox,
6542
+ zones: [],
6543
+ state: "moving",
6544
+ embedding: input.embedding,
6545
+ embeddingModelId: modelId,
6546
+ ...input.faceBbox !== void 0 ? { faceBbox: input.faceBbox } : {},
6547
+ ...input.alignedCropJpeg !== void 0 ? { faceAlignedCrop: input.alignedCropJpeg } : {}
6548
+ };
6549
+ await this.processFrame({
6550
+ deviceId: input.deviceId,
6551
+ timestamp: input.timestamp,
6552
+ frameWidth: input.frameWidth,
6553
+ frameHeight: input.frameHeight,
6554
+ tracked: [candidate],
6555
+ settings: input.settings,
6556
+ cropPadding: input.cropPadding,
6557
+ ...input.frameHandle !== void 0 ? { frameHandle: input.frameHandle } : {}
6558
+ });
6559
+ }
5530
6560
  async processFrame(input) {
5531
6561
  const { settings } = input;
5532
6562
  const candidates = input.tracked.filter((t) => Array.isArray(t.embedding) && t.embedding.length > 0 && typeof t.embeddingModelId === "string" && t.confidence >= settings.minFaceConfidence);
@@ -5564,7 +6594,8 @@ var FaceRecognizer = class {
5564
6594
  if (isNewBest || needsCrop) {
5565
6595
  const cropBbox = c.faceBbox ?? c.bbox;
5566
6596
  let crop;
5567
- if (c.faceBbox !== void 0 && input.frameHandle !== void 0) try {
6597
+ if (c.faceAlignedCrop !== void 0) crop = Buffer.from(c.faceAlignedCrop, "base64");
6598
+ else if (c.faceBbox !== void 0 && input.frameHandle !== void 0) try {
5568
6599
  crop = await this.deps.captureCrop(input.frameHandle, c.faceBbox, input.frameWidth, input.frameHeight, input.cropPadding) ?? void 0;
5569
6600
  } catch (err) {
5570
6601
  this.deps.logger.debug("FaceRecognizer crop capture failed", {
@@ -5743,6 +6774,24 @@ var FaceRecognizer = class {
5743
6774
  score: held.score
5744
6775
  }
5745
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
+ }
5746
6795
  } catch (err) {
5747
6796
  this.deps.logger.warn("FaceRecognizer faceStore insert failed", {
5748
6797
  tags: { deviceId },
@@ -5755,103 +6804,667 @@ var FaceRecognizer = class {
5755
6804
  }
5756
6805
  };
5757
6806
  //#endregion
5758
- //#region src/pipeline-analytics/store/plate-store.ts
5759
- var PLATES_COLLECTION = "pipeline-analytics:plates";
5760
- var PLATE_COLUMNS = [
5761
- {
5762
- name: "id",
5763
- type: "TEXT",
5764
- primaryKey: true,
5765
- notNull: true
5766
- },
5767
- {
5768
- name: "deviceId",
5769
- type: "INTEGER",
5770
- notNull: true
5771
- },
5772
- {
5773
- name: "trackId",
5774
- type: "TEXT",
5775
- notNull: true
5776
- },
5777
- {
5778
- name: "timestamp",
5779
- type: "INTEGER",
5780
- notNull: true
5781
- },
5782
- {
5783
- name: "text",
5784
- type: "TEXT",
5785
- notNull: true
5786
- },
5787
- {
5788
- name: "score",
5789
- type: "REAL",
5790
- notNull: true
5791
- },
5792
- {
5793
- name: "mediaKey",
5794
- type: "TEXT"
5795
- },
5796
- {
5797
- name: "corrected",
5798
- type: "BOOLEAN",
5799
- notNull: true
5800
- }
5801
- ];
5802
- var PLATE_INDEXES = [{
5803
- name: "idx_plates_device_ts",
5804
- columns: ["deviceId", "timestamp"]
5805
- }, {
5806
- name: "idx_plates_track",
5807
- columns: ["trackId"]
5808
- }];
5809
- var PlateStore = class {
5810
- store;
5811
- logger;
5812
- constructor(deps) {
5813
- this.store = deps.store;
5814
- this.logger = deps.logger;
5815
- }
5816
- static async declare(store) {
5817
- await store.declareCollection.mutate({
5818
- collection: PLATES_COLLECTION,
5819
- columns: [...PLATE_COLUMNS],
5820
- indexes: [...PLATE_INDEXES]
5821
- });
5822
- }
5823
- /** Upsert one buffered plate read (keyed by `plate.id` = `plate-${trackId}`). */
5824
- async insert(plate) {
5825
- const { id, ...rest } = plate;
5826
- try {
5827
- await this.store.set.mutate({
5828
- collection: PLATES_COLLECTION,
5829
- key: id,
5830
- value: rest
5831
- });
5832
- } catch (err) {
5833
- this.logger.warn("PlateStore.insert failed", {
5834
- tags: { deviceId: plate.deviceId },
5835
- meta: {
5836
- plateId: id,
5837
- error: String(err)
6807
+ //#region src/pipeline-analytics/detail-scheduler.ts
6808
+ /** Default backoff/period when a step's cadence omits `minIntervalMs`. */
6809
+ var DEFAULT_MIN_INTERVAL_MS = 1e3;
6810
+ /** Default fire cap for a `once` step when its announce omits `maxPerTrack`. */
6811
+ var DEFAULT_ONCE_MAX_PER_TRACK = 3;
6812
+ /**
6813
+ * Pure per-(track, step) scheduling state machine for detail-subtree
6814
+ * dispatch. Given a camera's announced child steps (`DetailStepAnnounce[]`,
6815
+ * read off `PipelineInferenceResultPayload.detailSteps`), decides WHEN each
6816
+ * step should run for a given track — independent of transport, I/O, or
6817
+ * timers. The caller drives it with wall-clock `nowMs` and dispatches the
6818
+ * returned `DetailRequest[]`.
6819
+ */
6820
+ var DetailScheduler = class {
6821
+ tracks = /* @__PURE__ */ new Map();
6822
+ /** Track appeared with class + announce; returns immediate requests. */
6823
+ onTrackStarted(trackId, className, announce, nowMs) {
6824
+ const steps = /* @__PURE__ */ new Map();
6825
+ const requests = [];
6826
+ for (const stepAnnounce of announce) {
6827
+ if (!stepAnnounce.inputClasses.includes(className)) continue;
6828
+ const state = {
6829
+ announce: stepAnnounce,
6830
+ firedCount: 1,
6831
+ lastFiredAt: nowMs,
6832
+ sticky: false,
6833
+ retryPending: false
6834
+ };
6835
+ steps.set(stepAnnounce.stepId, state);
6836
+ requests.push({
6837
+ trackId,
6838
+ stepId: stepAnnounce.stepId,
6839
+ reason: "new-track"
6840
+ });
6841
+ }
6842
+ this.tracks.set(trackId, steps);
6843
+ return requests;
6844
+ }
6845
+ /** Better candidate crop observed for the track. */
6846
+ onCandidateImproved(trackId, nowMs) {
6847
+ const steps = this.tracks.get(trackId);
6848
+ if (!steps) return [];
6849
+ const requests = [];
6850
+ for (const state of steps.values()) {
6851
+ if (state.announce.cadence.trigger !== "improve") continue;
6852
+ if (!this.canFire(state, nowMs)) continue;
6853
+ this.markFired(state, nowMs);
6854
+ requests.push({
6855
+ trackId,
6856
+ stepId: state.announce.stepId,
6857
+ reason: "improve"
6858
+ });
6859
+ }
6860
+ return requests;
6861
+ }
6862
+ /** Periodic tick (call ~1/s). Also carries pending retries for any trigger kind. */
6863
+ tick(nowMs) {
6864
+ const requests = [];
6865
+ for (const [trackId, steps] of this.tracks) for (const state of steps.values()) {
6866
+ if (state.sticky) continue;
6867
+ if (state.retryPending) {
6868
+ if (!this.intervalElapsed(state, nowMs)) continue;
6869
+ if (!this.underMaxPerTrack(state)) {
6870
+ state.retryPending = false;
6871
+ continue;
5838
6872
  }
6873
+ this.markFired(state, nowMs);
6874
+ requests.push({
6875
+ trackId,
6876
+ stepId: state.announce.stepId,
6877
+ reason: "retry"
6878
+ });
6879
+ continue;
6880
+ }
6881
+ if (state.announce.cadence.trigger !== "periodic") continue;
6882
+ if (!this.canFire(state, nowMs)) continue;
6883
+ this.markFired(state, nowMs);
6884
+ requests.push({
6885
+ trackId,
6886
+ stepId: state.announce.stepId,
6887
+ reason: "periodic"
5839
6888
  });
5840
6889
  }
6890
+ return requests;
5841
6891
  }
5842
- normalizeRow(r) {
5843
- const data = r.data;
5844
- return {
5845
- id: r.id,
5846
- ...data,
5847
- score: Number(r.data.score ?? 0),
5848
- corrected: Boolean(r.data.corrected),
5849
- mediaKey: data.mediaKey ?? void 0
5850
- };
6892
+ /**
6893
+ * Result arrived; confidence drives sticky/retry. null = failed (retry per
6894
+ * policy). `_nowMs` is part of the public signature for symmetry with the
6895
+ * other methods but isn't needed here — retry backoff is anchored to
6896
+ * `lastFiredAt` (set when the step was actually dispatched), not to when
6897
+ * its result came back.
6898
+ */
6899
+ onResult(trackId, stepId, confidence, _nowMs) {
6900
+ const steps = this.tracks.get(trackId);
6901
+ if (!steps) return;
6902
+ const state = steps.get(stepId);
6903
+ if (!state) return;
6904
+ if (state.sticky) return;
6905
+ const { stickyOnConfidence } = state.announce.cadence;
6906
+ if (confidence !== null && stickyOnConfidence !== void 0 && confidence >= stickyOnConfidence) {
6907
+ state.sticky = true;
6908
+ state.retryPending = false;
6909
+ return;
6910
+ }
6911
+ if (confidence === null) {
6912
+ if (this.underMaxPerTrack(state)) state.retryPending = true;
6913
+ return;
6914
+ }
6915
+ if (state.announce.cadence.trigger === "once" && stickyOnConfidence !== void 0) {
6916
+ if (this.underMaxPerTrack(state)) state.retryPending = true;
6917
+ }
5851
6918
  }
5852
- /** Recent plate reads for one device (or all when deviceId omitted), newest first. */
5853
- async listRecentPlates(input) {
5854
- const where = {};
6919
+ onTrackEnded(trackId) {
6920
+ this.tracks.delete(trackId);
6921
+ }
6922
+ canFire(state, nowMs) {
6923
+ if (state.sticky) return false;
6924
+ if (!this.underMaxPerTrack(state)) return false;
6925
+ return this.intervalElapsed(state, nowMs);
6926
+ }
6927
+ intervalElapsed(state, nowMs) {
6928
+ if (state.lastFiredAt === null) return true;
6929
+ const minIntervalMs = state.announce.cadence.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS;
6930
+ return nowMs - state.lastFiredAt >= minIntervalMs;
6931
+ }
6932
+ underMaxPerTrack(state) {
6933
+ const maxPerTrack = state.announce.cadence.maxPerTrack ?? (state.announce.cadence.trigger === "once" ? DEFAULT_ONCE_MAX_PER_TRACK : Infinity);
6934
+ return state.firedCount < maxPerTrack;
6935
+ }
6936
+ markFired(state, nowMs) {
6937
+ state.firedCount += 1;
6938
+ state.lastFiredAt = nowMs;
6939
+ state.retryPending = false;
6940
+ }
6941
+ };
6942
+ //#endregion
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
+ }
6963
+ /** Throttle for the per-device "detail call failed" warn — one line / minute. */
6964
+ var FAIL_WARN_THROTTLE_MS = 6e4;
6965
+ var TrackDetailDispatcher = class {
6966
+ deps;
6967
+ devices = /* @__PURE__ */ new Map();
6968
+ maxInFlight;
6969
+ tickIntervalMs;
6970
+ disposed = false;
6971
+ constructor(deps) {
6972
+ this.deps = deps;
6973
+ this.maxInFlight = deps.maxInFlightPerDevice ?? 2;
6974
+ this.tickIntervalMs = deps.tickIntervalMs ?? 1e3;
6975
+ }
6976
+ /** A track appeared: record its frame, seed its best-confidence, and dispatch
6977
+ * the scheduler's immediate (new-track) requests. */
6978
+ onTrackStarted(deviceId, trackId, className, announce, frame, nowMs) {
6979
+ if (this.disposed) return;
6980
+ const dev = this.ensureDevice(deviceId);
6981
+ dev.tracks.set(trackId, frame);
6982
+ dev.candidateBest.observe(trackId, frame.confidence, frame.timestamp);
6983
+ const requests = dev.scheduler.onTrackStarted(trackId, className, announce, nowMs);
6984
+ this.enqueue(deviceId, dev, requests);
6985
+ this.ensureTimer(deviceId, dev);
6986
+ }
6987
+ /** A subsequent frame for a live track: refresh its frame + fire
6988
+ * `improve`-cadence steps when the detector confidence strictly improves.
6989
+ *
6990
+ * `announce` is the frame's currently-announced detail chain. When a track
6991
+ * is alive but has NO dispatcher state yet — it existed before `detailSteps`
6992
+ * first appeared (a mid-track redeploy / config change) — this adopts it as a
6993
+ * new track so it starts getting scheduled instead of starving for its whole
6994
+ * life. Idempotent: guarded by the per-track state check, so a track that
6995
+ * already has state is never reset. */
6996
+ onFrame(deviceId, trackId, announce, frame, nowMs) {
6997
+ if (this.disposed) return;
6998
+ const dev = this.devices.get(deviceId);
6999
+ if (!dev || !dev.tracks.has(trackId)) {
7000
+ if (announce.length > 0) this.onTrackStarted(deviceId, trackId, frame.className, announce, frame, nowMs);
7001
+ return;
7002
+ }
7003
+ dev.tracks.set(trackId, frame);
7004
+ if (!dev.candidateBest.observe(trackId, frame.confidence, frame.timestamp)) return;
7005
+ const requests = dev.scheduler.onCandidateImproved(trackId, nowMs);
7006
+ this.enqueue(deviceId, dev, requests);
7007
+ }
7008
+ /** A track ended (durable TTL expiry): drop its scheduler + frame state. Any
7009
+ * queued request for it is discarded at dequeue. */
7010
+ onTrackEnded(deviceId, trackId) {
7011
+ const dev = this.devices.get(deviceId);
7012
+ if (!dev) return;
7013
+ dev.scheduler.onTrackEnded(trackId);
7014
+ dev.tracks.delete(trackId);
7015
+ dev.candidateBest.delete(trackId);
7016
+ if (dev.tracks.size === 0) this.clearTimer(dev);
7017
+ }
7018
+ dispose() {
7019
+ this.disposed = true;
7020
+ for (const dev of this.devices.values()) {
7021
+ this.clearTimer(dev);
7022
+ dev.queue.length = 0;
7023
+ dev.tracks.clear();
7024
+ dev.candidateBest.clear();
7025
+ }
7026
+ this.devices.clear();
7027
+ }
7028
+ ensureDevice(deviceId) {
7029
+ let dev = this.devices.get(deviceId);
7030
+ if (!dev) {
7031
+ dev = {
7032
+ scheduler: new DetailScheduler(),
7033
+ tracks: /* @__PURE__ */ new Map(),
7034
+ candidateBest: new BestDetectionTracker(),
7035
+ queue: [],
7036
+ inFlight: 0,
7037
+ timer: null,
7038
+ lastFailWarnAt: 0
7039
+ };
7040
+ this.devices.set(deviceId, dev);
7041
+ }
7042
+ return dev;
7043
+ }
7044
+ ensureTimer(deviceId, dev) {
7045
+ if (dev.timer) return;
7046
+ const timer = setInterval(() => this.tick(deviceId, dev), this.tickIntervalMs);
7047
+ if (typeof timer.unref === "function") timer.unref();
7048
+ dev.timer = timer;
7049
+ }
7050
+ clearTimer(dev) {
7051
+ if (dev.timer) {
7052
+ clearInterval(dev.timer);
7053
+ dev.timer = null;
7054
+ }
7055
+ }
7056
+ tick(deviceId, dev) {
7057
+ if (this.disposed) return;
7058
+ const requests = dev.scheduler.tick(Date.now());
7059
+ this.enqueue(deviceId, dev, requests);
7060
+ }
7061
+ enqueue(deviceId, dev, requests) {
7062
+ if (requests.length === 0) return;
7063
+ for (const r of requests) dev.queue.push(r);
7064
+ this.pump(deviceId, dev);
7065
+ }
7066
+ pump(deviceId, dev) {
7067
+ while (dev.inFlight < this.maxInFlight && dev.queue.length > 0) {
7068
+ const req = dev.queue.shift();
7069
+ if (req === void 0) break;
7070
+ const frame = dev.tracks.get(req.trackId);
7071
+ if (frame === void 0) continue;
7072
+ dev.inFlight += 1;
7073
+ this.dispatch(deviceId, dev, req, frame).finally(() => {
7074
+ dev.inFlight -= 1;
7075
+ this.pump(deviceId, dev);
7076
+ });
7077
+ }
7078
+ }
7079
+ async dispatch(deviceId, dev, req, frame) {
7080
+ const details = await this.runOnce(deviceId, dev, req, frame);
7081
+ let topScore = null;
7082
+ if (details !== null && details.length > 0) {
7083
+ topScore = details.reduce((max, d) => d.score > max ? d.score : max, Number.NEGATIVE_INFINITY);
7084
+ try {
7085
+ await this.deps.routeResults(deviceId, req.trackId, details, frame);
7086
+ } catch (err) {
7087
+ this.deps.logger.warn("detail result routing failed", {
7088
+ tags: { deviceId },
7089
+ meta: {
7090
+ trackId: req.trackId,
7091
+ stepId: req.stepId,
7092
+ error: String(err)
7093
+ }
7094
+ });
7095
+ }
7096
+ }
7097
+ dev.scheduler.onResult(req.trackId, req.stepId, topScore, Date.now());
7098
+ }
7099
+ /**
7100
+ * Run the request once via the frameHandle, and — on a miss (null OR throw)
7101
+ * — retry ONCE with a `cropJpeg` fallback when one can be captured. Returns
7102
+ * the detail list, or `null` when both attempts fail to produce a result.
7103
+ */
7104
+ async runOnce(deviceId, dev, req, frame) {
7105
+ const parent = {
7106
+ bbox: { ...frame.bbox },
7107
+ className: frame.className
7108
+ };
7109
+ const steps = composeDetailSteps(req, (trackId) => this.deps.hasTrackLabel?.(trackId) ?? false);
7110
+ if (frame.frameHandle !== void 0) try {
7111
+ const primary = await this.deps.runDetailSubtree({
7112
+ deviceId,
7113
+ frameHandle: frame.frameHandle,
7114
+ parent,
7115
+ steps
7116
+ }, frame.nodeId);
7117
+ if (primary !== null) return primary.details;
7118
+ } catch (err) {
7119
+ this.deps.logger.debug("detail primary call failed — trying crop fallback", {
7120
+ tags: { deviceId },
7121
+ meta: {
7122
+ trackId: req.trackId,
7123
+ stepId: req.stepId,
7124
+ error: String(err)
7125
+ }
7126
+ });
7127
+ }
7128
+ if (this.deps.captureCropBase64 !== void 0) try {
7129
+ const cropJpeg = await this.deps.captureCropBase64(frame);
7130
+ if (cropJpeg !== null) {
7131
+ const retry = await this.deps.runDetailSubtree({
7132
+ deviceId,
7133
+ cropJpeg,
7134
+ parent,
7135
+ steps
7136
+ }, frame.nodeId);
7137
+ if (retry !== null) return retry.details;
7138
+ }
7139
+ } catch (err) {
7140
+ this.deps.logger.debug("detail crop-fallback call failed", {
7141
+ tags: { deviceId },
7142
+ meta: {
7143
+ trackId: req.trackId,
7144
+ stepId: req.stepId,
7145
+ error: String(err)
7146
+ }
7147
+ });
7148
+ }
7149
+ this.warnFailThrottled(deviceId, dev, req);
7150
+ return null;
7151
+ }
7152
+ warnFailThrottled(deviceId, dev, req) {
7153
+ const now = Date.now();
7154
+ if (now - dev.lastFailWarnAt < FAIL_WARN_THROTTLE_MS) return;
7155
+ dev.lastFailWarnAt = now;
7156
+ this.deps.logger.warn("runDetailSubtree produced no result (frame + crop both missed)", {
7157
+ tags: { deviceId },
7158
+ meta: {
7159
+ trackId: req.trackId,
7160
+ stepId: req.stepId,
7161
+ reason: req.reason
7162
+ }
7163
+ });
7164
+ }
7165
+ };
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
7345
+ //#region src/pipeline-analytics/store/plate-store.ts
7346
+ var PLATES_COLLECTION = "pipeline-analytics:plates";
7347
+ var PLATE_COLUMNS = [
7348
+ {
7349
+ name: "id",
7350
+ type: "TEXT",
7351
+ primaryKey: true,
7352
+ notNull: true
7353
+ },
7354
+ {
7355
+ name: "deviceId",
7356
+ type: "INTEGER",
7357
+ notNull: true
7358
+ },
7359
+ {
7360
+ name: "trackId",
7361
+ type: "TEXT",
7362
+ notNull: true
7363
+ },
7364
+ {
7365
+ name: "timestamp",
7366
+ type: "INTEGER",
7367
+ notNull: true
7368
+ },
7369
+ {
7370
+ name: "text",
7371
+ type: "TEXT",
7372
+ notNull: true
7373
+ },
7374
+ {
7375
+ name: "score",
7376
+ type: "REAL",
7377
+ notNull: true
7378
+ },
7379
+ {
7380
+ name: "mediaKey",
7381
+ type: "TEXT"
7382
+ },
7383
+ {
7384
+ name: "corrected",
7385
+ type: "BOOLEAN",
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"
7408
+ }
7409
+ ];
7410
+ var PLATE_INDEXES = [{
7411
+ name: "idx_plates_device_ts",
7412
+ columns: ["deviceId", "timestamp"]
7413
+ }, {
7414
+ name: "idx_plates_track",
7415
+ columns: ["trackId"]
7416
+ }];
7417
+ var PlateStore = class {
7418
+ store;
7419
+ logger;
7420
+ constructor(deps) {
7421
+ this.store = deps.store;
7422
+ this.logger = deps.logger;
7423
+ }
7424
+ static async declare(store) {
7425
+ await store.declareCollection.mutate({
7426
+ collection: PLATES_COLLECTION,
7427
+ columns: [...PLATE_COLUMNS],
7428
+ indexes: [...PLATE_INDEXES]
7429
+ });
7430
+ }
7431
+ /** Upsert one buffered plate read (keyed by `plate.id` = `plate-${trackId}`). */
7432
+ async insert(plate) {
7433
+ const { id, ...rest } = plate;
7434
+ try {
7435
+ await this.store.set.mutate({
7436
+ collection: PLATES_COLLECTION,
7437
+ key: id,
7438
+ value: rest
7439
+ });
7440
+ } catch (err) {
7441
+ this.logger.warn("PlateStore.insert failed", {
7442
+ tags: { deviceId: plate.deviceId },
7443
+ meta: {
7444
+ plateId: id,
7445
+ error: String(err)
7446
+ }
7447
+ });
7448
+ }
7449
+ }
7450
+ normalizeRow(r) {
7451
+ const data = r.data;
7452
+ return {
7453
+ id: r.id,
7454
+ ...data,
7455
+ score: Number(r.data.score ?? 0),
7456
+ corrected: Boolean(r.data.corrected),
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
7463
+ };
7464
+ }
7465
+ /** Recent plate reads for one device (or all when deviceId omitted), newest first. */
7466
+ async listRecentPlates(input) {
7467
+ const where = {};
5855
7468
  if (input.deviceId !== void 0) where["deviceId"] = input.deviceId;
5856
7469
  return (await this.store.query.query({
5857
7470
  collection: PLATES_COLLECTION,
@@ -5917,6 +7530,76 @@ var PlateStore = class {
5917
7530
  } });
5918
7531
  }
5919
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
+ }
5920
7603
  /**
5921
7604
  * Retention across ALL devices: delete reads older than `cutoffMs` AND any
5922
7605
  * beyond the newest `maxPerDevice` per device. Corrected reads are exempt
@@ -5933,7 +7616,7 @@ var PlateStore = class {
5933
7616
  const byDevice = /* @__PURE__ */ new Map();
5934
7617
  for (const r of rows) {
5935
7618
  const data = r.data;
5936
- if (data.corrected) continue;
7619
+ if (data.corrected || data.assigned) continue;
5937
7620
  const deviceId = Number(data.deviceId ?? -1);
5938
7621
  const list = byDevice.get(deviceId) ?? [];
5939
7622
  list.push({
@@ -5963,10 +7646,320 @@ var PlateStore = class {
5963
7646
  error: String(err)
5964
7647
  } });
5965
7648
  }
5966
- return deleted;
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
+ });
7859
+ }
7860
+ return sample;
7861
+ }
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() {
7900
+ const rows = await this.store.query.query({
7901
+ collection: VEHICLE_SAMPLES_COLLECTION,
7902
+ filter: {}
7903
+ });
7904
+ const gallery = [];
7905
+ for (const r of rows) {
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
7911
+ });
7912
+ }
7913
+ return gallery;
5967
7914
  }
5968
7915
  };
5969
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
5970
7963
  //#region src/pipeline-analytics/plate-recognizer.ts
5971
7964
  /**
5972
7965
  * PlateRecognizer — buffers the best license-plate OCR read per vehicle track
@@ -5978,8 +7971,38 @@ var PlateStore = class {
5978
7971
  var PlateRecognizer = class {
5979
7972
  deps;
5980
7973
  bestPlate = /* @__PURE__ */ new Map();
7974
+ gallery = [];
7975
+ nameById = /* @__PURE__ */ new Map();
7976
+ matchOpts;
5981
7977
  constructor(deps) {
5982
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;
5983
8006
  }
5984
8007
  async processFrame(input) {
5985
8008
  const minConfidence = input.minConfidence ?? 0;
@@ -6035,6 +8058,8 @@ var PlateRecognizer = class {
6035
8058
  }
6036
8059
  });
6037
8060
  }
8061
+ const match = this.matchVehicle(held.text, held.score);
8062
+ const keyFrameMediaKey = this.deps.getKeyFrameMediaKey?.(trackId);
6038
8063
  try {
6039
8064
  await this.deps.plateStore.insert({
6040
8065
  id: plateId,
@@ -6044,7 +8069,16 @@ var PlateRecognizer = class {
6044
8069
  text: normalizePlate(held.text),
6045
8070
  score: held.score,
6046
8071
  ...mediaKey !== void 0 ? { mediaKey } : {},
6047
- 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"
6048
8082
  });
6049
8083
  this.deps.logger.info("plate: buffered to gallery", {
6050
8084
  tags: {
@@ -6055,7 +8089,8 @@ var PlateRecognizer = class {
6055
8089
  plateId,
6056
8090
  text: normalizePlate(held.text),
6057
8091
  score: held.score,
6058
- hasCrop: mediaKey !== void 0
8092
+ hasCrop: mediaKey !== void 0,
8093
+ recognizedVehicleId: match?.vehicleId ?? null
6059
8094
  }
6060
8095
  });
6061
8096
  } catch (err) {
@@ -6399,6 +8434,12 @@ function createEventMediaHandler(deps) {
6399
8434
  * surface to turn the refinement pipeline on/off for a camera.
6400
8435
  */
6401
8436
  var TTL_SWEEP_INTERVAL_MS = 5e3;
8437
+ /** Padding applied to the parent bbox for the `cropJpeg` retry fallback when a
8438
+ * scheduled detail call's frameHandle lease is already gone. */
8439
+ var DETAIL_FALLBACK_CROP_PADDING = .15;
8440
+ /** How long the active CLIP model id (from the embedding-encoder) is cached
8441
+ * before re-reading. */
8442
+ var CLIP_MODEL_ID_CACHE_TTL_MS = 6e4;
6402
8443
  var SETTINGS_CACHE_TTL_MS = 5e3;
6403
8444
  /** §5 best-frame: a track's `thumbnail` is overwritten only when the current
6404
8445
  * detection confidence beats the held best by at least this margin (hysteresis
@@ -6426,11 +8467,23 @@ var POST_PROCESSING_NODE_DEFAULT = "hub";
6426
8467
  * Absent / empty / non-string all fall back to the hub default — the exact
6427
8468
  * narrowing the old raw read applied inline. */
6428
8469
  var PostProcessingNodeIdSchema = require_dist.string().min(1);
8470
+ var EmbeddingEnabledSchema = require_dist.boolean();
6429
8471
  var SILENCE_FLOOR_DBFS = -55;
6430
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;
6431
8475
  var AUDIO_EVENT_HEARTBEAT_MS = 5e3;
6432
8476
  var MOTION_EVENT_HEARTBEAT_MS = 5e3;
6433
8477
  /**
8478
+ * Decode a base64 little-endian float32 vector (the `DetailResult.embedding`
8479
+ * wire encoding produced by `runDetailSubtree`) back into a plain number[].
8480
+ */
8481
+ function decodeEmbeddingBase64(base64) {
8482
+ const bytes = Buffer.from(base64, "base64");
8483
+ const view = new Float32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));
8484
+ return Array.from(view);
8485
+ }
8486
+ /**
6434
8487
  * Re-home the global analytics sections into the per-device `Analytics`
6435
8488
  * top-tab. Every section defaults to the `Analytics` tab (so the
6436
8489
  * device-manager aggregator groups them) AND is marked
@@ -6478,8 +8531,21 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
6478
8531
  faceStore = null;
6479
8532
  faceRecognizer = null;
6480
8533
  plateStore = null;
8534
+ vehicleStore = null;
6481
8535
  plateRecognizer = null;
6482
8536
  objectEmbeddingStore = null;
8537
+ /** Two-plane detail scheduler/dispatcher: per-track on-demand face/clip/
8538
+ * classifier/plate enrichment via `pipelineRunner.runDetailSubtree`. Replaces
8539
+ * the per-frame child consumption the executor no longer emits. */
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();
8545
+ /** Active CLIP image-embedding model id (== the text encoder's), TTL-cached.
8546
+ * Stamped on object-embedding rows from the detail plane so semantic search's
8547
+ * same-model gate keeps matching. */
8548
+ clipModelIdCache = null;
6483
8549
  /** Frame-based event/track media (crop + boxed full-frame) from the
6484
8550
  * detection-pipeline DECODED frame — the ONLY image source (never the
6485
8551
  * snapshot cap). Null when shm frame access is unavailable. */
@@ -6487,6 +8553,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
6487
8553
  /** Shared shm-ring reader cache for resolving `frameHandle`s to pixels.
6488
8554
  * Owned here so segments stay open across frames; closed once on shutdown. */
6489
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;
6490
8562
  bindingCache = null;
6491
8563
  zoneAnalytics = null;
6492
8564
  audioMetrics = null;
@@ -6537,6 +8609,10 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
6537
8609
  hysteresis: BEST_FRAME_HYSTERESIS,
6538
8610
  minGapMs: BEST_FRAME_MIN_GAP_MS
6539
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();
6540
8616
  /** Best (highest-confidence) CLIP-object detection per track — drives ONE
6541
8617
  * tight object-crop capture whose media key is written onto the embedding
6542
8618
  * row (so a semantic-search hit's thumbnail IS the embedded crop). Shares the
@@ -6548,6 +8624,11 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
6548
8624
  * at track end so a face row links to the SAME single key frame. Cleared on
6549
8625
  * track end. */
6550
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();
6551
8632
  /** The shared crop extractor (native-res first, detection-frame fallback),
6552
8633
  * captured in the constructor so `processFrame` can crop object thumbnails in
6553
8634
  * the same live-frame window as the face/plate/event-media captures. The
@@ -6563,6 +8644,15 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
6563
8644
  if (!this._postProcessingNodeState) this._postProcessingNodeState = this.state("postProcessingNodeId", PostProcessingNodeIdSchema, POST_PROCESSING_NODE_DEFAULT);
6564
8645
  return this._postProcessingNodeState;
6565
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
+ }
6566
8656
  /** GLOBAL face-recognition master switch (`enabled` key). Schema +
6567
8657
  * fallback derived from the face-settings source of truth. */
6568
8658
  _faceGlobalEnabledState = null;
@@ -6582,12 +8672,13 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
6582
8672
  await IdentityStore.declare(api.settingsStore);
6583
8673
  await FaceStore.declare(api.settingsStore);
6584
8674
  await PlateStore.declare(api.settingsStore);
8675
+ await VehicleStore.declare(api.settingsStore);
6585
8676
  await ObjectEmbeddingStore.declare(api.settingsStore);
6586
8677
  const logger = this.ctx.logger;
6587
8678
  let storage = this.ctx.kernel.storage;
6588
8679
  const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
6589
8680
  if (mediaRoot) {
6590
- const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-Cg_cGqs0.js"));
8681
+ const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-Cvhwrf43.js"));
6591
8682
  storage = new FilesystemStorageProvider(mediaRoot);
6592
8683
  logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
6593
8684
  }
@@ -6603,7 +8694,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
6603
8694
  });
6604
8695
  this.eventStore = new EventStore({
6605
8696
  store: api.settingsStore,
6606
- logger: logger.child("EventStore")
8697
+ logger: logger.child("EventStore"),
8698
+ media: this.mediaStore
6607
8699
  });
6608
8700
  this.identityStore = new IdentityStore({
6609
8701
  store: api.settingsStore,
@@ -6617,6 +8709,10 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
6617
8709
  store: api.settingsStore,
6618
8710
  logger: logger.child("PlateStore")
6619
8711
  });
8712
+ this.vehicleStore = new VehicleStore({
8713
+ store: api.settingsStore,
8714
+ logger: logger.child("VehicleStore")
8715
+ });
6620
8716
  const rawNodeId = this.ctx.kernel.localNodeId ?? this.ctx.id;
6621
8717
  const ownNodeId = rawNodeId.includes("/") ? rawNodeId.split("/")[0] : rawNodeId;
6622
8718
  {
@@ -6685,7 +8781,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
6685
8781
  return null;
6686
8782
  }
6687
8783
  };
6688
- const resolveFrameShared = createSharedFrameResolver((frameHandle) => require_resolve_frame.resolveFrame(frameHandle, {
8784
+ const resolveFrameShared = createSharedFrameResolver((frameHandle) => resolveFrame(frameHandle, {
6689
8785
  ownNodeId: ownNodeIdForFaces,
6690
8786
  readers: frameReadersForFaces,
6691
8787
  getRemoteFrame
@@ -6705,7 +8801,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
6705
8801
  bumpCropMetric(false);
6706
8802
  const decoded = await resolveFrameShared(frameHandle);
6707
8803
  if (!decoded || decoded.format !== "rgb") return null;
6708
- 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);
6709
8805
  return crop;
6710
8806
  };
6711
8807
  this.captureCrop = captureCrop;
@@ -6726,19 +8822,43 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
6726
8822
  }, trackId);
6727
8823
  },
6728
8824
  getKeyFrameMediaKey: (trackId) => this.keyFrameKeyByTrackId.get(trackId),
8825
+ emitFaceGalleryChanged: (payload) => this.emitFaceGalleryChanged(payload),
6729
8826
  logger: logger.child("FaceRecognizer")
6730
8827
  });
6731
8828
  this.faceRecognizer.refreshGallery();
6732
8829
  this.plateRecognizer = new PlateRecognizer({
6733
8830
  plateStore: this.plateStore,
8831
+ vehicleStore: this.vehicleStore,
6734
8832
  mediaStore: this.mediaStore,
6735
8833
  captureCrop,
8834
+ getKeyFrameMediaKey: (trackId) => this.keyFrameKeyByTrackId.get(trackId),
8835
+ emitPlateGalleryChanged: (payload) => this.emitPlateGalleryChanged(payload),
6736
8836
  logger: logger.child("PlateRecognizer")
6737
8837
  });
8838
+ this.plateRecognizer.refreshGallery();
6738
8839
  this.objectEmbeddingStore = new ObjectEmbeddingStore({
6739
8840
  store: api.settingsStore,
6740
8841
  logger: logger.child("ObjectEmbeddingStore")
6741
8842
  });
8843
+ const runnerApi = api.pipelineRunner;
8844
+ this.detailDispatcher = new TrackDetailDispatcher({
8845
+ logger: logger.child("DetailDispatcher"),
8846
+ runDetailSubtree: async (input, nodeId) => {
8847
+ if (!runnerApi?.runDetailSubtree) return null;
8848
+ if (nodeId !== void 0) return runnerApi.runDetailSubtree.mutate(input, require_dist.nodePin(nodeId));
8849
+ return runnerApi.runDetailSubtree.mutate(input);
8850
+ },
8851
+ routeResults: (deviceId, trackId, details, frame) => this.routeDetailResults(deviceId, trackId, details, frame),
8852
+ captureCropBase64: async (frame) => {
8853
+ if (frame.frameHandle === void 0 || !this.captureCrop) return null;
8854
+ const buf = await this.captureCrop(frame.frameHandle, { ...frame.bbox }, frame.frameWidth, frame.frameHeight, DETAIL_FALLBACK_CROP_PADDING);
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;
8860
+ }
8861
+ });
6742
8862
  this.bindingCache = new BindingCache({
6743
8863
  api,
6744
8864
  logger: logger.child("BindingCache")
@@ -6804,12 +8924,37 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
6804
8924
  const data = ev.data;
6805
8925
  this.handleNativeDetection(data);
6806
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)");
6807
8950
  }
6808
8951
  this.unsubBindings = this.ctx.eventBus.subscribe({ category: require_dist.EventCategory.DeviceBindingsChanged }, (ev) => {
6809
8952
  const data = ev.data;
6810
8953
  this.bindingCache?.onBindingsChanged(data);
6811
8954
  if (data.capName === "pipeline-analytics" && data.reason === "wrapper-deactivated") {
6812
8955
  this.trackStore?.clearDevice(data.deviceId);
8956
+ this.overlayState.clearDevice(data.deviceId);
8957
+ this.overlaySynthesisWarnAt.delete(data.deviceId);
6813
8958
  this.forgetDeviceProcessors(data.deviceId);
6814
8959
  this.levelStateByDevice.delete(data.deviceId);
6815
8960
  this.settingsCacheByDevice.delete(data.deviceId);
@@ -6822,6 +8967,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
6822
8967
  this.unsubDeviceUnreg = this.ctx.eventBus.subscribe({ category: require_dist.EventCategory.DeviceUnregistered }, (ev) => {
6823
8968
  const { deviceId } = ev.data;
6824
8969
  this.trackStore?.clearDevice(deviceId);
8970
+ this.overlayState.clearDevice(deviceId);
8971
+ this.overlaySynthesisWarnAt.delete(deviceId);
6825
8972
  this.forgetDeviceProcessors(deviceId);
6826
8973
  this.levelStateByDevice.delete(deviceId);
6827
8974
  this.settingsCacheByDevice.delete(deviceId);
@@ -6839,6 +8986,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
6839
8986
  }, TTL_SWEEP_INTERVAL_MS);
6840
8987
  this.retentionSweepTimer = setInterval(() => {
6841
8988
  this.sweepRetention();
8989
+ this.runTrackRetentionSweep();
6842
8990
  }, RETENTION_SWEEP_INTERVAL_MS);
6843
8991
  this.ctx.logger.info("pipeline-analytics subscribers installed");
6844
8992
  const widgetsProvider = { listWidgets: async () => [
@@ -7079,11 +9227,19 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
7079
9227
  mediaStore: this.mediaStore,
7080
9228
  trackStore: this.trackStore,
7081
9229
  eventStore: this.eventStore,
7082
- refreshGallery: () => this.faceRecognizer?.refreshGallery()
9230
+ refreshGallery: () => this.faceRecognizer?.refreshGallery(),
9231
+ emitFaceGalleryChanged: (payload) => this.emitFaceGalleryChanged(payload)
7083
9232
  });
7084
9233
  const plateGallery = new PlateGalleryProvider({
7085
9234
  plateStore: this.plateStore,
7086
- 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)
7087
9243
  });
7088
9244
  return [
7089
9245
  {
@@ -7132,6 +9288,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
7132
9288
  this.unsubBindings = null;
7133
9289
  this.unsubDeviceUnreg?.();
7134
9290
  this.unsubDeviceUnreg = null;
9291
+ await this.embeddingDispatcher?.stop();
9292
+ this.embeddingDispatcher = null;
7135
9293
  for (const id of this.proxies.keys()) this.releaseProxy(id);
7136
9294
  if (this.ttlSweepTimer) {
7137
9295
  clearInterval(this.ttlSweepTimer);
@@ -7143,10 +9301,15 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
7143
9301
  }
7144
9302
  this.zoneAnalytics?.destroy();
7145
9303
  this.audioMetrics?.destroy();
9304
+ this.detailDispatcher?.dispose();
9305
+ this.detailDispatcher = null;
9306
+ this.overlayState.clear();
9307
+ this.overlaySynthesisWarnAt.clear();
7146
9308
  this.processors.clear();
7147
9309
  this.lastActiveTrackIds.clear();
7148
9310
  this.dropoutSkipsByKey.clear();
7149
9311
  this.bestFrameTracker.clear();
9312
+ this.trackLifecycleUpdateMem.clear();
7150
9313
  this.objectEmbeddingBestSelector.clear();
7151
9314
  this.levelStateByDevice.clear();
7152
9315
  this.settingsCacheByDevice.clear();
@@ -7167,7 +9330,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
7167
9330
  async handleInferenceResult(data) {
7168
9331
  if (this.shuttingDown) return;
7169
9332
  const { deviceId, frame } = data;
7170
- await this.processFrame(deviceId, frame, "pipeline", data.frameHandle);
9333
+ await this.processFrame(deviceId, frame, "pipeline", data.frameHandle, data.detailSteps);
7171
9334
  }
7172
9335
  /**
7173
9336
  * Run one detection frame through the analysis layers for a given
@@ -7177,7 +9340,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
7177
9340
  * tracking/zone/event state never crosses between sources. Emits the
7178
9341
  * SAME canonical events, distinguished only by `source`.
7179
9342
  */
7180
- async processFrame(deviceId, frame, source, frameHandle) {
9343
+ async processFrame(deviceId, frame, source, frameHandle, detailSteps) {
7181
9344
  if (this.shuttingDown) return;
7182
9345
  if (!await this.bindingCache.isActive(deviceId)) return;
7183
9346
  const key = this.procKey(deviceId, source);
@@ -7278,6 +9441,24 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
7278
9441
  className: t.className
7279
9442
  }
7280
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
+ });
7281
9462
  }
7282
9463
  }
7283
9464
  let lostTrackCount = 0;
@@ -7289,6 +9470,26 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
7289
9470
  } });
7290
9471
  }
7291
9472
  this.lastActiveTrackIds.set(key, currentTrackIds);
9473
+ if (this.detailDispatcher && detailSteps && detailSteps.length > 0) {
9474
+ const dispatcher = this.detailDispatcher;
9475
+ const steps = detailSteps;
9476
+ for (const t of result.tracked) {
9477
+ const detailFrame = {
9478
+ bbox: { ...t.bbox },
9479
+ frameWidth: result.frameWidth,
9480
+ frameHeight: result.frameHeight,
9481
+ className: t.className,
9482
+ confidence: t.confidence,
9483
+ timestamp: result.timestamp,
9484
+ ...frameHandle !== void 0 ? {
9485
+ frameHandle,
9486
+ nodeId: frameHandle.nodeId
9487
+ } : {}
9488
+ };
9489
+ if (prevIds.has(t.trackId)) dispatcher.onFrame(deviceId, t.trackId, steps, detailFrame, result.timestamp);
9490
+ else dispatcher.onTrackStarted(deviceId, t.trackId, t.className, steps, detailFrame, result.timestamp);
9491
+ }
9492
+ }
7292
9493
  if (newTrackCount > 0 || lostTrackCount > 0 || result.objectEvents.length > 0) {
7293
9494
  const byState = {};
7294
9495
  for (const t of result.tracked) byState[t.state] = (byState[t.state] ?? 0) + 1;
@@ -7321,7 +9522,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
7321
9522
  const mediaSettings = await this.resolveDeviceMediaSettings(deviceId);
7322
9523
  if (this.eventMediaDispatcher && frameHandle) {
7323
9524
  const childCropsByEvent = buildEventChildCrops(result.objectEvents, frame.detections);
7324
- 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) => {
7325
9526
  const childCrops = childCropsByEvent.get(e.id);
7326
9527
  return {
7327
9528
  eventId: e.id,
@@ -7337,15 +9538,25 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
7337
9538
  let plateCrops = 0;
7338
9539
  for (const crops of childCropsByEvent.values()) for (const c of crops) if (c.kind === "faceCrop") faceCrops += 1;
7339
9540
  else plateCrops += 1;
7340
- const snapshotTargets = this.buildSnapshotTargets(result.tracked, result.timestamp, mediaSettings);
9541
+ const snapshotTargets = this.buildSnapshotTargets(deviceId, result.tracked, result.timestamp, mediaSettings);
7341
9542
  if (eventTargets.length > 0 || firstFrameTargets.length > 0 || snapshotTargets.length > 0) {
7342
- log.info("media capture", { meta: {
7343
- source,
9543
+ const captureCounts = {
7344
9544
  events: eventTargets.length,
7345
9545
  trackFrames: firstFrameTargets.length,
7346
9546
  snapshots: snapshotTargets.length,
7347
9547
  faceCrops,
7348
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
7349
9560
  } });
7350
9561
  this.eventMediaDispatcher.captureForFrame({
7351
9562
  deviceId,
@@ -7408,6 +9619,26 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
7408
9619
  zones: e.zones ?? []
7409
9620
  }
7410
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
+ }
7411
9642
  this.ctx.eventBus.emit({
7412
9643
  id: `pa-${(0, node_crypto.randomUUID)()}`,
7413
9644
  timestamp: new Date(result.timestamp),
@@ -7422,7 +9653,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
7422
9653
  timestamp: result.timestamp,
7423
9654
  frameWidth: result.frameWidth,
7424
9655
  frameHeight: result.frameHeight,
7425
- detections: result.tracked
9656
+ detections: overlayDetections
7426
9657
  }
7427
9658
  });
7428
9659
  }
@@ -7500,6 +9731,158 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
7500
9731
  return settings;
7501
9732
  }
7502
9733
  /**
9734
+ * Route one track's `runDetailSubtree` results (two-plane detail dispatch)
9735
+ * into the EXISTING per-track consumers, discriminated by payload SHAPE:
9736
+ * • embedding + alignedCropJpeg → FACE (arcface): the FaceRecognizer's
9737
+ * candidate/best-face/gallery/keyFrame path (input source cut-over);
9738
+ * • embedding only → CLIP object embedding → the object-embedding store
9739
+ * (semantic search);
9740
+ * • label only → classifier answer / plate OCR text → the track's
9741
+ * enrichment label the notifier/UI already read.
9742
+ * Best-effort (D8): a per-detail failure is logged and never propagated.
9743
+ */
9744
+ async routeDetailResults(deviceId, trackId, details, frame) {
9745
+ for (const d of details) try {
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);
9754
+ else if (d.embedding !== void 0) await this.routeClipDetail(deviceId, trackId, d, frame.timestamp);
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
+ }
9765
+ } catch (err) {
9766
+ this.ctx.logger.warn("detail result route failed", {
9767
+ tags: { deviceId },
9768
+ meta: {
9769
+ trackId,
9770
+ stepId: d.stepId,
9771
+ error: require_dist.errMsg(err)
9772
+ }
9773
+ });
9774
+ }
9775
+ }
9776
+ /** Face-embedding detail → the FaceRecognizer (same gate + logic as the
9777
+ * former per-frame face path; only the input source moved). */
9778
+ async routeFaceDetail(deviceId, trackId, detail, frame) {
9779
+ if (!this.faceRecognizer || detail.embedding === void 0) return;
9780
+ if (!await this.resolveGlobalFaceEnabled()) return;
9781
+ const [settings, media] = await Promise.all([this.resolveDeviceFaceSettings(deviceId), this.resolveDeviceMediaSettings(deviceId)]);
9782
+ await this.faceRecognizer.ingestFaceDetail({
9783
+ deviceId,
9784
+ trackId,
9785
+ timestamp: frame.timestamp,
9786
+ frameWidth: frame.frameWidth,
9787
+ frameHeight: frame.frameHeight,
9788
+ score: detail.score,
9789
+ embedding: decodeEmbeddingBase64(detail.embedding),
9790
+ parentBbox: { ...frame.bbox },
9791
+ ...detail.bbox !== void 0 ? { faceBbox: { ...detail.bbox } } : {},
9792
+ ...detail.alignedCropJpeg !== void 0 ? { alignedCropJpeg: detail.alignedCropJpeg } : {},
9793
+ settings,
9794
+ cropPadding: media.cropPadding,
9795
+ ...frame.frameHandle !== void 0 ? { frameHandle: frame.frameHandle } : {}
9796
+ });
9797
+ }
9798
+ /** CLIP object-embedding detail → the object-embedding store (semantic
9799
+ * search). Stamps the active encoder's model id so the same-model search
9800
+ * gate keeps matching. */
9801
+ async routeClipDetail(deviceId, trackId, detail, timestamp) {
9802
+ const store = this.objectEmbeddingStore;
9803
+ if (!store || detail.embedding === void 0) return;
9804
+ const modelId = await this.resolveClipModelId();
9805
+ if (modelId === null) {
9806
+ this.ctx.logger.debug("clip detail dropped — no active embedding model id", {
9807
+ tags: { deviceId },
9808
+ meta: {
9809
+ trackId,
9810
+ stepId: detail.stepId
9811
+ }
9812
+ });
9813
+ return;
9814
+ }
9815
+ await store.upsertIfBetter({
9816
+ trackId,
9817
+ deviceId,
9818
+ timestamp,
9819
+ className: detail.className,
9820
+ embedding: decodeEmbeddingBase64(detail.embedding),
9821
+ modelId,
9822
+ confidence: detail.score
9823
+ });
9824
+ }
9825
+ /** Classifier answer / plate OCR text → the track's enrichment label
9826
+ * (TrackStore + persisted events + importance), mirroring the FaceRecognizer
9827
+ * label-propagation path. */
9828
+ async applyTrackEnrichmentLabel(deviceId, trackId, label) {
9829
+ try {
9830
+ await this.trackStore?.setLabel(trackId, label);
9831
+ } catch (err) {
9832
+ this.ctx.logger.warn("detail label setLabel failed", {
9833
+ tags: { deviceId },
9834
+ meta: {
9835
+ trackId,
9836
+ error: require_dist.errMsg(err)
9837
+ }
9838
+ });
9839
+ }
9840
+ try {
9841
+ await this.eventStore?.setLabelForTrack(trackId, label);
9842
+ } catch (err) {
9843
+ this.ctx.logger.warn("detail label setLabelForTrack failed", {
9844
+ tags: { deviceId },
9845
+ meta: {
9846
+ trackId,
9847
+ error: require_dist.errMsg(err)
9848
+ }
9849
+ });
9850
+ }
9851
+ try {
9852
+ const trackStore = this.trackStore;
9853
+ const eventStore = this.eventStore;
9854
+ if (trackStore && eventStore) await recomputeTrackImportance({
9855
+ trackStore,
9856
+ eventStore
9857
+ }, trackId);
9858
+ } catch (err) {
9859
+ this.ctx.logger.debug("detail label recomputeImportance failed", {
9860
+ tags: { deviceId },
9861
+ meta: {
9862
+ trackId,
9863
+ error: require_dist.errMsg(err)
9864
+ }
9865
+ });
9866
+ }
9867
+ }
9868
+ /** Active CLIP image-embedding model id (== the text encoder's), TTL-cached.
9869
+ * Returns null when the embedding-encoder cap is unavailable. */
9870
+ async resolveClipModelId() {
9871
+ const now = Date.now();
9872
+ if (this.clipModelIdCache && now < this.clipModelIdCache.expiresAt) return this.clipModelIdCache.value;
9873
+ let value = null;
9874
+ try {
9875
+ value = (await this.ctx.api.embeddingEncoder.getInfo.query())?.modelId ?? null;
9876
+ } catch (err) {
9877
+ this.ctx.logger.debug("resolveClipModelId: getInfo failed", { meta: { error: require_dist.errMsg(err) } });
9878
+ }
9879
+ this.clipModelIdCache = {
9880
+ value,
9881
+ expiresAt: now + CLIP_MODEL_ID_CACHE_TTL_MS
9882
+ };
9883
+ return value;
9884
+ }
9885
+ /**
7503
9886
  * §5 — decide which active tracks need periodic media THIS frame. Pure over
7504
9887
  * TrackStore.lastSnapshotAt + the per-track best-confidence map:
7505
9888
  * • `snapshot` (append) + `lastFrame` (rolling overwrite) fire together on
@@ -7586,12 +9969,67 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
7586
9969
  });
7587
9970
  }));
7588
9971
  }
7589
- 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) {
7590
10027
  const targets = [];
7591
10028
  for (const t of tracked) {
7592
10029
  const lastSnap = this.trackStore.lastSnapshotAt(t.trackId);
7593
10030
  const dueSnapshot = media.saveThumbnails && lastSnap > 0 && timestamp - lastSnap >= media.snapshotIntervalMs;
7594
10031
  const isNewBest = this.bestFrameTracker.observe(t.trackId, t.confidence, timestamp);
10032
+ this.maybeEmitTrackLifecycleUpdate(deviceId, t, timestamp, isNewBest);
7595
10033
  if (!dueSnapshot && !isNewBest) continue;
7596
10034
  targets.push({
7597
10035
  trackId: t.trackId,
@@ -7857,6 +10295,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
7857
10295
  positions: t.positions.length
7858
10296
  }
7859
10297
  });
10298
+ const keyFrameMediaKey = this.keyFrameKeyByTrackId.get(t.trackId);
7860
10299
  const faceEnd = this.faceRecognizer?.onTrackEnd(t.deviceId, t.trackId);
7861
10300
  const dropKeyFrameKey = () => {
7862
10301
  this.keyFrameKeyByTrackId.delete(t.trackId);
@@ -7864,11 +10303,16 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
7864
10303
  if (faceEnd) faceEnd.finally(dropKeyFrameKey);
7865
10304
  else dropKeyFrameKey();
7866
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;
7867
10310
  try {
7868
10311
  const peak = await this.eventStore?.peakForTrack(t.trackId);
7869
10312
  if (peak) {
10313
+ endBestEventId = peak.bestEventId;
7870
10314
  const { importance, reason } = computeImportance({
7871
- peakConfidence: this.bestFrameTracker.peak(t.trackId)?.confidence ?? peak.peakConfidence,
10315
+ peakConfidence: trackerPeak?.confidence ?? peak.peakConfidence,
7872
10316
  className: t.className,
7873
10317
  durationMs: duration,
7874
10318
  peakBboxAreaFrac: peak.peakBboxAreaFrac,
@@ -7876,6 +10320,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
7876
10320
  zonesVisited: t.zonesVisited,
7877
10321
  ...t.label !== void 0 ? { label: t.label } : {}
7878
10322
  });
10323
+ endImportance = importance;
10324
+ endImportanceReason = reason;
7879
10325
  await this.trackStore?.setImportance(t.trackId, importance, reason, peak.bestEventId);
7880
10326
  if (peak.bestEventId !== void 0) await this.eventStore?.setImportanceForTrack(t.trackId, importance);
7881
10327
  }
@@ -7887,6 +10333,19 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
7887
10333
  }
7888
10334
  this.bestFrameTracker.delete(t.trackId);
7889
10335
  this.objectEmbeddingBestSelector.delete(t.trackId);
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
+ }
7890
10349
  this.ctx.eventBus.emit({
7891
10350
  id: `pa-end-${t.trackId}`,
7892
10351
  timestamp: new Date(t.lastSeen),
@@ -7903,6 +10362,26 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
7903
10362
  durationMs: duration
7904
10363
  }
7905
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);
7906
10385
  }
7907
10386
  } catch (err) {
7908
10387
  if (this.shuttingDown) return;
@@ -7994,6 +10473,62 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
7994
10473
  if (!frame) return;
7995
10474
  await this.processFrame(payload.cameraId, frame, "onboard");
7996
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
+ }
7997
10532
  /** Composite key for the per-(device, source) processor + track maps. */
7998
10533
  procKey(deviceId, source) {
7999
10534
  return `${deviceId}:${source}`;
@@ -8093,6 +10628,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
8093
10628
  }
8094
10629
  async clearTracks(input) {
8095
10630
  this.trackStore?.clearDevice(input.deviceId);
10631
+ this.overlayState.clearDevice(input.deviceId);
10632
+ this.overlaySynthesisWarnAt.delete(input.deviceId);
8096
10633
  const prefix = `${input.deviceId}:`;
8097
10634
  for (const k of this.lastActiveTrackIds.keys()) if (k.startsWith(prefix)) this.lastActiveTrackIds.delete(k);
8098
10635
  }
@@ -8269,6 +10806,168 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
8269
10806
  return counts;
8270
10807
  }
8271
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
+ /**
8272
10971
  * Decode a stored event image into `EventMedia` for the data-plane handler.
8273
10972
  * Preference order: `crop` (square-safe 16:9 preview) → `fullFrameBoxed`
8274
10973
  * (native-res boxed frame) → any available file. Returns `null` if the
@@ -8329,6 +11028,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
8329
11028
  label: "Post-processing node",
8330
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.",
8331
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
8332
11037
  }]
8333
11038
  },
8334
11039
  {
@@ -8435,9 +11140,20 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
8435
11140
  {
8436
11141
  id: "retention",
8437
11142
  title: "Retention",
8438
- 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.",
8439
11144
  columns: 3,
8440
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
+ },
8441
11157
  {
8442
11158
  type: "number",
8443
11159
  key: "retentionMotionDays",