@camstack/addon-post-analysis 1.2.190 → 1.2.191

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.
@@ -33253,6 +33253,52 @@ var ClosedRowMediaGate = class {
33253
33253
  }
33254
33254
  };
33255
33255
  //#endregion
33256
+ //#region src/pipeline-analytics/pipeline/co-moving-subjects.ts
33257
+ /**
33258
+ * Report at this much containment. A rider and a bicycle overlap heavily; two
33259
+ * people passing each other in the frame do not stay contained for long.
33260
+ * Deliberately a REPORTING threshold, not a decision one — nothing branches on
33261
+ * it, so it can be loose enough to see the near misses.
33262
+ */
33263
+ var CO_MOVING_MIN_CONTAINMENT = .6;
33264
+ function intersectionArea$4(a, b) {
33265
+ return Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x)) * Math.max(0, Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y));
33266
+ }
33267
+ /**
33268
+ * The overlaps a NEWLY born track has with live tracks of a DIFFERENT class.
33269
+ *
33270
+ * Same-class overlaps are excluded: those are the tracker's own duplicate
33271
+ * problem and are already measured elsewhere. A zero-area box reports nothing
33272
+ * rather than dividing by zero.
33273
+ */
33274
+ function findCoMovingPairs(born, live, minContainment = CO_MOVING_MIN_CONTAINMENT) {
33275
+ const out = [];
33276
+ for (const b of born) {
33277
+ const areaB = b.bbox.w * b.bbox.h;
33278
+ if (areaB <= 0) continue;
33279
+ for (const o of live) {
33280
+ if (o.trackId === b.trackId) continue;
33281
+ if (o.className === b.className) continue;
33282
+ const areaO = o.bbox.w * o.bbox.h;
33283
+ if (areaO <= 0) continue;
33284
+ const inter = intersectionArea$4(b.bbox, o.bbox);
33285
+ if (inter <= 0) continue;
33286
+ const union = areaB + areaO - inter;
33287
+ const containment = inter / Math.min(areaB, areaO);
33288
+ if (containment < minContainment) continue;
33289
+ out.push({
33290
+ trackId: b.trackId,
33291
+ className: b.className,
33292
+ otherTrackId: o.trackId,
33293
+ otherClassName: o.className,
33294
+ iou: union > 0 ? inter / union : 0,
33295
+ containment
33296
+ });
33297
+ }
33298
+ }
33299
+ return out;
33300
+ }
33301
+ //#endregion
33256
33302
  //#region src/pipeline-analytics/pipeline/edge-clear.ts
33257
33303
  /**
33258
33304
  * edge-clear — pure geometry for "is the subject fully in frame?" best-frame
@@ -33473,52 +33519,6 @@ function isEdgeAwareNewBest(current, candidate, hysteresis) {
33473
33519
  return false;
33474
33520
  }
33475
33521
  //#endregion
33476
- //#region src/pipeline-analytics/pipeline/co-moving-subjects.ts
33477
- /**
33478
- * Report at this much containment. A rider and a bicycle overlap heavily; two
33479
- * people passing each other in the frame do not stay contained for long.
33480
- * Deliberately a REPORTING threshold, not a decision one — nothing branches on
33481
- * it, so it can be loose enough to see the near misses.
33482
- */
33483
- var CO_MOVING_MIN_CONTAINMENT = .6;
33484
- function intersectionArea$4(a, b) {
33485
- return Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x)) * Math.max(0, Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y));
33486
- }
33487
- /**
33488
- * The overlaps a NEWLY born track has with live tracks of a DIFFERENT class.
33489
- *
33490
- * Same-class overlaps are excluded: those are the tracker's own duplicate
33491
- * problem and are already measured elsewhere. A zero-area box reports nothing
33492
- * rather than dividing by zero.
33493
- */
33494
- function findCoMovingPairs(born, live, minContainment = CO_MOVING_MIN_CONTAINMENT) {
33495
- const out = [];
33496
- for (const b of born) {
33497
- const areaB = b.bbox.w * b.bbox.h;
33498
- if (areaB <= 0) continue;
33499
- for (const o of live) {
33500
- if (o.trackId === b.trackId) continue;
33501
- if (o.className === b.className) continue;
33502
- const areaO = o.bbox.w * o.bbox.h;
33503
- if (areaO <= 0) continue;
33504
- const inter = intersectionArea$4(b.bbox, o.bbox);
33505
- if (inter <= 0) continue;
33506
- const union = areaB + areaO - inter;
33507
- const containment = inter / Math.min(areaB, areaO);
33508
- if (containment < minContainment) continue;
33509
- out.push({
33510
- trackId: b.trackId,
33511
- className: b.className,
33512
- otherTrackId: o.trackId,
33513
- otherClassName: o.className,
33514
- iou: union > 0 ? inter / union : 0,
33515
- containment
33516
- });
33517
- }
33518
- }
33519
- return out;
33520
- }
33521
- //#endregion
33522
33522
  //#region src/pipeline-analytics/pipeline/first-frame-retry-bbox.ts
33523
33523
  /** Crop box for a late firstFrame retry: the BIRTH geometry, not the
33524
33524
  * later frozen/coasted box (track 1b659d56). */
@@ -34042,7 +34042,7 @@ var MAX_PATH_LENGTH = 300;
34042
34042
  function clamp(value, min, max) {
34043
34043
  return Math.max(min, Math.min(max, value));
34044
34044
  }
34045
- function iou$2(a, b) {
34045
+ function iou$3(a, b) {
34046
34046
  const ax1 = a.x, ay1 = a.y, ax2 = a.x + a.w, ay2 = a.y + a.h;
34047
34047
  const bx1 = b.x, by1 = b.y, bx2 = b.x + b.w, by2 = b.y + b.h;
34048
34048
  const ix1 = Math.max(ax1, bx1), iy1 = Math.max(ay1, by1);
@@ -34110,7 +34110,7 @@ var SortTracker = class SortTracker {
34110
34110
  trackClass: track.class,
34111
34111
  detClass: det.class,
34112
34112
  detScore: det.score,
34113
- iou: iou$2(track.bbox, det.bbox),
34113
+ iou: iou$3(track.bbox, det.bbox),
34114
34114
  centroidDistPx: Math.hypot(tc.x - dc.x, tc.y - dc.y),
34115
34115
  trackDiagPx: Math.hypot(track.bbox.w, track.bbox.h),
34116
34116
  areaRatio: smaller > 0 ? Math.max(trackArea, detArea) / smaller : 0,
@@ -34174,14 +34174,14 @@ var SortTracker = class SortTracker {
34174
34174
  isDuplicateSpawn(det, live, timestamp) {
34175
34175
  for (const t of live) {
34176
34176
  if (!this.dedupCompatible(t.class, det.class)) continue;
34177
- if (iou$2(det.bbox, t.bbox) >= this.config.dedupSpawnIou) return true;
34178
- if (growthAdjacentMatch(t.bbox, det.bbox, SortTracker.growthContext(t, timestamp))) {
34177
+ if (iou$3(det.bbox, t.bbox) >= this.config.dedupSpawnIou) return true;
34178
+ if (t.class === det.class && growthAdjacentMatch(t.bbox, det.bbox, SortTracker.growthContext(t, timestamp))) {
34179
34179
  this.reportBind("spawn-dedup", "growth", t, det, timestamp);
34180
34180
  return true;
34181
34181
  }
34182
34182
  }
34183
34183
  if (this.config.personAnimalDedup && det.class === ANIMAL_CLASS && det.score < this.config.animalOverPersonMaxScore) {
34184
- for (const t of live) if (t.class === PERSON_CLASS && iou$2(det.bbox, t.bbox) >= this.config.animalOverPersonIou) return true;
34184
+ for (const t of live) if (t.class === PERSON_CLASS && iou$3(det.bbox, t.bbox) >= this.config.animalOverPersonIou) return true;
34185
34185
  }
34186
34186
  return false;
34187
34187
  }
@@ -34221,7 +34221,7 @@ var SortTracker = class SortTracker {
34221
34221
  if (dropIds.has(a.id) || dropIds.has(b.id)) continue;
34222
34222
  if (!this.dedupCompatible(a.class, b.class)) continue;
34223
34223
  const key = SortTracker.pairKey(a.id, b.id);
34224
- if (iou$2(a.bbox, b.bbox) < this.config.dedupMergeIou) {
34224
+ if (iou$3(a.bbox, b.bbox) < this.config.dedupMergeIou) {
34225
34225
  this.dupStreaks.delete(key);
34226
34226
  continue;
34227
34227
  }
@@ -34297,7 +34297,7 @@ var SortTracker = class SortTracker {
34297
34297
  */
34298
34298
  looseMatch(track, det, timestamp) {
34299
34299
  if (this.assocGroup(track.class) !== this.assocGroup(det.class)) return null;
34300
- if (iou$2(track.bbox, det.bbox) >= this.config.rescueIouThreshold) return "iou";
34300
+ if (iou$3(track.bbox, det.bbox) >= this.config.rescueIouThreshold) return "iou";
34301
34301
  const tc = bboxCentroid(track.bbox);
34302
34302
  const dc = bboxCentroid(det.bbox);
34303
34303
  const dist = Math.hypot(tc.x - dc.x, tc.y - dc.y);
@@ -34383,7 +34383,7 @@ var SortTracker = class SortTracker {
34383
34383
  if (used.has(di)) continue;
34384
34384
  const det = detections[di];
34385
34385
  if (!this.associable(track.class, det.class)) continue;
34386
- const score = iou$2(pbox, det.bbox);
34386
+ const score = iou$3(pbox, det.bbox);
34387
34387
  if (score >= iouThreshold) pairs.push({
34388
34388
  track,
34389
34389
  detIdx: di,
@@ -34445,7 +34445,7 @@ var SortTracker = class SortTracker {
34445
34445
  rescuePairs.push({
34446
34446
  track,
34447
34447
  detIdx: di,
34448
- score: iou$2(track.bbox, det.bbox),
34448
+ score: iou$3(track.bbox, det.bbox),
34449
34449
  gate
34450
34450
  });
34451
34451
  }
@@ -34522,7 +34522,7 @@ var SortTracker = class SortTracker {
34522
34522
  const gate = this.looseMatch(lost, det, timestamp);
34523
34523
  if (gate === null) continue;
34524
34524
  if (gate === "centroid" && this.isPlausibleBirth(det, frameArea)) continue;
34525
- const score = iou$2(lost.bbox, det.bbox);
34525
+ const score = iou$3(lost.bbox, det.bbox);
34526
34526
  const peers = this.sameGroupPeerCount(lost, surviving);
34527
34527
  if (this.config.resurrectionCrowdGuard && peers > 0 && score < this.config.resurrectionCrowdedIouThreshold) {
34528
34528
  if (score > refusedScore) {
@@ -44300,6 +44300,32 @@ var AnalyticsQueryFacade = class {
44300
44300
  }
44301
44301
  };
44302
44302
  //#endregion
44303
+ //#region src/pipeline-analytics/audio-confirm-window.ts
44304
+ function emptyAudioConfirmState() {
44305
+ return { hitsByClass: /* @__PURE__ */ new Map() };
44306
+ }
44307
+ function observeAudioConfirm(state, input, cfg) {
44308
+ if (cfg.confirmHits <= 1) return {
44309
+ confirmed: true,
44310
+ nextState: state
44311
+ };
44312
+ const cutoff = input.timestampMs - cfg.confirmWindowSec * 1e3;
44313
+ const kept = [...(state.hitsByClass.get(input.className) ?? []).filter((at) => at > cutoff), input.timestampMs];
44314
+ const next = new Map(state.hitsByClass);
44315
+ if (kept.length < cfg.confirmHits) {
44316
+ next.set(input.className, kept);
44317
+ return {
44318
+ confirmed: false,
44319
+ nextState: { hitsByClass: next }
44320
+ };
44321
+ }
44322
+ next.delete(input.className);
44323
+ return {
44324
+ confirmed: true,
44325
+ nextState: { hitsByClass: next }
44326
+ };
44327
+ }
44328
+ //#endregion
44303
44329
  //#region src/pipeline-analytics/audio-detection-settings.ts
44304
44330
  var AudioDetectionSettingsSchema = object({
44305
44331
  classificationMinScore: number().min(0).max(1).default(.6),
@@ -44560,32 +44586,6 @@ function observeAudioEpisode(state, sample, knobs) {
44560
44586
  };
44561
44587
  }
44562
44588
  //#endregion
44563
- //#region src/pipeline-analytics/audio-confirm-window.ts
44564
- function emptyAudioConfirmState() {
44565
- return { hitsByClass: /* @__PURE__ */ new Map() };
44566
- }
44567
- function observeAudioConfirm(state, input, cfg) {
44568
- if (cfg.confirmHits <= 1) return {
44569
- confirmed: true,
44570
- nextState: state
44571
- };
44572
- const cutoff = input.timestampMs - cfg.confirmWindowSec * 1e3;
44573
- const kept = [...(state.hitsByClass.get(input.className) ?? []).filter((at) => at > cutoff), input.timestampMs];
44574
- const next = new Map(state.hitsByClass);
44575
- if (kept.length < cfg.confirmHits) {
44576
- next.set(input.className, kept);
44577
- return {
44578
- confirmed: false,
44579
- nextState: { hitsByClass: next }
44580
- };
44581
- }
44582
- next.delete(input.className);
44583
- return {
44584
- confirmed: true,
44585
- nextState: { hitsByClass: next }
44586
- };
44587
- }
44588
- //#endregion
44589
44589
  //#region src/pipeline-analytics/audio-event-router.ts
44590
44590
  /**
44591
44591
  * Decide whether an audio frame is a (meaningful) classification event or a
@@ -45166,6 +45166,7 @@ async function runConfirmation(candidate, config, deps, exhausted) {
45166
45166
  } else if (!bestIncompatible || d.score > bestIncompatible.score) bestIncompatible = d;
45167
45167
  const bar = Math.max(config.minConfidence, deps.minConfidenceFor?.(candidate) ?? 0);
45168
45168
  const confirmed = best !== void 0 && best.score >= bar;
45169
+ const secondary = bestIncompatible ? promoteSecondary(candidate, bestIncompatible, bar, deps) : null;
45169
45170
  return {
45170
45171
  trackId: candidate.trackId,
45171
45172
  verdict: confirmed ? "confirmed" : "suppressed",
@@ -45176,7 +45177,30 @@ async function runConfirmation(candidate, config, deps, exhausted) {
45176
45177
  ...bestIncompatible ? {
45177
45178
  bestIncompatibleClass: bestIncompatible.macroClass,
45178
45179
  bestIncompatibleScore: bestIncompatible.score
45179
- } : {}
45180
+ } : {},
45181
+ ...secondary ? { secondary } : {}
45182
+ };
45183
+ }
45184
+ /**
45185
+ * The incompatible best becomes a secondary subject when it clears the SAME
45186
+ * bar the candidate faced and can be placed in the frame. The crop-normalised
45187
+ * box is scaled into the crop's frame rectangle — the one derivation the
45188
+ * capture used, supplied by the caller, never re-derived here.
45189
+ */
45190
+ function promoteSecondary(candidate, detection, bar, deps) {
45191
+ if (detection.score < bar || detection.bbox === void 0) return null;
45192
+ const rect = deps.cropRectFor?.(candidate) ?? null;
45193
+ if (rect === null || rect.w <= 0 || rect.h <= 0) return null;
45194
+ const box = detection.bbox;
45195
+ return {
45196
+ className: detection.macroClass,
45197
+ score: detection.score,
45198
+ bbox: {
45199
+ x: rect.x + box.x * rect.w,
45200
+ y: rect.y + box.y * rect.h,
45201
+ w: box.w * rect.w,
45202
+ h: box.h * rect.h
45203
+ }
45180
45204
  };
45181
45205
  }
45182
45206
  async function confirmOne(candidate, config, deps, exhausted) {
@@ -47936,6 +47960,336 @@ var MediaWriteGate = class {
47936
47960
  this.paused = false;
47937
47961
  }
47938
47962
  };
47963
+ //#endregion
47964
+ //#region src/pipeline-analytics/runtime/failure-report.ts
47965
+ /**
47966
+ * What `pipeline-analytics` reports through `failure-contribution` — the four
47967
+ * per-camera counters, and the ONE place their vocabulary is written down.
47968
+ *
47969
+ * ## Why the vocabulary is a module and not four string literals
47970
+ *
47971
+ * D276 gave a track row a `previewMissReason` composed from strings like
47972
+ * `keyframe:native-miss` and `thumbnail:overflow`. Those strings are now
47973
+ * joinable evidence: a row says which track lost its tile, and these counters
47974
+ * say how often that camera loses one and out of how many tries. A second
47975
+ * vocabulary for the same loss would break the join silently — the row would
47976
+ * say `native-miss` and the counter `nativeMiss`, and nobody would notice until
47977
+ * somebody tried to correlate them. So the reasons live here, named once, and
47978
+ * they deliberately reuse D276's spelling.
47979
+ *
47980
+ * ## Every family carries its denominator
47981
+ *
47982
+ * Each `note*` call is ONE attempt. The success calls are not decoration: a
47983
+ * failure count without them is the number that read as "35x worse than
47984
+ * yesterday" on 2026-08-28 and was flat all day once divided
47985
+ * (`packages/types/src/metrics/failure-counters.ts`).
47986
+ *
47987
+ * ## Nothing here logs
47988
+ *
47989
+ * These paths run at frame rate — 507 enrichment-crop misses in five minutes at
47990
+ * peak, ~100/min. A line each is its own defect, and the log lines that already
47991
+ * exist on these paths (`enrichment crop native miss`, `capture queue full`,
47992
+ * `plate: read discarded`) are unchanged and still throttled. This is the
47993
+ * counter that makes them countable without reading them.
47994
+ */
47995
+ /**
47996
+ * The enrichment crop capture — face / plate / clip / detail-classifier.
47997
+ * Denominator: every crop this addon asked the owning runner for.
47998
+ */
47999
+ var FAMILY_ENRICHMENT_CROP = "enrichment-crop";
48000
+ /**
48001
+ * The per-camera capture scheduler lane. Denominator: every queued capture that
48002
+ * reached a TERMINAL scheduling outcome — started, or dropped. A coalesced or
48003
+ * cancelled request is neither and is deliberately not counted: a track that
48004
+ * closed before its capture ran did not lose work, it stopped wanting it.
48005
+ */
48006
+ var FAMILY_CAPTURE_SCHEDULE = "capture-schedule";
48007
+ /** Plate OCR reads routed from the detail plane. Denominator: every read. */
48008
+ var FAMILY_PLATE_OCR = "plate-ocr";
48009
+ /**
48010
+ * Person tracks born on this camera. Denominator: every confirmed person birth.
48011
+ *
48012
+ * The "failure" here is NOT suppressed and must not be — a person standing over
48013
+ * a car is a real person, and this repo will not trade that away for a cleaner
48014
+ * count (D275 made the same choice for stationary suppression). It is counted so
48015
+ * the phantom rate is a number instead of an impression: 4 of 23 on camera 617
48016
+ * in two hours, measured by hand on 2026-08-28.
48017
+ */
48018
+ var FAMILY_PERSON_BIRTH = "person-birth";
48019
+ /**
48020
+ * The owning runner had no native pixels for the ROI.
48021
+ *
48022
+ * This collapses the runner-side causes the operator can already see in logs
48023
+ * (`worker-lease-gone`, `no-registry-entry`, `jpeg-encode-failed`) because
48024
+ * `pipelineRunner.getNativeCrop` answers an ROI miss with `null` and carries no
48025
+ * reason on the wire — and `FrameHandle` has no `deviceId`, so the runner could
48026
+ * not attribute the split per camera even if it reported it. Splitting this is a
48027
+ * framework change (a reason on the reply); until then the honest name is the
48028
+ * one the caller can actually stand behind.
48029
+ */
48030
+ var REASON_NATIVE_MISS = "native-miss";
48031
+ /** Queued past `staleAfterMs`; the frame it wanted is gone. */
48032
+ var REASON_STALE_DROP = "stale-drop";
48033
+ /** Shed because the camera's lane was at its queue cap. */
48034
+ var REASON_QUEUE_OVERFLOW = "queue-overflow";
48035
+ /**
48036
+ * The OCR step returned, with nothing in it.
48037
+ *
48038
+ * The defect this counter was built for: ~35% of plate reads came back with an
48039
+ * empty label and fell through the detail router's `d.label.length > 0` gate
48040
+ * into no branch at all — no store, no log, no row. Silence read as "this camera
48041
+ * never saw a plate", which is a completely different fix.
48042
+ */
48043
+ var REASON_EMPTY_READ = "empty-read";
48044
+ /** A plate detail with no bbox — nothing to crop, nothing to place. */
48045
+ var REASON_NO_BBOX = "no-bbox";
48046
+ /** `isPlausiblePlateRead` refused it (junk OCR off a distant/oblique plate). */
48047
+ var REASON_IMPLAUSIBLE = "implausible";
48048
+ /** Below the camera's configured minimum OCR confidence. */
48049
+ var REASON_BELOW_MIN_CONFIDENCE = "below-min-confidence";
48050
+ /** A person born with its box inside a vehicle's. See {@link FAMILY_PERSON_BIRTH}. */
48051
+ var REASON_PERSON_OVER_VEHICLE = "person-over-vehicle";
48052
+ /**
48053
+ * This addon's `failure-contribution` counters.
48054
+ *
48055
+ * One instance per addon process, handed to the paths that fail. Every method
48056
+ * takes the numeric `deviceId` first, for the same reason every log line does:
48057
+ * the question is always per camera.
48058
+ */
48059
+ var AnalyticsFailureReport = class {
48060
+ now;
48061
+ counters;
48062
+ constructor(now = Date.now, counters = new FailureCounters()) {
48063
+ this.now = now;
48064
+ this.counters = counters;
48065
+ }
48066
+ /** One enrichment crop capture. `reason` absent = the native ROI landed. */
48067
+ noteEnrichmentCrop(deviceId, reason) {
48068
+ this.counters.note({
48069
+ deviceId,
48070
+ family: FAMILY_ENRICHMENT_CROP,
48071
+ ...reason !== void 0 ? { reason } : {}
48072
+ }, this.now());
48073
+ }
48074
+ /** One capture that reached a terminal scheduling outcome. */
48075
+ noteCaptureSchedule(deviceId, reason) {
48076
+ this.counters.note({
48077
+ deviceId,
48078
+ family: FAMILY_CAPTURE_SCHEDULE,
48079
+ ...reason !== void 0 ? { reason } : {}
48080
+ }, this.now());
48081
+ }
48082
+ /** One plate OCR read. `reason` absent = the read was accepted and stored. */
48083
+ notePlateRead(deviceId, reason) {
48084
+ this.counters.note({
48085
+ deviceId,
48086
+ family: FAMILY_PLATE_OCR,
48087
+ ...reason !== void 0 ? { reason } : {}
48088
+ }, this.now());
48089
+ }
48090
+ /** One confirmed person birth. `reason` present = it was born over a vehicle. */
48091
+ notePersonBirth(deviceId, reason) {
48092
+ this.counters.note({
48093
+ deviceId,
48094
+ family: FAMILY_PERSON_BIRTH,
48095
+ ...reason !== void 0 ? { reason } : {}
48096
+ }, this.now());
48097
+ }
48098
+ /** The `failure-contribution` provider's payload. Reads, never resets. */
48099
+ list() {
48100
+ return this.counters.snapshot(this.now());
48101
+ }
48102
+ /** Addon disposal. */
48103
+ clear() {
48104
+ this.counters.clear();
48105
+ }
48106
+ };
48107
+ //#endregion
48108
+ //#region src/pipeline-analytics/pipeline/bbox-pad.ts
48109
+ /**
48110
+ * Pad a normalized bounding box by a fractional amount relative to its own
48111
+ * dimensions. All coordinates remain in the [0, 1] unit-square.
48112
+ *
48113
+ * Formula (before clamping):
48114
+ * newX = x - padding * w
48115
+ * newW = w + 2 * padding * w (i.e. w * (1 + 2 * padding))
48116
+ * newY = y - padding * h
48117
+ * newH = h + 2 * padding * h
48118
+ *
48119
+ * padding === 0 is the identity operation. Pure, immutable — always returns a
48120
+ * new object.
48121
+ *
48122
+ * NOTE: clamping SHIFTS, it does not truncate — a window that overflows a
48123
+ * bound slides inward and keeps the extent that was asked for, on either side.
48124
+ * It shrinks only when the padded window is larger than the frame itself
48125
+ * would (e.g. a left-hugging box gains a bit more right-side context). This is
48126
+ * acceptable for detection crops (more context, never out of [0,1]); the
48127
+ * downstream `extractCrop` clamps to pixel bounds again regardless.
48128
+ */
48129
+ function padBbox(bbox, padding) {
48130
+ const rawX = bbox.x - padding * bbox.w;
48131
+ const rawY = bbox.y - padding * bbox.h;
48132
+ const rawW = bbox.w * (1 + 2 * padding);
48133
+ const rawH = bbox.h * (1 + 2 * padding);
48134
+ const w = Math.min(rawW, 1);
48135
+ const h = Math.min(rawH, 1);
48136
+ return {
48137
+ x: Math.min(Math.max(0, rawX), 1 - w),
48138
+ y: Math.min(Math.max(0, rawY), 1 - h),
48139
+ w,
48140
+ h
48141
+ };
48142
+ }
48143
+ //#endregion
48144
+ //#region src/pipeline-analytics/pipeline/capture-crop.ts
48145
+ /**
48146
+ * The normalised window a capture asks the runner for: the box padded by
48147
+ * `padding` and slid inside the unit square. ONE derivation, shared by the
48148
+ * capture below and by any caller that must put a crop-relative box back
48149
+ * into the frame (the confirmation gate's secondary subject) — two copies of
48150
+ * this arithmetic would drift, and a drifted one places the subject somewhere
48151
+ * it is not.
48152
+ */
48153
+ function captureCropWindow(bbox, frameWidth, frameHeight, padding) {
48154
+ return padBbox({
48155
+ x: bbox.x / frameWidth,
48156
+ y: bbox.y / frameHeight,
48157
+ w: bbox.w / frameWidth,
48158
+ h: bbox.h / frameHeight
48159
+ }, padding);
48160
+ }
48161
+ /** {@link captureCropWindow} in FRAME pixels. */
48162
+ function captureCropFrameRect(bbox, frameWidth, frameHeight, padding) {
48163
+ const window = captureCropWindow(bbox, frameWidth, frameHeight, padding);
48164
+ return {
48165
+ x: window.x * frameWidth,
48166
+ y: window.y * frameHeight,
48167
+ w: window.w * frameWidth,
48168
+ h: window.h * frameHeight
48169
+ };
48170
+ }
48171
+ function createCaptureCrop(deps) {
48172
+ return async (frameHandle, bbox, frameWidth, frameHeight, padding, maxWidth, deviceId) => {
48173
+ const paddedNorm = captureCropWindow(bbox, frameWidth, frameHeight, padding);
48174
+ const nativeCrop = await deps.tryNativeCrop(frameHandle, paddedNorm, maxWidth);
48175
+ if (nativeCrop) {
48176
+ deps.bumpCropMetric(true);
48177
+ if (deviceId !== void 0) deps.noteCropOutcome?.(deviceId);
48178
+ return nativeCrop;
48179
+ }
48180
+ deps.bumpCropMetric(false);
48181
+ if (deviceId !== void 0) deps.noteCropOutcome?.(deviceId, REASON_NATIVE_MISS);
48182
+ deps.logger.debug("enrichment crop native miss — detail scheduler will re-run", {
48183
+ ...deviceId !== void 0 ? { tags: { deviceId } } : {},
48184
+ meta: { nodeId: frameHandle.nodeId }
48185
+ });
48186
+ return null;
48187
+ };
48188
+ }
48189
+ //#endregion
48190
+ //#region src/pipeline-analytics/pipeline/secondary-subjects.ts
48191
+ /**
48192
+ * How long a promoted subject may keep riding. Rides are consumed within
48193
+ * ~300 ms at 10 fps; this bound is what stops a promotion made just before a
48194
+ * stall from riding a frame that belongs to a scene that has moved on.
48195
+ */
48196
+ var SECONDARY_SUBJECT_TTL_MS = 3e3;
48197
+ var SecondarySubjectQueue = class {
48198
+ byKey = /* @__PURE__ */ new Map();
48199
+ nextId = 0;
48200
+ add(key, subject, foundBesideTrackId, now) {
48201
+ const pending = this.byKey.get(key) ?? [];
48202
+ this.nextId += 1;
48203
+ this.byKey.set(key, [...pending, {
48204
+ id: `s${this.nextId}`,
48205
+ subject,
48206
+ foundBesideTrackId,
48207
+ expiresAt: now + SECONDARY_SUBJECT_TTL_MS,
48208
+ ridesLeft: 3
48209
+ }]);
48210
+ }
48211
+ /**
48212
+ * Every subject still owed a ride for `key`, one ride spent. An entry out of
48213
+ * rides or past its TTL is forgotten here rather than served.
48214
+ */
48215
+ take(key, now) {
48216
+ const pending = this.byKey.get(key);
48217
+ if (!pending) return [];
48218
+ const live = pending.filter((entry) => entry.expiresAt > now && entry.ridesLeft > 0);
48219
+ const remaining = live.map((entry) => ({
48220
+ ...entry,
48221
+ ridesLeft: entry.ridesLeft - 1
48222
+ })).filter((entry) => entry.ridesLeft > 0);
48223
+ if (remaining.length === 0) this.byKey.delete(key);
48224
+ else this.byKey.set(key, remaining);
48225
+ return live.map((entry) => ({
48226
+ id: entry.id,
48227
+ subject: entry.subject,
48228
+ foundBesideTrackId: entry.foundBesideTrackId
48229
+ }));
48230
+ }
48231
+ /** A subject a real detection has taken over rides no further. */
48232
+ drop(key, id) {
48233
+ const pending = this.byKey.get(key);
48234
+ if (!pending) return;
48235
+ const remaining = pending.filter((entry) => entry.id !== id);
48236
+ if (remaining.length === 0) this.byKey.delete(key);
48237
+ else this.byKey.set(key, remaining);
48238
+ }
48239
+ forget(key) {
48240
+ this.byKey.delete(key);
48241
+ }
48242
+ };
48243
+ function iou$2(a, b) {
48244
+ const inter = Math.max(0, Math.min(a.x + a.w, b.x + b.width) - Math.max(a.x, b.x)) * Math.max(0, Math.min(a.y + a.h, b.y + b.height) - Math.max(a.y, b.y));
48245
+ const union = a.w * a.h + b.width * b.height - inter;
48246
+ return union > 0 ? inter / union : 0;
48247
+ }
48248
+ /**
48249
+ * The runner's detections plus one synthetic first-level detection per pending
48250
+ * subject that no same-class detection already covers. The synthetic box
48251
+ * carries the gate's score — a measurement on a native crop, not an invention
48252
+ * — and an id that says where it came from. The original result is not
48253
+ * mutated.
48254
+ */
48255
+ function injectSecondarySubjects(frame, pending) {
48256
+ if (pending.length === 0) return {
48257
+ frame,
48258
+ covered: []
48259
+ };
48260
+ const added = [];
48261
+ const covered = [];
48262
+ for (const { id, subject, foundBesideTrackId } of pending) {
48263
+ if ([...frame.detections, ...added].some((d) => d.kind === "first-level" && d.macroClass === subject.className && iou$2(subject.bbox, d.bbox) >= .5)) {
48264
+ covered.push(id);
48265
+ continue;
48266
+ }
48267
+ added.push({
48268
+ id: `secondary-${id}-${foundBesideTrackId}`,
48269
+ kind: "first-level",
48270
+ macroClass: subject.className,
48271
+ score: subject.score,
48272
+ labels: [],
48273
+ bbox: {
48274
+ x: subject.bbox.x,
48275
+ y: subject.bbox.y,
48276
+ width: subject.bbox.w,
48277
+ height: subject.bbox.h
48278
+ }
48279
+ });
48280
+ }
48281
+ if (added.length === 0) return {
48282
+ frame,
48283
+ covered
48284
+ };
48285
+ return {
48286
+ frame: {
48287
+ ...frame,
48288
+ detections: [...frame.detections, ...added]
48289
+ },
48290
+ covered
48291
+ };
48292
+ }
47939
48293
  var MACRO_SET = new Set([
47940
48294
  "person",
47941
48295
  "vehicle",
@@ -51785,42 +52139,6 @@ function decideKeyFrameCapture(input) {
51785
52139
  return { kind: "retry" };
51786
52140
  }
51787
52141
  //#endregion
51788
- //#region src/pipeline-analytics/pipeline/bbox-pad.ts
51789
- /**
51790
- * Pad a normalized bounding box by a fractional amount relative to its own
51791
- * dimensions. All coordinates remain in the [0, 1] unit-square.
51792
- *
51793
- * Formula (before clamping):
51794
- * newX = x - padding * w
51795
- * newW = w + 2 * padding * w (i.e. w * (1 + 2 * padding))
51796
- * newY = y - padding * h
51797
- * newH = h + 2 * padding * h
51798
- *
51799
- * padding === 0 is the identity operation. Pure, immutable — always returns a
51800
- * new object.
51801
- *
51802
- * NOTE: clamping SHIFTS, it does not truncate — a window that overflows a
51803
- * bound slides inward and keeps the extent that was asked for, on either side.
51804
- * It shrinks only when the padded window is larger than the frame itself
51805
- * would (e.g. a left-hugging box gains a bit more right-side context). This is
51806
- * acceptable for detection crops (more context, never out of [0,1]); the
51807
- * downstream `extractCrop` clamps to pixel bounds again regardless.
51808
- */
51809
- function padBbox(bbox, padding) {
51810
- const rawX = bbox.x - padding * bbox.w;
51811
- const rawY = bbox.y - padding * bbox.h;
51812
- const rawW = bbox.w * (1 + 2 * padding);
51813
- const rawH = bbox.h * (1 + 2 * padding);
51814
- const w = Math.min(rawW, 1);
51815
- const h = Math.min(rawH, 1);
51816
- return {
51817
- x: Math.min(Math.max(0, rawX), 1 - w),
51818
- y: Math.min(Math.max(0, rawY), 1 - h),
51819
- w,
51820
- h
51821
- };
51822
- }
51823
- //#endregion
51824
52142
  //#region src/pipeline-analytics/pipeline/subject-agreement.ts
51825
52143
  /**
51826
52144
  * `subjectAgreement` — the ONE predicate for "a rectangle measured on frame A
@@ -55175,150 +55493,6 @@ function votePlateText(reads) {
55175
55493
  return out;
55176
55494
  }
55177
55495
  //#endregion
55178
- //#region src/pipeline-analytics/runtime/failure-report.ts
55179
- /**
55180
- * What `pipeline-analytics` reports through `failure-contribution` — the four
55181
- * per-camera counters, and the ONE place their vocabulary is written down.
55182
- *
55183
- * ## Why the vocabulary is a module and not four string literals
55184
- *
55185
- * D276 gave a track row a `previewMissReason` composed from strings like
55186
- * `keyframe:native-miss` and `thumbnail:overflow`. Those strings are now
55187
- * joinable evidence: a row says which track lost its tile, and these counters
55188
- * say how often that camera loses one and out of how many tries. A second
55189
- * vocabulary for the same loss would break the join silently — the row would
55190
- * say `native-miss` and the counter `nativeMiss`, and nobody would notice until
55191
- * somebody tried to correlate them. So the reasons live here, named once, and
55192
- * they deliberately reuse D276's spelling.
55193
- *
55194
- * ## Every family carries its denominator
55195
- *
55196
- * Each `note*` call is ONE attempt. The success calls are not decoration: a
55197
- * failure count without them is the number that read as "35x worse than
55198
- * yesterday" on 2026-08-28 and was flat all day once divided
55199
- * (`packages/types/src/metrics/failure-counters.ts`).
55200
- *
55201
- * ## Nothing here logs
55202
- *
55203
- * These paths run at frame rate — 507 enrichment-crop misses in five minutes at
55204
- * peak, ~100/min. A line each is its own defect, and the log lines that already
55205
- * exist on these paths (`enrichment crop native miss`, `capture queue full`,
55206
- * `plate: read discarded`) are unchanged and still throttled. This is the
55207
- * counter that makes them countable without reading them.
55208
- */
55209
- /**
55210
- * The enrichment crop capture — face / plate / clip / detail-classifier.
55211
- * Denominator: every crop this addon asked the owning runner for.
55212
- */
55213
- var FAMILY_ENRICHMENT_CROP = "enrichment-crop";
55214
- /**
55215
- * The per-camera capture scheduler lane. Denominator: every queued capture that
55216
- * reached a TERMINAL scheduling outcome — started, or dropped. A coalesced or
55217
- * cancelled request is neither and is deliberately not counted: a track that
55218
- * closed before its capture ran did not lose work, it stopped wanting it.
55219
- */
55220
- var FAMILY_CAPTURE_SCHEDULE = "capture-schedule";
55221
- /** Plate OCR reads routed from the detail plane. Denominator: every read. */
55222
- var FAMILY_PLATE_OCR = "plate-ocr";
55223
- /**
55224
- * Person tracks born on this camera. Denominator: every confirmed person birth.
55225
- *
55226
- * The "failure" here is NOT suppressed and must not be — a person standing over
55227
- * a car is a real person, and this repo will not trade that away for a cleaner
55228
- * count (D275 made the same choice for stationary suppression). It is counted so
55229
- * the phantom rate is a number instead of an impression: 4 of 23 on camera 617
55230
- * in two hours, measured by hand on 2026-08-28.
55231
- */
55232
- var FAMILY_PERSON_BIRTH = "person-birth";
55233
- /**
55234
- * The owning runner had no native pixels for the ROI.
55235
- *
55236
- * This collapses the runner-side causes the operator can already see in logs
55237
- * (`worker-lease-gone`, `no-registry-entry`, `jpeg-encode-failed`) because
55238
- * `pipelineRunner.getNativeCrop` answers an ROI miss with `null` and carries no
55239
- * reason on the wire — and `FrameHandle` has no `deviceId`, so the runner could
55240
- * not attribute the split per camera even if it reported it. Splitting this is a
55241
- * framework change (a reason on the reply); until then the honest name is the
55242
- * one the caller can actually stand behind.
55243
- */
55244
- var REASON_NATIVE_MISS = "native-miss";
55245
- /** Queued past `staleAfterMs`; the frame it wanted is gone. */
55246
- var REASON_STALE_DROP = "stale-drop";
55247
- /** Shed because the camera's lane was at its queue cap. */
55248
- var REASON_QUEUE_OVERFLOW = "queue-overflow";
55249
- /**
55250
- * The OCR step returned, with nothing in it.
55251
- *
55252
- * The defect this counter was built for: ~35% of plate reads came back with an
55253
- * empty label and fell through the detail router's `d.label.length > 0` gate
55254
- * into no branch at all — no store, no log, no row. Silence read as "this camera
55255
- * never saw a plate", which is a completely different fix.
55256
- */
55257
- var REASON_EMPTY_READ = "empty-read";
55258
- /** A plate detail with no bbox — nothing to crop, nothing to place. */
55259
- var REASON_NO_BBOX = "no-bbox";
55260
- /** `isPlausiblePlateRead` refused it (junk OCR off a distant/oblique plate). */
55261
- var REASON_IMPLAUSIBLE = "implausible";
55262
- /** Below the camera's configured minimum OCR confidence. */
55263
- var REASON_BELOW_MIN_CONFIDENCE = "below-min-confidence";
55264
- /** A person born with its box inside a vehicle's. See {@link FAMILY_PERSON_BIRTH}. */
55265
- var REASON_PERSON_OVER_VEHICLE = "person-over-vehicle";
55266
- /**
55267
- * This addon's `failure-contribution` counters.
55268
- *
55269
- * One instance per addon process, handed to the paths that fail. Every method
55270
- * takes the numeric `deviceId` first, for the same reason every log line does:
55271
- * the question is always per camera.
55272
- */
55273
- var AnalyticsFailureReport = class {
55274
- now;
55275
- counters;
55276
- constructor(now = Date.now, counters = new FailureCounters()) {
55277
- this.now = now;
55278
- this.counters = counters;
55279
- }
55280
- /** One enrichment crop capture. `reason` absent = the native ROI landed. */
55281
- noteEnrichmentCrop(deviceId, reason) {
55282
- this.counters.note({
55283
- deviceId,
55284
- family: FAMILY_ENRICHMENT_CROP,
55285
- ...reason !== void 0 ? { reason } : {}
55286
- }, this.now());
55287
- }
55288
- /** One capture that reached a terminal scheduling outcome. */
55289
- noteCaptureSchedule(deviceId, reason) {
55290
- this.counters.note({
55291
- deviceId,
55292
- family: FAMILY_CAPTURE_SCHEDULE,
55293
- ...reason !== void 0 ? { reason } : {}
55294
- }, this.now());
55295
- }
55296
- /** One plate OCR read. `reason` absent = the read was accepted and stored. */
55297
- notePlateRead(deviceId, reason) {
55298
- this.counters.note({
55299
- deviceId,
55300
- family: FAMILY_PLATE_OCR,
55301
- ...reason !== void 0 ? { reason } : {}
55302
- }, this.now());
55303
- }
55304
- /** One confirmed person birth. `reason` present = it was born over a vehicle. */
55305
- notePersonBirth(deviceId, reason) {
55306
- this.counters.note({
55307
- deviceId,
55308
- family: FAMILY_PERSON_BIRTH,
55309
- ...reason !== void 0 ? { reason } : {}
55310
- }, this.now());
55311
- }
55312
- /** The `failure-contribution` provider's payload. Reads, never resets. */
55313
- list() {
55314
- return this.counters.snapshot(this.now());
55315
- }
55316
- /** Addon disposal. */
55317
- clear() {
55318
- this.counters.clear();
55319
- }
55320
- };
55321
- //#endregion
55322
55496
  //#region src/pipeline-analytics/plate-recognizer.ts
55323
55497
  /**
55324
55498
  * PlateRecognizer — collects EVERY license-plate OCR read a vehicle track
@@ -60377,31 +60551,6 @@ var NativeCropDedupe = class {
60377
60551
  }
60378
60552
  };
60379
60553
  //#endregion
60380
- //#region src/pipeline-analytics/pipeline/capture-crop.ts
60381
- function createCaptureCrop(deps) {
60382
- return async (frameHandle, bbox, frameWidth, frameHeight, padding, maxWidth, deviceId) => {
60383
- const paddedNorm = padBbox({
60384
- x: bbox.x / frameWidth,
60385
- y: bbox.y / frameHeight,
60386
- w: bbox.w / frameWidth,
60387
- h: bbox.h / frameHeight
60388
- }, padding);
60389
- const nativeCrop = await deps.tryNativeCrop(frameHandle, paddedNorm, maxWidth);
60390
- if (nativeCrop) {
60391
- deps.bumpCropMetric(true);
60392
- if (deviceId !== void 0) deps.noteCropOutcome?.(deviceId);
60393
- return nativeCrop;
60394
- }
60395
- deps.bumpCropMetric(false);
60396
- if (deviceId !== void 0) deps.noteCropOutcome?.(deviceId, REASON_NATIVE_MISS);
60397
- deps.logger.debug("enrichment crop native miss — detail scheduler will re-run", {
60398
- ...deviceId !== void 0 ? { tags: { deviceId } } : {},
60399
- meta: { nodeId: frameHandle.nodeId }
60400
- });
60401
- return null;
60402
- };
60403
- }
60404
- //#endregion
60405
60554
  //#region src/pipeline-analytics/pipeline/display-crop.ts
60406
60555
  /** Bound the once-per-owner memo so a long-lived process never leaks it. */
60407
60556
  var MAX_LOGGED_OWNERS = 5e3;
@@ -65277,6 +65426,144 @@ function rowFromMetadata(id, metadata) {
65277
65426
  };
65278
65427
  }
65279
65428
  //#endregion
65429
+ //#region src/pipeline-analytics/store/settings-raw-mirror.ts
65430
+ /** How long a mirrored entry is served before a refresh is started behind the
65431
+ * next reader. See the module doc for why this matches the per-kind caches'
65432
+ * own window rather than introducing a second staleness bound. */
65433
+ var RAW_SETTINGS_STALE_MS = 5e3;
65434
+ /**
65435
+ * How long a FAILED refresh is not retried.
65436
+ *
65437
+ * A refresh that throws leaves `fetchedAt` where it was, so without a
65438
+ * cooldown every subsequent reader would start another attempt — a broken
65439
+ * settings store would be hammered at frame rate by the very mechanism built
65440
+ * to stop that.
65441
+ */
65442
+ var RAW_SETTINGS_MISS_COOLDOWN_MS = 5e3;
65443
+ /**
65444
+ * The addon's per-device raw blob (`pipeline-analytics:addon-devices`), kept
65445
+ * fresh OFF every per-kind settings cache's hot path.
65446
+ *
65447
+ * `resolve` is COLD-awaiting and single-flight: a burst of settings kinds
65448
+ * (or frames) missing on the same device in the same tick costs ONE read,
65449
+ * not one per caller — the exact failure this class exists to remove.
65450
+ */
65451
+ var RawDeviceBlobMirror = class {
65452
+ deps;
65453
+ staleMs;
65454
+ missCooldownMs;
65455
+ entries = /* @__PURE__ */ new Map();
65456
+ inFlight = /* @__PURE__ */ new Map();
65457
+ now;
65458
+ constructor(deps, staleMs = RAW_SETTINGS_STALE_MS, missCooldownMs = RAW_SETTINGS_MISS_COOLDOWN_MS) {
65459
+ this.deps = deps;
65460
+ this.staleMs = staleMs;
65461
+ this.missCooldownMs = missCooldownMs;
65462
+ this.now = deps.now ?? (() => Date.now());
65463
+ }
65464
+ /**
65465
+ * The device's raw stored blob.
65466
+ *
65467
+ * COLD awaits — the first settings kind to touch a device has to get the
65468
+ * real answer. WARM returns immediately, stale or not, and starts the
65469
+ * refresh behind the caller.
65470
+ */
65471
+ async resolve(deviceId) {
65472
+ const entry = this.entries.get(deviceId);
65473
+ if (entry === void 0) return this.load(deviceId);
65474
+ if (this.now() - entry.fetchedAt >= this.staleMs) this.load(deviceId);
65475
+ return entry.value;
65476
+ }
65477
+ /** Drop a device's entry. Called by every in-process writer of this
65478
+ * device's row, so an operator's own change is never served stale. */
65479
+ invalidate(deviceId) {
65480
+ this.entries.delete(deviceId);
65481
+ }
65482
+ invalidateAll() {
65483
+ this.entries.clear();
65484
+ }
65485
+ async load(deviceId) {
65486
+ const pending = this.inFlight.get(deviceId);
65487
+ if (pending !== void 0) return pending;
65488
+ const run = this.deps.read(deviceId).then((value) => {
65489
+ this.entries.set(deviceId, {
65490
+ value,
65491
+ fetchedAt: this.now()
65492
+ });
65493
+ return value;
65494
+ }).catch((err) => {
65495
+ this.deps.onRefreshFailed?.(deviceId, err);
65496
+ const existing = this.entries.get(deviceId);
65497
+ if (existing !== void 0) {
65498
+ existing.fetchedAt = this.now() - this.staleMs + this.missCooldownMs;
65499
+ return existing.value;
65500
+ }
65501
+ return {};
65502
+ }).finally(() => {
65503
+ this.inFlight.delete(deviceId);
65504
+ });
65505
+ this.inFlight.set(deviceId, run);
65506
+ return run;
65507
+ }
65508
+ };
65509
+ /**
65510
+ * The addon's ONE `addon_settings` row (`pipeline-analytics:addon-settings`),
65511
+ * kept fresh OFF every per-kind settings cache's hot path. Same shape as
65512
+ * {@link RawDeviceBlobMirror}, singleton rather than keyed — there is exactly
65513
+ * one addon store per process.
65514
+ */
65515
+ var RawGlobalStoreMirror = class {
65516
+ deps;
65517
+ staleMs;
65518
+ missCooldownMs;
65519
+ entry = null;
65520
+ inFlight = null;
65521
+ now;
65522
+ constructor(deps, staleMs = RAW_SETTINGS_STALE_MS, missCooldownMs = RAW_SETTINGS_MISS_COOLDOWN_MS) {
65523
+ this.deps = deps;
65524
+ this.staleMs = staleMs;
65525
+ this.missCooldownMs = missCooldownMs;
65526
+ this.now = deps.now ?? (() => Date.now());
65527
+ }
65528
+ async resolve() {
65529
+ const entry = this.entry;
65530
+ if (entry === null) return this.load();
65531
+ if (this.now() - entry.fetchedAt >= this.staleMs) this.load();
65532
+ return entry.value;
65533
+ }
65534
+ /** Drop the mirrored store. Called by every in-process global-settings
65535
+ * writer (`onConfigChanged`), so an operator's own change is never served
65536
+ * stale. */
65537
+ invalidate() {
65538
+ this.entry = null;
65539
+ }
65540
+ async load() {
65541
+ if (this.inFlight !== null) return this.inFlight;
65542
+ const run = this.deps.read().then((value) => {
65543
+ this.entry = {
65544
+ value,
65545
+ fetchedAt: this.now()
65546
+ };
65547
+ return value;
65548
+ }).catch((err) => {
65549
+ this.deps.onRefreshFailed?.(err);
65550
+ const existing = this.entry;
65551
+ if (existing !== null) {
65552
+ this.entry = {
65553
+ value: existing.value,
65554
+ fetchedAt: this.now() - this.staleMs + this.missCooldownMs
65555
+ };
65556
+ return existing.value;
65557
+ }
65558
+ return {};
65559
+ }).finally(() => {
65560
+ this.inFlight = null;
65561
+ });
65562
+ this.inFlight = run;
65563
+ return run;
65564
+ }
65565
+ };
65566
+ //#endregion
65280
65567
  //#region src/pipeline-analytics/summary/summary-score.ts
65281
65568
  /** A member whose own box is covered this much by others is hidden. */
65282
65569
  var OCCLUDED_COVER_FRACTION = .5;
@@ -65581,144 +65868,6 @@ var SummarySealer = class {
65581
65868
  }
65582
65869
  };
65583
65870
  //#endregion
65584
- //#region src/pipeline-analytics/store/settings-raw-mirror.ts
65585
- /** How long a mirrored entry is served before a refresh is started behind the
65586
- * next reader. See the module doc for why this matches the per-kind caches'
65587
- * own window rather than introducing a second staleness bound. */
65588
- var RAW_SETTINGS_STALE_MS = 5e3;
65589
- /**
65590
- * How long a FAILED refresh is not retried.
65591
- *
65592
- * A refresh that throws leaves `fetchedAt` where it was, so without a
65593
- * cooldown every subsequent reader would start another attempt — a broken
65594
- * settings store would be hammered at frame rate by the very mechanism built
65595
- * to stop that.
65596
- */
65597
- var RAW_SETTINGS_MISS_COOLDOWN_MS = 5e3;
65598
- /**
65599
- * The addon's per-device raw blob (`pipeline-analytics:addon-devices`), kept
65600
- * fresh OFF every per-kind settings cache's hot path.
65601
- *
65602
- * `resolve` is COLD-awaiting and single-flight: a burst of settings kinds
65603
- * (or frames) missing on the same device in the same tick costs ONE read,
65604
- * not one per caller — the exact failure this class exists to remove.
65605
- */
65606
- var RawDeviceBlobMirror = class {
65607
- deps;
65608
- staleMs;
65609
- missCooldownMs;
65610
- entries = /* @__PURE__ */ new Map();
65611
- inFlight = /* @__PURE__ */ new Map();
65612
- now;
65613
- constructor(deps, staleMs = RAW_SETTINGS_STALE_MS, missCooldownMs = RAW_SETTINGS_MISS_COOLDOWN_MS) {
65614
- this.deps = deps;
65615
- this.staleMs = staleMs;
65616
- this.missCooldownMs = missCooldownMs;
65617
- this.now = deps.now ?? (() => Date.now());
65618
- }
65619
- /**
65620
- * The device's raw stored blob.
65621
- *
65622
- * COLD awaits — the first settings kind to touch a device has to get the
65623
- * real answer. WARM returns immediately, stale or not, and starts the
65624
- * refresh behind the caller.
65625
- */
65626
- async resolve(deviceId) {
65627
- const entry = this.entries.get(deviceId);
65628
- if (entry === void 0) return this.load(deviceId);
65629
- if (this.now() - entry.fetchedAt >= this.staleMs) this.load(deviceId);
65630
- return entry.value;
65631
- }
65632
- /** Drop a device's entry. Called by every in-process writer of this
65633
- * device's row, so an operator's own change is never served stale. */
65634
- invalidate(deviceId) {
65635
- this.entries.delete(deviceId);
65636
- }
65637
- invalidateAll() {
65638
- this.entries.clear();
65639
- }
65640
- async load(deviceId) {
65641
- const pending = this.inFlight.get(deviceId);
65642
- if (pending !== void 0) return pending;
65643
- const run = this.deps.read(deviceId).then((value) => {
65644
- this.entries.set(deviceId, {
65645
- value,
65646
- fetchedAt: this.now()
65647
- });
65648
- return value;
65649
- }).catch((err) => {
65650
- this.deps.onRefreshFailed?.(deviceId, err);
65651
- const existing = this.entries.get(deviceId);
65652
- if (existing !== void 0) {
65653
- existing.fetchedAt = this.now() - this.staleMs + this.missCooldownMs;
65654
- return existing.value;
65655
- }
65656
- return {};
65657
- }).finally(() => {
65658
- this.inFlight.delete(deviceId);
65659
- });
65660
- this.inFlight.set(deviceId, run);
65661
- return run;
65662
- }
65663
- };
65664
- /**
65665
- * The addon's ONE `addon_settings` row (`pipeline-analytics:addon-settings`),
65666
- * kept fresh OFF every per-kind settings cache's hot path. Same shape as
65667
- * {@link RawDeviceBlobMirror}, singleton rather than keyed — there is exactly
65668
- * one addon store per process.
65669
- */
65670
- var RawGlobalStoreMirror = class {
65671
- deps;
65672
- staleMs;
65673
- missCooldownMs;
65674
- entry = null;
65675
- inFlight = null;
65676
- now;
65677
- constructor(deps, staleMs = RAW_SETTINGS_STALE_MS, missCooldownMs = RAW_SETTINGS_MISS_COOLDOWN_MS) {
65678
- this.deps = deps;
65679
- this.staleMs = staleMs;
65680
- this.missCooldownMs = missCooldownMs;
65681
- this.now = deps.now ?? (() => Date.now());
65682
- }
65683
- async resolve() {
65684
- const entry = this.entry;
65685
- if (entry === null) return this.load();
65686
- if (this.now() - entry.fetchedAt >= this.staleMs) this.load();
65687
- return entry.value;
65688
- }
65689
- /** Drop the mirrored store. Called by every in-process global-settings
65690
- * writer (`onConfigChanged`), so an operator's own change is never served
65691
- * stale. */
65692
- invalidate() {
65693
- this.entry = null;
65694
- }
65695
- async load() {
65696
- if (this.inFlight !== null) return this.inFlight;
65697
- const run = this.deps.read().then((value) => {
65698
- this.entry = {
65699
- value,
65700
- fetchedAt: this.now()
65701
- };
65702
- return value;
65703
- }).catch((err) => {
65704
- this.deps.onRefreshFailed?.(err);
65705
- const existing = this.entry;
65706
- if (existing !== null) {
65707
- this.entry = {
65708
- value: existing.value,
65709
- fetchedAt: this.now() - this.staleMs + this.missCooldownMs
65710
- };
65711
- return existing.value;
65712
- }
65713
- return {};
65714
- }).finally(() => {
65715
- this.inFlight = null;
65716
- });
65717
- this.inFlight = run;
65718
- return run;
65719
- }
65720
- };
65721
- //#endregion
65722
65871
  //#region src/pipeline-analytics/track-retention-sweep.ts
65723
65872
  /**
65724
65873
  * Sweep every device with persisted tracks: skip retention-disabled devices,
@@ -67306,6 +67455,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
67306
67455
  suppressedBirths = new SuppressedBirthRegistry();
67307
67456
  /** Births the gate could not MEASURE, awaiting another look on a later frame. */
67308
67457
  deferredBirths = new DeferredBirthRegistry();
67458
+ /**
67459
+ * Subjects the confirmation gate found in a birth's crop BESIDES the birth
67460
+ * itself (a dog in a person's crop), waiting to ride the next frame of the
67461
+ * same (device, source) as detections — `pipeline/secondary-subjects.ts`.
67462
+ */
67463
+ secondarySubjects = new SecondarySubjectQueue();
67309
67464
  lastFrameDimsByDevice = /* @__PURE__ */ new Map();
67310
67465
  lastAudioInsertByDevice = /* @__PURE__ */ new Map();
67311
67466
  lastMotionInsertByDevice = /* @__PURE__ */ new Map();
@@ -70402,9 +70557,20 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
70402
70557
  processor.setZones(liveZones);
70403
70558
  processor.setDetectionRules(liveRules);
70404
70559
  processor.setZoneMembershipMinOverlap(trk.zoneMembershipMinOverlap);
70560
+ const injection = injectSecondarySubjects(frame, this.secondarySubjects.take(key, Date.now()));
70561
+ for (const id of injection.covered) this.secondarySubjects.drop(key, id);
70562
+ const trackerFrame = injection.frame;
70563
+ if (trackerFrame !== frame) this.ctx.logger.info("secondary subjects ride this frame as detections", {
70564
+ tags: { deviceId },
70565
+ meta: {
70566
+ source,
70567
+ added: trackerFrame.detections.length - frame.detections.length,
70568
+ classes: trackerFrame.detections.slice(frame.detections.length).map((d) => d.macroClass)
70569
+ }
70570
+ });
70405
70571
  const result = processor.process({
70406
70572
  timestamp: frame.timestamp,
70407
- frame
70573
+ frame: trackerFrame
70408
70574
  });
70409
70575
  for (const r of processor.getLastLabelWriteRefusals()) {
70410
70576
  const message = r.reason.kind === "macro-class" ? "label tier collapse refused" : "label write refused";
@@ -70547,6 +70713,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
70547
70713
  bbox: t.bbox
70548
70714
  })), {
70549
70715
  deviceId,
70716
+ source,
70550
70717
  frameHandle,
70551
70718
  frameWidth: result.frameWidth,
70552
70719
  frameHeight: result.frameHeight
@@ -71191,16 +71358,25 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
71191
71358
  const executor = this.pipelineExecutorApi();
71192
71359
  if (!executor?.runPipeline) return null;
71193
71360
  try {
71194
- const detections = (await executor.runPipeline.mutate({
71361
+ const result = await executor.runPipeline.mutate({
71195
71362
  steps: [step],
71196
71363
  image: new Uint8Array(cropJpeg),
71197
71364
  deviceId,
71198
71365
  plane: "frame"
71199
- }, nodePin(nodeId)))?.detections;
71366
+ }, nodePin(nodeId));
71367
+ const detections = result?.detections;
71200
71368
  if (!detections) return null;
71369
+ const cropWidth = result.width ?? 0;
71370
+ const cropHeight = result.height ?? 0;
71201
71371
  return detections.map((d) => ({
71202
71372
  macroClass: d.macroClass,
71203
- score: d.score
71373
+ score: d.score,
71374
+ ...d.bbox !== void 0 && cropWidth > 0 && cropHeight > 0 ? { bbox: {
71375
+ x: d.bbox.x / cropWidth,
71376
+ y: d.bbox.y / cropHeight,
71377
+ w: d.bbox.width / cropWidth,
71378
+ h: d.bbox.height / cropHeight
71379
+ } } : {}
71204
71380
  }));
71205
71381
  } catch (err) {
71206
71382
  this.ctx.logger.debug("confirmation gate: re-detection failed — fail-open", {
@@ -71226,7 +71402,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
71226
71402
  if (candidates.length === 0) return allConfirmed();
71227
71403
  const config = await this.resolveDeviceConfirmationGateSettings(params.deviceId);
71228
71404
  if (!config.enabled) return allConfirmed();
71229
- const { frameHandle, frameWidth, frameHeight, deviceId } = params;
71405
+ const { frameHandle, frameWidth, frameHeight, deviceId, source } = params;
71230
71406
  const captureCrop = this.captureCrop;
71231
71407
  if (!frameHandle || !captureCrop || frameWidth <= 0 || frameHeight <= 0) {
71232
71408
  this.ctx.logger.info("confirmation gate: no crop path — births allowed unmeasured", {
@@ -71266,6 +71442,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
71266
71442
  }, frameWidth, frameHeight, CONFIRMATION_CROP_PADDING, candidate.trackId, 320);
71267
71443
  },
71268
71444
  redetect: (cropJpeg) => this.redetectCropForConfirmation(nodeId, deviceId, cropJpeg),
71445
+ cropRectFor: (candidate) => captureCropFrameRect({
71446
+ x: candidate.bbox.x,
71447
+ y: candidate.bbox.y,
71448
+ w: candidate.bbox.w,
71449
+ h: candidate.bbox.h
71450
+ }, frameWidth, frameHeight, CONFIRMATION_CROP_PADDING),
71269
71451
  minConfidenceFor: (candidate) => this.phantomCells?.spawnBar({
71270
71452
  deviceId,
71271
71453
  className: candidate.className,
@@ -71310,6 +71492,20 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
71310
71492
  });
71311
71493
  break;
71312
71494
  }
71495
+ if (decision.secondary !== void 0) {
71496
+ this.secondarySubjects.add(this.procKey(deviceId, source), decision.secondary, decision.trackId, Date.now());
71497
+ this.ctx.logger.info("confirmation gate: secondary subject promoted from the crop", {
71498
+ tags: { deviceId },
71499
+ meta: {
71500
+ trackId: decision.trackId,
71501
+ className: decision.className,
71502
+ secondaryClass: decision.secondary.className,
71503
+ secondaryScore: decision.secondary.score,
71504
+ secondaryBbox: decision.secondary.bbox,
71505
+ verdict: decision.verdict
71506
+ }
71507
+ });
71508
+ }
71313
71509
  }
71314
71510
  }, exhaustedIds);
71315
71511
  }
@@ -73186,6 +73382,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
73186
73382
  const prefix = `${deviceId}:`;
73187
73383
  for (const k of this.processors.keys()) if (k.startsWith(prefix)) this.processors.delete(k);
73188
73384
  for (const k of this.lastActiveTrackIds.keys()) if (k.startsWith(prefix)) this.lastActiveTrackIds.delete(k);
73385
+ for (const source of ["pipeline", "onboard"]) this.secondarySubjects.forget(this.procKey(deviceId, source));
73189
73386
  for (const k of this.dropoutSkipsByKey.keys()) if (k.startsWith(prefix)) this.dropoutSkipsByKey.delete(k);
73190
73387
  this.lastFrameDimsByDevice.delete(deviceId);
73191
73388
  }