@camstack/addon-post-analysis 1.1.26 → 1.1.27

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,8 +2,8 @@ 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");
5
+ const require_dist = require("../dist-CtnFKuWh.js");
6
+ const require_resolve_frame = require("../resolve-frame-BAdpVnlx.js");
7
7
  let _camstack_shm_ring = require("@camstack/shm-ring");
8
8
  let sharp = require("sharp");
9
9
  sharp = require_dist.__toESM(sharp);
@@ -1527,6 +1527,7 @@ var FrameProcessor = class {
1527
1527
  const embeddingByBbox = /* @__PURE__ */ new Map();
1528
1528
  const firstLevelBboxById = /* @__PURE__ */ new Map();
1529
1529
  const faceBboxByBbox = /* @__PURE__ */ new Map();
1530
+ const faceAlignedCropByBbox = /* @__PURE__ */ new Map();
1530
1531
  const plateByBbox = /* @__PURE__ */ new Map();
1531
1532
  const maskByBbox = /* @__PURE__ */ new Map();
1532
1533
  const flatDetections = frame.detections.filter((d) => d.kind === "first-level").map((det) => {
@@ -1569,6 +1570,7 @@ var FrameProcessor = class {
1569
1570
  w: det.bbox.width,
1570
1571
  h: det.bbox.height
1571
1572
  });
1573
+ if (det.faceAlignedCrop !== void 0) faceAlignedCropByBbox.set(parentBbox, det.faceAlignedCrop);
1572
1574
  if (det.embedding !== void 0 && !embeddingByBbox.has(parentBbox)) embeddingByBbox.set(parentBbox, {
1573
1575
  embedding: det.embedding,
1574
1576
  ...det.embeddingModelId !== void 0 ? { embeddingModelId: det.embeddingModelId } : {}
@@ -1611,6 +1613,7 @@ var FrameProcessor = class {
1611
1613
  });
1612
1614
  const emb = embeddingByBbox.get(td.bbox);
1613
1615
  const faceBbox = faceBboxByBbox.get(td.bbox);
1616
+ const faceAlignedCrop = faceAlignedCropByBbox.get(td.bbox);
1614
1617
  const plate = plateByBbox.get(td.bbox);
1615
1618
  return {
1616
1619
  trackId: td.trackId,
@@ -1625,6 +1628,7 @@ var FrameProcessor = class {
1625
1628
  ...emb.embeddingModelId !== void 0 ? { embeddingModelId: emb.embeddingModelId } : {}
1626
1629
  } : {},
1627
1630
  ...faceBbox !== void 0 ? { faceBbox } : {},
1631
+ ...faceAlignedCrop !== void 0 ? { faceAlignedCrop } : {},
1628
1632
  ...plate !== void 0 ? {
1629
1633
  plateText: plate.text,
1630
1634
  plateScore: plate.score,
@@ -5500,6 +5504,14 @@ function updateTrackAggregate(prev, match, opts) {
5500
5504
  //#region src/pipeline-analytics/face-recognizer.ts
5501
5505
  /** At most one "dropping imageless track" log per this interval, per recognizer. */
5502
5506
  var FACE_IMAGELESS_LOG_THROTTLE_MS = 6e4;
5507
+ /**
5508
+ * arcface model id stamped on a detail-plane face candidate when the gallery is
5509
+ * empty (collect-only). The detail-subtree result carries no `embeddingModelId`
5510
+ * (the two-plane `DetailResult` schema omits it), so recognition uses the
5511
+ * gallery's own model id (all enrolled samples share one) and this constant is
5512
+ * only a placeholder for the collect-only case where the id is never compared.
5513
+ */
5514
+ var FALLBACK_FACE_MODEL_ID = "arcface";
5503
5515
  var FaceRecognizer = class {
5504
5516
  deps;
5505
5517
  gallery = [];
@@ -5527,6 +5539,38 @@ var FaceRecognizer = class {
5527
5539
  this.deps.logger.warn("FaceRecognizer.refreshGallery failed", { meta: { error: String(err) } });
5528
5540
  }
5529
5541
  }
5542
+ /**
5543
+ * Two-plane detail feed: ingest ONE `runDetailSubtree` face result for a
5544
+ * track and run it through the SAME `processFrame` logic (candidate → best
5545
+ * face → gallery match → crop hold). The result is synthesized into a single
5546
+ * `TrackedDetectionOut` candidate so no recognizer logic changes — only the
5547
+ * input source moves from the per-frame plane to this per-track call.
5548
+ */
5549
+ async ingestFaceDetail(input) {
5550
+ const modelId = this.gallery[0]?.modelId ?? FALLBACK_FACE_MODEL_ID;
5551
+ const candidate = {
5552
+ trackId: input.trackId,
5553
+ className: "face",
5554
+ confidence: input.score,
5555
+ bbox: input.parentBbox,
5556
+ zones: [],
5557
+ state: "moving",
5558
+ embedding: input.embedding,
5559
+ embeddingModelId: modelId,
5560
+ ...input.faceBbox !== void 0 ? { faceBbox: input.faceBbox } : {},
5561
+ ...input.alignedCropJpeg !== void 0 ? { faceAlignedCrop: input.alignedCropJpeg } : {}
5562
+ };
5563
+ await this.processFrame({
5564
+ deviceId: input.deviceId,
5565
+ timestamp: input.timestamp,
5566
+ frameWidth: input.frameWidth,
5567
+ frameHeight: input.frameHeight,
5568
+ tracked: [candidate],
5569
+ settings: input.settings,
5570
+ cropPadding: input.cropPadding,
5571
+ ...input.frameHandle !== void 0 ? { frameHandle: input.frameHandle } : {}
5572
+ });
5573
+ }
5530
5574
  async processFrame(input) {
5531
5575
  const { settings } = input;
5532
5576
  const candidates = input.tracked.filter((t) => Array.isArray(t.embedding) && t.embedding.length > 0 && typeof t.embeddingModelId === "string" && t.confidence >= settings.minFaceConfidence);
@@ -5564,7 +5608,8 @@ var FaceRecognizer = class {
5564
5608
  if (isNewBest || needsCrop) {
5565
5609
  const cropBbox = c.faceBbox ?? c.bbox;
5566
5610
  let crop;
5567
- if (c.faceBbox !== void 0 && input.frameHandle !== void 0) try {
5611
+ if (c.faceAlignedCrop !== void 0) crop = Buffer.from(c.faceAlignedCrop, "base64");
5612
+ else if (c.faceBbox !== void 0 && input.frameHandle !== void 0) try {
5568
5613
  crop = await this.deps.captureCrop(input.frameHandle, c.faceBbox, input.frameWidth, input.frameHeight, input.cropPadding) ?? void 0;
5569
5614
  } catch (err) {
5570
5615
  this.deps.logger.debug("FaceRecognizer crop capture failed", {
@@ -5755,6 +5800,346 @@ var FaceRecognizer = class {
5755
5800
  }
5756
5801
  };
5757
5802
  //#endregion
5803
+ //#region src/pipeline-analytics/detail-scheduler.ts
5804
+ /** Default backoff/period when a step's cadence omits `minIntervalMs`. */
5805
+ var DEFAULT_MIN_INTERVAL_MS = 1e3;
5806
+ /** Default fire cap for a `once` step when its announce omits `maxPerTrack`. */
5807
+ var DEFAULT_ONCE_MAX_PER_TRACK = 3;
5808
+ /**
5809
+ * Pure per-(track, step) scheduling state machine for detail-subtree
5810
+ * dispatch. Given a camera's announced child steps (`DetailStepAnnounce[]`,
5811
+ * read off `PipelineInferenceResultPayload.detailSteps`), decides WHEN each
5812
+ * step should run for a given track — independent of transport, I/O, or
5813
+ * timers. The caller drives it with wall-clock `nowMs` and dispatches the
5814
+ * returned `DetailRequest[]`.
5815
+ */
5816
+ var DetailScheduler = class {
5817
+ tracks = /* @__PURE__ */ new Map();
5818
+ /** Track appeared with class + announce; returns immediate requests. */
5819
+ onTrackStarted(trackId, className, announce, nowMs) {
5820
+ const steps = /* @__PURE__ */ new Map();
5821
+ const requests = [];
5822
+ for (const stepAnnounce of announce) {
5823
+ if (!stepAnnounce.inputClasses.includes(className)) continue;
5824
+ const state = {
5825
+ announce: stepAnnounce,
5826
+ firedCount: 1,
5827
+ lastFiredAt: nowMs,
5828
+ sticky: false,
5829
+ retryPending: false
5830
+ };
5831
+ steps.set(stepAnnounce.stepId, state);
5832
+ requests.push({
5833
+ trackId,
5834
+ stepId: stepAnnounce.stepId,
5835
+ reason: "new-track"
5836
+ });
5837
+ }
5838
+ this.tracks.set(trackId, steps);
5839
+ return requests;
5840
+ }
5841
+ /** Better candidate crop observed for the track. */
5842
+ onCandidateImproved(trackId, nowMs) {
5843
+ const steps = this.tracks.get(trackId);
5844
+ if (!steps) return [];
5845
+ const requests = [];
5846
+ for (const state of steps.values()) {
5847
+ if (state.announce.cadence.trigger !== "improve") continue;
5848
+ if (!this.canFire(state, nowMs)) continue;
5849
+ this.markFired(state, nowMs);
5850
+ requests.push({
5851
+ trackId,
5852
+ stepId: state.announce.stepId,
5853
+ reason: "improve"
5854
+ });
5855
+ }
5856
+ return requests;
5857
+ }
5858
+ /** Periodic tick (call ~1/s). Also carries pending retries for any trigger kind. */
5859
+ tick(nowMs) {
5860
+ const requests = [];
5861
+ for (const [trackId, steps] of this.tracks) for (const state of steps.values()) {
5862
+ if (state.sticky) continue;
5863
+ if (state.retryPending) {
5864
+ if (!this.intervalElapsed(state, nowMs)) continue;
5865
+ if (!this.underMaxPerTrack(state)) {
5866
+ state.retryPending = false;
5867
+ continue;
5868
+ }
5869
+ this.markFired(state, nowMs);
5870
+ requests.push({
5871
+ trackId,
5872
+ stepId: state.announce.stepId,
5873
+ reason: "retry"
5874
+ });
5875
+ continue;
5876
+ }
5877
+ if (state.announce.cadence.trigger !== "periodic") continue;
5878
+ if (!this.canFire(state, nowMs)) continue;
5879
+ this.markFired(state, nowMs);
5880
+ requests.push({
5881
+ trackId,
5882
+ stepId: state.announce.stepId,
5883
+ reason: "periodic"
5884
+ });
5885
+ }
5886
+ return requests;
5887
+ }
5888
+ /**
5889
+ * Result arrived; confidence drives sticky/retry. null = failed (retry per
5890
+ * policy). `_nowMs` is part of the public signature for symmetry with the
5891
+ * other methods but isn't needed here — retry backoff is anchored to
5892
+ * `lastFiredAt` (set when the step was actually dispatched), not to when
5893
+ * its result came back.
5894
+ */
5895
+ onResult(trackId, stepId, confidence, _nowMs) {
5896
+ const steps = this.tracks.get(trackId);
5897
+ if (!steps) return;
5898
+ const state = steps.get(stepId);
5899
+ if (!state) return;
5900
+ if (state.sticky) return;
5901
+ const { stickyOnConfidence } = state.announce.cadence;
5902
+ if (confidence !== null && stickyOnConfidence !== void 0 && confidence >= stickyOnConfidence) {
5903
+ state.sticky = true;
5904
+ state.retryPending = false;
5905
+ return;
5906
+ }
5907
+ if (confidence === null) {
5908
+ if (this.underMaxPerTrack(state)) state.retryPending = true;
5909
+ return;
5910
+ }
5911
+ if (state.announce.cadence.trigger === "once" && stickyOnConfidence !== void 0) {
5912
+ if (this.underMaxPerTrack(state)) state.retryPending = true;
5913
+ }
5914
+ }
5915
+ onTrackEnded(trackId) {
5916
+ this.tracks.delete(trackId);
5917
+ }
5918
+ canFire(state, nowMs) {
5919
+ if (state.sticky) return false;
5920
+ if (!this.underMaxPerTrack(state)) return false;
5921
+ return this.intervalElapsed(state, nowMs);
5922
+ }
5923
+ intervalElapsed(state, nowMs) {
5924
+ if (state.lastFiredAt === null) return true;
5925
+ const minIntervalMs = state.announce.cadence.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS;
5926
+ return nowMs - state.lastFiredAt >= minIntervalMs;
5927
+ }
5928
+ underMaxPerTrack(state) {
5929
+ const maxPerTrack = state.announce.cadence.maxPerTrack ?? (state.announce.cadence.trigger === "once" ? DEFAULT_ONCE_MAX_PER_TRACK : Infinity);
5930
+ return state.firedCount < maxPerTrack;
5931
+ }
5932
+ markFired(state, nowMs) {
5933
+ state.firedCount += 1;
5934
+ state.lastFiredAt = nowMs;
5935
+ state.retryPending = false;
5936
+ }
5937
+ };
5938
+ //#endregion
5939
+ //#region src/pipeline-analytics/detail-dispatcher.ts
5940
+ /** Throttle for the per-device "detail call failed" warn — one line / minute. */
5941
+ var FAIL_WARN_THROTTLE_MS = 6e4;
5942
+ var TrackDetailDispatcher = class {
5943
+ deps;
5944
+ devices = /* @__PURE__ */ new Map();
5945
+ maxInFlight;
5946
+ tickIntervalMs;
5947
+ disposed = false;
5948
+ constructor(deps) {
5949
+ this.deps = deps;
5950
+ this.maxInFlight = deps.maxInFlightPerDevice ?? 2;
5951
+ this.tickIntervalMs = deps.tickIntervalMs ?? 1e3;
5952
+ }
5953
+ /** A track appeared: record its frame, seed its best-confidence, and dispatch
5954
+ * the scheduler's immediate (new-track) requests. */
5955
+ onTrackStarted(deviceId, trackId, className, announce, frame, nowMs) {
5956
+ if (this.disposed) return;
5957
+ const dev = this.ensureDevice(deviceId);
5958
+ dev.tracks.set(trackId, frame);
5959
+ dev.candidateBest.observe(trackId, frame.confidence, frame.timestamp);
5960
+ const requests = dev.scheduler.onTrackStarted(trackId, className, announce, nowMs);
5961
+ this.enqueue(deviceId, dev, requests);
5962
+ this.ensureTimer(deviceId, dev);
5963
+ }
5964
+ /** A subsequent frame for a live track: refresh its frame + fire
5965
+ * `improve`-cadence steps when the detector confidence strictly improves.
5966
+ *
5967
+ * `announce` is the frame's currently-announced detail chain. When a track
5968
+ * is alive but has NO dispatcher state yet — it existed before `detailSteps`
5969
+ * first appeared (a mid-track redeploy / config change) — this adopts it as a
5970
+ * new track so it starts getting scheduled instead of starving for its whole
5971
+ * life. Idempotent: guarded by the per-track state check, so a track that
5972
+ * already has state is never reset. */
5973
+ onFrame(deviceId, trackId, announce, frame, nowMs) {
5974
+ if (this.disposed) return;
5975
+ const dev = this.devices.get(deviceId);
5976
+ if (!dev || !dev.tracks.has(trackId)) {
5977
+ if (announce.length > 0) this.onTrackStarted(deviceId, trackId, frame.className, announce, frame, nowMs);
5978
+ return;
5979
+ }
5980
+ dev.tracks.set(trackId, frame);
5981
+ if (!dev.candidateBest.observe(trackId, frame.confidence, frame.timestamp)) return;
5982
+ const requests = dev.scheduler.onCandidateImproved(trackId, nowMs);
5983
+ this.enqueue(deviceId, dev, requests);
5984
+ }
5985
+ /** A track ended (durable TTL expiry): drop its scheduler + frame state. Any
5986
+ * queued request for it is discarded at dequeue. */
5987
+ onTrackEnded(deviceId, trackId) {
5988
+ const dev = this.devices.get(deviceId);
5989
+ if (!dev) return;
5990
+ dev.scheduler.onTrackEnded(trackId);
5991
+ dev.tracks.delete(trackId);
5992
+ dev.candidateBest.delete(trackId);
5993
+ if (dev.tracks.size === 0) this.clearTimer(dev);
5994
+ }
5995
+ dispose() {
5996
+ this.disposed = true;
5997
+ for (const dev of this.devices.values()) {
5998
+ this.clearTimer(dev);
5999
+ dev.queue.length = 0;
6000
+ dev.tracks.clear();
6001
+ dev.candidateBest.clear();
6002
+ }
6003
+ this.devices.clear();
6004
+ }
6005
+ ensureDevice(deviceId) {
6006
+ let dev = this.devices.get(deviceId);
6007
+ if (!dev) {
6008
+ dev = {
6009
+ scheduler: new DetailScheduler(),
6010
+ tracks: /* @__PURE__ */ new Map(),
6011
+ candidateBest: new BestDetectionTracker(),
6012
+ queue: [],
6013
+ inFlight: 0,
6014
+ timer: null,
6015
+ lastFailWarnAt: 0
6016
+ };
6017
+ this.devices.set(deviceId, dev);
6018
+ }
6019
+ return dev;
6020
+ }
6021
+ ensureTimer(deviceId, dev) {
6022
+ if (dev.timer) return;
6023
+ const timer = setInterval(() => this.tick(deviceId, dev), this.tickIntervalMs);
6024
+ if (typeof timer.unref === "function") timer.unref();
6025
+ dev.timer = timer;
6026
+ }
6027
+ clearTimer(dev) {
6028
+ if (dev.timer) {
6029
+ clearInterval(dev.timer);
6030
+ dev.timer = null;
6031
+ }
6032
+ }
6033
+ tick(deviceId, dev) {
6034
+ if (this.disposed) return;
6035
+ const requests = dev.scheduler.tick(Date.now());
6036
+ this.enqueue(deviceId, dev, requests);
6037
+ }
6038
+ enqueue(deviceId, dev, requests) {
6039
+ if (requests.length === 0) return;
6040
+ for (const r of requests) dev.queue.push(r);
6041
+ this.pump(deviceId, dev);
6042
+ }
6043
+ pump(deviceId, dev) {
6044
+ while (dev.inFlight < this.maxInFlight && dev.queue.length > 0) {
6045
+ const req = dev.queue.shift();
6046
+ if (req === void 0) break;
6047
+ const frame = dev.tracks.get(req.trackId);
6048
+ if (frame === void 0) continue;
6049
+ dev.inFlight += 1;
6050
+ this.dispatch(deviceId, dev, req, frame).finally(() => {
6051
+ dev.inFlight -= 1;
6052
+ this.pump(deviceId, dev);
6053
+ });
6054
+ }
6055
+ }
6056
+ async dispatch(deviceId, dev, req, frame) {
6057
+ const details = await this.runOnce(deviceId, dev, req, frame);
6058
+ let topScore = null;
6059
+ if (details !== null && details.length > 0) {
6060
+ topScore = details.reduce((max, d) => d.score > max ? d.score : max, Number.NEGATIVE_INFINITY);
6061
+ try {
6062
+ await this.deps.routeResults(deviceId, req.trackId, details, frame);
6063
+ } catch (err) {
6064
+ this.deps.logger.warn("detail result routing failed", {
6065
+ tags: { deviceId },
6066
+ meta: {
6067
+ trackId: req.trackId,
6068
+ stepId: req.stepId,
6069
+ error: String(err)
6070
+ }
6071
+ });
6072
+ }
6073
+ }
6074
+ dev.scheduler.onResult(req.trackId, req.stepId, topScore, Date.now());
6075
+ }
6076
+ /**
6077
+ * Run the request once via the frameHandle, and — on a miss (null OR throw)
6078
+ * — retry ONCE with a `cropJpeg` fallback when one can be captured. Returns
6079
+ * the detail list, or `null` when both attempts fail to produce a result.
6080
+ */
6081
+ async runOnce(deviceId, dev, req, frame) {
6082
+ const parent = {
6083
+ bbox: { ...frame.bbox },
6084
+ className: frame.className
6085
+ };
6086
+ if (frame.frameHandle !== void 0) try {
6087
+ const primary = await this.deps.runDetailSubtree({
6088
+ deviceId,
6089
+ frameHandle: frame.frameHandle,
6090
+ parent,
6091
+ steps: [req.stepId]
6092
+ }, frame.nodeId);
6093
+ if (primary !== null) return primary.details;
6094
+ } catch (err) {
6095
+ this.deps.logger.debug("detail primary call failed — trying crop fallback", {
6096
+ tags: { deviceId },
6097
+ meta: {
6098
+ trackId: req.trackId,
6099
+ stepId: req.stepId,
6100
+ error: String(err)
6101
+ }
6102
+ });
6103
+ }
6104
+ if (this.deps.captureCropBase64 !== void 0) try {
6105
+ const cropJpeg = await this.deps.captureCropBase64(frame);
6106
+ if (cropJpeg !== null) {
6107
+ const retry = await this.deps.runDetailSubtree({
6108
+ deviceId,
6109
+ cropJpeg,
6110
+ parent,
6111
+ steps: [req.stepId]
6112
+ }, frame.nodeId);
6113
+ if (retry !== null) return retry.details;
6114
+ }
6115
+ } catch (err) {
6116
+ this.deps.logger.debug("detail crop-fallback call failed", {
6117
+ tags: { deviceId },
6118
+ meta: {
6119
+ trackId: req.trackId,
6120
+ stepId: req.stepId,
6121
+ error: String(err)
6122
+ }
6123
+ });
6124
+ }
6125
+ this.warnFailThrottled(deviceId, dev, req);
6126
+ return null;
6127
+ }
6128
+ warnFailThrottled(deviceId, dev, req) {
6129
+ const now = Date.now();
6130
+ if (now - dev.lastFailWarnAt < FAIL_WARN_THROTTLE_MS) return;
6131
+ dev.lastFailWarnAt = now;
6132
+ this.deps.logger.warn("runDetailSubtree produced no result (frame + crop both missed)", {
6133
+ tags: { deviceId },
6134
+ meta: {
6135
+ trackId: req.trackId,
6136
+ stepId: req.stepId,
6137
+ reason: req.reason
6138
+ }
6139
+ });
6140
+ }
6141
+ };
6142
+ //#endregion
5758
6143
  //#region src/pipeline-analytics/store/plate-store.ts
5759
6144
  var PLATES_COLLECTION = "pipeline-analytics:plates";
5760
6145
  var PLATE_COLUMNS = [
@@ -6399,6 +6784,12 @@ function createEventMediaHandler(deps) {
6399
6784
  * surface to turn the refinement pipeline on/off for a camera.
6400
6785
  */
6401
6786
  var TTL_SWEEP_INTERVAL_MS = 5e3;
6787
+ /** Padding applied to the parent bbox for the `cropJpeg` retry fallback when a
6788
+ * scheduled detail call's frameHandle lease is already gone. */
6789
+ var DETAIL_FALLBACK_CROP_PADDING = .15;
6790
+ /** How long the active CLIP model id (from the embedding-encoder) is cached
6791
+ * before re-reading. */
6792
+ var CLIP_MODEL_ID_CACHE_TTL_MS = 6e4;
6402
6793
  var SETTINGS_CACHE_TTL_MS = 5e3;
6403
6794
  /** §5 best-frame: a track's `thumbnail` is overwritten only when the current
6404
6795
  * detection confidence beats the held best by at least this margin (hysteresis
@@ -6431,6 +6822,15 @@ var RETENTION_SWEEP_INTERVAL_MS = 5 * 6e4;
6431
6822
  var AUDIO_EVENT_HEARTBEAT_MS = 5e3;
6432
6823
  var MOTION_EVENT_HEARTBEAT_MS = 5e3;
6433
6824
  /**
6825
+ * Decode a base64 little-endian float32 vector (the `DetailResult.embedding`
6826
+ * wire encoding produced by `runDetailSubtree`) back into a plain number[].
6827
+ */
6828
+ function decodeEmbeddingBase64(base64) {
6829
+ const bytes = Buffer.from(base64, "base64");
6830
+ const view = new Float32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));
6831
+ return Array.from(view);
6832
+ }
6833
+ /**
6434
6834
  * Re-home the global analytics sections into the per-device `Analytics`
6435
6835
  * top-tab. Every section defaults to the `Analytics` tab (so the
6436
6836
  * device-manager aggregator groups them) AND is marked
@@ -6480,6 +6880,14 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
6480
6880
  plateStore = null;
6481
6881
  plateRecognizer = null;
6482
6882
  objectEmbeddingStore = null;
6883
+ /** Two-plane detail scheduler/dispatcher: per-track on-demand face/clip/
6884
+ * classifier/plate enrichment via `pipelineRunner.runDetailSubtree`. Replaces
6885
+ * the per-frame child consumption the executor no longer emits. */
6886
+ detailDispatcher = null;
6887
+ /** Active CLIP image-embedding model id (== the text encoder's), TTL-cached.
6888
+ * Stamped on object-embedding rows from the detail plane so semantic search's
6889
+ * same-model gate keeps matching. */
6890
+ clipModelIdCache = null;
6483
6891
  /** Frame-based event/track media (crop + boxed full-frame) from the
6484
6892
  * detection-pipeline DECODED frame — the ONLY image source (never the
6485
6893
  * snapshot cap). Null when shm frame access is unavailable. */
@@ -6587,7 +6995,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
6587
6995
  let storage = this.ctx.kernel.storage;
6588
6996
  const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
6589
6997
  if (mediaRoot) {
6590
- const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-Cg_cGqs0.js"));
6998
+ const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-BN31iDiA.js"));
6591
6999
  storage = new FilesystemStorageProvider(mediaRoot);
6592
7000
  logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
6593
7001
  }
@@ -6739,6 +7147,21 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
6739
7147
  store: api.settingsStore,
6740
7148
  logger: logger.child("ObjectEmbeddingStore")
6741
7149
  });
7150
+ const runnerApi = api.pipelineRunner;
7151
+ this.detailDispatcher = new TrackDetailDispatcher({
7152
+ logger: logger.child("DetailDispatcher"),
7153
+ runDetailSubtree: async (input, nodeId) => {
7154
+ if (!runnerApi?.runDetailSubtree) return null;
7155
+ if (nodeId !== void 0) return runnerApi.runDetailSubtree.mutate(input, require_dist.nodePin(nodeId));
7156
+ return runnerApi.runDetailSubtree.mutate(input);
7157
+ },
7158
+ routeResults: (deviceId, trackId, details, frame) => this.routeDetailResults(deviceId, trackId, details, frame),
7159
+ captureCropBase64: async (frame) => {
7160
+ if (frame.frameHandle === void 0 || !this.captureCrop) return null;
7161
+ const buf = await this.captureCrop(frame.frameHandle, { ...frame.bbox }, frame.frameWidth, frame.frameHeight, DETAIL_FALLBACK_CROP_PADDING);
7162
+ return buf ? buf.toString("base64") : null;
7163
+ }
7164
+ });
6742
7165
  this.bindingCache = new BindingCache({
6743
7166
  api,
6744
7167
  logger: logger.child("BindingCache")
@@ -7143,6 +7566,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
7143
7566
  }
7144
7567
  this.zoneAnalytics?.destroy();
7145
7568
  this.audioMetrics?.destroy();
7569
+ this.detailDispatcher?.dispose();
7570
+ this.detailDispatcher = null;
7146
7571
  this.processors.clear();
7147
7572
  this.lastActiveTrackIds.clear();
7148
7573
  this.dropoutSkipsByKey.clear();
@@ -7167,7 +7592,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
7167
7592
  async handleInferenceResult(data) {
7168
7593
  if (this.shuttingDown) return;
7169
7594
  const { deviceId, frame } = data;
7170
- await this.processFrame(deviceId, frame, "pipeline", data.frameHandle);
7595
+ await this.processFrame(deviceId, frame, "pipeline", data.frameHandle, data.detailSteps);
7171
7596
  }
7172
7597
  /**
7173
7598
  * Run one detection frame through the analysis layers for a given
@@ -7177,7 +7602,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
7177
7602
  * tracking/zone/event state never crosses between sources. Emits the
7178
7603
  * SAME canonical events, distinguished only by `source`.
7179
7604
  */
7180
- async processFrame(deviceId, frame, source, frameHandle) {
7605
+ async processFrame(deviceId, frame, source, frameHandle, detailSteps) {
7181
7606
  if (this.shuttingDown) return;
7182
7607
  if (!await this.bindingCache.isActive(deviceId)) return;
7183
7608
  const key = this.procKey(deviceId, source);
@@ -7289,6 +7714,26 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
7289
7714
  } });
7290
7715
  }
7291
7716
  this.lastActiveTrackIds.set(key, currentTrackIds);
7717
+ if (this.detailDispatcher && detailSteps && detailSteps.length > 0) {
7718
+ const dispatcher = this.detailDispatcher;
7719
+ const steps = detailSteps;
7720
+ for (const t of result.tracked) {
7721
+ const detailFrame = {
7722
+ bbox: { ...t.bbox },
7723
+ frameWidth: result.frameWidth,
7724
+ frameHeight: result.frameHeight,
7725
+ className: t.className,
7726
+ confidence: t.confidence,
7727
+ timestamp: result.timestamp,
7728
+ ...frameHandle !== void 0 ? {
7729
+ frameHandle,
7730
+ nodeId: frameHandle.nodeId
7731
+ } : {}
7732
+ };
7733
+ if (prevIds.has(t.trackId)) dispatcher.onFrame(deviceId, t.trackId, steps, detailFrame, result.timestamp);
7734
+ else dispatcher.onTrackStarted(deviceId, t.trackId, t.className, steps, detailFrame, result.timestamp);
7735
+ }
7736
+ }
7292
7737
  if (newTrackCount > 0 || lostTrackCount > 0 || result.objectEvents.length > 0) {
7293
7738
  const byState = {};
7294
7739
  for (const t of result.tracked) byState[t.state] = (byState[t.state] ?? 0) + 1;
@@ -7500,6 +7945,142 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
7500
7945
  return settings;
7501
7946
  }
7502
7947
  /**
7948
+ * Route one track's `runDetailSubtree` results (two-plane detail dispatch)
7949
+ * into the EXISTING per-track consumers, discriminated by payload SHAPE:
7950
+ * • embedding + alignedCropJpeg → FACE (arcface): the FaceRecognizer's
7951
+ * candidate/best-face/gallery/keyFrame path (input source cut-over);
7952
+ * • embedding only → CLIP object embedding → the object-embedding store
7953
+ * (semantic search);
7954
+ * • label only → classifier answer / plate OCR text → the track's
7955
+ * enrichment label the notifier/UI already read.
7956
+ * Best-effort (D8): a per-detail failure is logged and never propagated.
7957
+ */
7958
+ async routeDetailResults(deviceId, trackId, details, frame) {
7959
+ for (const d of details) try {
7960
+ if (d.className === "face" && d.embedding !== void 0) await this.routeFaceDetail(deviceId, trackId, d, frame);
7961
+ else if (d.embedding !== void 0) await this.routeClipDetail(deviceId, trackId, d, frame.timestamp);
7962
+ else if (d.label !== void 0 && d.label.length > 0) await this.applyTrackEnrichmentLabel(deviceId, trackId, d.label);
7963
+ } catch (err) {
7964
+ this.ctx.logger.warn("detail result route failed", {
7965
+ tags: { deviceId },
7966
+ meta: {
7967
+ trackId,
7968
+ stepId: d.stepId,
7969
+ error: require_dist.errMsg(err)
7970
+ }
7971
+ });
7972
+ }
7973
+ }
7974
+ /** Face-embedding detail → the FaceRecognizer (same gate + logic as the
7975
+ * former per-frame face path; only the input source moved). */
7976
+ async routeFaceDetail(deviceId, trackId, detail, frame) {
7977
+ if (!this.faceRecognizer || detail.embedding === void 0) return;
7978
+ if (!await this.resolveGlobalFaceEnabled()) return;
7979
+ const [settings, media] = await Promise.all([this.resolveDeviceFaceSettings(deviceId), this.resolveDeviceMediaSettings(deviceId)]);
7980
+ await this.faceRecognizer.ingestFaceDetail({
7981
+ deviceId,
7982
+ trackId,
7983
+ timestamp: frame.timestamp,
7984
+ frameWidth: frame.frameWidth,
7985
+ frameHeight: frame.frameHeight,
7986
+ score: detail.score,
7987
+ embedding: decodeEmbeddingBase64(detail.embedding),
7988
+ parentBbox: { ...frame.bbox },
7989
+ ...detail.bbox !== void 0 ? { faceBbox: { ...detail.bbox } } : {},
7990
+ ...detail.alignedCropJpeg !== void 0 ? { alignedCropJpeg: detail.alignedCropJpeg } : {},
7991
+ settings,
7992
+ cropPadding: media.cropPadding,
7993
+ ...frame.frameHandle !== void 0 ? { frameHandle: frame.frameHandle } : {}
7994
+ });
7995
+ }
7996
+ /** CLIP object-embedding detail → the object-embedding store (semantic
7997
+ * search). Stamps the active encoder's model id so the same-model search
7998
+ * gate keeps matching. */
7999
+ async routeClipDetail(deviceId, trackId, detail, timestamp) {
8000
+ const store = this.objectEmbeddingStore;
8001
+ if (!store || detail.embedding === void 0) return;
8002
+ const modelId = await this.resolveClipModelId();
8003
+ if (modelId === null) {
8004
+ this.ctx.logger.debug("clip detail dropped — no active embedding model id", {
8005
+ tags: { deviceId },
8006
+ meta: {
8007
+ trackId,
8008
+ stepId: detail.stepId
8009
+ }
8010
+ });
8011
+ return;
8012
+ }
8013
+ await store.upsertIfBetter({
8014
+ trackId,
8015
+ deviceId,
8016
+ timestamp,
8017
+ className: detail.className,
8018
+ embedding: decodeEmbeddingBase64(detail.embedding),
8019
+ modelId,
8020
+ confidence: detail.score
8021
+ });
8022
+ }
8023
+ /** Classifier answer / plate OCR text → the track's enrichment label
8024
+ * (TrackStore + persisted events + importance), mirroring the FaceRecognizer
8025
+ * label-propagation path. */
8026
+ async applyTrackEnrichmentLabel(deviceId, trackId, label) {
8027
+ try {
8028
+ await this.trackStore?.setLabel(trackId, label);
8029
+ } catch (err) {
8030
+ this.ctx.logger.warn("detail label setLabel failed", {
8031
+ tags: { deviceId },
8032
+ meta: {
8033
+ trackId,
8034
+ error: require_dist.errMsg(err)
8035
+ }
8036
+ });
8037
+ }
8038
+ try {
8039
+ await this.eventStore?.setLabelForTrack(trackId, label);
8040
+ } catch (err) {
8041
+ this.ctx.logger.warn("detail label setLabelForTrack failed", {
8042
+ tags: { deviceId },
8043
+ meta: {
8044
+ trackId,
8045
+ error: require_dist.errMsg(err)
8046
+ }
8047
+ });
8048
+ }
8049
+ try {
8050
+ const trackStore = this.trackStore;
8051
+ const eventStore = this.eventStore;
8052
+ if (trackStore && eventStore) await recomputeTrackImportance({
8053
+ trackStore,
8054
+ eventStore
8055
+ }, trackId);
8056
+ } catch (err) {
8057
+ this.ctx.logger.debug("detail label recomputeImportance failed", {
8058
+ tags: { deviceId },
8059
+ meta: {
8060
+ trackId,
8061
+ error: require_dist.errMsg(err)
8062
+ }
8063
+ });
8064
+ }
8065
+ }
8066
+ /** Active CLIP image-embedding model id (== the text encoder's), TTL-cached.
8067
+ * Returns null when the embedding-encoder cap is unavailable. */
8068
+ async resolveClipModelId() {
8069
+ const now = Date.now();
8070
+ if (this.clipModelIdCache && now < this.clipModelIdCache.expiresAt) return this.clipModelIdCache.value;
8071
+ let value = null;
8072
+ try {
8073
+ value = (await this.ctx.api.embeddingEncoder.getInfo.query())?.modelId ?? null;
8074
+ } catch (err) {
8075
+ this.ctx.logger.debug("resolveClipModelId: getInfo failed", { meta: { error: require_dist.errMsg(err) } });
8076
+ }
8077
+ this.clipModelIdCache = {
8078
+ value,
8079
+ expiresAt: now + CLIP_MODEL_ID_CACHE_TTL_MS
8080
+ };
8081
+ return value;
8082
+ }
8083
+ /**
7503
8084
  * §5 — decide which active tracks need periodic media THIS frame. Pure over
7504
8085
  * TrackStore.lastSnapshotAt + the per-track best-confidence map:
7505
8086
  * • `snapshot` (append) + `lastFrame` (rolling overwrite) fire together on
@@ -7887,6 +8468,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
7887
8468
  }
7888
8469
  this.bestFrameTracker.delete(t.trackId);
7889
8470
  this.objectEmbeddingBestSelector.delete(t.trackId);
8471
+ this.detailDispatcher?.onTrackEnded(t.deviceId, t.trackId);
7890
8472
  this.ctx.eventBus.emit({
7891
8473
  id: `pa-end-${t.trackId}`,
7892
8474
  timestamp: new Date(t.lastSeen),