@camstack/addon-post-analysis 1.2.266 → 1.2.268

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,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-ZhDqWNZF.js");
5
+ const require_dist = require("../dist-Cgy2NW9x.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_dist.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -37235,6 +37235,11 @@ var SortTracker = class SortTracker {
37235
37235
  * three call sites reach and only one of them could have been handed it.
37236
37236
  */
37237
37237
  frameArea = 0;
37238
+ /** The frame DIMS of the pass, for the same reason `frameArea` is held: the
37239
+ * salvage-bind record carries both boxes, and a box without the frame it was
37240
+ * measured in cannot be put back on a picture. `0` = no frame context. */
37241
+ frameWidthPx = 0;
37242
+ frameHeightPx = 0;
37238
37243
  /** Sink for track deaths — see {@link setTrackLossObserver}. */
37239
37244
  trackLossObserver = null;
37240
37245
  trackLossSampler = new TrackLossSampler();
@@ -37472,6 +37477,20 @@ var SortTracker = class SortTracker {
37472
37477
  centroidDistPx: Math.hypot(tc.x - dc.x, tc.y - dc.y),
37473
37478
  trackDiagPx: Math.hypot(track.bbox.w, track.bbox.h),
37474
37479
  areaRatio: smaller > 0 ? Math.max(trackArea, detArea) / smaller : 0,
37480
+ trackBbox: {
37481
+ x: track.bbox.x,
37482
+ y: track.bbox.y,
37483
+ w: track.bbox.w,
37484
+ h: track.bbox.h
37485
+ },
37486
+ detBbox: {
37487
+ x: det.bbox.x,
37488
+ y: det.bbox.y,
37489
+ w: det.bbox.w,
37490
+ h: det.bbox.h
37491
+ },
37492
+ frameWidth: this.frameWidthPx,
37493
+ frameHeight: this.frameHeightPx,
37475
37494
  dtMs: timestamp - track.lastSeen,
37476
37495
  trackAgeMs: timestamp - track.firstSeen,
37477
37496
  bindCount,
@@ -38016,6 +38035,8 @@ var SortTracker = class SortTracker {
38016
38035
  this.frameSeq++;
38017
38036
  const frameArea = frameContext ? frameContext.frameWidth * frameContext.frameHeight : 0;
38018
38037
  this.frameArea = frameArea;
38038
+ this.frameWidthPx = frameContext?.frameWidth ?? 0;
38039
+ this.frameHeightPx = frameContext?.frameHeight ?? 0;
38019
38040
  const continuationOnly = frameContext?.continuationOnly ?? EMPTY_INDEX_SET;
38020
38041
  const resurrectedScene = /* @__PURE__ */ new Map();
38021
38042
  if (this.config.maxTrackLifetimeMs > 0) {
@@ -38363,7 +38384,7 @@ var StateAnalyzer = class {
38363
38384
  }
38364
38385
  const velocity = track.velocity ? Math.sqrt(track.velocity.dx ** 2 + track.velocity.dy ** 2) : 0;
38365
38386
  const dwellTimeMs = timestamp - ts.enteredAt;
38366
- const settled = velocity <= this.config.velocityThreshold || this.windowSaysStill(ts, track.bbox, timestamp);
38387
+ const settled = this.speedSaysSettled(ts, track.bbox, velocity) || this.windowSaysStill(ts, track.bbox, timestamp);
38367
38388
  let state;
38368
38389
  if (ts.frameCount <= this.config.enteringFrames) state = "entering";
38369
38390
  else if (settled) {
@@ -38399,6 +38420,37 @@ var StateAnalyzer = class {
38399
38420
  return results;
38400
38421
  }
38401
38422
  /**
38423
+ * The SPEED half of the settled test.
38424
+ *
38425
+ * Uncalibrated (`stillSpeedFracPerSec` undefined) this is the original line
38426
+ * verbatim — the tracker's px-per-update velocity against the flat
38427
+ * `velocityThreshold`. Nothing about an uncalibrated camera changes.
38428
+ *
38429
+ * Calibrated, the same question is asked in the camera's own units: how far
38430
+ * the centroid moved between the last two OBSERVED samples, over the
38431
+ * subject's own box diagonal, over the elapsed SECONDS. Per second and not
38432
+ * per update because these cameras are `armed:on-motion` and deliver a
38433
+ * settle burst plus a poll — 56 ms to 2135 ms between stored positions on
38434
+ * one subject (D399) — so a per-update figure says more about the cadence
38435
+ * than about the subject.
38436
+ *
38437
+ * An interval that cannot be measured (a single sample, a non-positive
38438
+ * elapsed time, a box with no size) is UNKNOWN, and unknown never reads as
38439
+ * settled. A track that young is still inside `enteringFrames` anyway.
38440
+ */
38441
+ speedSaysSettled(ts, bbox, velocity) {
38442
+ const bar = this.config.stillSpeedFracPerSec;
38443
+ if (bar === void 0) return velocity <= this.config.velocityThreshold;
38444
+ const last = ts.recent[ts.recent.length - 1];
38445
+ const prev = ts.recent[ts.recent.length - 2];
38446
+ if (!last || !prev) return false;
38447
+ const elapsedSec = (last.at - prev.at) / 1e3;
38448
+ if (!(elapsedSec > 0)) return false;
38449
+ const diagonal = Math.hypot(bbox.w, bbox.h);
38450
+ if (!(diagonal > 0)) return false;
38451
+ return Math.hypot(last.x - prev.x, last.y - prev.y) / diagonal / elapsedSec <= bar;
38452
+ }
38453
+ /**
38402
38454
  * Whether the centroid has stayed inside a small span for the whole window.
38403
38455
  *
38404
38456
  * The span is measured across the samples themselves (their bounding box),
@@ -41260,6 +41312,17 @@ var TrackResidentState = class {
41260
41312
  objectEmbeddingBestSelector = new TrackBestSelector(BEST_OBJECT_EMBED_POLICY);
41261
41313
  /** keyFrame keys of CLOSED tracks still readable until the recognizer-end
41262
41314
  * deferred drop settles (see module docstring). */
41315
+ /**
41316
+ * How many LIVE tracks carry the operator's `debug` flag, across every camera
41317
+ * this runner holds.
41318
+ *
41319
+ * A counter rather than a scan because the frame path asks it every frame:
41320
+ * it is what lets the host skip maintaining a per-frame handle mirror
41321
+ * entirely while nothing is marked, which is the state of the fleet
41322
+ * essentially always. Kept in step in the three places a resident's flag can
41323
+ * change: {@link setDebugMarked}, {@link close} and {@link clearAll}.
41324
+ */
41325
+ debugMarkedCount = 0;
41263
41326
  deferredKeyFrameKeys = /* @__PURE__ */ new Map();
41264
41327
  constructor(hooks = {}) {
41265
41328
  this.hooks = hooks;
@@ -41281,7 +41344,8 @@ var TrackResidentState = class {
41281
41344
  thumbnailLanded: false,
41282
41345
  keyFrameLanded: false,
41283
41346
  keyFrameRetries: 0,
41284
- confirmed: false
41347
+ confirmed: false,
41348
+ debugMarked: false
41285
41349
  };
41286
41350
  this.residents.set(trackId, created);
41287
41351
  return created;
@@ -41295,6 +41359,7 @@ var TrackResidentState = class {
41295
41359
  */
41296
41360
  close(trackId, mode) {
41297
41361
  const resident = this.residents.get(trackId);
41362
+ if (resident?.debugMarked === true) this.debugMarkedCount -= 1;
41298
41363
  this.residents.delete(trackId);
41299
41364
  this.bestFrameTracker.delete(trackId);
41300
41365
  this.objectEmbeddingBestSelector.delete(trackId);
@@ -41314,6 +41379,7 @@ var TrackResidentState = class {
41314
41379
  }
41315
41380
  /** Drop everything (addon dispose). */
41316
41381
  clearAll() {
41382
+ this.debugMarkedCount = 0;
41317
41383
  this.residents.clear();
41318
41384
  this.bestFrameTracker.clear();
41319
41385
  this.objectEmbeddingBestSelector.clear();
@@ -41505,6 +41571,42 @@ var TrackResidentState = class {
41505
41571
  markConfirmed(deviceId, trackId) {
41506
41572
  this.ensure(deviceId, trackId).confirmed = true;
41507
41573
  }
41574
+ /**
41575
+ * Is this track the one the operator is asking about?
41576
+ *
41577
+ * THE gate for every debug crop, and it must stay this cheap: one map lookup,
41578
+ * no store read, no allocation. An unknown track is not marked — a lookup
41579
+ * that finds nothing is the common case on every camera in the fleet.
41580
+ */
41581
+ isDebugMarked(trackId) {
41582
+ return this.residents.get(trackId)?.debugMarked ?? false;
41583
+ }
41584
+ /**
41585
+ * Record the operator's `debug` flag for a LIVE track.
41586
+ *
41587
+ * Called from the `setTrackFlags` cap mutation, which is the only thing that
41588
+ * can turn the flag on — so this runs once per press, not per frame.
41589
+ * `ensure` rather than a lookup: the button can be pressed on a track whose
41590
+ * first frame is still in flight, and a mark that silently missed would be
41591
+ * the operator clicking and getting nothing, which is the whole complaint.
41592
+ */
41593
+ setDebugMarked(deviceId, trackId, marked) {
41594
+ const resident = this.ensure(deviceId, trackId);
41595
+ if (resident.debugMarked === marked) return;
41596
+ resident.debugMarked = marked;
41597
+ this.debugMarkedCount += marked ? 1 : -1;
41598
+ }
41599
+ /**
41600
+ * Does ANY live track on this runner carry the flag?
41601
+ *
41602
+ * The frame path's own gate, one level above {@link isDebugMarked}: it lets
41603
+ * the host skip the per-frame bookkeeping a debug crop needs (the frame
41604
+ * handle mirror the salvage-bind observer reads) while nothing is marked —
41605
+ * which is the fleet's normal state. A counter read, no scan, no allocation.
41606
+ */
41607
+ anyDebugMarked() {
41608
+ return this.debugMarkedCount > 0;
41609
+ }
41508
41610
  /** Retain the FIRST detection-raster crop per track (zero-media fallback);
41509
41611
  * a later frame never overwrites an existing one. */
41510
41612
  retainRasterFallback(deviceId, trackId, fallback) {
@@ -48057,6 +48159,338 @@ function makeViewerSettingsActionHandlers(store) {
48057
48159
  };
48058
48160
  }
48059
48161
  //#endregion
48162
+ //#region src/pipeline-analytics/stillness-calibration-actions.ts
48163
+ /**
48164
+ * stillness-calibration-actions — "measure this camera's own stillness scale".
48165
+ *
48166
+ * ## Why an `addons.custom` action and not a cap method
48167
+ *
48168
+ * Same reason as `face.rescoreTrack` beside it: a cap method's wire schema
48169
+ * lives in `@camstack/types`, which travels inside the `@camstack/server`
48170
+ * closure, so a new one is not callable until a server train ships. Here the
48171
+ * schema lives in this addon's own dist and the hub forwards an opaque
48172
+ * envelope, so `camstack deploy` is the whole delivery.
48173
+ *
48174
+ * ## Why `apply` is explicit and not a fallback
48175
+ *
48176
+ * A calibration run READS recorded tracks and does arithmetic. Applying it
48177
+ * WRITES the camera's settings and changes what the tracker calls settled from
48178
+ * the next frame on. Those are different enough in consequence that a button
48179
+ * labelled "calibrate" must not choose between them on the operator's behalf —
48180
+ * the run reports, the operator applies. A REFUSED run never writes the bar
48181
+ * whatever `apply` says; it writes only the note, so the refusal and its reason
48182
+ * are visible in the form instead of being a toast that scrolls away.
48183
+ */
48184
+ /** Mirrors `StillnessCalibrationRefusal` — kept literal so the wire schema is
48185
+ * self-describing and a new reason is a deliberate edit in both places. */
48186
+ var StillnessRefusalSchema = require_dist._enum([
48187
+ "no-tracks",
48188
+ "too-few-tracks",
48189
+ "too-few-samples",
48190
+ "no-motion-evidence"
48191
+ ]);
48192
+ var StillnessMeasurementSchema = require_dist.object({
48193
+ /** Tracks that contributed at least one speed sample. */
48194
+ trackCount: require_dist.number().int(),
48195
+ /** Tracks the pass actually read, after the cost bound. */
48196
+ tracksRead: require_dist.number().int(),
48197
+ sampleCount: require_dist.number().int(),
48198
+ /** Speed quantiles, subject-box-diagonals per second. `null` when there was
48199
+ * nothing to measure — never 0, which would read as a measured stillness
48200
+ * (D393). */
48201
+ p25: require_dist.number().nullable(),
48202
+ p50: require_dist.number().nullable(),
48203
+ p90: require_dist.number().nullable(),
48204
+ /** Median gap between stored positions (s) — the camera's real cadence. */
48205
+ medianIntervalSec: require_dist.number().nullable(),
48206
+ /** Median subject box diagonal (px) the speeds were normalised by. */
48207
+ medianSubjectDiagonalPx: require_dist.number().nullable()
48208
+ });
48209
+ var StillnessCalibrateInputSchema = require_dist.object({
48210
+ deviceId: require_dist.number().int(),
48211
+ /** How far back to read. Bounded: this pass parses ~10 kB of trajectory a
48212
+ * row, so the window is a suggestion and `maxTracks` is the real bound. */
48213
+ sinceHours: require_dist.number().min(.25).max(720).default(72),
48214
+ /** Hard cost bound on tracks read, newest first. */
48215
+ maxTracks: require_dist.number().int().min(1).max(1e3).default(200),
48216
+ /**
48217
+ * Write the chosen bar onto the camera. Never implicit, and never done at
48218
+ * all for a refused run.
48219
+ */
48220
+ apply: require_dist.boolean().default(false)
48221
+ });
48222
+ var StillnessCalibrateResultSchema = require_dist.object({
48223
+ deviceId: require_dist.number().int(),
48224
+ /** The window actually read (epoch ms). */
48225
+ since: require_dist.number().int(),
48226
+ until: require_dist.number().int(),
48227
+ accepted: require_dist.boolean(),
48228
+ /** Why it refused. Absent on an accepted run. */
48229
+ reason: StillnessRefusalSchema.optional(),
48230
+ /** The chosen bar, subject-box-diagonals per second. Absent on a refusal —
48231
+ * never 0, which the settings layer reads as "uncalibrated". */
48232
+ bar: require_dist.number().optional(),
48233
+ /** The raw quantile before the rails, so the clamp is arguable. */
48234
+ rawBar: require_dist.number().optional(),
48235
+ clampedBy: require_dist._enum([
48236
+ "none",
48237
+ "floor",
48238
+ "ceiling",
48239
+ "min",
48240
+ "max"
48241
+ ]).optional(),
48242
+ measurement: StillnessMeasurementSchema,
48243
+ /** The bar this camera was on BEFORE the run — `null` = uncalibrated. Read
48244
+ * before any write, so the operator sees both numbers. */
48245
+ previousBar: require_dist.number().nullable(),
48246
+ /** Whether the camera's settings were actually written. */
48247
+ applied: require_dist.boolean(),
48248
+ /** The one-line note stored on the camera (accepted or refused). */
48249
+ note: require_dist.string()
48250
+ });
48251
+ var stillnessCalibrationActions = require_dist.defineCustomActions({
48252
+ /**
48253
+ * `admin` because it WRITES a per-camera detection threshold when `apply` is
48254
+ * set, and because the report describes how the camera's subjects move,
48255
+ * which is not a viewer-level fact.
48256
+ */
48257
+ "stillness.calibrate": require_dist.customAction(StillnessCalibrateInputSchema, StillnessCalibrateResultSchema, {
48258
+ kind: "mutation",
48259
+ auth: "admin"
48260
+ }) });
48261
+ /**
48262
+ * The shipped limits.
48263
+ *
48264
+ * `minTracks` 12 / `minSamples` 200: the four live cameras sampled above
48265
+ * produced 697–8739 samples from 42–51 tracks in a single 60-track read, so a
48266
+ * camera that cannot clear these has genuinely not been watched enough.
48267
+ * `maxTracks` 200 bounds the read: the `positions` blob averages ~10 kB a row
48268
+ * (`track-store.ts`), so this pass is ~2 MB of JSON at its worst.
48269
+ */
48270
+ var DEFAULT_STILLNESS_CALIBRATION_LIMITS = {
48271
+ minTracks: 12,
48272
+ minSamples: 200,
48273
+ maxTracks: 200,
48274
+ minIntervalSec: .02,
48275
+ maxIntervalSec: 3,
48276
+ quantile: .25,
48277
+ floorFactor: .02,
48278
+ ceilFactor: .25,
48279
+ minBar: .001,
48280
+ maxBar: 1
48281
+ };
48282
+ /** Linear-interpolated quantile. `NaN` for an empty list — never 0, which
48283
+ * would read as a measured zero (D393). */
48284
+ function quantileOf(values, p) {
48285
+ if (values.length === 0) return NaN;
48286
+ const sorted = [...values].sort((a, b) => a - b);
48287
+ const idx = (sorted.length - 1) * Math.min(Math.max(p, 0), 1);
48288
+ const lo = Math.floor(idx);
48289
+ const hi = Math.ceil(idx);
48290
+ const at = sorted[lo];
48291
+ const next = sorted[hi];
48292
+ if (at === void 0 || next === void 0) return NaN;
48293
+ return at + (next - at) * (idx - lo);
48294
+ }
48295
+ /**
48296
+ * Per-sample centroid speeds for a camera, in subject-box-diagonals per second.
48297
+ *
48298
+ * A pair of consecutive positions contributes a sample only when the interval
48299
+ * between them is inside `[minIntervalSec, maxIntervalSec]` and the later box
48300
+ * has a positive diagonal. A gap outside that band is a session boundary or a
48301
+ * duplicate read, not a measurement of how fast anything travelled; a box with
48302
+ * no size is unknown geometry, and unknown is never a sample.
48303
+ */
48304
+ function collectStillnessSamples(tracks, limits) {
48305
+ const speeds = [];
48306
+ const intervals = [];
48307
+ const diagonals = [];
48308
+ let trackCount = 0;
48309
+ const read = tracks.slice(0, limits.maxTracks);
48310
+ for (const track of read) {
48311
+ const positions = [...track.positions].sort((a, b) => a.timestamp - b.timestamp);
48312
+ let contributed = false;
48313
+ for (let i = 1; i < positions.length; i++) {
48314
+ const prev = positions[i - 1];
48315
+ const cur = positions[i];
48316
+ if (prev === void 0 || cur === void 0) continue;
48317
+ const dtSec = (cur.timestamp - prev.timestamp) / 1e3;
48318
+ if (dtSec < limits.minIntervalSec || dtSec > limits.maxIntervalSec) continue;
48319
+ const diagonal = Math.hypot(cur.bbox.w, cur.bbox.h);
48320
+ if (!(diagonal > 0)) continue;
48321
+ const travelled = Math.hypot(cur.x - prev.x, cur.y - prev.y);
48322
+ speeds.push(travelled / diagonal / dtSec);
48323
+ intervals.push(dtSec);
48324
+ diagonals.push(diagonal);
48325
+ contributed = true;
48326
+ }
48327
+ if (contributed) trackCount++;
48328
+ }
48329
+ return {
48330
+ speeds,
48331
+ intervals,
48332
+ diagonals,
48333
+ trackCount,
48334
+ tracksRead: read.length
48335
+ };
48336
+ }
48337
+ function measure$1(samples) {
48338
+ return {
48339
+ trackCount: samples.trackCount,
48340
+ sampleCount: samples.speeds.length,
48341
+ p25: quantileOf(samples.speeds, .25),
48342
+ p50: quantileOf(samples.speeds, .5),
48343
+ p90: quantileOf(samples.speeds, .9),
48344
+ medianIntervalSec: quantileOf(samples.intervals, .5),
48345
+ medianSubjectDiagonalPx: quantileOf(samples.diagonals, .5)
48346
+ };
48347
+ }
48348
+ /**
48349
+ * Choose this camera's stillness speed bar, or refuse with a named reason.
48350
+ *
48351
+ * The order of the gates is the order of the questions: is there anything at
48352
+ * all, is it enough tracks, is it enough samples, and did anything on this
48353
+ * camera ever actually move. The last one is not pedantry — the whole method
48354
+ * scales the bar against the camera's own travel, so with no travel there is
48355
+ * nothing to scale against and the default is the honest answer.
48356
+ */
48357
+ function calibrateStillnessSpeed(samples, limits) {
48358
+ const measurement = measure$1(samples);
48359
+ if (samples.tracksRead === 0) return {
48360
+ ok: false,
48361
+ reason: "no-tracks",
48362
+ measurement
48363
+ };
48364
+ if (samples.trackCount < limits.minTracks) return {
48365
+ ok: false,
48366
+ reason: "too-few-tracks",
48367
+ measurement
48368
+ };
48369
+ if (samples.speeds.length < limits.minSamples) return {
48370
+ ok: false,
48371
+ reason: "too-few-samples",
48372
+ measurement
48373
+ };
48374
+ if (!(measurement.p90 > 0)) return {
48375
+ ok: false,
48376
+ reason: "no-motion-evidence",
48377
+ measurement
48378
+ };
48379
+ const rawBar = quantileOf(samples.speeds, limits.quantile);
48380
+ const floor = measurement.p90 * limits.floorFactor;
48381
+ const ceiling = measurement.p90 * limits.ceilFactor;
48382
+ let bar = rawBar;
48383
+ let clampedBy = "none";
48384
+ if (bar < floor) {
48385
+ bar = floor;
48386
+ clampedBy = "floor";
48387
+ } else if (bar > ceiling) {
48388
+ bar = ceiling;
48389
+ clampedBy = "ceiling";
48390
+ }
48391
+ if (bar < limits.minBar) {
48392
+ bar = limits.minBar;
48393
+ clampedBy = "min";
48394
+ } else if (bar > limits.maxBar) {
48395
+ bar = limits.maxBar;
48396
+ clampedBy = "max";
48397
+ }
48398
+ return {
48399
+ ok: true,
48400
+ bar,
48401
+ rawBar,
48402
+ clampedBy,
48403
+ measurement
48404
+ };
48405
+ }
48406
+ /** One line an operator can read: what was measured, from how much, and what
48407
+ * rule produced the stored number. Kept short — it rides the settings blob. */
48408
+ function describeStillnessCalibration(outcome) {
48409
+ const m = outcome.measurement;
48410
+ const n = (v) => Number.isFinite(v) ? v.toFixed(4) : "unknown";
48411
+ const evidence = `${m.sampleCount} samples / ${m.trackCount} tracks, p25 ${n(m.p25)}, p50 ${n(m.p50)}, p90 ${n(m.p90)} subject-diagonals/s`;
48412
+ if (!outcome.ok) return `refused (${outcome.reason}) — ${evidence}`;
48413
+ return `bar ${n(outcome.bar)} (raw ${n(outcome.rawBar)}, clamp ${outcome.clampedBy}) — ${evidence}`;
48414
+ }
48415
+ //#endregion
48416
+ //#region src/pipeline-analytics/stillness-settings.ts
48417
+ /**
48418
+ * Per-device STILLNESS SCALE — the one number that says how fast a subject on
48419
+ * THIS camera has to move before it is travelling.
48420
+ *
48421
+ * A threshold belongs to whoever GENERATES the event, per device, never baked
48422
+ * into a classifier default. The judgement this feeds is
48423
+ * `StateAnalyzer.speedSaysSettled`; the measurement behind the number is
48424
+ * `pipeline/tracker/stillness-scale.ts`, which also carries the live
48425
+ * measurement showing why one number cannot serve a 30 m driveway and a 2 m
48426
+ * doorway.
48427
+ *
48428
+ * Cascade and shape mirror `stationary-settings` / `media-settings`: a
48429
+ * per-device override on top of the default, resolved per FIELD, and a parse
48430
+ * that never throws on a bad blob.
48431
+ *
48432
+ * **Absent behaves exactly as today.** The device store is a flat numeric blob
48433
+ * with no room for "unset", so the stored form of absent is `0` — and `0` is
48434
+ * not a bar anyone could want (it would mean nothing on this camera is ever
48435
+ * still) and not a value the calibration can produce (`minBar`). The decision
48436
+ * layer never sees it: {@link resolveStillSpeedFracPerSec} maps it to
48437
+ * `undefined`, and `undefined` is what leaves `StateAnalyzer` on its original
48438
+ * absolute `velocityThreshold` path.
48439
+ */
48440
+ var StillnessSettingsSchema = require_dist.object({
48441
+ /**
48442
+ * The camera's "not travelling" bar: centroid speed as a fraction of the
48443
+ * SUBJECT's own box diagonal, per second. `0` = not calibrated (see above).
48444
+ *
48445
+ * Measured p50 on the live hub 2026-09-19, for scale: 0.0136 (device 592,
48446
+ * close view, mostly parked subjects) to 0.3081 (device 615, far view, only
48447
+ * transits).
48448
+ */
48449
+ stillnessSpeedFracPerSec: require_dist.number().min(0).max(1).default(0),
48450
+ /**
48451
+ * When the number above was last CHOSEN — by a calibration run or by the
48452
+ * operator. `0` = never. Purely a readout; nothing gates on it.
48453
+ */
48454
+ stillnessCalibratedAt: require_dist.number().int().min(0).default(0),
48455
+ /**
48456
+ * What the last calibration run measured and decided, in one line
48457
+ * (`describeStillnessCalibration`) — including a REFUSAL and its named
48458
+ * reason, because "why is this camera still on the default" has to be
48459
+ * answerable without re-running anything.
48460
+ */
48461
+ stillnessCalibrationNote: require_dist.string().max(400).default("")
48462
+ });
48463
+ var STILLNESS_DEFAULTS = StillnessSettingsSchema.parse({});
48464
+ /**
48465
+ * Resolve a per-device store blob into typed stillness settings. Unknown or
48466
+ * invalid fields fall back to the default for that field — including a bar
48467
+ * outside the calibration's own rails, which is refused rather than clamped:
48468
+ * a number the calibration could never have produced is not evidence about
48469
+ * this camera, and silently bending it into range would present it as if it
48470
+ * were.
48471
+ */
48472
+ function resolveStillnessSettings(raw) {
48473
+ const pick = (key) => {
48474
+ const parsed = StillnessSettingsSchema.shape[key].safeParse(raw[key]);
48475
+ return parsed.success ? parsed.data : STILLNESS_DEFAULTS[key];
48476
+ };
48477
+ return {
48478
+ stillnessSpeedFracPerSec: pick("stillnessSpeedFracPerSec"),
48479
+ stillnessCalibratedAt: pick("stillnessCalibratedAt"),
48480
+ stillnessCalibrationNote: pick("stillnessCalibrationNote")
48481
+ };
48482
+ }
48483
+ /**
48484
+ * The bar the decision layer gets: a positive, in-rails number, or
48485
+ * `undefined` for "this camera is not calibrated — judge it exactly as
48486
+ * before". The ONE place the stored-zero convention is interpreted.
48487
+ */
48488
+ function resolveStillSpeedFracPerSec(settings) {
48489
+ const bar = settings.stillnessSpeedFracPerSec;
48490
+ if (!(bar >= .001) || bar > 1) return void 0;
48491
+ return bar;
48492
+ }
48493
+ //#endregion
48060
48494
  //#region src/pipeline-analytics/viewer-settings-legacy-adoption.ts
48061
48495
  /**
48062
48496
  * @durable class=config owner=pipeline-analytics
@@ -48765,6 +49199,298 @@ async function extractCropRaw(frameData, frameWidth, frameHeight, bbox) {
48765
49199
  };
48766
49200
  }
48767
49201
  //#endregion
49202
+ //#region src/pipeline-analytics/pipeline/bbox-pad.ts
49203
+ /**
49204
+ * Pad a normalized bounding box by a fractional amount relative to its own
49205
+ * dimensions. All coordinates remain in the [0, 1] unit-square.
49206
+ *
49207
+ * Formula (before clamping):
49208
+ * newX = x - padding * w
49209
+ * newW = w + 2 * padding * w (i.e. w * (1 + 2 * padding))
49210
+ * newY = y - padding * h
49211
+ * newH = h + 2 * padding * h
49212
+ *
49213
+ * padding === 0 is the identity operation. Pure, immutable — always returns a
49214
+ * new object.
49215
+ *
49216
+ * NOTE: clamping SHIFTS, it does not truncate — a window that overflows a
49217
+ * bound slides inward and keeps the extent that was asked for, on either side.
49218
+ * It shrinks only when the padded window is larger than the frame itself
49219
+ * would (e.g. a left-hugging box gains a bit more right-side context). This is
49220
+ * acceptable for detection crops (more context, never out of [0,1]); the
49221
+ * downstream `extractCrop` clamps to pixel bounds again regardless.
49222
+ */
49223
+ function padBbox(bbox, padding) {
49224
+ const rawX = bbox.x - padding * bbox.w;
49225
+ const rawY = bbox.y - padding * bbox.h;
49226
+ const rawW = bbox.w * (1 + 2 * padding);
49227
+ const rawH = bbox.h * (1 + 2 * padding);
49228
+ const w = Math.min(rawW, 1);
49229
+ const h = Math.min(rawH, 1);
49230
+ return {
49231
+ x: Math.min(Math.max(0, rawX), 1 - w),
49232
+ y: Math.min(Math.max(0, rawY), 1 - h),
49233
+ w,
49234
+ h
49235
+ };
49236
+ }
49237
+ /**
49238
+ * Legibility floor: the window's SHORT side is grown until it is at least this
49239
+ * fraction of the frame's short side.
49240
+ *
49241
+ * The 2026-09-18 traces included a 24x18 px subject. At the context ratio alone
49242
+ * that is a 72x54 window — and 72x54 source pixels are 72x54 delivered pixels,
49243
+ * because nothing in this repo may upscale. Growing the RECTANGLE is the only
49244
+ * honest way to make the picture bigger: more real pixels arrive, the subject
49245
+ * occupies less of the frame, and its drawn box says which part of it is the
49246
+ * subject. 0.18 of 1080 = 194 px, which renders a 24 px subject recognisably in
49247
+ * its surroundings.
49248
+ */
49249
+ var DEBUG_CROP_MIN_SIDE_FRAC = .18;
49250
+ function unionBox(a, b) {
49251
+ const x = Math.min(a.x, b.x);
49252
+ const y = Math.min(a.y, b.y);
49253
+ const right = Math.max(a.x + a.w, b.x + b.w);
49254
+ const bottom = Math.max(a.y + a.h, b.y + b.h);
49255
+ return {
49256
+ x,
49257
+ y,
49258
+ w: right - x,
49259
+ h: bottom - y
49260
+ };
49261
+ }
49262
+ /**
49263
+ * Which borders a box touches, on `edge-clear.ts`'s tolerance band — the SAME
49264
+ * band `isEdgeClear` uses, so "clipped" here and "not edge-clear" there can
49265
+ * never disagree about one frame.
49266
+ */
49267
+ function debugCropClipSides(bbox, frameWidth, frameHeight) {
49268
+ if (frameWidth <= 0 || frameHeight <= 0) return [];
49269
+ const tolX = DEFAULT_EDGE_TOLERANCE * frameWidth;
49270
+ const tolY = DEFAULT_EDGE_TOLERANCE * frameHeight;
49271
+ const sides = [];
49272
+ if (bbox.x <= tolX) sides.push("left");
49273
+ if (bbox.y <= tolY) sides.push("top");
49274
+ if (bbox.x + bbox.w >= frameWidth - tolX) sides.push("right");
49275
+ if (bbox.y + bbox.h >= frameHeight - tolY) sides.push("bottom");
49276
+ return sides;
49277
+ }
49278
+ /**
49279
+ * The ratio that makes `base` reach the legibility floor, or
49280
+ * {@link DEBUG_CROP_CONTEXT_RATIO} when it already does.
49281
+ *
49282
+ * Expressed as a PADDING RATIO rather than as a second rectangle so the window
49283
+ * still comes out of one {@link padBbox} call: there is exactly one place in
49284
+ * this file where a rectangle is grown, and it is not here.
49285
+ */
49286
+ function contextRatioFor(base, frameWidth, frameHeight) {
49287
+ const minSide = DEBUG_CROP_MIN_SIDE_FRAC * Math.min(frameWidth, frameHeight);
49288
+ const ratioFor = (extent) => extent <= 0 ? 1 : (minSide / extent - 1) / 2;
49289
+ const needed = Math.max(ratioFor(base.w), ratioFor(base.h));
49290
+ return Math.max(1, needed);
49291
+ }
49292
+ /**
49293
+ * The one debug-crop rectangle. Pure.
49294
+ *
49295
+ * A refusal is a VALUE, not a `null`: the caller logs the reason, because a
49296
+ * branch that stores nothing and says nothing reads as "never happened".
49297
+ */
49298
+ function planDebugCrop(input) {
49299
+ const { frameWidth, frameHeight, subject } = input;
49300
+ if (frameWidth <= 0 || frameHeight <= 0) return {
49301
+ kind: "refused",
49302
+ reason: "degenerate-frame"
49303
+ };
49304
+ if (subject.w <= 0 || subject.h <= 0) return {
49305
+ kind: "refused",
49306
+ reason: "degenerate-subject"
49307
+ };
49308
+ const base = input.rival === void 0 ? subject : unionBox(subject, input.rival);
49309
+ const contextRatio = contextRatioFor(base, frameWidth, frameHeight);
49310
+ const norm = padBbox({
49311
+ x: base.x / frameWidth,
49312
+ y: base.y / frameHeight,
49313
+ w: base.w / frameWidth,
49314
+ h: base.h / frameHeight
49315
+ }, contextRatio);
49316
+ const rect = {
49317
+ x: norm.x * frameWidth,
49318
+ y: norm.y * frameHeight,
49319
+ w: norm.w * frameWidth,
49320
+ h: norm.h * frameHeight
49321
+ };
49322
+ const relative = (role, box) => ({
49323
+ role,
49324
+ x: box.x - rect.x,
49325
+ y: box.y - rect.y,
49326
+ w: box.w,
49327
+ h: box.h
49328
+ });
49329
+ const boxes = input.rival === void 0 ? [relative("subject", subject)] : [relative("subject", subject), relative("rival", input.rival)];
49330
+ return {
49331
+ kind: "plan",
49332
+ decision: input.decision,
49333
+ rect,
49334
+ norm,
49335
+ boxes,
49336
+ contextRatio,
49337
+ floorApplied: contextRatio > 1,
49338
+ clippedSides: debugCropClipSides(subject, frameWidth, frameHeight),
49339
+ subjectCenterNorm: {
49340
+ x: (subject.x + subject.w / 2) / frameWidth,
49341
+ y: (subject.y + subject.h / 2) / frameHeight
49342
+ }
49343
+ };
49344
+ }
49345
+ function formatRect(rect) {
49346
+ return `${Math.round(rect.x)},${Math.round(rect.y)} ${Math.round(rect.w)}x${Math.round(rect.h)}`;
49347
+ }
49348
+ /**
49349
+ * The attribution bag: the deciding site's evidence FIRST, then the structural
49350
+ * facts — so no evidence key can rename the decision, the track or the
49351
+ * rectangle the crop belongs to. That ordering is the whole point: a crop whose
49352
+ * own label can be overwritten by a caller is back to being unattributable.
49353
+ */
49354
+ function debugCropMeta(plan, attribution) {
49355
+ return {
49356
+ ...attribution.evidence,
49357
+ decision: attribution.decision,
49358
+ trackId: attribution.trackId,
49359
+ ...attribution.rivalTrackId === void 0 ? {} : { rivalTrackId: attribution.rivalTrackId },
49360
+ timestamp: attribution.timestamp,
49361
+ cropRect: formatRect(plan.rect),
49362
+ contextRatio: plan.contextRatio,
49363
+ floorApplied: plan.floorApplied,
49364
+ clippedSides: plan.clippedSides.join("|"),
49365
+ subjectCenter: `${plan.subjectCenterNorm.x.toFixed(3)},${plan.subjectCenterNorm.y.toFixed(3)}`
49366
+ };
49367
+ }
49368
+ /**
49369
+ * The caption burnt into the crop's own pixels.
49370
+ *
49371
+ * Burnt in, not stored beside it, because a debug crop travels: it is exported,
49372
+ * pasted into a note, and looked at months later next to five others. A blob
49373
+ * that cannot say what it is, on its own, is the thing that made the last
49374
+ * investigation slow — and this store has no column to say it in.
49375
+ */
49376
+ function debugCropLabel(plan, attribution) {
49377
+ return `${`${attribution.decision} ${attribution.trackId}`}${attribution.rivalTrackId === void 0 ? "" : ` vs ${attribution.rivalTrackId}`}${plan.clippedSides.length === 0 ? "" : ` clipped:${plan.clippedSides.join("+")}`} ${Object.entries(attribution.evidence).map(([k, v]) => `${k}=${typeof v === "number" ? Math.round(v * 1e3) / 1e3 : String(v)}`).join(" ")}`.trim().slice(0, 160);
49378
+ }
49379
+ //#endregion
49380
+ //#region src/pipeline-analytics/pipeline/debug-crop-capture.ts
49381
+ /** Stroke colour of the box the decision was ABOUT. */
49382
+ var SUBJECT_COLOR = "#00e5ff";
49383
+ /** Stroke colour of the box it was weighed AGAINST — a different colour,
49384
+ * because "which one is which" is the first question a two-box crop raises. */
49385
+ var RIVAL_COLOR = "#ff9500";
49386
+ /** Encode quality: a debug crop is read by a person looking for a defect, so it
49387
+ * is encoded above the timeline-tile default (80) — a JPEG artefact that reads
49388
+ * as a mirror edge would waste the very investigation this exists for. */
49389
+ var DEBUG_CROP_QUALITY = 92;
49390
+ /** Bound the per-track counters so a long-lived process never leaks them. */
49391
+ var MAX_TRACKED_TRACKS = 2e3;
49392
+ function createDebugCropCapture(deps) {
49393
+ const storedPerTrack = /* @__PURE__ */ new Map();
49394
+ const cappedAnnounced = /* @__PURE__ */ new Set();
49395
+ const skip = (input, reason, extra) => {
49396
+ deps.logger.info("debug crop not stored", {
49397
+ tags: { deviceId: input.deviceId },
49398
+ meta: {
49399
+ reason,
49400
+ decision: input.decision,
49401
+ trackId: input.trackId,
49402
+ timestamp: input.timestamp,
49403
+ ...extra
49404
+ }
49405
+ });
49406
+ return null;
49407
+ };
49408
+ return async (input) => {
49409
+ if (!deps.isDebugMarked(input.trackId)) return null;
49410
+ const already = storedPerTrack.get(input.trackId) ?? 0;
49411
+ if (already >= 6) {
49412
+ if (cappedAnnounced.has(input.trackId)) return null;
49413
+ if (cappedAnnounced.size >= MAX_TRACKED_TRACKS) cappedAnnounced.clear();
49414
+ cappedAnnounced.add(input.trackId);
49415
+ return skip(input, "per-track-cap", {
49416
+ stored: already,
49417
+ cap: 6
49418
+ });
49419
+ }
49420
+ const plan = planDebugCrop({
49421
+ decision: input.decision,
49422
+ subject: input.subject,
49423
+ ...input.rival === void 0 ? {} : { rival: input.rival },
49424
+ frameWidth: input.frameWidth,
49425
+ frameHeight: input.frameHeight
49426
+ });
49427
+ if (plan.kind === "refused") return skip(input, plan.reason, {});
49428
+ const attribution = {
49429
+ decision: input.decision,
49430
+ trackId: input.trackId,
49431
+ ...input.rivalTrackId === void 0 ? {} : { rivalTrackId: input.rivalTrackId },
49432
+ timestamp: input.timestamp,
49433
+ evidence: input.evidence
49434
+ };
49435
+ const meta = debugCropMeta(plan, attribution);
49436
+ const frame = await deps.fetchFrameRgb(input.frameHandle, input.deviceId, input.trackId);
49437
+ if (frame === null || frame.width <= 0 || frame.height <= 0) return skip(input, "frame-unavailable", { nodeId: input.frameHandle.nodeId });
49438
+ let jpeg;
49439
+ try {
49440
+ const cut = await deps.extractCropRaw(frame.bytes, frame.width, frame.height, plan.norm);
49441
+ const scale = plan.rect.w > 0 ? cut.width / plan.rect.w : 1;
49442
+ const boxes = plan.boxes.map((b) => {
49443
+ const geometry = {
49444
+ x: b.x * scale,
49445
+ y: b.y * scale,
49446
+ w: b.w * scale,
49447
+ h: b.h * scale
49448
+ };
49449
+ return b.role === "subject" ? {
49450
+ ...geometry,
49451
+ color: SUBJECT_COLOR,
49452
+ label: debugCropLabel(plan, attribution)
49453
+ } : {
49454
+ ...geometry,
49455
+ color: RIVAL_COLOR,
49456
+ label: "rival"
49457
+ };
49458
+ });
49459
+ jpeg = await deps.drawBoxedFrame(cut.data, cut.width, cut.height, boxes, { quality: DEBUG_CROP_QUALITY });
49460
+ } catch (err) {
49461
+ return skip(input, "render-failed", { error: err instanceof Error ? err.message : String(err) });
49462
+ }
49463
+ try {
49464
+ const mediaId = await deps.putDebugCrop({
49465
+ deviceId: input.deviceId,
49466
+ trackId: input.trackId,
49467
+ timestamp: input.timestamp,
49468
+ data: jpeg
49469
+ });
49470
+ if (storedPerTrack.size >= MAX_TRACKED_TRACKS) storedPerTrack.clear();
49471
+ storedPerTrack.set(input.trackId, already + 1);
49472
+ deps.logger.info("debug crop stored", {
49473
+ tags: { deviceId: input.deviceId },
49474
+ meta: {
49475
+ ...meta,
49476
+ mediaId,
49477
+ deviceId: input.deviceId
49478
+ }
49479
+ });
49480
+ return mediaId;
49481
+ } catch (err) {
49482
+ deps.logger.warn("debug crop store failed", {
49483
+ tags: { deviceId: input.deviceId },
49484
+ meta: {
49485
+ ...meta,
49486
+ error: err instanceof Error ? err.message : String(err)
49487
+ }
49488
+ });
49489
+ return null;
49490
+ }
49491
+ };
49492
+ }
49493
+ //#endregion
48768
49494
  //#region src/shared/frame/encode-crop.ts
48769
49495
  /**
48770
49496
  * JPEG-encode an already-cropped raw RGB (24-bit) buffer. Used for the
@@ -53556,42 +54282,6 @@ var AnalyticsFailureReport = class {
53556
54282
  }
53557
54283
  };
53558
54284
  //#endregion
53559
- //#region src/pipeline-analytics/pipeline/bbox-pad.ts
53560
- /**
53561
- * Pad a normalized bounding box by a fractional amount relative to its own
53562
- * dimensions. All coordinates remain in the [0, 1] unit-square.
53563
- *
53564
- * Formula (before clamping):
53565
- * newX = x - padding * w
53566
- * newW = w + 2 * padding * w (i.e. w * (1 + 2 * padding))
53567
- * newY = y - padding * h
53568
- * newH = h + 2 * padding * h
53569
- *
53570
- * padding === 0 is the identity operation. Pure, immutable — always returns a
53571
- * new object.
53572
- *
53573
- * NOTE: clamping SHIFTS, it does not truncate — a window that overflows a
53574
- * bound slides inward and keeps the extent that was asked for, on either side.
53575
- * It shrinks only when the padded window is larger than the frame itself
53576
- * would (e.g. a left-hugging box gains a bit more right-side context). This is
53577
- * acceptable for detection crops (more context, never out of [0,1]); the
53578
- * downstream `extractCrop` clamps to pixel bounds again regardless.
53579
- */
53580
- function padBbox(bbox, padding) {
53581
- const rawX = bbox.x - padding * bbox.w;
53582
- const rawY = bbox.y - padding * bbox.h;
53583
- const rawW = bbox.w * (1 + 2 * padding);
53584
- const rawH = bbox.h * (1 + 2 * padding);
53585
- const w = Math.min(rawW, 1);
53586
- const h = Math.min(rawH, 1);
53587
- return {
53588
- x: Math.min(Math.max(0, rawX), 1 - w),
53589
- y: Math.min(Math.max(0, rawY), 1 - h),
53590
- w,
53591
- h
53592
- };
53593
- }
53594
- //#endregion
53595
54285
  //#region src/pipeline-analytics/pipeline/capture-crop.ts
53596
54286
  /**
53597
54287
  * The normalised window a capture asks the runner for: the box padded by
@@ -71417,6 +72107,56 @@ var SyntheticTrackMaterializer = class {
71417
72107
  return track;
71418
72108
  }
71419
72109
  };
72110
+ //#endregion
72111
+ //#region src/pipeline-analytics/stationary-settings.ts
72112
+ /**
72113
+ * Per-device stationary-object (parked/idle suppression) settings. Cascade: a
72114
+ * per-device override on top of the global default, resolved per field (an
72115
+ * invalid/missing value falls back to its default — parse never throws).
72116
+ * Mirrors `media-settings` / `tracking-settings`.
72117
+ *
72118
+ * The defaults are the SAME constants the registry uses at runtime
72119
+ * (`stationary-types.ts`), imported (not copied) so an unset value and a reset
72120
+ * value both resolve to exactly today's behaviour — "reset == unset == today",
72121
+ * with no drift. Guarded by a unit test asserting the equality.
72122
+ */
72123
+ var StationarySettingsSchema = require_dist.object({
72124
+ /** Master switch. Off ⇒ this camera gathers no evidence and suppresses
72125
+ * nothing (every parked object keeps spawning normal tracks). */
72126
+ enabled: require_dist.boolean().default(true),
72127
+ /** IoU at/above which a detection is the same parked object, unmoved →
72128
+ * suppress its spawn. `SUPPRESS_IOU`. */
72129
+ suppressIou: require_dist.number().min(.3).max(.9).default(SUPPRESS_IOU),
72130
+ /** Minimum IoU for a detection to be ASSOCIATED with an entry (confirm or
72131
+ * wake it) — the overlap gate that stops a different vehicle from waking a
72132
+ * parked entry (the 617 flood fix). `WAKE_ASSOC_IOU`. */
72133
+ wakeAssocIou: require_dist.number().min(.02).max(.5).default(WAKE_ASSOC_IOU),
72134
+ /** Recent-window stillness after which a track stops holding its motion
72135
+ * session open (also the minimum track age). Creates nothing — birth is
72136
+ * decided by repeated evidence. `SESSION_STILLNESS_WINDOW_MS`. */
72137
+ sessionStillnessWindowMs: require_dist.number().int().min(5e3).max(12e4).default(SESSION_STILLNESS_WINDOW_MS),
72138
+ /** Observed-time TTL: retire an entry unconfirmed for this long (measured on
72139
+ * frames-flowing time, not wall-clock). `ENTRY_TTL_MS`. */
72140
+ entryTtlMs: require_dist.number().int().min(6e4).max(18e5).default(ENTRY_TTL_MS)
72141
+ });
72142
+ var STATIONARY_DEFAULTS = StationarySettingsSchema.parse({});
72143
+ /**
72144
+ * Resolve a per-device store blob into typed stationary settings. Unknown/invalid
72145
+ * fields fall back to the default for that field (never throws on a bad blob).
72146
+ */
72147
+ function resolveStationarySettings(raw) {
72148
+ const pick = (key) => {
72149
+ const parsed = StationarySettingsSchema.shape[key].safeParse(raw[key]);
72150
+ return parsed.success ? parsed.data : STATIONARY_DEFAULTS[key];
72151
+ };
72152
+ return {
72153
+ enabled: pick("enabled"),
72154
+ suppressIou: pick("suppressIou"),
72155
+ wakeAssocIou: pick("wakeAssocIou"),
72156
+ sessionStillnessWindowMs: pick("sessionStillnessWindowMs"),
72157
+ entryTtlMs: pick("entryTtlMs")
72158
+ };
72159
+ }
71420
72160
  var TrackingSettingsSchema = require_dist.object({
71421
72161
  /**
71422
72162
  * How much of a detection's box must lie inside a zone (0-1 fraction of the
@@ -71617,56 +72357,6 @@ function resolveTrackingSettings(raw) {
71617
72357
  };
71618
72358
  }
71619
72359
  //#endregion
71620
- //#region src/pipeline-analytics/stationary-settings.ts
71621
- /**
71622
- * Per-device stationary-object (parked/idle suppression) settings. Cascade: a
71623
- * per-device override on top of the global default, resolved per field (an
71624
- * invalid/missing value falls back to its default — parse never throws).
71625
- * Mirrors `media-settings` / `tracking-settings`.
71626
- *
71627
- * The defaults are the SAME constants the registry uses at runtime
71628
- * (`stationary-types.ts`), imported (not copied) so an unset value and a reset
71629
- * value both resolve to exactly today's behaviour — "reset == unset == today",
71630
- * with no drift. Guarded by a unit test asserting the equality.
71631
- */
71632
- var StationarySettingsSchema = require_dist.object({
71633
- /** Master switch. Off ⇒ this camera gathers no evidence and suppresses
71634
- * nothing (every parked object keeps spawning normal tracks). */
71635
- enabled: require_dist.boolean().default(true),
71636
- /** IoU at/above which a detection is the same parked object, unmoved →
71637
- * suppress its spawn. `SUPPRESS_IOU`. */
71638
- suppressIou: require_dist.number().min(.3).max(.9).default(SUPPRESS_IOU),
71639
- /** Minimum IoU for a detection to be ASSOCIATED with an entry (confirm or
71640
- * wake it) — the overlap gate that stops a different vehicle from waking a
71641
- * parked entry (the 617 flood fix). `WAKE_ASSOC_IOU`. */
71642
- wakeAssocIou: require_dist.number().min(.02).max(.5).default(WAKE_ASSOC_IOU),
71643
- /** Recent-window stillness after which a track stops holding its motion
71644
- * session open (also the minimum track age). Creates nothing — birth is
71645
- * decided by repeated evidence. `SESSION_STILLNESS_WINDOW_MS`. */
71646
- sessionStillnessWindowMs: require_dist.number().int().min(5e3).max(12e4).default(SESSION_STILLNESS_WINDOW_MS),
71647
- /** Observed-time TTL: retire an entry unconfirmed for this long (measured on
71648
- * frames-flowing time, not wall-clock). `ENTRY_TTL_MS`. */
71649
- entryTtlMs: require_dist.number().int().min(6e4).max(18e5).default(ENTRY_TTL_MS)
71650
- });
71651
- var STATIONARY_DEFAULTS = StationarySettingsSchema.parse({});
71652
- /**
71653
- * Resolve a per-device store blob into typed stationary settings. Unknown/invalid
71654
- * fields fall back to the default for that field (never throws on a bad blob).
71655
- */
71656
- function resolveStationarySettings(raw) {
71657
- const pick = (key) => {
71658
- const parsed = StationarySettingsSchema.shape[key].safeParse(raw[key]);
71659
- return parsed.success ? parsed.data : STATIONARY_DEFAULTS[key];
71660
- };
71661
- return {
71662
- enabled: pick("enabled"),
71663
- suppressIou: pick("suppressIou"),
71664
- wakeAssocIou: pick("wakeAssocIou"),
71665
- sessionStillnessWindowMs: pick("sessionStillnessWindowMs"),
71666
- entryTtlMs: pick("entryTtlMs")
71667
- };
71668
- }
71669
- //#endregion
71670
72360
  //#region src/pipeline-analytics/settings-schema-sections.ts
71671
72361
  /** The detection-knob sections, in their original in-form order. */
71672
72362
  function buildDetectionSettingsSections() {
@@ -72212,10 +72902,45 @@ function buildDetectionSettingsSections() {
72212
72902
  displayScale: 1e3
72213
72903
  }
72214
72904
  ]
72215
- }
72905
+ },
72906
+ buildStillnessScaleSection()
72216
72907
  ];
72217
72908
  }
72218
72909
  /**
72910
+ * The per-camera STILLNESS SCALE — one measured number plus its provenance.
72911
+ *
72912
+ * Its own section rather than a field on `detection-sensitivity` because the
72913
+ * two answer different questions: `stationaryThresholdSec` is HOW LONG a
72914
+ * settled subject must stay settled, which is an operator preference; this is
72915
+ * HOW SLOW counts as settled, which is a fact about the camera and is
72916
+ * measured. Mixing them is how a measurement ends up being guessed at.
72917
+ */
72918
+ function buildStillnessScaleSection() {
72919
+ return {
72920
+ id: "stillness-scale",
72921
+ title: "Stillness scale (per camera)",
72922
+ description: "How slowly a subject has to move on THIS camera before it counts as settled. A camera looking down a 30 m driveway and one watching a doorway two metres away disagree about this by more than an order of magnitude, so one fleet-wide number cannot be right for both. Leave it at 0 and the camera behaves exactly as it always has (a flat 2 px-per-update bar). Run the `stillness.calibrate` action to measure it from the camera’s own recorded tracks — the run reports what it measured and REFUSES, with a reason, when the evidence is too thin.",
72923
+ columns: 1,
72924
+ fields: [{
72925
+ type: "number",
72926
+ key: "stillnessSpeedFracPerSec",
72927
+ label: "Stillness speed bar",
72928
+ description: "Centroid speed, as a fraction of the subject’s OWN box diagonal, per second, at or below which the subject is not travelling. Subject-relative, so perspective is already divided out; per second, so an irregular on-motion cadence cannot masquerade as speed. 0 = not calibrated (the shipped absolute bar decides, exactly as before). Live reference: 0.0136 on a close, mostly-parked view; 0.3081 on a far view whose subjects only transit.",
72929
+ min: 0,
72930
+ max: 1,
72931
+ step: .001,
72932
+ default: STILLNESS_DEFAULTS.stillnessSpeedFracPerSec
72933
+ }, {
72934
+ type: "text",
72935
+ key: "stillnessCalibrationNote",
72936
+ label: "Last calibration",
72937
+ description: "What the last run measured, from how many samples and tracks, and what it chose — or which named reason it refused for. Display only.",
72938
+ default: STILLNESS_DEFAULTS.stillnessCalibrationNote,
72939
+ readonlyField: true
72940
+ }]
72941
+ };
72942
+ }
72943
+ /**
72219
72944
  * Re-home the global analytics sections into the per-device `Analytics`
72220
72945
  * top-tab. Every section defaults to the `Analytics` tab (so the
72221
72946
  * device-manager aggregator groups them) AND is marked
@@ -72241,6 +72966,7 @@ function toAnalyticsDeviceSections(sections) {
72241
72966
  * - `tracking` — the tracker-tuning form incl. the FP knobs (dedup, person↔
72242
72967
  * animal dedup, class voting, confirm-bypass, per-class min score).
72243
72968
  * - `stationary-objects` — stationary promotion + occupancy tuning.
72969
+ * - `stillness-scale` — the per-camera measured "how slow is settled" bar.
72244
72970
  * `DeviceDetail` folds `tab: 'detection-pipeline'` top-tab sections into the
72245
72971
  * structural Detection pipeline tab, so re-tagging is all that's needed —
72246
72972
  * there is no admin-ui change and no duplicate render (a section has one tab).
@@ -72249,7 +72975,8 @@ var DETECTION_PIPELINE_SECTION_IDS = new Set([
72249
72975
  "detection-sensitivity",
72250
72976
  "confirmation-gate",
72251
72977
  "tracking",
72252
- "stationary-objects"
72978
+ "stationary-objects",
72979
+ "stillness-scale"
72253
72980
  ]);
72254
72981
  /**
72255
72982
  * Re-home the detection-knob sections from the `Analytics` top-tab onto the
@@ -75981,9 +76708,23 @@ var customActions = {
75981
76708
  ...locationDiscardActions,
75982
76709
  ...viewerSettingsActions,
75983
76710
  ...photoEnrollActions,
75984
- ...faceRescoreActions
76711
+ ...faceRescoreActions,
76712
+ ...stillnessCalibrationActions
75985
76713
  };
75986
76714
  /**
76715
+ * Width cap on the ONE full frame a debug crop is cut from.
76716
+ *
76717
+ * A debug crop is a picture for a person, and the plan has already widened the
76718
+ * window to a neighbourhood — past this width the extra pixels buy nothing and
76719
+ * a 4K native surface would cross the transport for every marked track.
76720
+ */
76721
+ var DEBUG_CROP_FETCH_MAX_WIDTH = 1280;
76722
+ /** How long a queued debug-crop frame fetch is still worth executing. Past
76723
+ * this the handle's frame has aged out of the runner's retention and the next
76724
+ * decision will ask again with a young one, so the queued entry is dead
76725
+ * weight in a lane real media is waiting in. */
76726
+ var DEBUG_CROP_FETCH_STALE_MS = 2e3;
76727
+ /**
75987
76728
  * Assist threshold when the caller does not pin one. Packages have their own
75988
76729
  * (0.35, on the zone crop only — `retrain/retrain-assist.ts`); everything else
75989
76730
  * starts here and the operator drops the ones they do not want.
@@ -76808,6 +77549,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
76808
77549
  * the frame path like the stationary one and a cold miss answers the
76809
77550
  * measured default. */
76810
77551
  summarySettingsCache = this.settingsCaches.registerDeviceCache("summary", SETTINGS_CACHE_TTL_MS);
77552
+ /** Per-device STILLNESS SCALE cache — the measured "how slow is settled" bar
77553
+ * (`stillness-settings.ts`). Read once per FrameProcessor build, so this is
77554
+ * a cold-path cache; it exists so a calibration applied through
77555
+ * `stillness.calibrate` reaches the tracker on the next processor rebuild
77556
+ * rather than on the next addon restart. */
77557
+ stillnessSettingsCache = this.settingsCaches.registerDeviceCache("stillness", SETTINGS_CACHE_TTL_MS);
76811
77558
  /** Turns stationary appear/depart into package-delivered/picked-up events. */
76812
77559
  packageDropDetector = null;
76813
77560
  /**
@@ -77017,7 +77764,51 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
77017
77764
  * `docs/decisions/` — the gate is a model input, so this is a deliberate
77018
77765
  * exception to "model-input crops keep captureCrop".
77019
77766
  */
77767
+ /**
77768
+ * The operator-debug crop capture (`pipeline/debug-crop-capture.ts`).
77769
+ *
77770
+ * Null until the transport and the media store exist. Reached from three
77771
+ * decision sites and gated INSIDE the capture on the track's `debug` flag, so
77772
+ * a camera with no marked track pays one map lookup per decision — see the
77773
+ * capture's module doc for why the gate lives there and not at each site.
77774
+ */
77775
+ captureDebugCropFn = null;
77020
77776
  captureDisplayCropFn = null;
77777
+ /**
77778
+ * Offer one decision's pixels to the operator-debug crop capture.
77779
+ *
77780
+ * Fire-and-forget and NEVER throws: this is the addon's own bookkeeping, and
77781
+ * a frame path that can be slowed or broken by a diagnostic is worse than no
77782
+ * diagnostic. The gate, the per-track cap and all six refusal reasons live in
77783
+ * the capture (`pipeline/debug-crop-capture.ts`); this is only the seam.
77784
+ */
77785
+ /**
77786
+ * The CURRENT frame's handle and dims, per camera — the one thing the
77787
+ * salvage-bind observer lacks.
77788
+ *
77789
+ * The tracker knows no `deviceId`, holds no handle and writes no media (that
77790
+ * is the point of `pipeline/tracker/**`), so a growth bind reports its two
77791
+ * boxes and stops. This mirror is what lets the host put those boxes back on
77792
+ * a picture. Written ONLY while some track on this runner is marked
77793
+ * (`residents.anyDebugMarked()`), so an ordinary fleet does not allocate one
77794
+ * object per frame per camera for a diagnostic nobody asked for.
77795
+ */
77796
+ debugFrameContext = /* @__PURE__ */ new Map();
77797
+ offerDebugCrop(input) {
77798
+ const capture = this.captureDebugCropFn;
77799
+ if (capture === null) return;
77800
+ capture(input).catch((err) => {
77801
+ this.ctx.logger.debug("debug crop offer threw", {
77802
+ tags: { deviceId: input.deviceId },
77803
+ meta: {
77804
+ deviceId: input.deviceId,
77805
+ trackId: input.trackId,
77806
+ decision: input.decision,
77807
+ error: err instanceof Error ? err.message : String(err)
77808
+ }
77809
+ });
77810
+ });
77811
+ }
77021
77812
  captureCrop = null;
77022
77813
  /** Full NATIVE frame by handle (downscaled worker-side to `maxWidth`), routed
77023
77814
  * to the owning runner, PLUS the source `tier`. Captured in the async init
@@ -77382,6 +78173,36 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
77382
78173
  isNodeOffline: (nodeId) => this.nodeLiveness.isKnownOffline(nodeId, Date.now())
77383
78174
  });
77384
78175
  this.captureCrop = transport.captureCrop;
78176
+ const fetchDebugFullFrame = transport.getNativeFullFrameRgb;
78177
+ this.captureDebugCropFn = createDebugCropCapture({
78178
+ isDebugMarked: (trackId) => this.residents.isDebugMarked(trackId),
78179
+ fetchFrameRgb: async (handle, deviceId, trackId) => {
78180
+ const frame = await this.captureScheduler.request({
78181
+ deviceId,
78182
+ trackId,
78183
+ kind: "other",
78184
+ coalesceKey: captureCoalesceKey(deviceId, "other", trackId),
78185
+ staleAfterMs: DEBUG_CROP_FETCH_STALE_MS,
78186
+ exec: () => fetchDebugFullFrame(handle, DEBUG_CROP_FETCH_MAX_WIDTH)
78187
+ });
78188
+ return frame === null || frame === void 0 ? null : {
78189
+ bytes: frame.data,
78190
+ width: frame.width,
78191
+ height: frame.height
78192
+ };
78193
+ },
78194
+ extractCropRaw: (data, w, h, box) => extractCropRaw(data, w, h, box),
78195
+ drawBoxedFrame: (data, w, h, boxes, options) => drawBoxedFrame(data, w, h, boxes, options ?? {}),
78196
+ putDebugCrop: (put) => stores.mediaStore.put({
78197
+ deviceId: put.deviceId,
78198
+ ownerKind: "track",
78199
+ ownerId: put.trackId,
78200
+ kind: "debugCrop",
78201
+ timestamp: put.timestamp,
78202
+ data: put.data
78203
+ }),
78204
+ logger: logger.child("DebugCrop")
78205
+ });
77385
78206
  this.eventMediaDispatcher = new EventMediaDispatcher({
77386
78207
  getRemoteFrame: transport.getRemoteFrame,
77387
78208
  getNativeCropJpeg: transport.getNativeCropJpeg,
@@ -77438,7 +78259,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
77438
78259
  ...locationDiscardActions,
77439
78260
  ...viewerSettingsActions,
77440
78261
  ...photoEnrollActions,
77441
- ...faceRescoreActions
78262
+ ...faceRescoreActions,
78263
+ ...stillnessCalibrationActions
77442
78264
  },
77443
78265
  actionHandlers: {
77444
78266
  ...ncHandlers,
@@ -77446,6 +78268,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
77446
78268
  ...this.buildDebugActionHandlers(),
77447
78269
  ...this.buildPhotoEnrollActionHandlers(),
77448
78270
  ...this.buildFaceRescoreActionHandlers(),
78271
+ ...this.buildStillnessCalibrationActionHandlers(),
77449
78272
  ...this.viewerSettingsSnapshots ? makeViewerSettingsActionHandlers(this.viewerSettingsSnapshots) : {}
77450
78273
  }
77451
78274
  } : {}
@@ -80382,6 +81205,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
80382
81205
  frameHeight: result.frameHeight
80383
81206
  }, exhaustedIds);
80384
81207
  const outcome = run.outcome;
81208
+ if (frameHandle !== void 0 && this.residents.anyDebugMarked()) this.debugFrameContext.set(deviceId, {
81209
+ handle: frameHandle,
81210
+ width: result.frameWidth,
81211
+ height: result.frameHeight,
81212
+ timestamp: result.timestamp
81213
+ });
80385
81214
  const gateNowMs = Date.now();
80386
81215
  for (const { id, t } of bornCandidates) {
80387
81216
  if (outcome.undecided.has(id)) {
@@ -80440,6 +81269,32 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
80440
81269
  error: String(err)
80441
81270
  } });
80442
81271
  });
81272
+ if (frameHandle !== void 0) {
81273
+ const decision = run.decisions.get(id);
81274
+ this.offerDebugCrop({
81275
+ deviceId,
81276
+ trackId: id,
81277
+ decision: "birth-confirmation",
81278
+ subject: {
81279
+ x: t.bbox.x,
81280
+ y: t.bbox.y,
81281
+ w: t.bbox.w,
81282
+ h: t.bbox.h
81283
+ },
81284
+ frameHandle,
81285
+ frameWidth: result.frameWidth,
81286
+ frameHeight: result.frameHeight,
81287
+ timestamp: gateNowMs,
81288
+ evidence: {
81289
+ className: t.className,
81290
+ source,
81291
+ verdict: "suppression-held",
81292
+ ...decision?.cropSource !== void 0 ? { cropSource: decision.cropSource } : {},
81293
+ ...decision?.bestScore !== void 0 ? { bestScore: decision.bestScore } : {},
81294
+ judgedCropKept: judged !== void 0
81295
+ }
81296
+ });
81297
+ }
80443
81298
  log.info("birth suppression held — the gate is not this root detector", {
80444
81299
  tags: { deviceId },
80445
81300
  meta: {
@@ -81397,6 +82252,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
81397
82252
  async resolveDeviceStationarySettings(deviceId) {
81398
82253
  return this.stationarySettingsCache.get(deviceId, (id) => this.readDeviceSettings(id, resolveStationarySettings));
81399
82254
  }
82255
+ /** Per-device stillness scale. An absent/zero bar resolves to `undefined`
82256
+ * ({@link resolveStillSpeedFracPerSec}) and leaves the state analyzer on
82257
+ * its original absolute threshold — uncalibrated is exactly today. */
82258
+ async resolveDeviceStillnessSettings(deviceId) {
82259
+ return this.stillnessSettingsCache.get(deviceId, (id) => this.readDeviceSettings(id, resolveStillnessSettings));
82260
+ }
81400
82261
  /**
81401
82262
  * Count SHORT + MOTIONLESS track closes per frame cell.
81402
82263
  *
@@ -82468,6 +83329,32 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
82468
83329
  meta: { ...bestShotPromotionMeta(promotionAttempt) }
82469
83330
  });
82470
83331
  }
83332
+ if (frameHandle !== void 0) {
83333
+ const heldBbox = this.residents.bestSeenBbox(t.trackId);
83334
+ const rival = heldBbox !== void 0 && heldBbox.frameWidth === frameWidth && heldBbox.frameHeight === frameHeight ? {
83335
+ x: heldBbox.x,
83336
+ y: heldBbox.y,
83337
+ w: heldBbox.w,
83338
+ h: heldBbox.h
83339
+ } : void 0;
83340
+ this.offerDebugCrop({
83341
+ deviceId,
83342
+ trackId: t.trackId,
83343
+ decision: "best-shot",
83344
+ subject: {
83345
+ x: t.bbox.x,
83346
+ y: t.bbox.y,
83347
+ w: t.bbox.w,
83348
+ h: t.bbox.h
83349
+ },
83350
+ ...rival !== void 0 ? { rival } : {},
83351
+ frameHandle,
83352
+ frameWidth,
83353
+ frameHeight,
83354
+ timestamp,
83355
+ evidence: { ...bestShotPromotionMeta(promotionAttempt) }
83356
+ });
83357
+ }
82471
83358
  if (!plan.appendSnapshot && !rollingLastFrame && !bestThumbnail && keyFrame === "skip") continue;
82472
83359
  targets.push({
82473
83360
  trackId: t.trackId,
@@ -83767,6 +84654,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
83767
84654
  if (!p) {
83768
84655
  const { minHits, stationaryThresholdSec, minTrackAge, cooldownSec } = await this.resolveDeviceDetectionSensitivitySettings(deviceId);
83769
84656
  const trk = await this.resolveDeviceTrackingSettings(deviceId);
84657
+ const stillSpeedFracPerSec = resolveStillSpeedFracPerSec(await this.resolveDeviceStillnessSettings(deviceId));
83770
84658
  p = new FrameProcessor(deviceId, {
83771
84659
  minHits,
83772
84660
  iouThreshold: trk.iouThreshold,
@@ -83808,7 +84696,10 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
83808
84696
  vehicle: trk.minSpawnAreaFracVehicle
83809
84697
  },
83810
84698
  classGroupAssoc: trk.classGroupAssoc
83811
- }, { stationaryThresholdSec }, {
84699
+ }, {
84700
+ stationaryThresholdSec,
84701
+ ...stillSpeedFracPerSec !== void 0 ? { stillSpeedFracPerSec } : {}
84702
+ }, {
83812
84703
  minTrackAge,
83813
84704
  cooldownSec,
83814
84705
  minTrackAgeMs: trk.minTrackAgeMs
@@ -83821,6 +84712,33 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
83821
84712
  ...record
83822
84713
  }
83823
84714
  });
84715
+ if (record.gate !== "growth") return;
84716
+ const frame = this.debugFrameContext.get(deviceId);
84717
+ if (frame === void 0 || frame.width !== record.frameWidth) return;
84718
+ this.offerDebugCrop({
84719
+ deviceId,
84720
+ trackId: record.trackId,
84721
+ decision: "growth-rescue",
84722
+ subject: record.trackBbox,
84723
+ rival: record.detBbox,
84724
+ frameHandle: frame.handle,
84725
+ frameWidth: record.frameWidth,
84726
+ frameHeight: record.frameHeight,
84727
+ timestamp: frame.timestamp,
84728
+ evidence: {
84729
+ stage: record.stage,
84730
+ trackClass: record.trackClass,
84731
+ detClass: record.detClass,
84732
+ detScore: record.detScore,
84733
+ iou: record.iou,
84734
+ centroidDistPx: record.centroidDistPx,
84735
+ trackDiagPx: record.trackDiagPx,
84736
+ areaRatio: record.areaRatio,
84737
+ dtMs: record.dtMs,
84738
+ trackAgeMs: record.trackAgeMs,
84739
+ source
84740
+ }
84741
+ });
83824
84742
  });
83825
84743
  p.setSpawnRefusalObserver((record) => {
83826
84744
  this.ctx.logger.info("tracker: detection dropped — nothing downstream received it", {
@@ -85503,6 +86421,109 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
85503
86421
  buildFaceRescoreActionHandlers() {
85504
86422
  return { "face.rescoreTrack": (input) => this.rescoreFaceForTrack(input) };
85505
86423
  }
86424
+ buildStillnessCalibrationActionHandlers() {
86425
+ return { "stillness.calibrate": (input) => this.calibrateStillnessForDevice(input) };
86426
+ }
86427
+ /**
86428
+ * Measure ONE camera's stillness scale from its own recorded tracks.
86429
+ *
86430
+ * Cost-bounded by construction: one `queryRecent` page of at most
86431
+ * `maxTracks` rows over the requested window, `projection: 'full'` because
86432
+ * the trajectory IS the measurement, and nothing else is read. The arithmetic
86433
+ * is `stillness-scale.ts` and is pure — this method only fetches, reports and
86434
+ * (on `apply`) writes.
86435
+ *
86436
+ * A REFUSAL never writes the bar. It does write the note, because "why is
86437
+ * this camera still on the default" has to be answerable from the form
86438
+ * rather than from a toast nobody kept.
86439
+ */
86440
+ async calibrateStillnessForDevice(input) {
86441
+ const trackStore = this.trackStore;
86442
+ if (!trackStore) throw new Error("stillness.calibrate: the track store is not ready");
86443
+ const until = Date.now();
86444
+ const since = until - Math.round(input.sinceHours * 36e5);
86445
+ const limits = {
86446
+ ...DEFAULT_STILLNESS_CALIBRATION_LIMITS,
86447
+ maxTracks: input.maxTracks
86448
+ };
86449
+ const previous = resolveStillSpeedFracPerSec(await this.resolveDeviceStillnessSettings(input.deviceId));
86450
+ const samples = collectStillnessSamples((await trackStore.queryRecent({
86451
+ deviceIds: [input.deviceId],
86452
+ since,
86453
+ until,
86454
+ limit: limits.maxTracks,
86455
+ projection: "full"
86456
+ })).tracks.map((t) => ({
86457
+ trackId: t.trackId,
86458
+ className: t.className,
86459
+ positions: t.positions.map((pos) => ({
86460
+ x: pos.x,
86461
+ y: pos.y,
86462
+ timestamp: pos.timestamp,
86463
+ bbox: {
86464
+ w: pos.bbox.w,
86465
+ h: pos.bbox.h
86466
+ }
86467
+ }))
86468
+ })), limits);
86469
+ const outcome = calibrateStillnessSpeed(samples, limits);
86470
+ const note = describeStillnessCalibration(outcome);
86471
+ const finite = (v) => Number.isFinite(v) ? v : null;
86472
+ const measurement = {
86473
+ trackCount: outcome.measurement.trackCount,
86474
+ tracksRead: samples.tracksRead,
86475
+ sampleCount: outcome.measurement.sampleCount,
86476
+ p25: finite(outcome.measurement.p25),
86477
+ p50: finite(outcome.measurement.p50),
86478
+ p90: finite(outcome.measurement.p90),
86479
+ medianIntervalSec: finite(outcome.measurement.medianIntervalSec),
86480
+ medianSubjectDiagonalPx: finite(outcome.measurement.medianSubjectDiagonalPx)
86481
+ };
86482
+ let applied = false;
86483
+ if (input.apply) {
86484
+ const patch = {
86485
+ stillnessCalibrationNote: note,
86486
+ stillnessCalibratedAt: until
86487
+ };
86488
+ if (outcome.ok) patch["stillnessSpeedFracPerSec"] = outcome.bar;
86489
+ await this.applyDeviceSettingsPatch({
86490
+ deviceId: input.deviceId,
86491
+ patch
86492
+ });
86493
+ applied = outcome.ok;
86494
+ }
86495
+ this.ctx.logger.info("stillness calibration", {
86496
+ tags: { deviceId: input.deviceId },
86497
+ meta: {
86498
+ accepted: outcome.ok,
86499
+ ...outcome.ok ? {} : { reason: outcome.reason },
86500
+ ...outcome.ok ? {
86501
+ bar: outcome.bar,
86502
+ clampedBy: outcome.clampedBy
86503
+ } : {},
86504
+ previousBar: previous ?? null,
86505
+ applied,
86506
+ windowHours: input.sinceHours,
86507
+ ...measurement
86508
+ }
86509
+ });
86510
+ return {
86511
+ deviceId: input.deviceId,
86512
+ since,
86513
+ until,
86514
+ accepted: outcome.ok,
86515
+ ...outcome.ok ? {} : { reason: outcome.reason },
86516
+ ...outcome.ok ? {
86517
+ bar: outcome.bar,
86518
+ rawBar: outcome.rawBar,
86519
+ clampedBy: outcome.clampedBy
86520
+ } : {},
86521
+ measurement,
86522
+ previousBar: previous ?? null,
86523
+ applied,
86524
+ note
86525
+ };
86526
+ }
85506
86527
  buildPhotoEnrollActionHandlers() {
85507
86528
  return {
85508
86529
  "photo.analyze": async (input) => {
@@ -85967,6 +86988,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
85967
86988
  logger: this.ctx.logger,
85968
86989
  ...this.debugNoteArchive !== null ? { noteArchive: this.debugNoteArchive } : {}
85969
86990
  }, input);
86991
+ this.residents.setDebugMarked(input.deviceId, input.trackId, out.debug);
85970
86992
  this.ctx.logger.info("track operator flags set", {
85971
86993
  tags: { deviceId: input.deviceId },
85972
86994
  meta: {
@@ -86584,6 +87606,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
86584
87606
  this.trackingSettingsCache.delete(input.deviceId);
86585
87607
  this.confirmationGateSettingsCache.delete(input.deviceId);
86586
87608
  this.mediaSettingsCache.delete(input.deviceId);
87609
+ this.stillnessSettingsCache.delete(input.deviceId);
86587
87610
  this.forgetDeviceProcessors(input.deviceId);
86588
87611
  return { success: true };
86589
87612
  }