@camstack/addon-pipeline-orchestrator 1.2.50 → 1.2.51

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.
package/dist/index.js CHANGED
@@ -25147,13 +25147,24 @@ method(object({
25147
25147
  /** Playback-speed multiplier for the render (1 = realtime). */
25148
25148
  var ExportSpeedSchema = number().min(.25).max(32);
25149
25149
  /**
25150
- * One dense interval, in SECONDS FROM THE EXPORT'S OWN `fromMs`.
25150
+ * One dense interval, in WALL-CLOCK SECONDS FROM THE EXPORT'S OWN `fromMs`.
25151
25151
  *
25152
- * Relative and not absolute epoch on purpose: the renderer's frame-select
25153
- * expression sees ffmpeg's `t`, which starts at 0 for the export's source
25154
- * playlist. Handing it absolute epochs would make every call site responsible
25155
- * for the same subtraction, and the one that forgot would emit a filter that
25156
- * selects nothing silently, as a uniform timelapse.
25152
+ * **Wall clock, not ffmpeg's `t`** and the recorder translates. A caller
25153
+ * derives these bounds from things that happened at a TIME (a track's
25154
+ * `firstSeen`), while `t` runs over the source playlist: the concatenation of
25155
+ * every segment present for the range, with each recording GAP removed. The
25156
+ * two agree only on a window that recorded without one interruption, and only
25157
+ * the render side knows the segments, so the translation lives there
25158
+ * (`export-dense-map.ts`, addon-pipeline).
25159
+ *
25160
+ * It was not always so. These seconds were fed to `between(t,…)` verbatim, and
25161
+ * on a 10 h window holding 29,393 s of footage every range landed late by the
25162
+ * gap accumulated before it — up to 6,607 s, well past EOF. Nothing matched,
25163
+ * the video was a uniform timelapse, and the log line reported the five ranges
25164
+ * that had been ASKED for (2026-08-13, export `57d14363`, camera 615).
25165
+ *
25166
+ * Relative and not absolute epoch, because an absolute epoch would make every
25167
+ * call site responsible for the same subtraction.
25157
25168
  */
25158
25169
  var ExportDenseRangeSchema = object({
25159
25170
  fromSec: number().nonnegative(),
@@ -32395,8 +32406,8 @@ var DETAIL_CROP_PADDING_FIELD = {
32395
32406
  default: DEFAULT_DETAIL_CROP_CONVENTION.paddingRatio
32396
32407
  };
32397
32408
  /**
32398
- * THE native-frame **lease** knobs — TTL, RAM budget and demand window for the
32399
- * decode worker's native-resolution frame retention.
32409
+ * THE native-frame **lease** knobs — hold depth, RAM budget, demand window and
32410
+ * subject-tile budget for the decode worker's native-resolution retention.
32400
32411
  *
32401
32412
  * ## Why they live here and not in the addon that reads them
32402
32413
  *
@@ -32412,20 +32423,25 @@ var DETAIL_CROP_PADDING_FIELD = {
32412
32423
  * The lease is a per-decode-worker RAM window. Its purpose — the late
32413
32424
  * cross-process native crop landing on a full-resolution frame rather than the
32414
32425
  * ≤640 detection fallback — is a property of the PIPELINE, not of a node's
32415
- * hardware: a per-node TTL would mean the same camera produces different crop
32426
+ * hardware: a per-node window would mean the same camera produces different crop
32416
32427
  * quality depending on which node the balancer placed it on, and nobody could
32417
32428
  * tell that from the stored media. Node-level RAM pressure is already handled
32418
32429
  * by the per-session budget ceiling, which is itself one of these knobs.
32419
32430
  *
32420
32431
  * ## What each knob costs
32421
32432
  *
32422
- * A retained frame is a full NATIVE-resolution copy in system RAM. With the
32433
+ * A HELD frame is a full NATIVE-resolution copy in system RAM. With the
32423
32434
  * default pinned-RGB24 lease path (`CAMSTACK_SESSION_PINNED_RGB_CROP`, on):
32424
32435
  * 4K ≈ 24.9 MB/frame, 1080p ≈ 6.2 MB/frame. On the YUV420P path (flag off, and
32425
32436
  * for software-decoded sessions): 4K ≈ 12.4 MB, 1080p ≈ 3.1 MB. Worst-case
32426
- * resident RAM for ONE busy camera frameBytes × deliveredFps × ttlSeconds,
32427
- * clamped by the budget ceiling. See `docs/design/decode-path.md` "Lease
32428
- * admission" for what actually gets admitted.
32437
+ * resident RAM for ONE busy camera is now `frameBytes × holdFrames`, clamped by
32438
+ * the budget ceiling bounded by a COUNT because a held frame is waiting for
32439
+ * one specific event (its own detection result), not for a clock.
32440
+ *
32441
+ * A TILE is one subject at native resolution, JPEG-encoded: ~60-120 KB on 4K,
32442
+ * and nothing at all on a frame that detected nothing. That is the asymmetry
32443
+ * this whole shape exists for — see
32444
+ * `docs/design/2026-08-13-native-lease-two-tier-redesign.md`.
32429
32445
  */
32430
32446
  /**
32431
32447
  * Store identity of the lease knobs in `pipeline-orchestrator`'s GLOBAL
@@ -32433,10 +32449,11 @@ var DETAIL_CROP_PADDING_FIELD = {
32433
32449
  * the reader can walk every section instead of trusting the section id.
32434
32450
  */
32435
32451
  var NATIVE_LEASE_SECTION_ID = "native-lease";
32436
- var NATIVE_LEASE_TTL_KEY = "nativeLeaseTtlMs";
32452
+ var NATIVE_LEASE_HOLD_KEY = "nativeLeaseHoldFrames";
32437
32453
  var NATIVE_LEASE_BUDGET_KEY = "nativeLeaseBudgetMb";
32438
32454
  var NATIVE_LEASE_ACTIVITY_KEY = "nativeLeaseActivityMs";
32439
32455
  var NATIVE_LEASE_ADMISSION_KEY = "nativeLeaseAdmission";
32456
+ var NATIVE_LEASE_TILE_BUDGET_KEY = "nativeTileBudgetMb";
32440
32457
  /**
32441
32458
  * WHICH delivered frames the decode worker retains a native copy of.
32442
32459
  *
@@ -32458,25 +32475,32 @@ var NATIVE_LEASE_ADMISSION_KEY = "nativeLeaseAdmission";
32458
32475
  var NativeLeaseAdmissionSchema = _enum(["all", "inferred"]);
32459
32476
  object({
32460
32477
  /**
32461
- * How long a retained native frame is served before it counts as a miss.
32478
+ * How many delivered frames the worker HOLDS at once, waiting for each one's
32479
+ * detection result.
32480
+ *
32481
+ * This replaced a TTL on 2026-08-13, and the replacement is the whole point:
32482
+ * a time window was never related to the event the pixels were waiting for.
32483
+ * A held frame now lives from delivery until the runner has its `FrameResult`
32484
+ * — at which moment the runner cuts the subject tiles it actually wanted and
32485
+ * releases the frame. The bound exists only so a runner that stops answering
32486
+ * cannot pin RAM: above it the OLDEST held frame is dropped and counted.
32462
32487
  *
32463
- * Must cover the FULL late-crop horizon: detection inference + the
32464
- * cross-process inference-result hop to hub post-analysis + tracking + the
32465
- * tRPC crop round-trip back. Below ~500 ms the busiest cameras' subject crops
32466
- * outrun it and fall back to the ≤640 detection frame; above ~3 s the resident
32467
- * RAM per busy camera grows linearly with no measured hit-rate gain.
32488
+ * Sizing: the steady state is `inferenceLatency × deliveredFps`, measured at
32489
+ * 40-160 ms × ≤25 fps = 1-4 frames. The default leaves headroom for a hiccup
32490
+ * without ever approaching the old resident set (43 frames × 24.9 MB at 4K).
32491
+ * Raising it does not buy hit rate it buys tolerance for a slow runner, and
32492
+ * `holdOverflow` on the metrics line is what says you need it.
32468
32493
  */
32469
- ttlMs: number().int().min(250).max(1e4),
32494
+ holdFrames: number().int().min(1).max(64),
32470
32495
  /**
32471
32496
  * Hard per-decode-worker RAM ceiling for retained native frames, in MB.
32472
32497
  *
32473
- * Intended as a SAFETY ceiling with the TTL as the effective cap — but check
32474
- * which one is actually binding before reasoning from that. At the shipped
32475
- * 1024 MB and a 2 800 ms TTL, a 4K camera hits the CEILING first (~43 frames
32476
- * at ~24 MB each) and the TTL never gets to expire anything; `leaseMb` /
32477
- * `leaseFrames` on the metrics line say which. When the ceiling binds, a
32478
- * change that admits fewer frames buys retention WINDOW at constant RAM
32479
- * rather than giving RAM back — lower this knob if RAM is what you wanted.
32498
+ * Since 2026-08-13 this is a SAFETY ceiling and nothing else: `holdFrames`
32499
+ * is what decides how much is held, and the ceiling is the number above which
32500
+ * something is wrong. Before that it was the effective cap at 1024 MB with
32501
+ * a 2 800 ms TTL a 4K camera sat pinned at `leaseMb:1020, leaseFrames:43`
32502
+ * with the TTL expiring nothing, which is exactly the confusion the hold
32503
+ * removes. `leaseMb` / `leaseFrames` still say what is resident.
32480
32504
  * `0` DISABLES the lease entirely and falls the worker back to the tiny
32481
32505
  * leak-prone GPU surface ring (~85% crop miss; that is what the lease exists
32482
32506
  * to replace).
@@ -32502,25 +32526,47 @@ object({
32502
32526
  * there is the signal that some caller names frames outside the inference set
32503
32527
  * and that this must go back to `all`.
32504
32528
  */
32505
- admission: NativeLeaseAdmissionSchema
32529
+ admission: NativeLeaseAdmissionSchema,
32530
+ /**
32531
+ * RAM ceiling per decode worker, in MB, for the SUBJECT TILES — the
32532
+ * compressed native crops the worker cuts at the moment a frame's detection
32533
+ * result arrives, and keeps long after the frame itself is freed.
32534
+ *
32535
+ * This is the knob that replaced the old retention window, and it buys about
32536
+ * three orders of magnitude more of it: a tile is one subject at native
32537
+ * resolution, JPEG-encoded (~60-120 KB on a 4K person), against ~24.9 MB for
32538
+ * the frame it was cut from. A frame on which nothing was detected costs
32539
+ * nothing at all, which is the real change — the old lease paid per FRAME and
32540
+ * was interrogated per SUBJECT.
32541
+ *
32542
+ * `0` DISABLES tiles, leaving only the hold window and the ≤640 RAM
32543
+ * fallback — i.e. the pre-2026-08-13 miss profile. Set it there only to
32544
+ * reproduce that.
32545
+ */
32546
+ tileBudgetMb: number().int().min(0).max(1024)
32506
32547
  });
32507
32548
  /**
32508
- * The values in force when the operator has set nothing — byte-for-byte the
32509
- * constants the decode worker shipped with as env-var defaults, so making these
32510
- * settings changed no behaviour on the day it landed.
32549
+ * The values in force when the operator has set nothing.
32550
+ *
32551
+ * `budgetMb` stays at 1024 on the day the hold landed, deliberately: it stopped
32552
+ * being the retention window and became the OOM ceiling, and lowering a ceiling
32553
+ * in the same change that redefines it would make a regression and a retune
32554
+ * indistinguishable. Cut it once `tileHits` / `holdOverflow` have been read on
32555
+ * live traffic.
32511
32556
  */
32512
32557
  var DEFAULT_NATIVE_LEASE_SETTINGS = {
32513
- ttlMs: 1200,
32558
+ holdFrames: 8,
32514
32559
  budgetMb: 1024,
32515
32560
  activityMs: 15e3,
32561
+ tileBudgetMb: 64,
32516
32562
  admission: "inferred"
32517
32563
  };
32518
32564
  /** Slider bounds for the operator-facing knobs (orchestrator settings UI). */
32519
- var NATIVE_LEASE_TTL_FIELD = {
32520
- min: 250,
32521
- max: 1e4,
32522
- step: 50,
32523
- default: DEFAULT_NATIVE_LEASE_SETTINGS.ttlMs
32565
+ var NATIVE_LEASE_HOLD_FIELD = {
32566
+ min: 1,
32567
+ max: 64,
32568
+ step: 1,
32569
+ default: DEFAULT_NATIVE_LEASE_SETTINGS.holdFrames
32524
32570
  };
32525
32571
  var NATIVE_LEASE_BUDGET_FIELD = {
32526
32572
  min: 0,
@@ -32534,6 +32580,12 @@ var NATIVE_LEASE_ACTIVITY_FIELD = {
32534
32580
  step: 1e3,
32535
32581
  default: DEFAULT_NATIVE_LEASE_SETTINGS.activityMs
32536
32582
  };
32583
+ var NATIVE_LEASE_TILE_BUDGET_FIELD = {
32584
+ min: 0,
32585
+ max: 1024,
32586
+ step: 16,
32587
+ default: DEFAULT_NATIVE_LEASE_SETTINGS.tileBudgetMb
32588
+ };
32537
32589
  /** Select options for the admission knob (orchestrator settings UI). */
32538
32590
  var NATIVE_LEASE_ADMISSION_FIELD = {
32539
32591
  options: [{
@@ -32907,7 +32959,7 @@ var TRANSIENT_SLOT_READ = Symbol("transient-slot-read");
32907
32959
  * shape so video and audio plumbing self-heal identically.
32908
32960
  */
32909
32961
  /** Poll period — audio chunks arrive ~every 500ms; 200ms keeps latency low. */
32910
- var POLL_INTERVAL_MS = 200;
32962
+ var POLL_INTERVAL_MS$1 = 200;
32911
32963
  /** How many chunks to drain per poll — a small burst absorbs jitter. */
32912
32964
  var PULL_MAX_COUNT = 8;
32913
32965
  /**
@@ -33090,7 +33142,7 @@ function startPolling(options, lifecycle) {
33090
33142
  } });
33091
33143
  if (!lifecycle.stopped && consecutiveFailures >= RESUBSCRIBE_AFTER_FAILURES && (consecutiveFailures - RESUBSCRIBE_AFTER_FAILURES) % RESUBSCRIBE_THROTTLE_TICKS === 0) await resubscribe();
33092
33144
  }
33093
- if (!lifecycle.stopped) lifecycle.pollTimer = setTimeout(() => void tick(), POLL_INTERVAL_MS);
33145
+ if (!lifecycle.stopped) lifecycle.pollTimer = setTimeout(() => void tick(), POLL_INTERVAL_MS$1);
33094
33146
  };
33095
33147
  tick();
33096
33148
  }
@@ -41631,6 +41683,79 @@ async function migrateStepGating(deps) {
41631
41683
  } });
41632
41684
  }
41633
41685
  //#endregion
41686
+ //#region src/zone-mirror-hydration.ts
41687
+ /** How long to wait for a camera list: the hub wires `ctx.api` AFTER the addon
41688
+ * init chain resolves, and `device-manager` answers a moment later still, so a
41689
+ * task kicked off from `onInitialize` loses both races. Same shape as the
41690
+ * bindings migration's poll — one budget covering "no api yet" and "api, but
41691
+ * device-manager not answering yet", because to this sweep they are the same
41692
+ * thing: no fleet to hydrate. */
41693
+ var CAMERA_LIST_WAIT_MS = 6e3;
41694
+ var POLL_INTERVAL_MS = 200;
41695
+ /**
41696
+ * Hydrate the `zones` mirror for every camera once, at boot. Never throws: a
41697
+ * hydration sweep that cannot run must not take the orchestrator's boot with
41698
+ * it — but it is never silent either, because a skipped sweep is exactly the
41699
+ * failure this exists to end.
41700
+ */
41701
+ async function hydrateZoneMirrorsAtBoot(deps) {
41702
+ const cameraIds = await readCameraIds(deps);
41703
+ if (cameraIds === null) return;
41704
+ for (const hydrator of deps.hydrators) {
41705
+ let written = 0;
41706
+ let unchanged = 0;
41707
+ let failed = 0;
41708
+ for (const deviceId of cameraIds) try {
41709
+ const outcome = await hydrator.hydrate(deviceId);
41710
+ if (outcome === "written") written++;
41711
+ else if (outcome === "unchanged") unchanged++;
41712
+ else failed++;
41713
+ } catch (err) {
41714
+ failed++;
41715
+ deps.logger.warn("zone mirror boot hydration failed for this camera", {
41716
+ tags: { deviceId },
41717
+ meta: {
41718
+ mirror: hydrator.mirror,
41719
+ error: errMsg(err)
41720
+ }
41721
+ });
41722
+ }
41723
+ deps.logger.info("zone mirror boot hydration complete", { meta: {
41724
+ mirror: hydrator.mirror,
41725
+ cameras: cameraIds.length,
41726
+ written,
41727
+ unchanged,
41728
+ failed
41729
+ } });
41730
+ }
41731
+ }
41732
+ /**
41733
+ * The camera fleet, or `null` when it never became readable inside the budget
41734
+ * — in which case this has already said so. Retried inside one budget rather
41735
+ * than once, because at orchestrator boot "no api yet" and "device-manager not
41736
+ * answering yet" are both transient and indistinguishable from here.
41737
+ */
41738
+ async function readCameraIds(deps) {
41739
+ const deadline = Date.now() + (deps.apiWaitMs ?? CAMERA_LIST_WAIT_MS);
41740
+ let lastError = null;
41741
+ for (;;) {
41742
+ const api = deps.api();
41743
+ if (api) try {
41744
+ return (await api.deviceManager.listAll.query({
41745
+ isCamera: true,
41746
+ projection: "slim"
41747
+ })).map((camera) => camera.id);
41748
+ } catch (err) {
41749
+ lastError = err;
41750
+ }
41751
+ if (Date.now() >= deadline) {
41752
+ deps.logger.warn("zones mirror boot hydration SKIPPED — camera list never became readable; mirror-only consumers keep whatever the last write left", { meta: { error: lastError === null ? "ctx.api unavailable" : errMsg(lastError) } });
41753
+ return null;
41754
+ }
41755
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
41756
+ }
41757
+ }
41758
+ //#endregion
41634
41759
  //#region src/zone-rules-provider.ts
41635
41760
  /**
41636
41761
  * `zone-rules-provider.ts` — implements `zoneRulesCapability` for the
@@ -41647,6 +41772,20 @@ async function migrateStepGating(deps) {
41647
41772
  * before persisting — partial / corrupt writes are rejected outright
41648
41773
  * since rules drive runtime filtering and a bad payload would silently
41649
41774
  * widen the operator's intended scope.
41775
+ *
41776
+ * ── THE MIRROR IS HYDRATED AT BOOT, NOT ONLY ON MUTATION ──────────
41777
+ *
41778
+ * Same root cause as the `zones` slice (see `zones-provider.ts`): the
41779
+ * mirror used to be written only by `persist`, so a camera nobody had
41780
+ * mutated since its runtime-state row was last written had NO
41781
+ * `zone-rules` slice, and every mirror-only consumer — motion-wasm's
41782
+ * zone gate, the detection-pipeline zone gate, the admin rules editor
41783
+ * — read "no rules" until an operator happened to save one. Zones and
41784
+ * rules gate together, so hydrating one without the other still leaves
41785
+ * both runner-side gates inert. {@link ZoneRulesProvider.hydrateMirror}
41786
+ * is what the boot sweep (`zone-mirror-hydration.ts`) calls; it is an
41787
+ * RPC read, never an event replay (D8), and a stage that could not be
41788
+ * read leaves the mirror untouched (D49).
41650
41789
  */
41651
41790
  /**
41652
41791
  * Every zone-rule stage, in the declared enum order. The unified device-state
@@ -41690,6 +41829,11 @@ var ZoneRulesProvider = class {
41690
41829
  * write on one stage can never drop the other. Built lazily + memoised.
41691
41830
  */
41692
41831
  stateByDevice = /* @__PURE__ */ new Map();
41832
+ /** Per-device fingerprint of the slice this process last successfully wrote.
41833
+ * Absent ⇒ never mirrored here, so the next hydration writes. */
41834
+ mirroredFingerprint = /* @__PURE__ */ new Map();
41835
+ /** Devices already reported as unmirrorable — one warn per episode. */
41836
+ reportedMirrorFailure = /* @__PURE__ */ new Set();
41693
41837
  constructor(ctx) {
41694
41838
  this.ctx = ctx;
41695
41839
  }
@@ -41732,10 +41876,52 @@ var ZoneRulesProvider = class {
41732
41876
  if (!parsed.success) throw new Error(`zone-rules.setRules: invalid rule payload — ${parsed.error.issues.map((i) => i.message).join("; ")}`);
41733
41877
  await this.persist(deviceId, stage, parsed.data);
41734
41878
  }
41879
+ /**
41880
+ * Reconcile ONE device's `zone-rules` mirror against the durable block.
41881
+ * Called by the boot sweep for every camera. Never throws.
41882
+ *
41883
+ * A stage whose read FAILED aborts the whole hydration: the mirror is a
41884
+ * single slice carrying every stage, so writing a partially-read block would
41885
+ * publish "this stage has no rules" off a store blip — and an empty
41886
+ * `motion`/`detection` array is what makes a gate stop gating.
41887
+ */
41888
+ async hydrateMirror(deviceId) {
41889
+ const perDevice = this.stageCache(deviceId);
41890
+ for (const stage of ALL_STAGES) {
41891
+ const read = await this.readRules(deviceId, stage);
41892
+ if (!read.ok) return "unreadable";
41893
+ perDevice.set(stage, read.rules);
41894
+ }
41895
+ const slice = await this.buildSliceValue(deviceId, perDevice);
41896
+ const fingerprint = JSON.stringify(slice);
41897
+ if (this.mirroredFingerprint.get(deviceId) === fingerprint) return "unchanged";
41898
+ try {
41899
+ await (await this.ctx.fetchDevice(deviceId)).deviceState.setCapSlice({
41900
+ capName: ZONE_RULES_CAP_NAME,
41901
+ slice
41902
+ });
41903
+ } catch (err) {
41904
+ if (!this.reportedMirrorFailure.has(deviceId)) {
41905
+ this.reportedMirrorFailure.add(deviceId);
41906
+ this.ctx.logger.warn("zone-rules mirror write failed — mirror-only zone gates see NO rules for this camera until it lands", {
41907
+ tags: { deviceId },
41908
+ meta: { error: err instanceof Error ? err.message : String(err) }
41909
+ });
41910
+ }
41911
+ return "write-failed";
41912
+ }
41913
+ const first = !this.mirroredFingerprint.has(deviceId);
41914
+ this.mirroredFingerprint.set(deviceId, fingerprint);
41915
+ this.reportedMirrorFailure.delete(deviceId);
41916
+ if (first) this.ctx.logger.info("zone-rules mirror hydrated from the durable block", { tags: { deviceId } });
41917
+ return "written";
41918
+ }
41735
41919
  /** Drop a device's cache entries. Called when the device is removed. */
41736
41920
  forgetDevice(deviceId) {
41737
41921
  this.cache.delete(deviceId);
41738
41922
  this.stateByDevice.delete(deviceId);
41923
+ this.mirroredFingerprint.delete(deviceId);
41924
+ this.reportedMirrorFailure.delete(deviceId);
41739
41925
  }
41740
41926
  /** Cap-surface read: a failure folds to the empty list, as it always has. */
41741
41927
  async loadRules(deviceId, stage) {
@@ -41750,11 +41936,7 @@ var ZoneRulesProvider = class {
41750
41936
  * momentary store blip into a permanent one.
41751
41937
  */
41752
41938
  async readRules(deviceId, stage) {
41753
- let perDevice = this.cache.get(deviceId);
41754
- if (!perDevice) {
41755
- perDevice = /* @__PURE__ */ new Map();
41756
- this.cache.set(deviceId, perDevice);
41757
- }
41939
+ const perDevice = this.stageCache(deviceId);
41758
41940
  const cached = perDevice.get(stage);
41759
41941
  if (cached) return {
41760
41942
  ok: true,
@@ -41795,11 +41977,7 @@ var ZoneRulesProvider = class {
41795
41977
  };
41796
41978
  }
41797
41979
  async persist(deviceId, stage, rules) {
41798
- let perDevice = this.cache.get(deviceId);
41799
- if (!perDevice) {
41800
- perDevice = /* @__PURE__ */ new Map();
41801
- this.cache.set(deviceId, perDevice);
41802
- }
41980
+ const perDevice = this.stageCache(deviceId);
41803
41981
  perDevice.set(stage, rules);
41804
41982
  await this.rulesState(deviceId).update((prev) => ({
41805
41983
  ...prev,
@@ -41811,6 +41989,8 @@ var ZoneRulesProvider = class {
41811
41989
  capName: ZONE_RULES_CAP_NAME,
41812
41990
  slice: sliceValue
41813
41991
  });
41992
+ this.mirroredFingerprint.set(deviceId, JSON.stringify(sliceValue));
41993
+ this.reportedMirrorFailure.delete(deviceId);
41814
41994
  } catch (err) {
41815
41995
  this.ctx.logger.debug("zone-rules slice mirror failed", {
41816
41996
  tags: { deviceId },
@@ -41829,6 +42009,15 @@ var ZoneRulesProvider = class {
41829
42009
  * also warms the cache). Iterates {@link ALL_STAGES} so it stays exhaustive
41830
42010
  * over the cap's stage discriminator without a per-stage branch.
41831
42011
  */
42012
+ /** The per-stage cache map for a device, created on first use. */
42013
+ stageCache(deviceId) {
42014
+ let perDevice = this.cache.get(deviceId);
42015
+ if (!perDevice) {
42016
+ perDevice = /* @__PURE__ */ new Map();
42017
+ this.cache.set(deviceId, perDevice);
42018
+ }
42019
+ return perDevice;
42020
+ }
41832
42021
  async buildSliceValue(deviceId, perDevice) {
41833
42022
  const slice = {
41834
42023
  motion: [],
@@ -41846,11 +42035,38 @@ var ZoneRulesProvider = class {
41846
42035
  *
41847
42036
  * Per-camera CRUD over polygon detection zones. Persists to the
41848
42037
  * orchestrator's per-device settings store under the `zones` key and
41849
- * mirrors every change into the device-state `zones` slice via
42038
+ * mirrors the catalogue into the device-state `zones` slice via
41850
42039
  * `api.deviceState.setCapSlice`. Downstream consumers (motion-wasm,
41851
42040
  * pipeline-executor, analytics, admin UI) read the live state with
41852
42041
  * the canonical `dev.state.zones.onChanged` channel.
41853
42042
  *
42043
+ * ── THIS PROVIDER OWNS THE MIRROR FOR ITS WHOLE LIFETIME ──────────
42044
+ *
42045
+ * The mirror used to be written in exactly ONE place: `persist`, i.e.
42046
+ * only when an operator mutates a zone. Nothing seeded it at startup,
42047
+ * so a camera whose runtime-state row had never been written (or had
42048
+ * been reset) had no `zones` slice at all, and every mirror-only
42049
+ * consumer concluded "this camera has no zones" — forever, because no
42050
+ * mutation was coming. Live cost (camera 617 'Parcheggio',
42051
+ * 2026-08-12): `zones.listZones {617}` returned 'Parcheggio papà'
42052
+ * while `deviceState.getCapSlice {617,'zones'}` returned `null`;
42053
+ * occupancy dropped all three parked cars into `unzoned`, its zone
42054
+ * rule could never fire, and the admin Zones tab — which reads the
42055
+ * same mirror — showed "No zones yet".
42056
+ *
42057
+ * So the mirror is reconciled against the durable catalogue on the
42058
+ * READ path too ({@link ZonesProvider.hydrateMirror}), and the boot
42059
+ * sweep in `zone-mirror-hydration.ts` walks every camera once at
42060
+ * startup. That is deliberately an RPC read, never an event replay:
42061
+ * events are lossy telemetry and a slice change that was dropped is
42062
+ * never re-sent (D8).
42063
+ *
42064
+ * Two rules the hydration path must keep (D49):
42065
+ * - a durable read that FAILED changes nothing — it must never be
42066
+ * mirrored, and must not be cached as "this camera has no zones";
42067
+ * - hydration is a reconcile, not a mutation: `onZonesChanged` is
42068
+ * NOT fired, so nothing downstream re-dispatches on a boot read.
42069
+ *
41854
42070
  * Onboard / firmware-reported zones are out of scope for now — every
41855
42071
  * zone is operator-drawn. The provider keeps the surface symmetric:
41856
42072
  * `addZone` rejects id collisions, `updateZone` requires an existing
@@ -41859,6 +42075,11 @@ var ZoneRulesProvider = class {
41859
42075
  var ZONES_STORE_KEY = "zones";
41860
42076
  var ZONES_CAP_NAME = "zones";
41861
42077
  var ZonesArraySchema = array(ZoneSchema);
42078
+ /** Identity of a mirrored catalogue — cheap enough to compare on every read,
42079
+ * and it changes whenever anything an operator can see changes. */
42080
+ function fingerprintZones(zones) {
42081
+ return JSON.stringify(zones);
42082
+ }
41862
42083
  var ZonesProvider = class {
41863
42084
  ctx;
41864
42085
  /** Per-device cache. Hydrated lazily on first read for a device. */
@@ -41869,6 +42090,16 @@ var ZonesProvider = class {
41869
42090
  * be dropped on persist. Built lazily + memoised per device.
41870
42091
  */
41871
42092
  stateByDevice = /* @__PURE__ */ new Map();
42093
+ /**
42094
+ * Per-device fingerprint of the catalogue this process last successfully
42095
+ * wrote to the mirror. Absent ⇒ this process has never mirrored the device,
42096
+ * so the next read hydrates; a write that FAILED leaves it absent, which is
42097
+ * what makes the retry happen on the next read rather than never.
42098
+ */
42099
+ mirroredFingerprint = /* @__PURE__ */ new Map();
42100
+ /** Devices already reported as unmirrorable — keeps the warn to one per
42101
+ * episode instead of one per read. */
42102
+ reportedMirrorFailure = /* @__PURE__ */ new Set();
41872
42103
  constructor(ctx) {
41873
42104
  this.ctx = ctx;
41874
42105
  }
@@ -41894,55 +42125,141 @@ var ZonesProvider = class {
41894
42125
  }
41895
42126
  return handle;
41896
42127
  }
42128
+ /**
42129
+ * The device's catalogue — and, on the way past, the one place a mirror-only
42130
+ * consumer's boot blindness is cured: every authoritative read reconciles the
42131
+ * device-state slice against what it just read.
42132
+ */
41897
42133
  async listZones({ deviceId }) {
41898
- return this.loadZones(deviceId);
42134
+ const read = await this.readCatalogue(deviceId);
42135
+ if (read.ok) await this.ensureMirror(deviceId, read.zones);
42136
+ return read.zones;
41899
42137
  }
41900
42138
  async addZone({ deviceId, zone }) {
41901
- const existing = await this.loadZones(deviceId);
42139
+ const existing = await this.readForMutation(deviceId);
41902
42140
  if (existing.some((entry) => entry.id === zone.id)) throw new Error(`zones.addZone: id ${zone.id} already exists`);
41903
42141
  await this.persist(deviceId, [...existing, zone]);
41904
42142
  }
41905
42143
  async updateZone({ deviceId, zone }) {
41906
- const existing = await this.loadZones(deviceId);
42144
+ const existing = await this.readForMutation(deviceId);
41907
42145
  if (!existing.some((entry) => entry.id === zone.id)) throw new Error(`zones.updateZone: id ${zone.id} not found`);
41908
42146
  const next = existing.map((entry) => entry.id === zone.id ? zone : entry);
41909
42147
  await this.persist(deviceId, next);
41910
42148
  }
41911
42149
  async removeZone({ deviceId, zoneId }) {
41912
- const existing = await this.loadZones(deviceId);
42150
+ const existing = await this.readForMutation(deviceId);
41913
42151
  if (!existing.some((entry) => entry.id === zoneId)) return;
41914
42152
  await this.persist(deviceId, existing.filter((entry) => entry.id !== zoneId));
41915
42153
  }
41916
42154
  /**
42155
+ * Reconcile ONE device's mirror against the durable catalogue. The boot
42156
+ * sweep (`zone-mirror-hydration.ts`) calls this for every camera so a
42157
+ * mirror-only consumer never starts blind; `listZones` calls it too, so a
42158
+ * camera adopted after boot is covered by its first read.
42159
+ *
42160
+ * Never throws — a hydration that cannot happen is reported, and reported
42161
+ * once (see {@link ZoneMirrorHydration}).
42162
+ */
42163
+ async hydrateMirror(deviceId) {
42164
+ const read = await this.readCatalogue(deviceId);
42165
+ if (!read.ok) return "unreadable";
42166
+ return this.ensureMirror(deviceId, read.zones);
42167
+ }
42168
+ /**
41917
42169
  * Drop a device's cache entry. Called when the device is removed so
41918
42170
  * the next attach starts from a fresh disk read.
41919
42171
  */
41920
42172
  forgetDevice(deviceId) {
41921
42173
  this.cache.delete(deviceId);
41922
42174
  this.stateByDevice.delete(deviceId);
42175
+ this.mirroredFingerprint.delete(deviceId);
42176
+ this.reportedMirrorFailure.delete(deviceId);
41923
42177
  }
41924
- async loadZones(deviceId) {
42178
+ /**
42179
+ * The catalogue for a mutation. A mutation is read-modify-write over the
42180
+ * WHOLE array, so proceeding from a failed read would persist the operator's
42181
+ * zones away — refuse instead.
42182
+ */
42183
+ async readForMutation(deviceId) {
42184
+ const read = await this.readCatalogue(deviceId);
42185
+ if (!read.ok) throw new Error(`zones: catalogue unreadable for device ${deviceId} — refusing to write`);
42186
+ return read.zones;
42187
+ }
42188
+ /**
42189
+ * Read the durable catalogue, cached per device. A FAILED read is neither
42190
+ * cached nor reported as `[]` — the caller decides what an unknown answer
42191
+ * means for it.
42192
+ */
42193
+ async readCatalogue(deviceId) {
41925
42194
  const cached = this.cache.get(deviceId);
41926
- if (cached) return cached;
41927
- let zones = [];
42195
+ if (cached) return {
42196
+ ok: true,
42197
+ zones: cached
42198
+ };
42199
+ let zones;
41928
42200
  try {
41929
42201
  zones = await this.zonesState(deviceId).get();
41930
42202
  } catch (err) {
41931
- this.ctx.logger.warn("zones store read failed — using empty list", {
42203
+ this.ctx.logger.warn("zones store read failed — catalogue UNKNOWN for this device", {
41932
42204
  tags: { deviceId },
41933
42205
  meta: { error: err instanceof Error ? err.message : String(err) }
41934
42206
  });
42207
+ return {
42208
+ ok: false,
42209
+ zones: []
42210
+ };
41935
42211
  }
41936
42212
  this.cache.set(deviceId, zones);
41937
- return zones;
42213
+ return {
42214
+ ok: true,
42215
+ zones
42216
+ };
41938
42217
  }
41939
- async persist(deviceId, zones) {
41940
- this.cache.set(deviceId, zones);
41941
- await this.zonesState(deviceId).set(zones);
42218
+ /**
42219
+ * Make the device-state mirror agree with `zones`. Idempotent per process
42220
+ * via the fingerprint; the hub itself also no-ops an identical
42221
+ * `setCapSlice`, so this is belt-and-braces against needless RPCs, not
42222
+ * against needless writes.
42223
+ */
42224
+ async ensureMirror(deviceId, zones) {
42225
+ const fingerprint = fingerprintZones(zones);
42226
+ if (this.mirroredFingerprint.get(deviceId) === fingerprint) return "unchanged";
42227
+ try {
42228
+ await this.writeMirror(deviceId, zones);
42229
+ } catch (err) {
42230
+ if (!this.reportedMirrorFailure.has(deviceId)) {
42231
+ this.reportedMirrorFailure.add(deviceId);
42232
+ this.ctx.logger.warn("zones mirror write failed — mirror-only consumers see NO zones for this camera until it lands", {
42233
+ tags: { deviceId },
42234
+ meta: {
42235
+ zones: zones.length,
42236
+ error: err instanceof Error ? err.message : String(err)
42237
+ }
42238
+ });
42239
+ }
42240
+ return "write-failed";
42241
+ }
42242
+ const first = !this.mirroredFingerprint.has(deviceId);
42243
+ this.mirroredFingerprint.set(deviceId, fingerprint);
42244
+ this.reportedMirrorFailure.delete(deviceId);
42245
+ if (first) this.ctx.logger.info("zones mirror hydrated from the durable catalogue", {
42246
+ tags: { deviceId },
42247
+ meta: { zones: zones.length }
42248
+ });
42249
+ return "written";
42250
+ }
42251
+ async writeMirror(deviceId, zones) {
41942
42252
  await (await this.ctx.fetchDevice(deviceId)).deviceState.setCapSlice({
41943
42253
  capName: ZONES_CAP_NAME,
41944
42254
  slice: { zones }
41945
42255
  });
42256
+ }
42257
+ async persist(deviceId, zones) {
42258
+ this.cache.set(deviceId, zones);
42259
+ await this.zonesState(deviceId).set(zones);
42260
+ await this.writeMirror(deviceId, zones);
42261
+ this.mirroredFingerprint.set(deviceId, fingerprintZones(zones));
42262
+ this.reportedMirrorFailure.delete(deviceId);
41946
42263
  this.ctx.onZonesChanged?.(deviceId, zones);
41947
42264
  }
41948
42265
  };
@@ -42355,6 +42672,19 @@ async function buildOrchestratorControllers(deps) {
42355
42672
  });
42356
42673
  }
42357
42674
  });
42675
+ hydrateZoneMirrorsAtBoot({
42676
+ api: () => deps.ctx().api ?? null,
42677
+ hydrators: [{
42678
+ mirror: "zones",
42679
+ hydrate: (deviceId) => zonesProvider.hydrateMirror(deviceId)
42680
+ }, {
42681
+ mirror: "zone-rules",
42682
+ hydrate: (deviceId) => zoneRulesProvider.hydrateMirror(deviceId)
42683
+ }],
42684
+ logger: deps.ctx().logger.child("zones")
42685
+ }).catch((err) => {
42686
+ deps.ctxIfReady()?.logger.warn("zones mirror boot hydration failed", { meta: { error: errMsg(err) } });
42687
+ });
42358
42688
  const unsubOrchestratorSubscriptions = wireOrchestratorSubscriptions({
42359
42689
  eventBus: deps.ctx().eventBus,
42360
42690
  logger: deps.ctx().logger,
@@ -42658,25 +42988,37 @@ function buildGlobalSettingsSections(options) {
42658
42988
  id: NATIVE_LEASE_SECTION_ID,
42659
42989
  title: "Native frame lease",
42660
42990
  tab: "pipeline",
42661
- description: "How long each decode worker keeps a full-resolution copy of a delivered frame in RAM so a LATE native crop (post-analysis snapshot, face/plate detail) can still be cut from real pixels instead of the ≤640 detection frame. Cost is real: a retained 4K frame is ~24.9 MB on the default pinned-RGB24 path (~12.4 MB as YUV420P), a 1080p frame ~6.2 MB / ~3.1 MB. Worst case for ONE busy camera frameBytes × delivered fps × TTL seconds, capped by the budget below. Takes effect on the NEXT decode session for a camera, not on sessions already running.",
42991
+ description: "How a decode worker keeps native pixels available for a LATE crop (post-analysis snapshot, face/plate detail) instead of falling back to the ≤640 detection frame. Two things are kept and they cost very differently: a HELD FRAME is a full native raster (~24.9 MB at 4K on the default pinned-RGB24 path, ~6.2 MB at 1080p) and lives only until its own detection result arrives; a TILE is one subject cut from that frame at native resolution and JPEG-encoded (~60-120 KB at 4K), and lives for tens of seconds. A frame on which nothing was detected produces no tiles and costs nothing. Takes effect on the NEXT decode session for a camera, not on sessions already running.",
42662
42992
  fields: [
42663
42993
  {
42664
- key: NATIVE_LEASE_TTL_KEY,
42994
+ key: NATIVE_LEASE_HOLD_KEY,
42665
42995
  type: "slider",
42666
- label: "Lease TTL",
42667
- description: "How long a retained native frame stays claimable before it counts as a miss. It must cover the whole late-crop horizon detection inference, the cross-process hop to hub post-analysis, tracking, and the tRPC crop round-trip back. Below ~500 ms the busiest cameras outrun it and their crops silently fall back to the downscaled detection frame; every extra second multiplies resident RAM by roughly (frame bytes × delivered fps). 1200 ms is the shipped value.",
42668
- min: NATIVE_LEASE_TTL_FIELD.min,
42669
- max: NATIVE_LEASE_TTL_FIELD.max,
42670
- step: NATIVE_LEASE_TTL_FIELD.step,
42671
- default: NATIVE_LEASE_TTL_FIELD.default,
42996
+ label: "Frames held at once",
42997
+ description: "How many delivered frames a worker keeps alive while waiting for their detection results. A frame is freed as soon as its own result comes back and its subject tiles have been cut, so the steady state is inference latency × delivered fps 1 to 4 frames in practice. This number is only the bound above which the OLDEST held frame is dropped, which is what stops a runner that has stopped answering from pinning RAM. Raising it does not improve crop hit rate; it buys tolerance for a slow runner, and holdOverflow on the metrics line is what tells you that you need it.",
42998
+ min: NATIVE_LEASE_HOLD_FIELD.min,
42999
+ max: NATIVE_LEASE_HOLD_FIELD.max,
43000
+ step: NATIVE_LEASE_HOLD_FIELD.step,
43001
+ default: NATIVE_LEASE_HOLD_FIELD.default,
42672
43002
  showValue: true,
42673
- unit: "ms"
43003
+ unit: "frames"
43004
+ },
43005
+ {
43006
+ key: NATIVE_LEASE_TILE_BUDGET_KEY,
43007
+ type: "slider",
43008
+ label: "Subject tile RAM",
43009
+ description: "RAM per decode worker for the compressed SUBJECT TILES — the native-resolution crops taken at the moment a frame's detections are known, and kept long after the frame itself is gone. This is what serves a crop that arrives seconds late, which measurement says is the ordinary case (2 to 10 seconds on this cluster). At ~60-120 KB a tile, 64 MB is many hundreds of subjects. 0 turns tiles OFF and restores the old behaviour, where a late crop had nothing to fall back to but the ≤640 detection frame.",
43010
+ min: NATIVE_LEASE_TILE_BUDGET_FIELD.min,
43011
+ max: NATIVE_LEASE_TILE_BUDGET_FIELD.max,
43012
+ step: NATIVE_LEASE_TILE_BUDGET_FIELD.step,
43013
+ default: NATIVE_LEASE_TILE_BUDGET_FIELD.default,
43014
+ showValue: true,
43015
+ unit: "MB"
42674
43016
  },
42675
43017
  {
42676
43018
  key: NATIVE_LEASE_BUDGET_KEY,
42677
43019
  type: "slider",
42678
43020
  label: "Lease RAM ceiling",
42679
- description: "Hard RAM ceiling for retained frames, PER decode worker (one worker per camera per plane). This is a safety ceiling, not the working size — the TTL above is what normally reclaims frames, and at 1024 MB the ceiling is never the binding constraint on a single camera. Lower it on a small host to bound the worst case. 0 DISABLES the lease entirely and falls the worker back to the tiny GPU surface ring, which misses roughly 85% of late crops — that is the behaviour the lease exists to replace, so 0 is a diagnostic setting, not a tuning one.",
43021
+ description: "Hard RAM ceiling for held frames, PER decode worker (one worker per camera per plane). This is a safety ceiling, not the working size — the hold count above is what reclaims frames now, and the ceiling is the number above which something is wrong. Lower it on a small host to bound the worst case. 0 DISABLES the lease entirely and falls the worker back to the tiny GPU surface ring, which misses roughly 85% of late crops — that is the behaviour the lease exists to replace, so 0 is a diagnostic setting, not a tuning one.",
42680
43022
  min: NATIVE_LEASE_BUDGET_FIELD.min,
42681
43023
  max: NATIVE_LEASE_BUDGET_FIELD.max,
42682
43024
  step: NATIVE_LEASE_BUDGET_FIELD.step,