@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.
- package/dist/{dist-CO6pe7bi.js → dist-BtIolp2d.js} +11 -1
- package/dist/{dist-Gn2fFMov.mjs → dist-R0Wd863b.mjs} +11 -1
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-BYzwylnw.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-CEBcz1NX.mjs} +1 -1
- package/dist/pipeline-analytics/{hostInit-TlKH3NQ8.mjs → hostInit-CP5WsAw5.mjs} +1 -1
- package/dist/pipeline-analytics/index.js +197 -6
- package/dist/pipeline-analytics/index.mjs +197 -6
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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";
|
|
@@ -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-
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
53237
|
-
*
|
|
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-
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
53174
|
-
*
|
|
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-
|
|
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.
|
|
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",
|