@camstack/addon-post-analysis 1.2.118 → 1.2.119

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.
@@ -5831,6 +5831,13 @@ var BaseAddon = class {
5831
5831
  _readinessGeneration = typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2, 14);
5832
5832
  /** Capability names this addon registered at init — used to emit matching `down` events on shutdown. */
5833
5833
  _registeredCapNames = [];
5834
+ /**
5835
+ * True only after `readAddonStore` actually answered. Constructor
5836
+ * defaults look like stored config when the store is down — a forked
5837
+ * addon that auto-starts from those defaults (cloudflare-tunnel quick
5838
+ * mode, 2026-08-25) is not "the operator chose this".
5839
+ */
5840
+ settingsStoreReady = false;
5834
5841
  /** Default config values. Provided via constructor. */
5835
5842
  defaults;
5836
5843
  constructor(defaults) {
@@ -6231,7 +6238,9 @@ var BaseAddon = class {
6231
6238
  ];
6232
6239
  let lastErr;
6233
6240
  for (let attempt = 0; attempt <= delaysMs.length; attempt++) try {
6234
- return await settings.readAddonStore() ?? {};
6241
+ const stored = await settings.readAddonStore() ?? {};
6242
+ this.settingsStoreReady = true;
6243
+ return stored;
6235
6244
  } catch (err) {
6236
6245
  lastErr = err;
6237
6246
  const msg = err instanceof Error ? err.message : String(err);
@@ -6239,6 +6248,7 @@ var BaseAddon = class {
6239
6248
  if (attempt === delaysMs.length) break;
6240
6249
  await new Promise((r) => setTimeout(r, delaysMs[attempt]));
6241
6250
  }
6251
+ this.settingsStoreReady = false;
6242
6252
  this._ctx?.logger?.warn?.("readAddonStore: settings-store unavailable after retries — using defaults", { meta: { error: lastErr instanceof Error ? lastErr.message : String(lastErr) } });
6243
6253
  return {};
6244
6254
  }
@@ -5800,6 +5800,13 @@ var BaseAddon = class {
5800
5800
  _readinessGeneration = typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2, 14);
5801
5801
  /** Capability names this addon registered at init — used to emit matching `down` events on shutdown. */
5802
5802
  _registeredCapNames = [];
5803
+ /**
5804
+ * True only after `readAddonStore` actually answered. Constructor
5805
+ * defaults look like stored config when the store is down — a forked
5806
+ * addon that auto-starts from those defaults (cloudflare-tunnel quick
5807
+ * mode, 2026-08-25) is not "the operator chose this".
5808
+ */
5809
+ settingsStoreReady = false;
5803
5810
  /** Default config values. Provided via constructor. */
5804
5811
  defaults;
5805
5812
  constructor(defaults) {
@@ -6200,7 +6207,9 @@ var BaseAddon = class {
6200
6207
  ];
6201
6208
  let lastErr;
6202
6209
  for (let attempt = 0; attempt <= delaysMs.length; attempt++) try {
6203
- return await settings.readAddonStore() ?? {};
6210
+ const stored = await settings.readAddonStore() ?? {};
6211
+ this.settingsStoreReady = true;
6212
+ return stored;
6204
6213
  } catch (err) {
6205
6214
  lastErr = err;
6206
6215
  const msg = err instanceof Error ? err.message : String(err);
@@ -6208,6 +6217,7 @@ var BaseAddon = class {
6208
6217
  if (attempt === delaysMs.length) break;
6209
6218
  await new Promise((r) => setTimeout(r, delaysMs[attempt]));
6210
6219
  }
6220
+ this.settingsStoreReady = false;
6211
6221
  this._ctx?.logger?.warn?.("readAddonStore: settings-store unavailable after retries — using defaults", { meta: { error: lastErr instanceof Error ? lastErr.message : String(lastErr) } });
6212
6222
  return {};
6213
6223
  }
@@ -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-CO6pe7bi.js");
5
+ const require_dist = require("../dist-BtIolp2d.js");
6
6
  let node_fs = require("node:fs");
7
7
  let node_fs$1 = require_dist.__toESM(node_fs, 1);
8
8
  node_fs = require_dist.__toESM(node_fs);
@@ -1,4 +1,4 @@
1
- import { M as PoolMemoryWatchdog, at as hfModelUrl, gt as resolvePoolMemoryPolicy, nt as embeddingEncoderCapability, ut as parseProcStatus, wt as BaseAddon } from "../dist-Gn2fFMov.mjs";
1
+ import { M as PoolMemoryWatchdog, at as hfModelUrl, gt as resolvePoolMemoryPolicy, nt as embeddingEncoderCapability, ut as parseProcStatus, wt as BaseAddon } from "../dist-R0Wd863b.mjs";
2
2
  import { createRequire } from "node:module";
3
3
  import * as fs from "node:fs";
4
4
  import "node:fs";
@@ -18,7 +18,7 @@ var e = {
18
18
  },
19
19
  "@camstack/types": {
20
20
  name: "@camstack/types",
21
- version: "1.2.102",
21
+ version: "1.2.103",
22
22
  scope: ["default"],
23
23
  loaded: !1,
24
24
  from: "addon_pipeline_analytics_widgets",
@@ -36,7 +36,7 @@ async function r() {
36
36
  }
37
37
  },
38
38
  "@camstack/types": {
39
- version: "1.2.102",
39
+ version: "1.2.103",
40
40
  scope: "default",
41
41
  shareConfig: {
42
42
  singleton: !0,
@@ -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-CO6pe7bi.js");
5
+ const require_dist = require("../dist-BtIolp2d.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");
@@ -4398,10 +4398,28 @@ var SCENE_LATCH_INDEXES = [{
4398
4398
  function sceneKey(deviceId, monitorId) {
4399
4399
  return `${deviceId}:${monitorId}`;
4400
4400
  }
4401
+ /**
4402
+ * Reseed cap for BOTH scene ledgers.
4403
+ *
4404
+ * `DurableLedger` defaults to 100_000 and its own docblock says the owner "sets
4405
+ * it from the row set it expects". Neither scene ledger did, so the 30s
4406
+ * reconcile asked SQLite — through hub-main's event loop, which is the
4407
+ * cluster's only queue (D181) — for up to a hundred thousand rows twice a tick,
4408
+ * on a hub holding one scene and one latch (measured live 2026-08-25). A bound
4409
+ * nobody chose is not a bound.
4410
+ *
4411
+ * A scene is an operator-drawn monitor on ONE camera and a latch is at most one
4412
+ * row per scene, so the row set is `cameras x monitors-per-camera`. 5_000 is
4413
+ * ~5 monitors on each of a thousand cameras — far past any installation this
4414
+ * ships to, and still small enough that a runaway write loop is CAUGHT by the
4415
+ * bound instead of being paged in whole.
4416
+ */
4417
+ var SCENE_LEDGER_LOAD_LIMIT = 5e3;
4401
4418
  var SCENE_CONFIG_SPEC = {
4402
4419
  collection: SCENE_COLLECTION,
4403
4420
  columns: SCENE_COLUMNS,
4404
4421
  indexes: SCENE_INDEXES,
4422
+ loadLimit: SCENE_LEDGER_LOAD_LIMIT,
4405
4423
  writeMode: "write-behind",
4406
4424
  keyOf: (row) => row.key,
4407
4425
  toValue: (row) => ({
@@ -4417,6 +4435,7 @@ var SCENE_LATCH_SPEC = {
4417
4435
  collection: SCENE_LATCH_COLLECTION,
4418
4436
  columns: SCENE_LATCH_COLUMNS,
4419
4437
  indexes: SCENE_LATCH_INDEXES,
4438
+ loadLimit: SCENE_LEDGER_LOAD_LIMIT,
4420
4439
  writeMode: "write-through",
4421
4440
  keyOf: (row) => row.key,
4422
4441
  toValue: (row) => ({
@@ -4446,6 +4465,9 @@ var SceneStore = class {
4446
4465
  config;
4447
4466
  latch;
4448
4467
  logger;
4468
+ /** `scenes/latches` as last announced. Null until the first load, so the
4469
+ * first one always speaks. */
4470
+ lastLoggedCounts = null;
4449
4471
  constructor(deps) {
4450
4472
  this.logger = deps.logger;
4451
4473
  this.config = new DurableLedger({
@@ -4470,10 +4492,20 @@ var SceneStore = class {
4470
4492
  * a container recreate, "loaded 0" is the one line that explains why every
4471
4493
  * scene suddenly reads `unknown`, and its absence is what turns a data loss
4472
4494
  * into a three-hour hunt.
4495
+ *
4496
+ * ON CHANGE ONLY, though — including the first load, where there is nothing
4497
+ * to compare against. This runs on the 30s `SCENE_RELOAD_INTERVAL_MS` tick on
4498
+ * every node, and it used to print unconditionally: measured live 2026-08-25,
4499
+ * `scene state loaded {scenes=1 latches=1}` every 30 seconds forever. A count
4500
+ * reprinted unchanged twice a minute is exactly how the "loaded 0" this line
4501
+ * exists for gets scrolled past.
4473
4502
  */
4474
4503
  async load() {
4475
4504
  const scenes = await this.config.load();
4476
4505
  const latches = await this.latch.load();
4506
+ const counts = `${scenes.length}/${latches.length}`;
4507
+ if (counts === this.lastLoggedCounts) return;
4508
+ this.lastLoggedCounts = counts;
4477
4509
  this.logger.info("scene state loaded", { meta: {
4478
4510
  scenes: scenes.length,
4479
4511
  latches: latches.length
@@ -8539,6 +8571,49 @@ var NC_LANGUAGES = ["en", "it"];
8539
8571
  * surface's view all validate against this one, so a third language is added
8540
8572
  * to `NC_LANGUAGES` and nowhere else. */
8541
8573
  var NcLanguageSchema = require_dist._enum(NC_LANGUAGES);
8574
+ /**
8575
+ * Whether two snapshots say the same thing.
8576
+ *
8577
+ * ## Why this is a function and not `===`
8578
+ *
8579
+ * The reload guard in `reloadTexts()` used to be
8580
+ * `before.overrides === next.overrides`, and `next` comes from
8581
+ * `NcTextCatalogStore.load()`, which `parse()`s the row through zod on every
8582
+ * call. The right-hand side is therefore a NEW object on every tick by
8583
+ * construction: the comparison was `false` at byte-identical content, forever.
8584
+ * Measured on the live hub 2026-08-25: 58 "notification texts loaded" lines in
8585
+ * 30 minutes on a hub whose language had not changed, from a line whose own
8586
+ * docblock promises it fires on CHANGE only.
8587
+ *
8588
+ * The document is two levels deep and installation-small (at most one entry per
8589
+ * language, each a flat key→string map), so the comparison is written out
8590
+ * rather than reached for through a deep-equal dependency — and written out it
8591
+ * can state the direction that matters: a REMOVED key must not read as equal,
8592
+ * which a one-directional key walk would get wrong.
8593
+ */
8594
+ function ncTextSnapshotsEqual(a, b) {
8595
+ if (a.language !== b.language) return false;
8596
+ return overrideMapsEqual(a.overrides, b.overrides);
8597
+ }
8598
+ function overrideMapsEqual(a, b) {
8599
+ const languages = new Set([...Object.keys(a), ...Object.keys(b)]);
8600
+ for (const language of languages) {
8601
+ const left = a[language];
8602
+ const right = b[language];
8603
+ if (left === void 0 || right === void 0) {
8604
+ if (Object.keys(left ?? right ?? {}).length > 0) return false;
8605
+ continue;
8606
+ }
8607
+ if (!flatMapsEqual(left, right)) return false;
8608
+ }
8609
+ return true;
8610
+ }
8611
+ function flatMapsEqual(a, b) {
8612
+ const keys = Object.keys(a);
8613
+ if (keys.length !== Object.keys(b).length) return false;
8614
+ for (const key of keys) if (a[key] !== b[key]) return false;
8615
+ return true;
8616
+ }
8542
8617
  /** The shipped files, keyed by language. Data, never mutated. */
8543
8618
  var NC_TEXT_BUNDLES = {
8544
8619
  en: en_default,
@@ -22484,7 +22559,7 @@ var NotificationCenter = class NotificationCenter {
22484
22559
  const before = this.texts.snapshot;
22485
22560
  const next = await this.textStore.load();
22486
22561
  this.texts.apply(next);
22487
- if (before.language === next.language && before.overrides === next.overrides) return;
22562
+ if (ncTextSnapshotsEqual(before, next)) return;
22488
22563
  this.logger.info("notification texts loaded", { meta: {
22489
22564
  language: next.language,
22490
22565
  overriddenKeys: Object.values(next.overrides).reduce((sum, keys) => sum + Object.keys(keys ?? {}).length, 0)
@@ -35459,6 +35534,18 @@ var EmbeddingDispatcher = class {
35459
35534
  logger;
35460
35535
  getRemoteFrame;
35461
35536
  lastEmbedTime = /* @__PURE__ */ new Map();
35537
+ /**
35538
+ * trackId -> the frame pinned for it. Each entry holds a WHOLE decoded RGB
35539
+ * frame (`PendingCrop.frameData`, ~0.7 MB), not a crop.
35540
+ *
35541
+ * Under the shipped `cropStrategy: 'first'` this doubles as the in-flight
35542
+ * DEDUPE FLAG — `'first'` gates on `!pendingCrops.has(trackId)` — and there
35543
+ * is no flush timer for that strategy, so nothing ever sweeps it. Until
35544
+ * 2026-08-25 the delete lived on `processOne`'s success path only: an
35545
+ * `extractCrop` throw or an `encoder.encode` rejection (a cross-process cap
35546
+ * over UDS, with no timeout) stranded the frame permanently AND made that
35547
+ * trackId un-embeddable forever. `processOne` now releases in a `finally`.
35548
+ */
35462
35549
  pendingCrops = /* @__PURE__ */ new Map();
35463
35550
  flushTimer = null;
35464
35551
  unsubscribe = null;
@@ -35616,7 +35703,6 @@ var EmbeddingDispatcher = class {
35616
35703
  this._processedCount++;
35617
35704
  this._totalInferenceMs += inferenceMs;
35618
35705
  this.lastEmbedTime.set(pending.deviceId, Date.now());
35619
- this.pendingCrops.delete(pending.trackId);
35620
35706
  const embeddingId = `${pending.deviceId}/${pending.trackId}/${Date.now()}`;
35621
35707
  const payload = {
35622
35708
  deviceId: Number(pending.deviceId),
@@ -35648,8 +35734,21 @@ var EmbeddingDispatcher = class {
35648
35734
  error: String(err)
35649
35735
  }
35650
35736
  });
35737
+ } finally {
35738
+ if (this.pendingCrops.get(pending.trackId) === pending) this.pendingCrops.delete(pending.trackId);
35651
35739
  }
35652
35740
  }
35741
+ /**
35742
+ * Decoded frames currently pinned in {@link pendingCrops}.
35743
+ *
35744
+ * ~0.7 MB each (a whole RGB detection frame, not a crop), so this number IS
35745
+ * the dispatcher's memory footprint. Exposed because the entries are
35746
+ * invisible in every functional read: a stranded one looks exactly like a
35747
+ * track that was never detected.
35748
+ */
35749
+ pendingFrameCount() {
35750
+ return this.pendingCrops.size;
35751
+ }
35653
35752
  };
35654
35753
  /**
35655
35754
  * Re-embed every listed track from its key frame.
@@ -51444,9 +51543,14 @@ function deriveFrameDispatchKind(content) {
51444
51543
  }
51445
51544
  var DEFAULT_MAX_IN_FLIGHT_PER_DEVICE = 3;
51446
51545
  var DEFAULT_LOG_WINDOW_MS = 6e4;
51546
+ /** One `capture queue full` line per device per this interval. The condition
51547
+ * holds for as long as the lane is saturated; one line per shed request would
51548
+ * be thousands. */
51549
+ var OVERFLOW_WARN_THROTTLE_MS = 6e4;
51447
51550
  var CaptureScheduler = class {
51448
51551
  logger;
51449
51552
  budget;
51553
+ maxQueued;
51450
51554
  windowMs;
51451
51555
  now;
51452
51556
  lanes = /* @__PURE__ */ new Map();
@@ -51455,9 +51559,12 @@ var CaptureScheduler = class {
51455
51559
  /** holdKey → refcount of queued/running requests holding it. */
51456
51560
  heldKeys = /* @__PURE__ */ new Map();
51457
51561
  windows = /* @__PURE__ */ new Map();
51562
+ /** Per-device throttle for the `capture queue full` warn. */
51563
+ lastOverflowWarnAt = /* @__PURE__ */ new Map();
51458
51564
  constructor(options) {
51459
51565
  this.logger = options.logger;
51460
51566
  this.budget = options.maxInFlightPerDevice ?? DEFAULT_MAX_IN_FLIGHT_PER_DEVICE;
51567
+ this.maxQueued = Math.max(1, options.maxQueuedPerDevice ?? 24);
51461
51568
  this.windowMs = options.logWindowMs ?? DEFAULT_LOG_WINDOW_MS;
51462
51569
  this.now = options.now ?? Date.now;
51463
51570
  }
@@ -51527,6 +51634,7 @@ var CaptureScheduler = class {
51527
51634
  failed: w.failed,
51528
51635
  coalesced: w.coalesced,
51529
51636
  droppedStale: w.droppedStale,
51637
+ droppedOverflow: w.droppedOverflow,
51530
51638
  cancelled: w.cancelled,
51531
51639
  maxQueueDepth: w.maxQueueDepth
51532
51640
  };
@@ -51575,7 +51683,37 @@ var CaptureScheduler = class {
51575
51683
  throw err;
51576
51684
  });
51577
51685
  }
51578
- /** Queue an over-budget request (priority-ordered, FIFO within priority). */
51686
+ /**
51687
+ * Queue an over-budget request (priority-ordered, FIFO within priority),
51688
+ * under a BOUND enforced here rather than at dequeue.
51689
+ *
51690
+ * ## Why the queue needs its own bound
51691
+ *
51692
+ * `staleAfterMs` is evaluated in {@link pump}, and `pump` only touches the
51693
+ * queue while `inFlight < budget`. A lane that stays saturated therefore
51694
+ * never inspects its queue at all — nothing expires, nothing is shed, and the
51695
+ * queue grows for as long as the pressure lasts. The budget bounds concurrent
51696
+ * WORK; it has never bounded queued work.
51697
+ *
51698
+ * That gap is not theoretical: the per-frame batch dispatch PINS a ~0.7 MB
51699
+ * copy of the detection frame BEFORE queueing (so ring recycling cannot
51700
+ * invalidate it), and the queued closure holds that copy alive. Measured on
51701
+ * the live hub, `hub/pipeline-analytics` ran at rss=3911MB with heapUsed only
51702
+ * 153MB and arrayBuffers=2281MB — 2.28 GB of JS-reachable Buffers, roughly
51703
+ * 3 200 pinned frames, in a runner whose heap limit is 1216 MB. The call site
51704
+ * that pins them says the cost is "bounded by the lane"; it was not.
51705
+ *
51706
+ * Two things happen on the way in, in this order:
51707
+ *
51708
+ * 1. **expire what is already dead.** Entries past their `staleAfterMs` are
51709
+ * dropped now. They were doomed the moment they expired; `pump` simply
51710
+ * never got to look. This is the reclaim that costs nothing.
51711
+ * 2. **shed the least important.** Still over the cap, the TAIL goes — the
51712
+ * queue is ordered by priority, so the tail is by construction the work
51713
+ * this scheduler already ranked last. Whether that is the newcomer or an
51714
+ * older low-priority entry is decided by the same ordering that decides
51715
+ * execution, so a `firstFrame` never loses its place to an `embed`.
51716
+ */
51579
51717
  enqueue(lane, input) {
51580
51718
  return new Promise((resolve, reject) => {
51581
51719
  const entry = {
@@ -51588,11 +51726,53 @@ var CaptureScheduler = class {
51588
51726
  },
51589
51727
  drop: () => resolve(null)
51590
51728
  };
51729
+ this.dropExpired(input.deviceId, lane);
51591
51730
  this.insertByPriority(lane, entry);
51731
+ this.shedToCap(input.deviceId, lane);
51592
51732
  const w = this.window(input.deviceId);
51593
51733
  if (lane.queue.length > w.maxQueueDepth) w.maxQueueDepth = lane.queue.length;
51594
51734
  });
51595
51735
  }
51736
+ /** Drop every queued entry already past its `staleAfterMs`. Entries without
51737
+ * one (e.g. `confirm`, which gates track birth) are never expired here. */
51738
+ dropExpired(deviceId, lane) {
51739
+ const nowMs = this.now();
51740
+ for (let i = lane.queue.length - 1; i >= 0; i -= 1) {
51741
+ const entry = lane.queue[i];
51742
+ if (entry === void 0) continue;
51743
+ if (entry.staleAfterMs === void 0 || nowMs - entry.enqueuedAt <= entry.staleAfterMs) continue;
51744
+ lane.queue.splice(i, 1);
51745
+ this.bump(deviceId, "droppedStale");
51746
+ entry.drop();
51747
+ }
51748
+ }
51749
+ /** Shed from the tail (lowest priority, newest within it) until the lane is
51750
+ * at its cap. Loud: a capture that is dropped rather than merely late is a
51751
+ * missing thumbnail or first frame, and silence would read as "never asked". */
51752
+ shedToCap(deviceId, lane) {
51753
+ let shed = 0;
51754
+ while (lane.queue.length > this.maxQueued) {
51755
+ const victim = lane.queue.pop();
51756
+ if (victim === void 0) break;
51757
+ this.bump(deviceId, "droppedOverflow");
51758
+ victim.drop();
51759
+ shed += 1;
51760
+ }
51761
+ if (shed === 0) return;
51762
+ const nowMs = this.now();
51763
+ if (nowMs - (this.lastOverflowWarnAt.get(deviceId) ?? Number.NEGATIVE_INFINITY) < OVERFLOW_WARN_THROTTLE_MS) return;
51764
+ this.lastOverflowWarnAt.set(deviceId, nowMs);
51765
+ this.logger.warn("capture queue full — shedding the lowest-priority captures for this camera", {
51766
+ tags: { deviceId },
51767
+ meta: {
51768
+ shed,
51769
+ cap: this.maxQueued,
51770
+ queueDepth: lane.queue.length,
51771
+ inFlight: lane.inFlight,
51772
+ budget: this.budget
51773
+ }
51774
+ });
51775
+ }
51596
51776
  insertByPriority(lane, entry) {
51597
51777
  let index = lane.queue.length;
51598
51778
  while (index > 0) {
@@ -51639,6 +51819,7 @@ var CaptureScheduler = class {
51639
51819
  failed: 0,
51640
51820
  coalesced: 0,
51641
51821
  droppedStale: 0,
51822
+ droppedOverflow: 0,
51642
51823
  cancelled: 0,
51643
51824
  maxQueueDepth: 0
51644
51825
  };
@@ -51663,6 +51844,7 @@ var CaptureScheduler = class {
51663
51844
  failed: w.failed,
51664
51845
  coalesced: w.coalesced,
51665
51846
  droppedStale: w.droppedStale,
51847
+ droppedOverflow: w.droppedOverflow,
51666
51848
  cancelled: w.cancelled,
51667
51849
  maxQueueDepth: w.maxQueueDepth,
51668
51850
  queueDepth: this.queueDepth(deviceId),
@@ -53233,8 +53415,14 @@ var EventMediaDispatcher = class {
53233
53415
  * scheduler request against a 3-slot device lane and can start seconds late,
53234
53416
  * by which time the handle's ring slot is usually recycled and the by-handle
53235
53417
  * resolve in {@link captureForFrame} finds nothing (the 2026-07-23 boxed-tile
53236
- * regression). ~0.7MB per pinned frame, freed with the batch — bounded by the
53237
- * lane. Returns `null` on a genuine live-window miss.
53418
+ * regression). ~0.7MB per pinned frame, freed with the batch.
53419
+ *
53420
+ * THE COPY IS ONLY AS BOUNDED AS THE QUEUE THAT HOLDS IT. This said "bounded
53421
+ * by the lane" while the lane bounded in-flight execs (3) and nothing at all
53422
+ * bounded the queue behind them, so a saturated camera accumulated pinned
53423
+ * frames without limit — 2.28 GB of `arrayBuffers` measured on the live hub.
53424
+ * Every caller must therefore pass `staleAfterMs`, and `CaptureScheduler`
53425
+ * caps queue depth per device. Returns `null` on a genuine live-window miss.
53238
53426
  *
53239
53427
  * Both miss paths are WARN, throttled to one line per device per minute. They
53240
53428
  * are the FIRST half of a pair whose second half (`capture batch lost`) has
@@ -60708,6 +60896,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
60708
60896
  hasSnapshot: snapshotTargets.some((t) => t.appendSnapshot)
60709
60897
  }),
60710
60898
  holdKeys: heldCaptureKeys,
60899
+ staleAfterMs: ENRICHMENT_CAPTURE_STALE_MS,
60711
60900
  exec: () => pinnedFramePromise.then((pinnedFrame) => dispatcher.captureForFrame({
60712
60901
  deviceId,
60713
60902
  frameHandle,
@@ -61637,7 +61826,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
61637
61826
  this.captureScheduler.request({
61638
61827
  deviceId,
61639
61828
  kind: "thumbnail",
61829
+ trackId,
61640
61830
  holdKeys: [captureCoalesceKey(deviceId, "thumbnail", trackId)],
61831
+ staleAfterMs: ENRICHMENT_CAPTURE_STALE_MS,
61641
61832
  exec: async () => {
61642
61833
  const [pinnedFrame, media] = await Promise.all([pinnedFramePromise, this.resolveDeviceMediaSettings(deviceId)]);
61643
61834
  const res = await dispatcher.captureForFrame({
@@ -1,4 +1,4 @@
1
- import { $ as customAction, A as NcTaxonomySchema, At as sleep, B as TIMELAPSE_DENSE_FLOOR_SEC, Bt as string, C as NcRuleSchema, Ct as errMsg, D as NcSnoozeSchema, Dt as hydrateSchema, E as NcSnoozeInputSchema, Et as createEvent, Ft as literal, G as VISIT_MERGE_GAP_MS, H as TimelapseRulePatchSchema, Ht as EventCategory, I as SCENE_DEFAULT_ANCHOR_THRESHOLD, It as number, J as assertTimelapseCadences, K as addonWidgetsSourceCapability, L as SCENE_DEFAULT_UNCOVERED_POLICY, Lt as object, Mt as array, N as RECORDING_EXPORT_MAX_READ_BYTES, Nt as boolean, O as NcSnoozeSuppressedSchema, Ot as isDeviceScopedCap, P as RetrainStatusSchema, Pt as discriminatedUnion, Q as cosineSimilarity$1, R as SCENE_DIVERGED, Rt as partialRecord, S as NcRulePatchSchema, St as zoneAnalyticsCapability, T as NcScheduleSchema, Tt as DeviceType, U as TimelapseRuleSchema, V as TimelapseRuleInputSchema, Vt as unknown, W as TrackSourceSchema, X as audioModeOf, Y as audioMetricsCapability, Z as buildEventKindDescriptor, _ as NC_DEFAULT_SNOOZE_MINUTES, _t as sceneMonitorCapability, a as DEFAULT_EVENT_COLOR, b as NcConditionDescriptorSchema, bt as vectorDimFromBase64, ct as kebabToCamel, d as EVENT_PAD_MS, dt as pickClusterStepModels, et as defineCustomActions, f as FULL_IMAGE_BBOX, ft as pipelineAnalyticsCapability, g as NC_CONDITION_CATALOG, h as NC_ALARM_SYSTEM_EVENT_KINDS, ht as readTimelapseGeneratedAt, i as CLUSTER_MODEL_SCOPED_STEPS, it as faceGalleryCapability, j as OpsLogEntrySchema, jt as _enum, k as NcSystemEventKindSchema, kt as nodePin, l as DeclaredDevices, lt as notificationRulesCapability, m as MACRO_LABELS, mt as readDeviceStateFrom, n as AnalyticsGroupRecordSchema, ot as isDetectionMacroClass, p as LabelAttributionSchema, pt as plateGalleryCapability, q as alarmPanelCapability, r as BaseDevice, rt as encodeVectorBase64, s as DETECTION_MACRO_CLASSES, st as isScheduleActive, t as AnalyticsGroupDetailSchema, tt as deriveRecordingMode, u as EVENT_KIND_BY_CAP, vt as subKindsOf, w as NcRuleTargetSchema, wt as BaseAddon, x as NcRuleInputSchema, xt as videoclipsCapability, y as NC_TAXONOMY, yt as systemEventFilterApplies, z as SceneMonitorSchema, zt as record } from "../dist-Gn2fFMov.mjs";
1
+ import { $ as customAction, A as NcTaxonomySchema, At as sleep, B as TIMELAPSE_DENSE_FLOOR_SEC, Bt as string, C as NcRuleSchema, Ct as errMsg, D as NcSnoozeSchema, Dt as hydrateSchema, E as NcSnoozeInputSchema, Et as createEvent, Ft as literal, G as VISIT_MERGE_GAP_MS, H as TimelapseRulePatchSchema, Ht as EventCategory, I as SCENE_DEFAULT_ANCHOR_THRESHOLD, It as number, J as assertTimelapseCadences, K as addonWidgetsSourceCapability, L as SCENE_DEFAULT_UNCOVERED_POLICY, Lt as object, Mt as array, N as RECORDING_EXPORT_MAX_READ_BYTES, Nt as boolean, O as NcSnoozeSuppressedSchema, Ot as isDeviceScopedCap, P as RetrainStatusSchema, Pt as discriminatedUnion, Q as cosineSimilarity$1, R as SCENE_DIVERGED, Rt as partialRecord, S as NcRulePatchSchema, St as zoneAnalyticsCapability, T as NcScheduleSchema, Tt as DeviceType, U as TimelapseRuleSchema, V as TimelapseRuleInputSchema, Vt as unknown, W as TrackSourceSchema, X as audioModeOf, Y as audioMetricsCapability, Z as buildEventKindDescriptor, _ as NC_DEFAULT_SNOOZE_MINUTES, _t as sceneMonitorCapability, a as DEFAULT_EVENT_COLOR, b as NcConditionDescriptorSchema, bt as vectorDimFromBase64, ct as kebabToCamel, d as EVENT_PAD_MS, dt as pickClusterStepModels, et as defineCustomActions, f as FULL_IMAGE_BBOX, ft as pipelineAnalyticsCapability, g as NC_CONDITION_CATALOG, h as NC_ALARM_SYSTEM_EVENT_KINDS, ht as readTimelapseGeneratedAt, i as CLUSTER_MODEL_SCOPED_STEPS, it as faceGalleryCapability, j as OpsLogEntrySchema, jt as _enum, k as NcSystemEventKindSchema, kt as nodePin, l as DeclaredDevices, lt as notificationRulesCapability, m as MACRO_LABELS, mt as readDeviceStateFrom, n as AnalyticsGroupRecordSchema, ot as isDetectionMacroClass, p as LabelAttributionSchema, pt as plateGalleryCapability, q as alarmPanelCapability, r as BaseDevice, rt as encodeVectorBase64, s as DETECTION_MACRO_CLASSES, st as isScheduleActive, t as AnalyticsGroupDetailSchema, tt as deriveRecordingMode, u as EVENT_KIND_BY_CAP, vt as subKindsOf, w as NcRuleTargetSchema, wt as BaseAddon, x as NcRuleInputSchema, xt as videoclipsCapability, y as NC_TAXONOMY, yt as systemEventFilterApplies, z as SceneMonitorSchema, zt as record } from "../dist-R0Wd863b.mjs";
2
2
  import { t as __exportAll } from "../embedding-encoder/index.mjs";
3
3
  import * as fs from "node:fs";
4
4
  import { promises } from "node:fs";
@@ -4391,10 +4391,28 @@ var SCENE_LATCH_INDEXES = [{
4391
4391
  function sceneKey(deviceId, monitorId) {
4392
4392
  return `${deviceId}:${monitorId}`;
4393
4393
  }
4394
+ /**
4395
+ * Reseed cap for BOTH scene ledgers.
4396
+ *
4397
+ * `DurableLedger` defaults to 100_000 and its own docblock says the owner "sets
4398
+ * it from the row set it expects". Neither scene ledger did, so the 30s
4399
+ * reconcile asked SQLite — through hub-main's event loop, which is the
4400
+ * cluster's only queue (D181) — for up to a hundred thousand rows twice a tick,
4401
+ * on a hub holding one scene and one latch (measured live 2026-08-25). A bound
4402
+ * nobody chose is not a bound.
4403
+ *
4404
+ * A scene is an operator-drawn monitor on ONE camera and a latch is at most one
4405
+ * row per scene, so the row set is `cameras x monitors-per-camera`. 5_000 is
4406
+ * ~5 monitors on each of a thousand cameras — far past any installation this
4407
+ * ships to, and still small enough that a runaway write loop is CAUGHT by the
4408
+ * bound instead of being paged in whole.
4409
+ */
4410
+ var SCENE_LEDGER_LOAD_LIMIT = 5e3;
4394
4411
  var SCENE_CONFIG_SPEC = {
4395
4412
  collection: SCENE_COLLECTION,
4396
4413
  columns: SCENE_COLUMNS,
4397
4414
  indexes: SCENE_INDEXES,
4415
+ loadLimit: SCENE_LEDGER_LOAD_LIMIT,
4398
4416
  writeMode: "write-behind",
4399
4417
  keyOf: (row) => row.key,
4400
4418
  toValue: (row) => ({
@@ -4410,6 +4428,7 @@ var SCENE_LATCH_SPEC = {
4410
4428
  collection: SCENE_LATCH_COLLECTION,
4411
4429
  columns: SCENE_LATCH_COLUMNS,
4412
4430
  indexes: SCENE_LATCH_INDEXES,
4431
+ loadLimit: SCENE_LEDGER_LOAD_LIMIT,
4413
4432
  writeMode: "write-through",
4414
4433
  keyOf: (row) => row.key,
4415
4434
  toValue: (row) => ({
@@ -4439,6 +4458,9 @@ var SceneStore = class {
4439
4458
  config;
4440
4459
  latch;
4441
4460
  logger;
4461
+ /** `scenes/latches` as last announced. Null until the first load, so the
4462
+ * first one always speaks. */
4463
+ lastLoggedCounts = null;
4442
4464
  constructor(deps) {
4443
4465
  this.logger = deps.logger;
4444
4466
  this.config = new DurableLedger({
@@ -4463,10 +4485,20 @@ var SceneStore = class {
4463
4485
  * a container recreate, "loaded 0" is the one line that explains why every
4464
4486
  * scene suddenly reads `unknown`, and its absence is what turns a data loss
4465
4487
  * into a three-hour hunt.
4488
+ *
4489
+ * ON CHANGE ONLY, though — including the first load, where there is nothing
4490
+ * to compare against. This runs on the 30s `SCENE_RELOAD_INTERVAL_MS` tick on
4491
+ * every node, and it used to print unconditionally: measured live 2026-08-25,
4492
+ * `scene state loaded {scenes=1 latches=1}` every 30 seconds forever. A count
4493
+ * reprinted unchanged twice a minute is exactly how the "loaded 0" this line
4494
+ * exists for gets scrolled past.
4466
4495
  */
4467
4496
  async load() {
4468
4497
  const scenes = await this.config.load();
4469
4498
  const latches = await this.latch.load();
4499
+ const counts = `${scenes.length}/${latches.length}`;
4500
+ if (counts === this.lastLoggedCounts) return;
4501
+ this.lastLoggedCounts = counts;
4470
4502
  this.logger.info("scene state loaded", { meta: {
4471
4503
  scenes: scenes.length,
4472
4504
  latches: latches.length
@@ -8532,6 +8564,49 @@ var NC_LANGUAGES = ["en", "it"];
8532
8564
  * surface's view all validate against this one, so a third language is added
8533
8565
  * to `NC_LANGUAGES` and nowhere else. */
8534
8566
  var NcLanguageSchema = _enum(NC_LANGUAGES);
8567
+ /**
8568
+ * Whether two snapshots say the same thing.
8569
+ *
8570
+ * ## Why this is a function and not `===`
8571
+ *
8572
+ * The reload guard in `reloadTexts()` used to be
8573
+ * `before.overrides === next.overrides`, and `next` comes from
8574
+ * `NcTextCatalogStore.load()`, which `parse()`s the row through zod on every
8575
+ * call. The right-hand side is therefore a NEW object on every tick by
8576
+ * construction: the comparison was `false` at byte-identical content, forever.
8577
+ * Measured on the live hub 2026-08-25: 58 "notification texts loaded" lines in
8578
+ * 30 minutes on a hub whose language had not changed, from a line whose own
8579
+ * docblock promises it fires on CHANGE only.
8580
+ *
8581
+ * The document is two levels deep and installation-small (at most one entry per
8582
+ * language, each a flat key→string map), so the comparison is written out
8583
+ * rather than reached for through a deep-equal dependency — and written out it
8584
+ * can state the direction that matters: a REMOVED key must not read as equal,
8585
+ * which a one-directional key walk would get wrong.
8586
+ */
8587
+ function ncTextSnapshotsEqual(a, b) {
8588
+ if (a.language !== b.language) return false;
8589
+ return overrideMapsEqual(a.overrides, b.overrides);
8590
+ }
8591
+ function overrideMapsEqual(a, b) {
8592
+ const languages = new Set([...Object.keys(a), ...Object.keys(b)]);
8593
+ for (const language of languages) {
8594
+ const left = a[language];
8595
+ const right = b[language];
8596
+ if (left === void 0 || right === void 0) {
8597
+ if (Object.keys(left ?? right ?? {}).length > 0) return false;
8598
+ continue;
8599
+ }
8600
+ if (!flatMapsEqual(left, right)) return false;
8601
+ }
8602
+ return true;
8603
+ }
8604
+ function flatMapsEqual(a, b) {
8605
+ const keys = Object.keys(a);
8606
+ if (keys.length !== Object.keys(b).length) return false;
8607
+ for (const key of keys) if (a[key] !== b[key]) return false;
8608
+ return true;
8609
+ }
8535
8610
  /** The shipped files, keyed by language. Data, never mutated. */
8536
8611
  var NC_TEXT_BUNDLES = {
8537
8612
  en: en_default,
@@ -22462,7 +22537,7 @@ var NotificationCenter = class NotificationCenter {
22462
22537
  const before = this.texts.snapshot;
22463
22538
  const next = await this.textStore.load();
22464
22539
  this.texts.apply(next);
22465
- if (before.language === next.language && before.overrides === next.overrides) return;
22540
+ if (ncTextSnapshotsEqual(before, next)) return;
22466
22541
  this.logger.info("notification texts loaded", { meta: {
22467
22542
  language: next.language,
22468
22543
  overriddenKeys: Object.values(next.overrides).reduce((sum, keys) => sum + Object.keys(keys ?? {}).length, 0)
@@ -35397,6 +35472,18 @@ var EmbeddingDispatcher = class {
35397
35472
  logger;
35398
35473
  getRemoteFrame;
35399
35474
  lastEmbedTime = /* @__PURE__ */ new Map();
35475
+ /**
35476
+ * trackId -> the frame pinned for it. Each entry holds a WHOLE decoded RGB
35477
+ * frame (`PendingCrop.frameData`, ~0.7 MB), not a crop.
35478
+ *
35479
+ * Under the shipped `cropStrategy: 'first'` this doubles as the in-flight
35480
+ * DEDUPE FLAG — `'first'` gates on `!pendingCrops.has(trackId)` — and there
35481
+ * is no flush timer for that strategy, so nothing ever sweeps it. Until
35482
+ * 2026-08-25 the delete lived on `processOne`'s success path only: an
35483
+ * `extractCrop` throw or an `encoder.encode` rejection (a cross-process cap
35484
+ * over UDS, with no timeout) stranded the frame permanently AND made that
35485
+ * trackId un-embeddable forever. `processOne` now releases in a `finally`.
35486
+ */
35400
35487
  pendingCrops = /* @__PURE__ */ new Map();
35401
35488
  flushTimer = null;
35402
35489
  unsubscribe = null;
@@ -35554,7 +35641,6 @@ var EmbeddingDispatcher = class {
35554
35641
  this._processedCount++;
35555
35642
  this._totalInferenceMs += inferenceMs;
35556
35643
  this.lastEmbedTime.set(pending.deviceId, Date.now());
35557
- this.pendingCrops.delete(pending.trackId);
35558
35644
  const embeddingId = `${pending.deviceId}/${pending.trackId}/${Date.now()}`;
35559
35645
  const payload = {
35560
35646
  deviceId: Number(pending.deviceId),
@@ -35586,8 +35672,21 @@ var EmbeddingDispatcher = class {
35586
35672
  error: String(err)
35587
35673
  }
35588
35674
  });
35675
+ } finally {
35676
+ if (this.pendingCrops.get(pending.trackId) === pending) this.pendingCrops.delete(pending.trackId);
35589
35677
  }
35590
35678
  }
35679
+ /**
35680
+ * Decoded frames currently pinned in {@link pendingCrops}.
35681
+ *
35682
+ * ~0.7 MB each (a whole RGB detection frame, not a crop), so this number IS
35683
+ * the dispatcher's memory footprint. Exposed because the entries are
35684
+ * invisible in every functional read: a stranded one looks exactly like a
35685
+ * track that was never detected.
35686
+ */
35687
+ pendingFrameCount() {
35688
+ return this.pendingCrops.size;
35689
+ }
35591
35690
  };
35592
35691
  /**
35593
35692
  * Re-embed every listed track from its key frame.
@@ -51381,9 +51480,14 @@ function deriveFrameDispatchKind(content) {
51381
51480
  }
51382
51481
  var DEFAULT_MAX_IN_FLIGHT_PER_DEVICE = 3;
51383
51482
  var DEFAULT_LOG_WINDOW_MS = 6e4;
51483
+ /** One `capture queue full` line per device per this interval. The condition
51484
+ * holds for as long as the lane is saturated; one line per shed request would
51485
+ * be thousands. */
51486
+ var OVERFLOW_WARN_THROTTLE_MS = 6e4;
51384
51487
  var CaptureScheduler = class {
51385
51488
  logger;
51386
51489
  budget;
51490
+ maxQueued;
51387
51491
  windowMs;
51388
51492
  now;
51389
51493
  lanes = /* @__PURE__ */ new Map();
@@ -51392,9 +51496,12 @@ var CaptureScheduler = class {
51392
51496
  /** holdKey → refcount of queued/running requests holding it. */
51393
51497
  heldKeys = /* @__PURE__ */ new Map();
51394
51498
  windows = /* @__PURE__ */ new Map();
51499
+ /** Per-device throttle for the `capture queue full` warn. */
51500
+ lastOverflowWarnAt = /* @__PURE__ */ new Map();
51395
51501
  constructor(options) {
51396
51502
  this.logger = options.logger;
51397
51503
  this.budget = options.maxInFlightPerDevice ?? DEFAULT_MAX_IN_FLIGHT_PER_DEVICE;
51504
+ this.maxQueued = Math.max(1, options.maxQueuedPerDevice ?? 24);
51398
51505
  this.windowMs = options.logWindowMs ?? DEFAULT_LOG_WINDOW_MS;
51399
51506
  this.now = options.now ?? Date.now;
51400
51507
  }
@@ -51464,6 +51571,7 @@ var CaptureScheduler = class {
51464
51571
  failed: w.failed,
51465
51572
  coalesced: w.coalesced,
51466
51573
  droppedStale: w.droppedStale,
51574
+ droppedOverflow: w.droppedOverflow,
51467
51575
  cancelled: w.cancelled,
51468
51576
  maxQueueDepth: w.maxQueueDepth
51469
51577
  };
@@ -51512,7 +51620,37 @@ var CaptureScheduler = class {
51512
51620
  throw err;
51513
51621
  });
51514
51622
  }
51515
- /** Queue an over-budget request (priority-ordered, FIFO within priority). */
51623
+ /**
51624
+ * Queue an over-budget request (priority-ordered, FIFO within priority),
51625
+ * under a BOUND enforced here rather than at dequeue.
51626
+ *
51627
+ * ## Why the queue needs its own bound
51628
+ *
51629
+ * `staleAfterMs` is evaluated in {@link pump}, and `pump` only touches the
51630
+ * queue while `inFlight < budget`. A lane that stays saturated therefore
51631
+ * never inspects its queue at all — nothing expires, nothing is shed, and the
51632
+ * queue grows for as long as the pressure lasts. The budget bounds concurrent
51633
+ * WORK; it has never bounded queued work.
51634
+ *
51635
+ * That gap is not theoretical: the per-frame batch dispatch PINS a ~0.7 MB
51636
+ * copy of the detection frame BEFORE queueing (so ring recycling cannot
51637
+ * invalidate it), and the queued closure holds that copy alive. Measured on
51638
+ * the live hub, `hub/pipeline-analytics` ran at rss=3911MB with heapUsed only
51639
+ * 153MB and arrayBuffers=2281MB — 2.28 GB of JS-reachable Buffers, roughly
51640
+ * 3 200 pinned frames, in a runner whose heap limit is 1216 MB. The call site
51641
+ * that pins them says the cost is "bounded by the lane"; it was not.
51642
+ *
51643
+ * Two things happen on the way in, in this order:
51644
+ *
51645
+ * 1. **expire what is already dead.** Entries past their `staleAfterMs` are
51646
+ * dropped now. They were doomed the moment they expired; `pump` simply
51647
+ * never got to look. This is the reclaim that costs nothing.
51648
+ * 2. **shed the least important.** Still over the cap, the TAIL goes — the
51649
+ * queue is ordered by priority, so the tail is by construction the work
51650
+ * this scheduler already ranked last. Whether that is the newcomer or an
51651
+ * older low-priority entry is decided by the same ordering that decides
51652
+ * execution, so a `firstFrame` never loses its place to an `embed`.
51653
+ */
51516
51654
  enqueue(lane, input) {
51517
51655
  return new Promise((resolve, reject) => {
51518
51656
  const entry = {
@@ -51525,11 +51663,53 @@ var CaptureScheduler = class {
51525
51663
  },
51526
51664
  drop: () => resolve(null)
51527
51665
  };
51666
+ this.dropExpired(input.deviceId, lane);
51528
51667
  this.insertByPriority(lane, entry);
51668
+ this.shedToCap(input.deviceId, lane);
51529
51669
  const w = this.window(input.deviceId);
51530
51670
  if (lane.queue.length > w.maxQueueDepth) w.maxQueueDepth = lane.queue.length;
51531
51671
  });
51532
51672
  }
51673
+ /** Drop every queued entry already past its `staleAfterMs`. Entries without
51674
+ * one (e.g. `confirm`, which gates track birth) are never expired here. */
51675
+ dropExpired(deviceId, lane) {
51676
+ const nowMs = this.now();
51677
+ for (let i = lane.queue.length - 1; i >= 0; i -= 1) {
51678
+ const entry = lane.queue[i];
51679
+ if (entry === void 0) continue;
51680
+ if (entry.staleAfterMs === void 0 || nowMs - entry.enqueuedAt <= entry.staleAfterMs) continue;
51681
+ lane.queue.splice(i, 1);
51682
+ this.bump(deviceId, "droppedStale");
51683
+ entry.drop();
51684
+ }
51685
+ }
51686
+ /** Shed from the tail (lowest priority, newest within it) until the lane is
51687
+ * at its cap. Loud: a capture that is dropped rather than merely late is a
51688
+ * missing thumbnail or first frame, and silence would read as "never asked". */
51689
+ shedToCap(deviceId, lane) {
51690
+ let shed = 0;
51691
+ while (lane.queue.length > this.maxQueued) {
51692
+ const victim = lane.queue.pop();
51693
+ if (victim === void 0) break;
51694
+ this.bump(deviceId, "droppedOverflow");
51695
+ victim.drop();
51696
+ shed += 1;
51697
+ }
51698
+ if (shed === 0) return;
51699
+ const nowMs = this.now();
51700
+ if (nowMs - (this.lastOverflowWarnAt.get(deviceId) ?? Number.NEGATIVE_INFINITY) < OVERFLOW_WARN_THROTTLE_MS) return;
51701
+ this.lastOverflowWarnAt.set(deviceId, nowMs);
51702
+ this.logger.warn("capture queue full — shedding the lowest-priority captures for this camera", {
51703
+ tags: { deviceId },
51704
+ meta: {
51705
+ shed,
51706
+ cap: this.maxQueued,
51707
+ queueDepth: lane.queue.length,
51708
+ inFlight: lane.inFlight,
51709
+ budget: this.budget
51710
+ }
51711
+ });
51712
+ }
51533
51713
  insertByPriority(lane, entry) {
51534
51714
  let index = lane.queue.length;
51535
51715
  while (index > 0) {
@@ -51576,6 +51756,7 @@ var CaptureScheduler = class {
51576
51756
  failed: 0,
51577
51757
  coalesced: 0,
51578
51758
  droppedStale: 0,
51759
+ droppedOverflow: 0,
51579
51760
  cancelled: 0,
51580
51761
  maxQueueDepth: 0
51581
51762
  };
@@ -51600,6 +51781,7 @@ var CaptureScheduler = class {
51600
51781
  failed: w.failed,
51601
51782
  coalesced: w.coalesced,
51602
51783
  droppedStale: w.droppedStale,
51784
+ droppedOverflow: w.droppedOverflow,
51603
51785
  cancelled: w.cancelled,
51604
51786
  maxQueueDepth: w.maxQueueDepth,
51605
51787
  queueDepth: this.queueDepth(deviceId),
@@ -53170,8 +53352,14 @@ var EventMediaDispatcher = class {
53170
53352
  * scheduler request against a 3-slot device lane and can start seconds late,
53171
53353
  * by which time the handle's ring slot is usually recycled and the by-handle
53172
53354
  * resolve in {@link captureForFrame} finds nothing (the 2026-07-23 boxed-tile
53173
- * regression). ~0.7MB per pinned frame, freed with the batch — bounded by the
53174
- * lane. Returns `null` on a genuine live-window miss.
53355
+ * regression). ~0.7MB per pinned frame, freed with the batch.
53356
+ *
53357
+ * THE COPY IS ONLY AS BOUNDED AS THE QUEUE THAT HOLDS IT. This said "bounded
53358
+ * by the lane" while the lane bounded in-flight execs (3) and nothing at all
53359
+ * bounded the queue behind them, so a saturated camera accumulated pinned
53360
+ * frames without limit — 2.28 GB of `arrayBuffers` measured on the live hub.
53361
+ * Every caller must therefore pass `staleAfterMs`, and `CaptureScheduler`
53362
+ * caps queue depth per device. Returns `null` on a genuine live-window miss.
53175
53363
  *
53176
53364
  * Both miss paths are WARN, throttled to one line per device per minute. They
53177
53365
  * are the FIRST half of a pair whose second half (`capture batch lost`) has
@@ -60645,6 +60833,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
60645
60833
  hasSnapshot: snapshotTargets.some((t) => t.appendSnapshot)
60646
60834
  }),
60647
60835
  holdKeys: heldCaptureKeys,
60836
+ staleAfterMs: ENRICHMENT_CAPTURE_STALE_MS,
60648
60837
  exec: () => pinnedFramePromise.then((pinnedFrame) => dispatcher.captureForFrame({
60649
60838
  deviceId,
60650
60839
  frameHandle,
@@ -61574,7 +61763,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
61574
61763
  this.captureScheduler.request({
61575
61764
  deviceId,
61576
61765
  kind: "thumbnail",
61766
+ trackId,
61577
61767
  holdKeys: [captureCoalesceKey(deviceId, "thumbnail", trackId)],
61768
+ staleAfterMs: ENRICHMENT_CAPTURE_STALE_MS,
61578
61769
  exec: async () => {
61579
61770
  const [pinnedFrame, media] = await Promise.all([pinnedFramePromise, this.resolveDeviceMediaSettings(deviceId)]);
61580
61771
  const res = await dispatcher.captureForFrame({
@@ -30,7 +30,7 @@ async function d(e) {
30
30
  }
31
31
  }
32
32
  async function f() {
33
- return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-BYzwylnw.mjs")).catch((e) => {
33
+ return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-CEBcz1NX.mjs")).catch((e) => {
34
34
  throw l = void 0, e;
35
35
  }), l;
36
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-post-analysis",
3
- "version": "1.2.118",
3
+ "version": "1.2.119",
4
4
  "description": "Post-Analysis bundle — enrichment, embedding-encoder, pipeline-analytics. Multi-entry npm package shipping addons that consume pipeline output.",
5
5
  "keywords": [
6
6
  "camstack",