@camstack/addon-post-analysis 1.2.273 → 1.2.275
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-m5QdWXqR.js → dist-Br0p61E2.js} +4653 -473
- package/dist/{dist-PZVHV70g.mjs → dist-D3Yub96m.mjs} +4606 -474
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/pipeline-analytics/_stub.js +2 -2
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-FrVPrAXW.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-Byz9Hzo0.mjs} +2 -2
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-B7oKeWXX.mjs +26 -0
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BZetaopl.mjs +26 -0
- package/dist/pipeline-analytics/{hostInit-jsytt0xF.mjs → hostInit-Dza3GRpt.mjs} +2 -2
- package/dist/pipeline-analytics/index.js +1035 -713
- package/dist/pipeline-analytics/index.mjs +1033 -711
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BhgGVqqB.mjs +0 -26
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-mG9AoIzw.mjs +0 -26
|
@@ -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-Br0p61E2.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");
|
|
@@ -1460,7 +1460,8 @@ var NcAlarmPanelDevice = class extends require_dist.BaseDevice {
|
|
|
1460
1460
|
return {
|
|
1461
1461
|
...this.readDelays(),
|
|
1462
1462
|
announceArm: this.config.get("announceArm"),
|
|
1463
|
-
announceTargets: [...this.config.get("announceTargets")]
|
|
1463
|
+
announceTargets: [...this.config.get("announceTargets")],
|
|
1464
|
+
nonBlocking: []
|
|
1464
1465
|
};
|
|
1465
1466
|
}
|
|
1466
1467
|
/**
|
|
@@ -10822,6 +10823,23 @@ function requireCapCaller(method, caller) {
|
|
|
10822
10823
|
return caller;
|
|
10823
10824
|
}
|
|
10824
10825
|
//#endregion
|
|
10826
|
+
//#region src/notification-center/clock-text.ts
|
|
10827
|
+
/**
|
|
10828
|
+
* `{{time}}` — hours and minutes in the NOTIFICATION language.
|
|
10829
|
+
*
|
|
10830
|
+
* It used to be `toLocaleTimeString()` in the server's own locale, so an
|
|
10831
|
+
* Italian hub running an English container sent "8:05:13 PM" inside an
|
|
10832
|
+
* Italian sentence. The time zone is the host's unless a caller (a spec)
|
|
10833
|
+
* pins one.
|
|
10834
|
+
*/
|
|
10835
|
+
function ncClockText(language, atMs, timeZone) {
|
|
10836
|
+
return new Intl.DateTimeFormat(language, {
|
|
10837
|
+
hour: "2-digit",
|
|
10838
|
+
minute: "2-digit",
|
|
10839
|
+
...timeZone !== void 0 ? { timeZone } : {}
|
|
10840
|
+
}).format(new Date(atMs));
|
|
10841
|
+
}
|
|
10842
|
+
//#endregion
|
|
10825
10843
|
//#region src/notification-center/system-event-tap-url.ts
|
|
10826
10844
|
/** The admin UI's own routes, as `packages/addon-admin-ui/src/App.tsx` declares them. */
|
|
10827
10845
|
var ADMIN_ADDONS_PATH = "/system/addons";
|
|
@@ -12790,8 +12808,7 @@ var NcDispatcher = class {
|
|
|
12790
12808
|
async buildNotification(entry, target) {
|
|
12791
12809
|
const subject = entry.payload.subject;
|
|
12792
12810
|
const systemEvent = subject.systemEvent;
|
|
12793
|
-
const
|
|
12794
|
-
const deviceName = systemEvent !== void 0 && systemEvent.deviceId === void 0 ? "system" : await this.deps.getDeviceName(displayDeviceId).catch(() => null) ?? `camera ${displayDeviceId}`;
|
|
12811
|
+
const deviceName = await notificationDeviceName(entry, this.deps.getDeviceName);
|
|
12795
12812
|
const alarmSource = systemEvent?.alarmSource;
|
|
12796
12813
|
const zoneLabels = systemEvent === void 0 ? await resolveZoneLabels(this.deps.getZoneNames, subject.deviceId, subject.zones) : alarmSource?.zones !== void 0 && alarmSource.zones.length > 0 ? await resolveZoneLabels(this.deps.getZoneNames, alarmSource.deviceId, alarmSource.zones) : [];
|
|
12797
12814
|
const vars = buildTemplateVars(this.texts, entry, deviceName, zoneLabels);
|
|
@@ -13545,17 +13562,25 @@ async function resolveZoneLabels(getZoneNames, deviceId, zoneIds) {
|
|
|
13545
13562
|
function percentOf(confidence) {
|
|
13546
13563
|
return confidence !== void 0 ? `${Math.round(confidence * 100)}%` : "";
|
|
13547
13564
|
}
|
|
13565
|
+
/** What `{{camera}}` renders on a system row that names no device. */
|
|
13566
|
+
var NC_DEVICELESS_CAMERA_NAME = "system";
|
|
13548
13567
|
/**
|
|
13549
|
-
* The
|
|
13550
|
-
*
|
|
13568
|
+
* The name `{{camera}}` renders for a row — the ONE derivation, shared by the
|
|
13569
|
+
* dispatcher and the template-variable parity spec.
|
|
13551
13570
|
*
|
|
13552
|
-
*
|
|
13553
|
-
*
|
|
13554
|
-
*
|
|
13555
|
-
*
|
|
13556
|
-
*
|
|
13557
|
-
* outrank the live vocabulary for exactly those names.
|
|
13571
|
+
* A system row that names no device (a node, a backup, an update) renders the
|
|
13572
|
+
* filler {@link NC_DEVICELESS_CAMERA_NAME}: there is no camera to name, which
|
|
13573
|
+
* is why the catalog offers `{{camera}}` on a system rule only for the kinds
|
|
13574
|
+
* that carry a device (template-vars.ts). Everything else asks the directory,
|
|
13575
|
+
* and falls back to `camera <id>` when it cannot answer.
|
|
13558
13576
|
*/
|
|
13577
|
+
async function notificationDeviceName(entry, getDeviceName) {
|
|
13578
|
+
const subject = entry.payload.subject;
|
|
13579
|
+
const systemEvent = subject.systemEvent;
|
|
13580
|
+
if (systemEvent !== void 0 && systemEvent.deviceId === void 0) return NC_DEVICELESS_CAMERA_NAME;
|
|
13581
|
+
const displayDeviceId = systemEvent?.deviceId ?? subject.deviceId;
|
|
13582
|
+
return await getDeviceName(displayDeviceId).catch(() => null) ?? `camera ${displayDeviceId}`;
|
|
13583
|
+
}
|
|
13559
13584
|
/** Class composition of a group, busiest classes in first-seen order. */
|
|
13560
13585
|
function classCounts(members) {
|
|
13561
13586
|
const counts = /* @__PURE__ */ new Map();
|
|
@@ -13565,7 +13590,18 @@ function classCounts(members) {
|
|
|
13565
13590
|
count
|
|
13566
13591
|
}));
|
|
13567
13592
|
}
|
|
13568
|
-
|
|
13593
|
+
/**
|
|
13594
|
+
* The variable map ONE render pass sees: scalars, then the composites built
|
|
13595
|
+
* from them, then the params a system-event row froze at intake.
|
|
13596
|
+
*
|
|
13597
|
+
* The three groups are layered in that order deliberately. A composite
|
|
13598
|
+
* (`{{subject}}`, `{{inZones}}`, `{{op}}`) is a finished phrase and must not be
|
|
13599
|
+
* visible to the fragments it was built from — that is the depth bound, and it
|
|
13600
|
+
* is enforced by `text-compose.ts` never being handed this map. The frozen
|
|
13601
|
+
* params go last because they are the row's own truth about a system event and
|
|
13602
|
+
* outrank the live vocabulary for exactly those names.
|
|
13603
|
+
*/
|
|
13604
|
+
function buildTemplateVars(texts, entry, deviceName, zoneLabels, timeZone) {
|
|
13569
13605
|
const subject = entry.payload.subject;
|
|
13570
13606
|
const occupancy = subject.occupancy;
|
|
13571
13607
|
const systemEvent = subject.systemEvent;
|
|
@@ -13573,7 +13609,7 @@ function buildTemplateVars(texts, entry, deviceName, zoneLabels) {
|
|
|
13573
13609
|
const group = entry.payload.group;
|
|
13574
13610
|
return {
|
|
13575
13611
|
camera: deviceName,
|
|
13576
|
-
class: detection?.className ?? subject.className,
|
|
13612
|
+
class: detection?.className ?? (systemEvent !== void 0 ? "" : subject.className),
|
|
13577
13613
|
label: detection?.label ?? subject.label ?? "",
|
|
13578
13614
|
zones: zoneLabels.join(", "),
|
|
13579
13615
|
subject: detection?.className !== void 0 ? ncSubjectText(texts, {
|
|
@@ -13586,10 +13622,14 @@ function buildTemplateVars(texts, entry, deviceName, zoneLabels) {
|
|
|
13586
13622
|
}),
|
|
13587
13623
|
detectionSummary: group !== void 0 && group.members.length > 1 ? ncDetectionSummary(texts, classCounts(group.members)) : "",
|
|
13588
13624
|
inZones: ncInZonesText(texts, zoneLabels),
|
|
13589
|
-
scope: occupancy
|
|
13625
|
+
scope: occupancy !== void 0 ? occupancy.zone ?? deviceName : "",
|
|
13590
13626
|
zone: occupancy?.zone ?? zoneLabels[0] ?? "",
|
|
13591
13627
|
confidence: percentOf(detection?.confidence ?? subject.confidence),
|
|
13592
|
-
time:
|
|
13628
|
+
time: ncClockText(texts.language, subject.timestamp, timeZone),
|
|
13629
|
+
sounds: subject.audio?.labels.join(", ") ?? "",
|
|
13630
|
+
hitPercent: subject.audio?.hitPercent !== void 0 ? `${Math.round(subject.audio.hitPercent)}` : "",
|
|
13631
|
+
db: subject.audio?.peakDbfs !== void 0 ? `${Math.round(subject.audio.peakDbfs)}` : "",
|
|
13632
|
+
eventType: subject.eventType ?? "",
|
|
13593
13633
|
rule: entry.payload.ruleName,
|
|
13594
13634
|
count: occupancy !== void 0 ? `${occupancy.count}` : group !== void 0 && group.members.length > 1 ? `${group.members.length}` : "",
|
|
13595
13635
|
capacity: occupancy !== void 0 ? `${occupancy.capacity}` : "",
|
|
@@ -17420,12 +17460,22 @@ var NcRuleStore = class {
|
|
|
17420
17460
|
await this.ledger.put(rule);
|
|
17421
17461
|
return rule;
|
|
17422
17462
|
}
|
|
17423
|
-
/**
|
|
17424
|
-
|
|
17463
|
+
/**
|
|
17464
|
+
* Apply a partial patch. Immutable: returns the NEW rule object.
|
|
17465
|
+
*
|
|
17466
|
+
* `clear` names optional fields to REMOVE before the merge — an omitted key
|
|
17467
|
+
* keeps its stored value, so this is how an editor empties one. Setting and
|
|
17468
|
+
* clearing the same key is a contradiction and is refused rather than
|
|
17469
|
+
* resolved by ordering.
|
|
17470
|
+
*/
|
|
17471
|
+
async update(ruleId, patch, clear = []) {
|
|
17425
17472
|
const existing = this.ledger.get(ruleId);
|
|
17426
17473
|
if (!existing) throw new Error(`notification rule not found: ${ruleId}`);
|
|
17474
|
+
const conflict = clear.find((key) => patch[key] !== void 0);
|
|
17475
|
+
if (conflict !== void 0) throw new Error(`notification rule patch both sets and clears '${conflict}'`);
|
|
17476
|
+
const cleared = new Set(clear);
|
|
17427
17477
|
const candidate = {
|
|
17428
|
-
...existing,
|
|
17478
|
+
...Object.fromEntries(Object.entries(existing).filter(([key]) => !cleared.has(key))),
|
|
17429
17479
|
...patch,
|
|
17430
17480
|
id: existing.id,
|
|
17431
17481
|
createdBy: existing.createdBy,
|
|
@@ -19333,13 +19383,18 @@ function clockOf$2(atMs) {
|
|
|
19333
19383
|
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
19334
19384
|
}
|
|
19335
19385
|
/** Template vocabulary. Deliberately the SAME names the detection and timelapse
|
|
19336
|
-
* bodies use (`rule`, `from`, `to`, `time`) plus the digest-only ones.
|
|
19337
|
-
|
|
19386
|
+
* bodies use (`rule`, `from`, `to`, `time`) plus the digest-only ones.
|
|
19387
|
+
*
|
|
19388
|
+
* Exported as `summaryTemplateVars` — not just an implementation detail of
|
|
19389
|
+
* {@link buildSummaryOutboxInputs} — so the parity spec
|
|
19390
|
+
* (`template-var-parity.spec.ts`) can assert this map directly against the
|
|
19391
|
+
* catalog, the same way `buildTemplateVars` is asserted for rule rows. */
|
|
19392
|
+
function summaryTemplateVars(input) {
|
|
19338
19393
|
return {
|
|
19339
19394
|
rule: input.ruleName,
|
|
19340
19395
|
from: clockOf$2(input.window.startMs),
|
|
19341
19396
|
to: clockOf$2(input.window.endMs),
|
|
19342
|
-
time:
|
|
19397
|
+
time: ncClockText(input.texts.language, input.generatedAt),
|
|
19343
19398
|
events: String(input.tileCount),
|
|
19344
19399
|
cameras: String(input.cameraCount),
|
|
19345
19400
|
matched: String(input.matchedCount),
|
|
@@ -19446,7 +19501,7 @@ function summaryCaptionText(input) {
|
|
|
19446
19501
|
* @returns rows ready for `NcOutbox.enqueue` — never sent from here.
|
|
19447
19502
|
*/
|
|
19448
19503
|
function buildSummaryOutboxInputs(input) {
|
|
19449
|
-
const vars =
|
|
19504
|
+
const vars = summaryTemplateVars(input);
|
|
19450
19505
|
const optedOut = new Set(input.disabledTargetIds ?? []);
|
|
19451
19506
|
const title = renderTemplate(input.template?.title, vars) ?? input.texts.text({
|
|
19452
19507
|
key: "summary.title",
|
|
@@ -21685,6 +21740,67 @@ async function deriveSyntheticMedia(jpeg, bbox, timestamp) {
|
|
|
21685
21740
|
return files;
|
|
21686
21741
|
}
|
|
21687
21742
|
//#endregion
|
|
21743
|
+
//#region src/notification-center/template-preview.ts
|
|
21744
|
+
/**
|
|
21745
|
+
* `previewTemplate` — render a rule's draft title/body against EXAMPLE values,
|
|
21746
|
+
* so an operator sees `{{scope}}: {{count}}/{{capacity}}` become
|
|
21747
|
+
* `Cancello: 3/2` before saving the rule, not after the first real event.
|
|
21748
|
+
*
|
|
21749
|
+
* Pure with respect to the caller: it never sends anything, and the only
|
|
21750
|
+
* side-channel it reads is the live `NcTextCatalog` handed in, so a preview
|
|
21751
|
+
* always speaks the hub's CURRENT language and CURRENT overrides — the same
|
|
21752
|
+
* words a delivered notification would use.
|
|
21753
|
+
*/
|
|
21754
|
+
/**
|
|
21755
|
+
* The instant `{{time}}` is previewed at — fixed, and in UTC, so the example
|
|
21756
|
+
* is the same on every host: "20:05" in Italian, "08:05 PM" in English.
|
|
21757
|
+
*/
|
|
21758
|
+
var PREVIEW_INSTANT_MS = Date.UTC(2026, 8, 24, 20, 5);
|
|
21759
|
+
var PREVIEW_TIME_ZONE = "UTC";
|
|
21760
|
+
/**
|
|
21761
|
+
* The example map: every declared example, with the COMPOSITES rendered by the
|
|
21762
|
+
* real composers so the preview speaks the hub's language exactly as a
|
|
21763
|
+
* delivered notification would.
|
|
21764
|
+
*/
|
|
21765
|
+
function exampleVars(texts) {
|
|
21766
|
+
return {
|
|
21767
|
+
...Object.fromEntries(require_dist.NC_TEMPLATE_VARS.filter((d) => d.dynamic === void 0).map((d) => [d.name, d.example])),
|
|
21768
|
+
subject: ncSubjectText(texts, {
|
|
21769
|
+
className: "person",
|
|
21770
|
+
label: "Mario",
|
|
21771
|
+
labelKind: "identity"
|
|
21772
|
+
}),
|
|
21773
|
+
inZones: ncInZonesText(texts, ["Cancello"]),
|
|
21774
|
+
op: ncOccupancyOp(texts, true),
|
|
21775
|
+
time: ncClockText(texts.language, PREVIEW_INSTANT_MS, PREVIEW_TIME_ZONE),
|
|
21776
|
+
detectionSummary: ncDetectionSummary(texts, [{
|
|
21777
|
+
className: "person",
|
|
21778
|
+
count: 2
|
|
21779
|
+
}, {
|
|
21780
|
+
className: "car",
|
|
21781
|
+
count: 1
|
|
21782
|
+
}])
|
|
21783
|
+
};
|
|
21784
|
+
}
|
|
21785
|
+
var DYNAMIC_EXAMPLE = "3";
|
|
21786
|
+
function previewTemplate(texts, input) {
|
|
21787
|
+
const ctx = input.context;
|
|
21788
|
+
const allowed = new Set(require_dist.templateVarsFor(ctx).map((d) => d.name));
|
|
21789
|
+
const examples = exampleVars(texts);
|
|
21790
|
+
const used = [input.template.title ?? "", input.template.body ?? ""].flatMap((t) => [...templatePlaceholders(t)]);
|
|
21791
|
+
const unknown = [...new Set(used.filter((name) => !require_dist.isKnownTemplateVar(name, ctx)))];
|
|
21792
|
+
const vars = {};
|
|
21793
|
+
for (const name of used) {
|
|
21794
|
+
if (!require_dist.isKnownTemplateVar(name, ctx)) continue;
|
|
21795
|
+
vars[name] = allowed.has(name) ? examples[name] ?? "" : DYNAMIC_EXAMPLE;
|
|
21796
|
+
}
|
|
21797
|
+
return {
|
|
21798
|
+
title: renderTemplate(input.template.title, vars),
|
|
21799
|
+
body: renderTemplate(input.template.body, vars),
|
|
21800
|
+
unknown
|
|
21801
|
+
};
|
|
21802
|
+
}
|
|
21803
|
+
//#endregion
|
|
21688
21804
|
//#region src/notification-center/text-catalog-view.ts
|
|
21689
21805
|
/**
|
|
21690
21806
|
* The text catalog, projected for an EDITOR — Phase 2's read model, pure.
|
|
@@ -22124,14 +22240,19 @@ function clockOf(atMs) {
|
|
|
22124
22240
|
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
22125
22241
|
}
|
|
22126
22242
|
/** Template vocabulary. Deliberately the same NAMES the rule templates use
|
|
22127
|
-
* (`camera`, `rule`, `time`) plus the timelapse-only ones.
|
|
22128
|
-
|
|
22243
|
+
* (`camera`, `rule`, `time`) plus the timelapse-only ones.
|
|
22244
|
+
*
|
|
22245
|
+
* Exported as `timelapseTemplateVars` — not just an implementation detail of
|
|
22246
|
+
* {@link buildTimelapseOutboxInputs} — so the parity spec
|
|
22247
|
+
* (`template-var-parity.spec.ts`) can assert this map directly against the
|
|
22248
|
+
* catalog, the same way `buildTemplateVars` is asserted for rule rows. */
|
|
22249
|
+
function timelapseTemplateVars(input) {
|
|
22129
22250
|
return {
|
|
22130
22251
|
camera: input.deviceName ?? `camera ${input.deviceId}`,
|
|
22131
22252
|
rule: input.ruleName,
|
|
22132
22253
|
from: clockOf(input.window.startMs),
|
|
22133
22254
|
to: clockOf(input.window.endMs),
|
|
22134
|
-
time:
|
|
22255
|
+
time: ncClockText(input.texts.language, input.generatedAt),
|
|
22135
22256
|
coverage: String(input.coverage.percent),
|
|
22136
22257
|
events: String(input.denseRanges),
|
|
22137
22258
|
...detectionTemplateVars(input.texts, input.detections ?? NO_DETECTIONS)
|
|
@@ -22212,7 +22333,7 @@ function timelapseRecordId(deviceId, windowEndMs) {
|
|
|
22212
22333
|
* @returns rows ready for `NcOutbox.enqueue` — never sent from here.
|
|
22213
22334
|
*/
|
|
22214
22335
|
function buildTimelapseOutboxInputs(input) {
|
|
22215
|
-
const vars =
|
|
22336
|
+
const vars = timelapseTemplateVars(input);
|
|
22216
22337
|
const optedOut = new Set(input.disabledTargetIds ?? []);
|
|
22217
22338
|
const title = renderTemplate(input.template?.title, vars) ?? input.texts.text({
|
|
22218
22339
|
key: "timelapse.title",
|
|
@@ -23195,7 +23316,8 @@ function consumablesSliceOf(event) {
|
|
|
23195
23316
|
var NC_ALARM_SETTINGS_FALLBACK = {
|
|
23196
23317
|
...DEFAULT_DELAYS,
|
|
23197
23318
|
announceArm: false,
|
|
23198
|
-
announceTargets: []
|
|
23319
|
+
announceTargets: [],
|
|
23320
|
+
nonBlocking: []
|
|
23199
23321
|
};
|
|
23200
23322
|
/**
|
|
23201
23323
|
* How often the "matched NO rule" report may fire per device. Long enough that
|
|
@@ -24445,6 +24567,10 @@ var NotificationCenter = class NotificationCenter {
|
|
|
24445
24567
|
get textCatalogEditor() {
|
|
24446
24568
|
return this.textEditor;
|
|
24447
24569
|
}
|
|
24570
|
+
/** The live catalog every producer renders with — the preview must use the same one. */
|
|
24571
|
+
get textCatalog() {
|
|
24572
|
+
return this.texts;
|
|
24573
|
+
}
|
|
24448
24574
|
/**
|
|
24449
24575
|
* Produce the last closed window for one camera of one timelapse rule, now.
|
|
24450
24576
|
*
|
|
@@ -25307,17 +25433,19 @@ var NotificationCenter = class NotificationCenter {
|
|
|
25307
25433
|
} });
|
|
25308
25434
|
return { rule: created };
|
|
25309
25435
|
},
|
|
25310
|
-
updateRule: async ({ ruleId, patch, caller }) => {
|
|
25436
|
+
updateRule: async ({ ruleId, patch, clear, caller }) => {
|
|
25311
25437
|
if (patch.targets !== void 0) await this.validateTargetRefs(patch.targets.map((t) => t.targetId));
|
|
25312
|
-
|
|
25438
|
+
const clearsUsers = clear?.includes("targetUsers") ?? false;
|
|
25439
|
+
if (patch.targets !== void 0 || patch.targetUsers !== void 0 || clearsUsers) {
|
|
25313
25440
|
const existing = this.rules.get(ruleId);
|
|
25314
|
-
NotificationCenter.assertHasAddressee(patch.targets ?? existing?.targets ?? [], patch.targetUsers ?? existing?.targetUsers);
|
|
25441
|
+
NotificationCenter.assertHasAddressee(patch.targets ?? existing?.targets ?? [], clearsUsers ? void 0 : patch.targetUsers ?? existing?.targetUsers);
|
|
25315
25442
|
}
|
|
25316
25443
|
const { disabledTargetIds: _optOut, ...safePatch } = patch;
|
|
25317
|
-
const updated = await this.rules.update(ruleId, safePatch);
|
|
25444
|
+
const updated = await this.rules.update(ruleId, safePatch, clear ?? []);
|
|
25318
25445
|
this.logger.info("notification rule updated", { meta: {
|
|
25319
25446
|
ruleId,
|
|
25320
|
-
by: caller.userId
|
|
25447
|
+
by: caller.userId,
|
|
25448
|
+
...clear && clear.length > 0 ? { cleared: clear } : {}
|
|
25321
25449
|
} });
|
|
25322
25450
|
return { rule: updated };
|
|
25323
25451
|
},
|
|
@@ -25334,6 +25462,8 @@ var NotificationCenter = class NotificationCenter {
|
|
|
25334
25462
|
catalog: [...require_dist.NC_CONDITION_CATALOG],
|
|
25335
25463
|
taxonomy: require_dist.NC_TAXONOMY
|
|
25336
25464
|
}),
|
|
25465
|
+
getTemplateCatalog: async () => ({ vars: [...require_dist.NC_TEMPLATE_VARS] }),
|
|
25466
|
+
previewTemplate: async (input) => previewTemplate(this.texts, input),
|
|
25337
25467
|
getHistory: async ({ filter }) => {
|
|
25338
25468
|
const entries = await this.outbox.queryHistory({
|
|
25339
25469
|
...filter.ruleId !== void 0 ? { ruleId: filter.ruleId } : {},
|
|
@@ -26521,6 +26651,12 @@ var NotificationCenter = class NotificationCenter {
|
|
|
26521
26651
|
...subject.observedAt !== void 0 ? { observedAt: subject.observedAt } : {},
|
|
26522
26652
|
...subject.occupancy !== void 0 ? { occupancy: frozenOccupancy(rule, subject.occupancy) } : {},
|
|
26523
26653
|
...subject.packagePhase !== void 0 ? { packagePhase: subject.packagePhase } : {},
|
|
26654
|
+
...subject.audioWindow !== void 0 ? { audio: {
|
|
26655
|
+
labels: subject.audioWindow.labels,
|
|
26656
|
+
...subject.audioWindow.mode === "level" ? { hitPercent: subject.audioWindow.hitPercent } : {},
|
|
26657
|
+
...subject.audioWindow.peakDbfs !== void 0 ? { peakDbfs: subject.audioWindow.peakDbfs } : {}
|
|
26658
|
+
} } : {},
|
|
26659
|
+
...subject.eventType !== void 0 ? { eventType: subject.eventType } : {},
|
|
26524
26660
|
...subject.systemEvent !== void 0 ? { systemEvent: subject.systemEvent } : {}
|
|
26525
26661
|
}
|
|
26526
26662
|
};
|
|
@@ -27709,14 +27845,13 @@ var ncActions = require_dist.defineCustomActions({
|
|
|
27709
27845
|
catalog: require_dist.array(require_dist.NcConditionDescriptorSchema),
|
|
27710
27846
|
taxonomy: require_dist.NcTaxonomySchema
|
|
27711
27847
|
})),
|
|
27848
|
+
"nc.getTemplateCatalog": require_dist.customAction(require_dist.object({}), require_dist.object({ vars: require_dist.array(require_dist.NcTemplateVarDescriptorSchema) })),
|
|
27849
|
+
"nc.previewTemplate": require_dist.customAction(require_dist.NcTemplatePreviewInputSchema, require_dist.NcTemplatePreviewSchema, { kind: "mutation" }),
|
|
27712
27850
|
"nc.createRule": require_dist.customAction(require_dist.object({ rule: require_dist.NcRuleInputSchema }), require_dist.object({ rule: require_dist.NcRuleSchema }), {
|
|
27713
27851
|
kind: "mutation",
|
|
27714
27852
|
caller: "required"
|
|
27715
27853
|
}),
|
|
27716
|
-
"nc.updateRule": require_dist.customAction(require_dist.object({
|
|
27717
|
-
ruleId: require_dist.string(),
|
|
27718
|
-
patch: require_dist.NcRulePatchSchema
|
|
27719
|
-
}), require_dist.object({ rule: require_dist.NcRuleSchema }), {
|
|
27854
|
+
"nc.updateRule": require_dist.customAction(require_dist.NcRuleUpdateInputSchema, require_dist.object({ rule: require_dist.NcRuleSchema }), {
|
|
27720
27855
|
kind: "mutation",
|
|
27721
27856
|
caller: "required"
|
|
27722
27857
|
}),
|
|
@@ -27735,14 +27870,17 @@ var ncActions = require_dist.defineCustomActions({
|
|
|
27735
27870
|
/**
|
|
27736
27871
|
* THE AI SECTION of the ordinary rules — read and write.
|
|
27737
27872
|
*
|
|
27738
|
-
* A pair of its own rather than two more keys on
|
|
27739
|
-
*
|
|
27740
|
-
*
|
|
27741
|
-
*
|
|
27742
|
-
*
|
|
27743
|
-
*
|
|
27744
|
-
* (`
|
|
27745
|
-
* `
|
|
27873
|
+
* A pair of its own rather than two more keys on the rule CRUD, and that is
|
|
27874
|
+
* the whole point of the design. The addon builds self-contained and INLINES
|
|
27875
|
+
* `@camstack/types`, so its own copy of `NcRuleInputSchema` is never the
|
|
27876
|
+
* problem and nothing here fails at load. The coupling is the HUB: the cap
|
|
27877
|
+
* route (`notificationRules.createRule` / `updateRule`, which the admin
|
|
27878
|
+
* editor uses) validates input against the copy of `@camstack/types` that
|
|
27879
|
+
* `@camstack/system` inlines (`bundleTypesMainEntry`), so a new key on
|
|
27880
|
+
* `NcRuleInputSchema` is SILENTLY STRIPPED there until a `@camstack/server`
|
|
27881
|
+
* release carrying it is installed. These two validate only here, against a
|
|
27882
|
+
* schema that lives in the addon (`rule-ai.ts`), so a field added to it is
|
|
27883
|
+
* live on the next `camstack deploy packages/addon-post-analysis`.
|
|
27746
27884
|
*
|
|
27747
27885
|
* Scoped exactly as the rule CRUD is: reading needs a rule the caller can
|
|
27748
27886
|
* SEE, writing needs one they own (admins bypass ownership, never the caller
|
|
@@ -28234,6 +28372,12 @@ function makeNcActionHandlers(deps) {
|
|
|
28234
28372
|
catalog: [...require_dist.NC_CONDITION_CATALOG],
|
|
28235
28373
|
taxonomy: require_dist.NC_TAXONOMY
|
|
28236
28374
|
}),
|
|
28375
|
+
"nc.getTemplateCatalog": async () => ({ vars: [...require_dist.NC_TEMPLATE_VARS] }),
|
|
28376
|
+
"nc.previewTemplate": async (input) => {
|
|
28377
|
+
const texts = deps.previewTexts;
|
|
28378
|
+
if (texts === void 0) throw new Error("nc.previewTemplate: text catalog not wired");
|
|
28379
|
+
return previewTemplate(texts, input);
|
|
28380
|
+
},
|
|
28237
28381
|
"nc.createRule": async (input, caller) => {
|
|
28238
28382
|
const c = requireCaller$1(caller);
|
|
28239
28383
|
const parsed = require_dist.NcRuleInputSchema.parse(input.rule);
|
|
@@ -28254,10 +28398,11 @@ function makeNcActionHandlers(deps) {
|
|
|
28254
28398
|
const patch = require_dist.NcRulePatchSchema.parse(input.patch);
|
|
28255
28399
|
if (patch.targets !== void 0) await assertTargetsOwned(patch.targets.map((t) => t.targetId), c);
|
|
28256
28400
|
const { ownerUserId: _owner, disabledTargetIds: _optOut, ...safe } = patch;
|
|
28257
|
-
const rule = await deps.ruleStore.update(input.ruleId, safe);
|
|
28401
|
+
const rule = await deps.ruleStore.update(input.ruleId, safe, input.clear ?? []);
|
|
28258
28402
|
deps.logger.info("nc rule updated", { meta: {
|
|
28259
28403
|
ruleId: rule.id,
|
|
28260
|
-
owner: c.userId
|
|
28404
|
+
owner: c.userId,
|
|
28405
|
+
...input.clear && input.clear.length > 0 ? { cleared: input.clear } : {}
|
|
28261
28406
|
} });
|
|
28262
28407
|
return { rule };
|
|
28263
28408
|
},
|
|
@@ -41716,6 +41861,160 @@ var TrackResidentState = class {
|
|
|
41716
41861
|
if (resident.rasterFallback === void 0) resident.rasterFallback = fallback;
|
|
41717
41862
|
}
|
|
41718
41863
|
};
|
|
41864
|
+
/**
|
|
41865
|
+
* The shipped limits.
|
|
41866
|
+
*
|
|
41867
|
+
* `minTracks` 12 / `minSamples` 200: the four live cameras sampled above
|
|
41868
|
+
* produced 697–8739 samples from 42–51 tracks in a single 60-track read, so a
|
|
41869
|
+
* camera that cannot clear these has genuinely not been watched enough.
|
|
41870
|
+
* `maxTracks` 200 bounds the read: the `positions` blob averages ~10 kB a row
|
|
41871
|
+
* (`track-store.ts`), so this pass is ~2 MB of JSON at its worst.
|
|
41872
|
+
*/
|
|
41873
|
+
var DEFAULT_STILLNESS_CALIBRATION_LIMITS = {
|
|
41874
|
+
minTracks: 12,
|
|
41875
|
+
minSamples: 200,
|
|
41876
|
+
maxTracks: 200,
|
|
41877
|
+
minIntervalSec: .02,
|
|
41878
|
+
maxIntervalSec: 3,
|
|
41879
|
+
quantile: .25,
|
|
41880
|
+
floorFactor: .02,
|
|
41881
|
+
ceilFactor: .25,
|
|
41882
|
+
minBar: .001,
|
|
41883
|
+
maxBar: 1
|
|
41884
|
+
};
|
|
41885
|
+
/** Linear-interpolated quantile. `NaN` for an empty list — never 0, which
|
|
41886
|
+
* would read as a measured zero (D393). */
|
|
41887
|
+
function quantileOf(values, p) {
|
|
41888
|
+
if (values.length === 0) return NaN;
|
|
41889
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
41890
|
+
const idx = (sorted.length - 1) * Math.min(Math.max(p, 0), 1);
|
|
41891
|
+
const lo = Math.floor(idx);
|
|
41892
|
+
const hi = Math.ceil(idx);
|
|
41893
|
+
const at = sorted[lo];
|
|
41894
|
+
const next = sorted[hi];
|
|
41895
|
+
if (at === void 0 || next === void 0) return NaN;
|
|
41896
|
+
return at + (next - at) * (idx - lo);
|
|
41897
|
+
}
|
|
41898
|
+
/**
|
|
41899
|
+
* Per-sample centroid speeds for a camera, in subject-box-diagonals per second.
|
|
41900
|
+
*
|
|
41901
|
+
* A pair of consecutive positions contributes a sample only when the interval
|
|
41902
|
+
* between them is inside `[minIntervalSec, maxIntervalSec]` and the later box
|
|
41903
|
+
* has a positive diagonal. A gap outside that band is a session boundary or a
|
|
41904
|
+
* duplicate read, not a measurement of how fast anything travelled; a box with
|
|
41905
|
+
* no size is unknown geometry, and unknown is never a sample.
|
|
41906
|
+
*/
|
|
41907
|
+
function collectStillnessSamples(tracks, limits) {
|
|
41908
|
+
const speeds = [];
|
|
41909
|
+
const intervals = [];
|
|
41910
|
+
const diagonals = [];
|
|
41911
|
+
let trackCount = 0;
|
|
41912
|
+
const read = tracks.slice(0, limits.maxTracks);
|
|
41913
|
+
for (const track of read) {
|
|
41914
|
+
const positions = [...track.positions].sort((a, b) => a.timestamp - b.timestamp);
|
|
41915
|
+
let contributed = false;
|
|
41916
|
+
for (let i = 1; i < positions.length; i++) {
|
|
41917
|
+
const prev = positions[i - 1];
|
|
41918
|
+
const cur = positions[i];
|
|
41919
|
+
if (prev === void 0 || cur === void 0) continue;
|
|
41920
|
+
const dtSec = (cur.timestamp - prev.timestamp) / 1e3;
|
|
41921
|
+
if (dtSec < limits.minIntervalSec || dtSec > limits.maxIntervalSec) continue;
|
|
41922
|
+
const diagonal = Math.hypot(cur.bbox.w, cur.bbox.h);
|
|
41923
|
+
if (!(diagonal > 0)) continue;
|
|
41924
|
+
const travelled = Math.hypot(cur.x - prev.x, cur.y - prev.y);
|
|
41925
|
+
speeds.push(travelled / diagonal / dtSec);
|
|
41926
|
+
intervals.push(dtSec);
|
|
41927
|
+
diagonals.push(diagonal);
|
|
41928
|
+
contributed = true;
|
|
41929
|
+
}
|
|
41930
|
+
if (contributed) trackCount++;
|
|
41931
|
+
}
|
|
41932
|
+
return {
|
|
41933
|
+
speeds,
|
|
41934
|
+
intervals,
|
|
41935
|
+
diagonals,
|
|
41936
|
+
trackCount,
|
|
41937
|
+
tracksRead: read.length
|
|
41938
|
+
};
|
|
41939
|
+
}
|
|
41940
|
+
function measure$1(samples) {
|
|
41941
|
+
return {
|
|
41942
|
+
trackCount: samples.trackCount,
|
|
41943
|
+
sampleCount: samples.speeds.length,
|
|
41944
|
+
p25: quantileOf(samples.speeds, .25),
|
|
41945
|
+
p50: quantileOf(samples.speeds, .5),
|
|
41946
|
+
p90: quantileOf(samples.speeds, .9),
|
|
41947
|
+
medianIntervalSec: quantileOf(samples.intervals, .5),
|
|
41948
|
+
medianSubjectDiagonalPx: quantileOf(samples.diagonals, .5)
|
|
41949
|
+
};
|
|
41950
|
+
}
|
|
41951
|
+
/**
|
|
41952
|
+
* Choose this camera's stillness speed bar, or refuse with a named reason.
|
|
41953
|
+
*
|
|
41954
|
+
* The order of the gates is the order of the questions: is there anything at
|
|
41955
|
+
* all, is it enough tracks, is it enough samples, and did anything on this
|
|
41956
|
+
* camera ever actually move. The last one is not pedantry — the whole method
|
|
41957
|
+
* scales the bar against the camera's own travel, so with no travel there is
|
|
41958
|
+
* nothing to scale against and the default is the honest answer.
|
|
41959
|
+
*/
|
|
41960
|
+
function calibrateStillnessSpeed(samples, limits) {
|
|
41961
|
+
const measurement = measure$1(samples);
|
|
41962
|
+
if (samples.tracksRead === 0) return {
|
|
41963
|
+
ok: false,
|
|
41964
|
+
reason: "no-tracks",
|
|
41965
|
+
measurement
|
|
41966
|
+
};
|
|
41967
|
+
if (samples.trackCount < limits.minTracks) return {
|
|
41968
|
+
ok: false,
|
|
41969
|
+
reason: "too-few-tracks",
|
|
41970
|
+
measurement
|
|
41971
|
+
};
|
|
41972
|
+
if (samples.speeds.length < limits.minSamples) return {
|
|
41973
|
+
ok: false,
|
|
41974
|
+
reason: "too-few-samples",
|
|
41975
|
+
measurement
|
|
41976
|
+
};
|
|
41977
|
+
if (!(measurement.p90 > 0)) return {
|
|
41978
|
+
ok: false,
|
|
41979
|
+
reason: "no-motion-evidence",
|
|
41980
|
+
measurement
|
|
41981
|
+
};
|
|
41982
|
+
const rawBar = quantileOf(samples.speeds, limits.quantile);
|
|
41983
|
+
const floor = measurement.p90 * limits.floorFactor;
|
|
41984
|
+
const ceiling = measurement.p90 * limits.ceilFactor;
|
|
41985
|
+
let bar = rawBar;
|
|
41986
|
+
let clampedBy = "none";
|
|
41987
|
+
if (bar < floor) {
|
|
41988
|
+
bar = floor;
|
|
41989
|
+
clampedBy = "floor";
|
|
41990
|
+
} else if (bar > ceiling) {
|
|
41991
|
+
bar = ceiling;
|
|
41992
|
+
clampedBy = "ceiling";
|
|
41993
|
+
}
|
|
41994
|
+
if (bar < limits.minBar) {
|
|
41995
|
+
bar = limits.minBar;
|
|
41996
|
+
clampedBy = "min";
|
|
41997
|
+
} else if (bar > limits.maxBar) {
|
|
41998
|
+
bar = limits.maxBar;
|
|
41999
|
+
clampedBy = "max";
|
|
42000
|
+
}
|
|
42001
|
+
return {
|
|
42002
|
+
ok: true,
|
|
42003
|
+
bar,
|
|
42004
|
+
rawBar,
|
|
42005
|
+
clampedBy,
|
|
42006
|
+
measurement
|
|
42007
|
+
};
|
|
42008
|
+
}
|
|
42009
|
+
/** One line an operator can read: what was measured, from how much, and what
|
|
42010
|
+
* rule produced the stored number. Kept short — it rides the settings blob. */
|
|
42011
|
+
function describeStillnessCalibration(outcome) {
|
|
42012
|
+
const m = outcome.measurement;
|
|
42013
|
+
const n = (v) => Number.isFinite(v) ? v.toFixed(4) : "unknown";
|
|
42014
|
+
const evidence = `${m.sampleCount} samples / ${m.trackCount} tracks, p25 ${n(m.p25)}, p50 ${n(m.p50)}, p90 ${n(m.p90)} subject-diagonals/s`;
|
|
42015
|
+
if (!outcome.ok) return `refused (${outcome.reason}) — ${evidence}`;
|
|
42016
|
+
return `bar ${n(outcome.bar)} (raw ${n(outcome.rawBar)}, clamp ${outcome.clampedBy}) — ${evidence}`;
|
|
42017
|
+
}
|
|
41719
42018
|
//#endregion
|
|
41720
42019
|
//#region src/pipeline-analytics/pipeline/plate-clustering.ts
|
|
41721
42020
|
/**
|
|
@@ -47714,6 +48013,184 @@ async function retentionScopeDeviceIds(deps, onError) {
|
|
|
47714
48013
|
return [...ids];
|
|
47715
48014
|
}
|
|
47716
48015
|
//#endregion
|
|
48016
|
+
//#region src/pipeline-analytics/stillness-calibration-actions.ts
|
|
48017
|
+
/**
|
|
48018
|
+
* stillness-calibration-actions — "measure this camera's own stillness scale".
|
|
48019
|
+
*
|
|
48020
|
+
* ## Why an `addons.custom` action and not a cap method
|
|
48021
|
+
*
|
|
48022
|
+
* Same reason as `face.rescoreTrack` beside it: a cap method's wire schema
|
|
48023
|
+
* lives in `@camstack/types`, which travels inside the `@camstack/server`
|
|
48024
|
+
* closure, so a new one is not callable until a server train ships. Here the
|
|
48025
|
+
* schema lives in this addon's own dist and the hub forwards an opaque
|
|
48026
|
+
* envelope, so `camstack deploy` is the whole delivery.
|
|
48027
|
+
*
|
|
48028
|
+
* ## Why `apply` is explicit and not a fallback
|
|
48029
|
+
*
|
|
48030
|
+
* A calibration run READS recorded tracks and does arithmetic. Applying it
|
|
48031
|
+
* WRITES the camera's settings and changes what the tracker calls settled from
|
|
48032
|
+
* the next frame on. Those are different enough in consequence that a button
|
|
48033
|
+
* labelled "calibrate" must not choose between them on the operator's behalf —
|
|
48034
|
+
* the run reports, the operator applies. A REFUSED run never writes the bar
|
|
48035
|
+
* whatever `apply` says; it writes only the note, so the refusal and its reason
|
|
48036
|
+
* are visible in the form instead of being a toast that scrolls away.
|
|
48037
|
+
*/
|
|
48038
|
+
/** Mirrors `StillnessCalibrationRefusal` — kept literal so the wire schema is
|
|
48039
|
+
* self-describing and a new reason is a deliberate edit in both places. */
|
|
48040
|
+
var StillnessRefusalSchema = require_dist._enum([
|
|
48041
|
+
"no-tracks",
|
|
48042
|
+
"too-few-tracks",
|
|
48043
|
+
"too-few-samples",
|
|
48044
|
+
"no-motion-evidence"
|
|
48045
|
+
]);
|
|
48046
|
+
var StillnessMeasurementSchema = require_dist.object({
|
|
48047
|
+
/** Tracks that contributed at least one speed sample. */
|
|
48048
|
+
trackCount: require_dist.number().int(),
|
|
48049
|
+
/** Tracks the pass actually read, after the cost bound. */
|
|
48050
|
+
tracksRead: require_dist.number().int(),
|
|
48051
|
+
sampleCount: require_dist.number().int(),
|
|
48052
|
+
/** Speed quantiles, subject-box-diagonals per second. `null` when there was
|
|
48053
|
+
* nothing to measure — never 0, which would read as a measured stillness
|
|
48054
|
+
* (D393). */
|
|
48055
|
+
p25: require_dist.number().nullable(),
|
|
48056
|
+
p50: require_dist.number().nullable(),
|
|
48057
|
+
p90: require_dist.number().nullable(),
|
|
48058
|
+
/** Median gap between stored positions (s) — the camera's real cadence. */
|
|
48059
|
+
medianIntervalSec: require_dist.number().nullable(),
|
|
48060
|
+
/** Median subject box diagonal (px) the speeds were normalised by. */
|
|
48061
|
+
medianSubjectDiagonalPx: require_dist.number().nullable()
|
|
48062
|
+
});
|
|
48063
|
+
var StillnessCalibrateInputSchema = require_dist.object({
|
|
48064
|
+
deviceId: require_dist.number().int(),
|
|
48065
|
+
/** How far back to read. Bounded: this pass parses ~10 kB of trajectory a
|
|
48066
|
+
* row, so the window is a suggestion and `maxTracks` is the real bound. */
|
|
48067
|
+
sinceHours: require_dist.number().min(.25).max(720).default(72),
|
|
48068
|
+
/** Hard cost bound on tracks read, newest first. */
|
|
48069
|
+
maxTracks: require_dist.number().int().min(1).max(1e3).default(200),
|
|
48070
|
+
/**
|
|
48071
|
+
* Write the chosen bar onto the camera. Never implicit, and never done at
|
|
48072
|
+
* all for a refused run.
|
|
48073
|
+
*/
|
|
48074
|
+
apply: require_dist.boolean().default(false)
|
|
48075
|
+
});
|
|
48076
|
+
var StillnessCalibrateResultSchema = require_dist.object({
|
|
48077
|
+
deviceId: require_dist.number().int(),
|
|
48078
|
+
/** The window actually read (epoch ms). */
|
|
48079
|
+
since: require_dist.number().int(),
|
|
48080
|
+
until: require_dist.number().int(),
|
|
48081
|
+
accepted: require_dist.boolean(),
|
|
48082
|
+
/** Why it refused. Absent on an accepted run. */
|
|
48083
|
+
reason: StillnessRefusalSchema.optional(),
|
|
48084
|
+
/** The chosen bar, subject-box-diagonals per second. Absent on a refusal —
|
|
48085
|
+
* never 0, which the settings layer reads as "uncalibrated". */
|
|
48086
|
+
bar: require_dist.number().optional(),
|
|
48087
|
+
/** The raw quantile before the rails, so the clamp is arguable. */
|
|
48088
|
+
rawBar: require_dist.number().optional(),
|
|
48089
|
+
clampedBy: require_dist._enum([
|
|
48090
|
+
"none",
|
|
48091
|
+
"floor",
|
|
48092
|
+
"ceiling",
|
|
48093
|
+
"min",
|
|
48094
|
+
"max"
|
|
48095
|
+
]).optional(),
|
|
48096
|
+
measurement: StillnessMeasurementSchema,
|
|
48097
|
+
/** The bar this camera was on BEFORE the run — `null` = uncalibrated. Read
|
|
48098
|
+
* before any write, so the operator sees both numbers. */
|
|
48099
|
+
previousBar: require_dist.number().nullable(),
|
|
48100
|
+
/** Whether the camera's settings were actually written. */
|
|
48101
|
+
applied: require_dist.boolean(),
|
|
48102
|
+
/** The one-line note stored on the camera (accepted or refused). */
|
|
48103
|
+
note: require_dist.string()
|
|
48104
|
+
});
|
|
48105
|
+
var stillnessCalibrationActions = require_dist.defineCustomActions({
|
|
48106
|
+
/**
|
|
48107
|
+
* `admin` because it WRITES a per-camera detection threshold when `apply` is
|
|
48108
|
+
* set, and because the report describes how the camera's subjects move,
|
|
48109
|
+
* which is not a viewer-level fact.
|
|
48110
|
+
*/
|
|
48111
|
+
"stillness.calibrate": require_dist.customAction(StillnessCalibrateInputSchema, StillnessCalibrateResultSchema, {
|
|
48112
|
+
kind: "mutation",
|
|
48113
|
+
auth: "admin"
|
|
48114
|
+
}) });
|
|
48115
|
+
//#endregion
|
|
48116
|
+
//#region src/pipeline-analytics/stillness-settings.ts
|
|
48117
|
+
/**
|
|
48118
|
+
* Per-device STILLNESS SCALE — the one number that says how fast a subject on
|
|
48119
|
+
* THIS camera has to move before it is travelling.
|
|
48120
|
+
*
|
|
48121
|
+
* A threshold belongs to whoever GENERATES the event, per device, never baked
|
|
48122
|
+
* into a classifier default. The judgement this feeds is
|
|
48123
|
+
* `StateAnalyzer.speedSaysSettled`; the measurement behind the number is
|
|
48124
|
+
* `pipeline/tracker/stillness-scale.ts`, which also carries the live
|
|
48125
|
+
* measurement showing why one number cannot serve a 30 m driveway and a 2 m
|
|
48126
|
+
* doorway.
|
|
48127
|
+
*
|
|
48128
|
+
* Cascade and shape mirror `stationary-settings` / `media-settings`: a
|
|
48129
|
+
* per-device override on top of the default, resolved per FIELD, and a parse
|
|
48130
|
+
* that never throws on a bad blob.
|
|
48131
|
+
*
|
|
48132
|
+
* **Absent behaves exactly as today.** The device store is a flat numeric blob
|
|
48133
|
+
* with no room for "unset", so the stored form of absent is `0` — and `0` is
|
|
48134
|
+
* not a bar anyone could want (it would mean nothing on this camera is ever
|
|
48135
|
+
* still) and not a value the calibration can produce (`minBar`). The decision
|
|
48136
|
+
* layer never sees it: {@link resolveStillSpeedFracPerSec} maps it to
|
|
48137
|
+
* `undefined`, and `undefined` is what leaves `StateAnalyzer` on its original
|
|
48138
|
+
* absolute `velocityThreshold` path.
|
|
48139
|
+
*/
|
|
48140
|
+
var StillnessSettingsSchema = require_dist.object({
|
|
48141
|
+
/**
|
|
48142
|
+
* The camera's "not travelling" bar: centroid speed as a fraction of the
|
|
48143
|
+
* SUBJECT's own box diagonal, per second. `0` = not calibrated (see above).
|
|
48144
|
+
*
|
|
48145
|
+
* Measured p50 on the live hub 2026-09-19, for scale: 0.0136 (device 592,
|
|
48146
|
+
* close view, mostly parked subjects) to 0.3081 (device 615, far view, only
|
|
48147
|
+
* transits).
|
|
48148
|
+
*/
|
|
48149
|
+
stillnessSpeedFracPerSec: require_dist.number().min(0).max(1).default(0),
|
|
48150
|
+
/**
|
|
48151
|
+
* When the number above was last CHOSEN — by a calibration run or by the
|
|
48152
|
+
* operator. `0` = never. Purely a readout; nothing gates on it.
|
|
48153
|
+
*/
|
|
48154
|
+
stillnessCalibratedAt: require_dist.number().int().min(0).default(0),
|
|
48155
|
+
/**
|
|
48156
|
+
* What the last calibration run measured and decided, in one line
|
|
48157
|
+
* (`describeStillnessCalibration`) — including a REFUSAL and its named
|
|
48158
|
+
* reason, because "why is this camera still on the default" has to be
|
|
48159
|
+
* answerable without re-running anything.
|
|
48160
|
+
*/
|
|
48161
|
+
stillnessCalibrationNote: require_dist.string().max(400).default("")
|
|
48162
|
+
});
|
|
48163
|
+
var STILLNESS_DEFAULTS = StillnessSettingsSchema.parse({});
|
|
48164
|
+
/**
|
|
48165
|
+
* Resolve a per-device store blob into typed stillness settings. Unknown or
|
|
48166
|
+
* invalid fields fall back to the default for that field — including a bar
|
|
48167
|
+
* outside the calibration's own rails, which is refused rather than clamped:
|
|
48168
|
+
* a number the calibration could never have produced is not evidence about
|
|
48169
|
+
* this camera, and silently bending it into range would present it as if it
|
|
48170
|
+
* were.
|
|
48171
|
+
*/
|
|
48172
|
+
function resolveStillnessSettings(raw) {
|
|
48173
|
+
const pick = (key) => {
|
|
48174
|
+
const parsed = StillnessSettingsSchema.shape[key].safeParse(raw[key]);
|
|
48175
|
+
return parsed.success ? parsed.data : STILLNESS_DEFAULTS[key];
|
|
48176
|
+
};
|
|
48177
|
+
return {
|
|
48178
|
+
stillnessSpeedFracPerSec: pick("stillnessSpeedFracPerSec"),
|
|
48179
|
+
stillnessCalibratedAt: pick("stillnessCalibratedAt"),
|
|
48180
|
+
stillnessCalibrationNote: pick("stillnessCalibrationNote")
|
|
48181
|
+
};
|
|
48182
|
+
}
|
|
48183
|
+
/**
|
|
48184
|
+
* The bar the decision layer gets: a positive, in-rails number, or
|
|
48185
|
+
* `undefined` for "this camera is not calibrated — judge it exactly as
|
|
48186
|
+
* before". The ONE place the stored-zero convention is interpreted.
|
|
48187
|
+
*/
|
|
48188
|
+
function resolveStillSpeedFracPerSec(settings) {
|
|
48189
|
+
const bar = settings.stillnessSpeedFracPerSec;
|
|
48190
|
+
if (!(bar >= .001) || bar > 1) return void 0;
|
|
48191
|
+
return bar;
|
|
48192
|
+
}
|
|
48193
|
+
//#endregion
|
|
47717
48194
|
//#region src/pipeline-analytics/viewer-settings-actions.ts
|
|
47718
48195
|
/**
|
|
47719
48196
|
* Viewer settings snapshots — hub-shared named blobs of the viewer's stores.
|
|
@@ -47934,338 +48411,6 @@ function makeViewerSettingsActionHandlers(store) {
|
|
|
47934
48411
|
};
|
|
47935
48412
|
}
|
|
47936
48413
|
//#endregion
|
|
47937
|
-
//#region src/pipeline-analytics/stillness-calibration-actions.ts
|
|
47938
|
-
/**
|
|
47939
|
-
* stillness-calibration-actions — "measure this camera's own stillness scale".
|
|
47940
|
-
*
|
|
47941
|
-
* ## Why an `addons.custom` action and not a cap method
|
|
47942
|
-
*
|
|
47943
|
-
* Same reason as `face.rescoreTrack` beside it: a cap method's wire schema
|
|
47944
|
-
* lives in `@camstack/types`, which travels inside the `@camstack/server`
|
|
47945
|
-
* closure, so a new one is not callable until a server train ships. Here the
|
|
47946
|
-
* schema lives in this addon's own dist and the hub forwards an opaque
|
|
47947
|
-
* envelope, so `camstack deploy` is the whole delivery.
|
|
47948
|
-
*
|
|
47949
|
-
* ## Why `apply` is explicit and not a fallback
|
|
47950
|
-
*
|
|
47951
|
-
* A calibration run READS recorded tracks and does arithmetic. Applying it
|
|
47952
|
-
* WRITES the camera's settings and changes what the tracker calls settled from
|
|
47953
|
-
* the next frame on. Those are different enough in consequence that a button
|
|
47954
|
-
* labelled "calibrate" must not choose between them on the operator's behalf —
|
|
47955
|
-
* the run reports, the operator applies. A REFUSED run never writes the bar
|
|
47956
|
-
* whatever `apply` says; it writes only the note, so the refusal and its reason
|
|
47957
|
-
* are visible in the form instead of being a toast that scrolls away.
|
|
47958
|
-
*/
|
|
47959
|
-
/** Mirrors `StillnessCalibrationRefusal` — kept literal so the wire schema is
|
|
47960
|
-
* self-describing and a new reason is a deliberate edit in both places. */
|
|
47961
|
-
var StillnessRefusalSchema = require_dist._enum([
|
|
47962
|
-
"no-tracks",
|
|
47963
|
-
"too-few-tracks",
|
|
47964
|
-
"too-few-samples",
|
|
47965
|
-
"no-motion-evidence"
|
|
47966
|
-
]);
|
|
47967
|
-
var StillnessMeasurementSchema = require_dist.object({
|
|
47968
|
-
/** Tracks that contributed at least one speed sample. */
|
|
47969
|
-
trackCount: require_dist.number().int(),
|
|
47970
|
-
/** Tracks the pass actually read, after the cost bound. */
|
|
47971
|
-
tracksRead: require_dist.number().int(),
|
|
47972
|
-
sampleCount: require_dist.number().int(),
|
|
47973
|
-
/** Speed quantiles, subject-box-diagonals per second. `null` when there was
|
|
47974
|
-
* nothing to measure — never 0, which would read as a measured stillness
|
|
47975
|
-
* (D393). */
|
|
47976
|
-
p25: require_dist.number().nullable(),
|
|
47977
|
-
p50: require_dist.number().nullable(),
|
|
47978
|
-
p90: require_dist.number().nullable(),
|
|
47979
|
-
/** Median gap between stored positions (s) — the camera's real cadence. */
|
|
47980
|
-
medianIntervalSec: require_dist.number().nullable(),
|
|
47981
|
-
/** Median subject box diagonal (px) the speeds were normalised by. */
|
|
47982
|
-
medianSubjectDiagonalPx: require_dist.number().nullable()
|
|
47983
|
-
});
|
|
47984
|
-
var StillnessCalibrateInputSchema = require_dist.object({
|
|
47985
|
-
deviceId: require_dist.number().int(),
|
|
47986
|
-
/** How far back to read. Bounded: this pass parses ~10 kB of trajectory a
|
|
47987
|
-
* row, so the window is a suggestion and `maxTracks` is the real bound. */
|
|
47988
|
-
sinceHours: require_dist.number().min(.25).max(720).default(72),
|
|
47989
|
-
/** Hard cost bound on tracks read, newest first. */
|
|
47990
|
-
maxTracks: require_dist.number().int().min(1).max(1e3).default(200),
|
|
47991
|
-
/**
|
|
47992
|
-
* Write the chosen bar onto the camera. Never implicit, and never done at
|
|
47993
|
-
* all for a refused run.
|
|
47994
|
-
*/
|
|
47995
|
-
apply: require_dist.boolean().default(false)
|
|
47996
|
-
});
|
|
47997
|
-
var StillnessCalibrateResultSchema = require_dist.object({
|
|
47998
|
-
deviceId: require_dist.number().int(),
|
|
47999
|
-
/** The window actually read (epoch ms). */
|
|
48000
|
-
since: require_dist.number().int(),
|
|
48001
|
-
until: require_dist.number().int(),
|
|
48002
|
-
accepted: require_dist.boolean(),
|
|
48003
|
-
/** Why it refused. Absent on an accepted run. */
|
|
48004
|
-
reason: StillnessRefusalSchema.optional(),
|
|
48005
|
-
/** The chosen bar, subject-box-diagonals per second. Absent on a refusal —
|
|
48006
|
-
* never 0, which the settings layer reads as "uncalibrated". */
|
|
48007
|
-
bar: require_dist.number().optional(),
|
|
48008
|
-
/** The raw quantile before the rails, so the clamp is arguable. */
|
|
48009
|
-
rawBar: require_dist.number().optional(),
|
|
48010
|
-
clampedBy: require_dist._enum([
|
|
48011
|
-
"none",
|
|
48012
|
-
"floor",
|
|
48013
|
-
"ceiling",
|
|
48014
|
-
"min",
|
|
48015
|
-
"max"
|
|
48016
|
-
]).optional(),
|
|
48017
|
-
measurement: StillnessMeasurementSchema,
|
|
48018
|
-
/** The bar this camera was on BEFORE the run — `null` = uncalibrated. Read
|
|
48019
|
-
* before any write, so the operator sees both numbers. */
|
|
48020
|
-
previousBar: require_dist.number().nullable(),
|
|
48021
|
-
/** Whether the camera's settings were actually written. */
|
|
48022
|
-
applied: require_dist.boolean(),
|
|
48023
|
-
/** The one-line note stored on the camera (accepted or refused). */
|
|
48024
|
-
note: require_dist.string()
|
|
48025
|
-
});
|
|
48026
|
-
var stillnessCalibrationActions = require_dist.defineCustomActions({
|
|
48027
|
-
/**
|
|
48028
|
-
* `admin` because it WRITES a per-camera detection threshold when `apply` is
|
|
48029
|
-
* set, and because the report describes how the camera's subjects move,
|
|
48030
|
-
* which is not a viewer-level fact.
|
|
48031
|
-
*/
|
|
48032
|
-
"stillness.calibrate": require_dist.customAction(StillnessCalibrateInputSchema, StillnessCalibrateResultSchema, {
|
|
48033
|
-
kind: "mutation",
|
|
48034
|
-
auth: "admin"
|
|
48035
|
-
}) });
|
|
48036
|
-
/**
|
|
48037
|
-
* The shipped limits.
|
|
48038
|
-
*
|
|
48039
|
-
* `minTracks` 12 / `minSamples` 200: the four live cameras sampled above
|
|
48040
|
-
* produced 697–8739 samples from 42–51 tracks in a single 60-track read, so a
|
|
48041
|
-
* camera that cannot clear these has genuinely not been watched enough.
|
|
48042
|
-
* `maxTracks` 200 bounds the read: the `positions` blob averages ~10 kB a row
|
|
48043
|
-
* (`track-store.ts`), so this pass is ~2 MB of JSON at its worst.
|
|
48044
|
-
*/
|
|
48045
|
-
var DEFAULT_STILLNESS_CALIBRATION_LIMITS = {
|
|
48046
|
-
minTracks: 12,
|
|
48047
|
-
minSamples: 200,
|
|
48048
|
-
maxTracks: 200,
|
|
48049
|
-
minIntervalSec: .02,
|
|
48050
|
-
maxIntervalSec: 3,
|
|
48051
|
-
quantile: .25,
|
|
48052
|
-
floorFactor: .02,
|
|
48053
|
-
ceilFactor: .25,
|
|
48054
|
-
minBar: .001,
|
|
48055
|
-
maxBar: 1
|
|
48056
|
-
};
|
|
48057
|
-
/** Linear-interpolated quantile. `NaN` for an empty list — never 0, which
|
|
48058
|
-
* would read as a measured zero (D393). */
|
|
48059
|
-
function quantileOf(values, p) {
|
|
48060
|
-
if (values.length === 0) return NaN;
|
|
48061
|
-
const sorted = [...values].sort((a, b) => a - b);
|
|
48062
|
-
const idx = (sorted.length - 1) * Math.min(Math.max(p, 0), 1);
|
|
48063
|
-
const lo = Math.floor(idx);
|
|
48064
|
-
const hi = Math.ceil(idx);
|
|
48065
|
-
const at = sorted[lo];
|
|
48066
|
-
const next = sorted[hi];
|
|
48067
|
-
if (at === void 0 || next === void 0) return NaN;
|
|
48068
|
-
return at + (next - at) * (idx - lo);
|
|
48069
|
-
}
|
|
48070
|
-
/**
|
|
48071
|
-
* Per-sample centroid speeds for a camera, in subject-box-diagonals per second.
|
|
48072
|
-
*
|
|
48073
|
-
* A pair of consecutive positions contributes a sample only when the interval
|
|
48074
|
-
* between them is inside `[minIntervalSec, maxIntervalSec]` and the later box
|
|
48075
|
-
* has a positive diagonal. A gap outside that band is a session boundary or a
|
|
48076
|
-
* duplicate read, not a measurement of how fast anything travelled; a box with
|
|
48077
|
-
* no size is unknown geometry, and unknown is never a sample.
|
|
48078
|
-
*/
|
|
48079
|
-
function collectStillnessSamples(tracks, limits) {
|
|
48080
|
-
const speeds = [];
|
|
48081
|
-
const intervals = [];
|
|
48082
|
-
const diagonals = [];
|
|
48083
|
-
let trackCount = 0;
|
|
48084
|
-
const read = tracks.slice(0, limits.maxTracks);
|
|
48085
|
-
for (const track of read) {
|
|
48086
|
-
const positions = [...track.positions].sort((a, b) => a.timestamp - b.timestamp);
|
|
48087
|
-
let contributed = false;
|
|
48088
|
-
for (let i = 1; i < positions.length; i++) {
|
|
48089
|
-
const prev = positions[i - 1];
|
|
48090
|
-
const cur = positions[i];
|
|
48091
|
-
if (prev === void 0 || cur === void 0) continue;
|
|
48092
|
-
const dtSec = (cur.timestamp - prev.timestamp) / 1e3;
|
|
48093
|
-
if (dtSec < limits.minIntervalSec || dtSec > limits.maxIntervalSec) continue;
|
|
48094
|
-
const diagonal = Math.hypot(cur.bbox.w, cur.bbox.h);
|
|
48095
|
-
if (!(diagonal > 0)) continue;
|
|
48096
|
-
const travelled = Math.hypot(cur.x - prev.x, cur.y - prev.y);
|
|
48097
|
-
speeds.push(travelled / diagonal / dtSec);
|
|
48098
|
-
intervals.push(dtSec);
|
|
48099
|
-
diagonals.push(diagonal);
|
|
48100
|
-
contributed = true;
|
|
48101
|
-
}
|
|
48102
|
-
if (contributed) trackCount++;
|
|
48103
|
-
}
|
|
48104
|
-
return {
|
|
48105
|
-
speeds,
|
|
48106
|
-
intervals,
|
|
48107
|
-
diagonals,
|
|
48108
|
-
trackCount,
|
|
48109
|
-
tracksRead: read.length
|
|
48110
|
-
};
|
|
48111
|
-
}
|
|
48112
|
-
function measure$1(samples) {
|
|
48113
|
-
return {
|
|
48114
|
-
trackCount: samples.trackCount,
|
|
48115
|
-
sampleCount: samples.speeds.length,
|
|
48116
|
-
p25: quantileOf(samples.speeds, .25),
|
|
48117
|
-
p50: quantileOf(samples.speeds, .5),
|
|
48118
|
-
p90: quantileOf(samples.speeds, .9),
|
|
48119
|
-
medianIntervalSec: quantileOf(samples.intervals, .5),
|
|
48120
|
-
medianSubjectDiagonalPx: quantileOf(samples.diagonals, .5)
|
|
48121
|
-
};
|
|
48122
|
-
}
|
|
48123
|
-
/**
|
|
48124
|
-
* Choose this camera's stillness speed bar, or refuse with a named reason.
|
|
48125
|
-
*
|
|
48126
|
-
* The order of the gates is the order of the questions: is there anything at
|
|
48127
|
-
* all, is it enough tracks, is it enough samples, and did anything on this
|
|
48128
|
-
* camera ever actually move. The last one is not pedantry — the whole method
|
|
48129
|
-
* scales the bar against the camera's own travel, so with no travel there is
|
|
48130
|
-
* nothing to scale against and the default is the honest answer.
|
|
48131
|
-
*/
|
|
48132
|
-
function calibrateStillnessSpeed(samples, limits) {
|
|
48133
|
-
const measurement = measure$1(samples);
|
|
48134
|
-
if (samples.tracksRead === 0) return {
|
|
48135
|
-
ok: false,
|
|
48136
|
-
reason: "no-tracks",
|
|
48137
|
-
measurement
|
|
48138
|
-
};
|
|
48139
|
-
if (samples.trackCount < limits.minTracks) return {
|
|
48140
|
-
ok: false,
|
|
48141
|
-
reason: "too-few-tracks",
|
|
48142
|
-
measurement
|
|
48143
|
-
};
|
|
48144
|
-
if (samples.speeds.length < limits.minSamples) return {
|
|
48145
|
-
ok: false,
|
|
48146
|
-
reason: "too-few-samples",
|
|
48147
|
-
measurement
|
|
48148
|
-
};
|
|
48149
|
-
if (!(measurement.p90 > 0)) return {
|
|
48150
|
-
ok: false,
|
|
48151
|
-
reason: "no-motion-evidence",
|
|
48152
|
-
measurement
|
|
48153
|
-
};
|
|
48154
|
-
const rawBar = quantileOf(samples.speeds, limits.quantile);
|
|
48155
|
-
const floor = measurement.p90 * limits.floorFactor;
|
|
48156
|
-
const ceiling = measurement.p90 * limits.ceilFactor;
|
|
48157
|
-
let bar = rawBar;
|
|
48158
|
-
let clampedBy = "none";
|
|
48159
|
-
if (bar < floor) {
|
|
48160
|
-
bar = floor;
|
|
48161
|
-
clampedBy = "floor";
|
|
48162
|
-
} else if (bar > ceiling) {
|
|
48163
|
-
bar = ceiling;
|
|
48164
|
-
clampedBy = "ceiling";
|
|
48165
|
-
}
|
|
48166
|
-
if (bar < limits.minBar) {
|
|
48167
|
-
bar = limits.minBar;
|
|
48168
|
-
clampedBy = "min";
|
|
48169
|
-
} else if (bar > limits.maxBar) {
|
|
48170
|
-
bar = limits.maxBar;
|
|
48171
|
-
clampedBy = "max";
|
|
48172
|
-
}
|
|
48173
|
-
return {
|
|
48174
|
-
ok: true,
|
|
48175
|
-
bar,
|
|
48176
|
-
rawBar,
|
|
48177
|
-
clampedBy,
|
|
48178
|
-
measurement
|
|
48179
|
-
};
|
|
48180
|
-
}
|
|
48181
|
-
/** One line an operator can read: what was measured, from how much, and what
|
|
48182
|
-
* rule produced the stored number. Kept short — it rides the settings blob. */
|
|
48183
|
-
function describeStillnessCalibration(outcome) {
|
|
48184
|
-
const m = outcome.measurement;
|
|
48185
|
-
const n = (v) => Number.isFinite(v) ? v.toFixed(4) : "unknown";
|
|
48186
|
-
const evidence = `${m.sampleCount} samples / ${m.trackCount} tracks, p25 ${n(m.p25)}, p50 ${n(m.p50)}, p90 ${n(m.p90)} subject-diagonals/s`;
|
|
48187
|
-
if (!outcome.ok) return `refused (${outcome.reason}) — ${evidence}`;
|
|
48188
|
-
return `bar ${n(outcome.bar)} (raw ${n(outcome.rawBar)}, clamp ${outcome.clampedBy}) — ${evidence}`;
|
|
48189
|
-
}
|
|
48190
|
-
//#endregion
|
|
48191
|
-
//#region src/pipeline-analytics/stillness-settings.ts
|
|
48192
|
-
/**
|
|
48193
|
-
* Per-device STILLNESS SCALE — the one number that says how fast a subject on
|
|
48194
|
-
* THIS camera has to move before it is travelling.
|
|
48195
|
-
*
|
|
48196
|
-
* A threshold belongs to whoever GENERATES the event, per device, never baked
|
|
48197
|
-
* into a classifier default. The judgement this feeds is
|
|
48198
|
-
* `StateAnalyzer.speedSaysSettled`; the measurement behind the number is
|
|
48199
|
-
* `pipeline/tracker/stillness-scale.ts`, which also carries the live
|
|
48200
|
-
* measurement showing why one number cannot serve a 30 m driveway and a 2 m
|
|
48201
|
-
* doorway.
|
|
48202
|
-
*
|
|
48203
|
-
* Cascade and shape mirror `stationary-settings` / `media-settings`: a
|
|
48204
|
-
* per-device override on top of the default, resolved per FIELD, and a parse
|
|
48205
|
-
* that never throws on a bad blob.
|
|
48206
|
-
*
|
|
48207
|
-
* **Absent behaves exactly as today.** The device store is a flat numeric blob
|
|
48208
|
-
* with no room for "unset", so the stored form of absent is `0` — and `0` is
|
|
48209
|
-
* not a bar anyone could want (it would mean nothing on this camera is ever
|
|
48210
|
-
* still) and not a value the calibration can produce (`minBar`). The decision
|
|
48211
|
-
* layer never sees it: {@link resolveStillSpeedFracPerSec} maps it to
|
|
48212
|
-
* `undefined`, and `undefined` is what leaves `StateAnalyzer` on its original
|
|
48213
|
-
* absolute `velocityThreshold` path.
|
|
48214
|
-
*/
|
|
48215
|
-
var StillnessSettingsSchema = require_dist.object({
|
|
48216
|
-
/**
|
|
48217
|
-
* The camera's "not travelling" bar: centroid speed as a fraction of the
|
|
48218
|
-
* SUBJECT's own box diagonal, per second. `0` = not calibrated (see above).
|
|
48219
|
-
*
|
|
48220
|
-
* Measured p50 on the live hub 2026-09-19, for scale: 0.0136 (device 592,
|
|
48221
|
-
* close view, mostly parked subjects) to 0.3081 (device 615, far view, only
|
|
48222
|
-
* transits).
|
|
48223
|
-
*/
|
|
48224
|
-
stillnessSpeedFracPerSec: require_dist.number().min(0).max(1).default(0),
|
|
48225
|
-
/**
|
|
48226
|
-
* When the number above was last CHOSEN — by a calibration run or by the
|
|
48227
|
-
* operator. `0` = never. Purely a readout; nothing gates on it.
|
|
48228
|
-
*/
|
|
48229
|
-
stillnessCalibratedAt: require_dist.number().int().min(0).default(0),
|
|
48230
|
-
/**
|
|
48231
|
-
* What the last calibration run measured and decided, in one line
|
|
48232
|
-
* (`describeStillnessCalibration`) — including a REFUSAL and its named
|
|
48233
|
-
* reason, because "why is this camera still on the default" has to be
|
|
48234
|
-
* answerable without re-running anything.
|
|
48235
|
-
*/
|
|
48236
|
-
stillnessCalibrationNote: require_dist.string().max(400).default("")
|
|
48237
|
-
});
|
|
48238
|
-
var STILLNESS_DEFAULTS = StillnessSettingsSchema.parse({});
|
|
48239
|
-
/**
|
|
48240
|
-
* Resolve a per-device store blob into typed stillness settings. Unknown or
|
|
48241
|
-
* invalid fields fall back to the default for that field — including a bar
|
|
48242
|
-
* outside the calibration's own rails, which is refused rather than clamped:
|
|
48243
|
-
* a number the calibration could never have produced is not evidence about
|
|
48244
|
-
* this camera, and silently bending it into range would present it as if it
|
|
48245
|
-
* were.
|
|
48246
|
-
*/
|
|
48247
|
-
function resolveStillnessSettings(raw) {
|
|
48248
|
-
const pick = (key) => {
|
|
48249
|
-
const parsed = StillnessSettingsSchema.shape[key].safeParse(raw[key]);
|
|
48250
|
-
return parsed.success ? parsed.data : STILLNESS_DEFAULTS[key];
|
|
48251
|
-
};
|
|
48252
|
-
return {
|
|
48253
|
-
stillnessSpeedFracPerSec: pick("stillnessSpeedFracPerSec"),
|
|
48254
|
-
stillnessCalibratedAt: pick("stillnessCalibratedAt"),
|
|
48255
|
-
stillnessCalibrationNote: pick("stillnessCalibrationNote")
|
|
48256
|
-
};
|
|
48257
|
-
}
|
|
48258
|
-
/**
|
|
48259
|
-
* The bar the decision layer gets: a positive, in-rails number, or
|
|
48260
|
-
* `undefined` for "this camera is not calibrated — judge it exactly as
|
|
48261
|
-
* before". The ONE place the stored-zero convention is interpreted.
|
|
48262
|
-
*/
|
|
48263
|
-
function resolveStillSpeedFracPerSec(settings) {
|
|
48264
|
-
const bar = settings.stillnessSpeedFracPerSec;
|
|
48265
|
-
if (!(bar >= .001) || bar > 1) return void 0;
|
|
48266
|
-
return bar;
|
|
48267
|
-
}
|
|
48268
|
-
//#endregion
|
|
48269
48414
|
//#region src/pipeline-analytics/viewer-settings-legacy-adoption.ts
|
|
48270
48415
|
/**
|
|
48271
48416
|
* @durable class=config owner=pipeline-analytics
|
|
@@ -48443,6 +48588,230 @@ async function adoptLegacyViewerSettingsSnapshots(deps) {
|
|
|
48443
48588
|
return retired;
|
|
48444
48589
|
}
|
|
48445
48590
|
/**
|
|
48591
|
+
* The VIDEO's playback rate — 4×, the same as {@link NC_GIF_SPEED}.
|
|
48592
|
+
*
|
|
48593
|
+
* This was 1, and the reasoning for 1 was sound as far as it went: `speed !== 1`
|
|
48594
|
+
* fails `clipCanCopy`, so a sped-up video cannot be the camera's own H.264
|
|
48595
|
+
* copied — it is a `libx264` burst. The OPERATOR priced that and took it. It is
|
|
48596
|
+
* one encode per event over a ~12 s window, not a permanent transcode child
|
|
48597
|
+
* ([D84](../../../../../../docs/decisions/adr-0084.md) is about the latter), and
|
|
48598
|
+
* it was measured on a real 615 720p cut before being chosen: **0.23 s of
|
|
48599
|
+
* encode, 254 KB out**, against the 922 KB the copy of the same window carried.
|
|
48600
|
+
* The re-encode is smaller than what it replaces.
|
|
48601
|
+
*
|
|
48602
|
+
* So both attachments now agree on the timeline as well as on the window: one
|
|
48603
|
+
* clip, one rate, two containers.
|
|
48604
|
+
*/
|
|
48605
|
+
var DEFAULT_SPEED = 4;
|
|
48606
|
+
/** The fallback mp4's requested width — the cap's own ceiling, so a rendition
|
|
48607
|
+
* at or below 1080p is an identity scale and the broker copies it. */
|
|
48608
|
+
var RING_MP4_MAX_WIDTH = 1920;
|
|
48609
|
+
/** MP4 keeps the source cadence; this only bounds the muxer. The cap caps at 15. */
|
|
48610
|
+
var RING_MP4_FPS = 15;
|
|
48611
|
+
var NO_MEDIA = {
|
|
48612
|
+
mp4: null,
|
|
48613
|
+
gif: null,
|
|
48614
|
+
source: "none",
|
|
48615
|
+
startOffsetMs: null,
|
|
48616
|
+
endOffsetMs: null,
|
|
48617
|
+
profile: null,
|
|
48618
|
+
video: null
|
|
48619
|
+
};
|
|
48620
|
+
var EventMediaService = class {
|
|
48621
|
+
deps;
|
|
48622
|
+
constructor(deps) {
|
|
48623
|
+
this.deps = deps;
|
|
48624
|
+
}
|
|
48625
|
+
/**
|
|
48626
|
+
* Cut this event. Never throws — a notification that lost its media is still
|
|
48627
|
+
* a notification, and every branch that drops it logs why.
|
|
48628
|
+
*/
|
|
48629
|
+
async cut(request) {
|
|
48630
|
+
if (!request.wantMp4 && !request.wantGif) return NO_MEDIA;
|
|
48631
|
+
const produced = await this.produce(request);
|
|
48632
|
+
if (produced !== null) return produced;
|
|
48633
|
+
return this.fallbackToRing(request);
|
|
48634
|
+
}
|
|
48635
|
+
async produce(request) {
|
|
48636
|
+
const deviceId = request.deviceId;
|
|
48637
|
+
const log = this.deps.logger;
|
|
48638
|
+
const kinds = [];
|
|
48639
|
+
if (request.wantMp4) kinds.push("mp4");
|
|
48640
|
+
if (request.wantGif) kinds.push("gif");
|
|
48641
|
+
let production;
|
|
48642
|
+
try {
|
|
48643
|
+
production = await this.deps.produce({
|
|
48644
|
+
deviceId,
|
|
48645
|
+
aroundMs: request.aroundMs,
|
|
48646
|
+
preSeconds: request.preRollSec,
|
|
48647
|
+
postSeconds: request.postRollSec,
|
|
48648
|
+
kinds,
|
|
48649
|
+
gifMaxWidth: 640,
|
|
48650
|
+
gifFps: 12,
|
|
48651
|
+
gifSpeed: 4,
|
|
48652
|
+
speed: request.speed ?? DEFAULT_SPEED,
|
|
48653
|
+
...request.profile !== void 0 ? { profile: request.profile } : {}
|
|
48654
|
+
});
|
|
48655
|
+
} catch (err) {
|
|
48656
|
+
log.warn("nc media: the broker could not produce this event — falling back to the clip ring", {
|
|
48657
|
+
tags: { deviceId },
|
|
48658
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
48659
|
+
});
|
|
48660
|
+
return null;
|
|
48661
|
+
}
|
|
48662
|
+
const mp4 = await this.redeem(deviceId, production.media, "mp4");
|
|
48663
|
+
const gif = await this.redeem(deviceId, production.media, "gif");
|
|
48664
|
+
if (mp4 === null && gif === null) {
|
|
48665
|
+
log.warn("nc media: the production carried no artifact — falling back to the clip ring", {
|
|
48666
|
+
tags: { deviceId },
|
|
48667
|
+
meta: {
|
|
48668
|
+
kinds: kinds.join(","),
|
|
48669
|
+
profile: production.profile
|
|
48670
|
+
}
|
|
48671
|
+
});
|
|
48672
|
+
return null;
|
|
48673
|
+
}
|
|
48674
|
+
const startOffsetMs = production.coverage.fromTs - request.aroundMs;
|
|
48675
|
+
const endOffsetMs = production.coverage.toTs - request.aroundMs;
|
|
48676
|
+
log.info("nc media: one production, every attachment", {
|
|
48677
|
+
tags: { deviceId },
|
|
48678
|
+
meta: {
|
|
48679
|
+
profile: production.profile,
|
|
48680
|
+
video: production.video,
|
|
48681
|
+
mp4Bytes: mp4?.byteLength ?? null,
|
|
48682
|
+
gifBytes: gif?.byteLength ?? null,
|
|
48683
|
+
startOffsetMs,
|
|
48684
|
+
endOffsetMs,
|
|
48685
|
+
sharedWindow: true
|
|
48686
|
+
}
|
|
48687
|
+
});
|
|
48688
|
+
return {
|
|
48689
|
+
mp4,
|
|
48690
|
+
gif,
|
|
48691
|
+
source: "produced",
|
|
48692
|
+
startOffsetMs,
|
|
48693
|
+
endOffsetMs,
|
|
48694
|
+
profile: production.profile,
|
|
48695
|
+
video: production.video
|
|
48696
|
+
};
|
|
48697
|
+
}
|
|
48698
|
+
/**
|
|
48699
|
+
* Redeem one artifact handle at the node that produced it.
|
|
48700
|
+
*
|
|
48701
|
+
* `null` is not an error here — a production simply may not carry the kind
|
|
48702
|
+
* (a gif whose derive failed says so on the broker's own log line). A handle
|
|
48703
|
+
* that FAILS to redeem is different and is logged, because it means the bytes
|
|
48704
|
+
* existed and did not arrive.
|
|
48705
|
+
*/
|
|
48706
|
+
async redeem(deviceId, media, kind) {
|
|
48707
|
+
const artifact = media.find((m) => m.kind === kind);
|
|
48708
|
+
if (artifact === void 0) return null;
|
|
48709
|
+
try {
|
|
48710
|
+
const res = await this.deps.fetch(artifact.handle, artifact.nodeId);
|
|
48711
|
+
if (res === null) {
|
|
48712
|
+
this.deps.logger.warn("nc media: an artifact handle expired before it could be fetched", {
|
|
48713
|
+
tags: { deviceId },
|
|
48714
|
+
meta: {
|
|
48715
|
+
kind,
|
|
48716
|
+
handle: artifact.handle,
|
|
48717
|
+
nodeId: artifact.nodeId
|
|
48718
|
+
}
|
|
48719
|
+
});
|
|
48720
|
+
return null;
|
|
48721
|
+
}
|
|
48722
|
+
const buf = Buffer.from(res.base64, "base64");
|
|
48723
|
+
if (buf.byteLength === 0) return null;
|
|
48724
|
+
const bytes = new Uint8Array(buf.byteLength);
|
|
48725
|
+
bytes.set(buf);
|
|
48726
|
+
return bytes;
|
|
48727
|
+
} catch (err) {
|
|
48728
|
+
this.deps.logger.warn("nc media: fetching an artifact failed — that attachment is dropped", {
|
|
48729
|
+
tags: { deviceId },
|
|
48730
|
+
meta: {
|
|
48731
|
+
kind,
|
|
48732
|
+
handle: artifact.handle,
|
|
48733
|
+
nodeId: artifact.nodeId,
|
|
48734
|
+
error: err instanceof Error ? err.message : String(err)
|
|
48735
|
+
}
|
|
48736
|
+
});
|
|
48737
|
+
return null;
|
|
48738
|
+
}
|
|
48739
|
+
}
|
|
48740
|
+
/**
|
|
48741
|
+
* The pre-`produceEventMedia` path: `renderPreBufferClip`, once per container.
|
|
48742
|
+
*
|
|
48743
|
+
* Weaker than a production and deliberately so — the coherence here rests on
|
|
48744
|
+
* the two calls being given IDENTICAL parameters rather than on there being
|
|
48745
|
+
* one render. That is defensible because the ring only ever grows forward and
|
|
48746
|
+
* `windowAround` selects by wall clock around the same instant, so two calls
|
|
48747
|
+
* seconds apart choose the same packets; the one thing that could differ is
|
|
48748
|
+
* the rendition, so the profile is pinned to whatever the FIRST call actually
|
|
48749
|
+
* used and handed to the second.
|
|
48750
|
+
*
|
|
48751
|
+
* It exists for one reason: a hub whose `@camstack/server` predates the
|
|
48752
|
+
* production method must not stop attaching footage, and three of the four
|
|
48753
|
+
* live rules on this install ask for a gif and nothing else.
|
|
48754
|
+
*/
|
|
48755
|
+
async fallbackToRing(request) {
|
|
48756
|
+
const deviceId = request.deviceId;
|
|
48757
|
+
const speed = request.speed ?? DEFAULT_SPEED;
|
|
48758
|
+
const window = {
|
|
48759
|
+
deviceId,
|
|
48760
|
+
aroundMs: request.aroundMs,
|
|
48761
|
+
preRollSec: request.preRollSec,
|
|
48762
|
+
postRollSec: request.postRollSec,
|
|
48763
|
+
speed,
|
|
48764
|
+
...request.profile !== void 0 ? { profile: request.profile } : {}
|
|
48765
|
+
};
|
|
48766
|
+
try {
|
|
48767
|
+
const mp4 = request.wantMp4 ? await this.deps.renderRingClip({
|
|
48768
|
+
...window,
|
|
48769
|
+
format: "mp4",
|
|
48770
|
+
maxWidth: RING_MP4_MAX_WIDTH,
|
|
48771
|
+
fps: RING_MP4_FPS
|
|
48772
|
+
}) : null;
|
|
48773
|
+
const gif = request.wantGif ? await this.deps.renderRingClip({
|
|
48774
|
+
...window,
|
|
48775
|
+
format: "gif",
|
|
48776
|
+
maxWidth: 640,
|
|
48777
|
+
fps: 12 / 4,
|
|
48778
|
+
speed: 4
|
|
48779
|
+
}) : null;
|
|
48780
|
+
if ((mp4 === null || mp4.byteLength === 0) && (gif === null || gif.byteLength === 0)) {
|
|
48781
|
+
this.deps.logger.warn("nc media: the clip ring covered nothing — no footage attached", {
|
|
48782
|
+
tags: { deviceId },
|
|
48783
|
+
meta: { aroundMs: request.aroundMs }
|
|
48784
|
+
});
|
|
48785
|
+
return NO_MEDIA;
|
|
48786
|
+
}
|
|
48787
|
+
this.deps.logger.info("nc media: served by the clip-ring FALLBACK", {
|
|
48788
|
+
tags: { deviceId },
|
|
48789
|
+
meta: {
|
|
48790
|
+
mp4Bytes: mp4?.byteLength ?? null,
|
|
48791
|
+
gifBytes: gif?.byteLength ?? null,
|
|
48792
|
+
profile: request.profile ?? null,
|
|
48793
|
+
sharedWindow: "by-parameters"
|
|
48794
|
+
}
|
|
48795
|
+
});
|
|
48796
|
+
return {
|
|
48797
|
+
mp4: mp4 !== null && mp4.byteLength > 0 ? mp4 : null,
|
|
48798
|
+
gif: gif !== null && gif.byteLength > 0 ? gif : null,
|
|
48799
|
+
source: "ring-fallback",
|
|
48800
|
+
startOffsetMs: -Math.max(0, request.preRollSec) * 1e3,
|
|
48801
|
+
endOffsetMs: Math.max(0, request.postRollSec) * 1e3,
|
|
48802
|
+
profile: request.profile ?? null,
|
|
48803
|
+
video: null
|
|
48804
|
+
};
|
|
48805
|
+
} catch (err) {
|
|
48806
|
+
this.deps.logger.warn("nc media: the clip ring failed too — this event ships no footage", {
|
|
48807
|
+
tags: { deviceId },
|
|
48808
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
48809
|
+
});
|
|
48810
|
+
return NO_MEDIA;
|
|
48811
|
+
}
|
|
48812
|
+
}
|
|
48813
|
+
};
|
|
48814
|
+
/**
|
|
48446
48815
|
* How long the recorder keeps the file we are about to read and delete.
|
|
48447
48816
|
*
|
|
48448
48817
|
* `maxLifeMs` is `z.number().int().positive()` on the capability, so "do not
|
|
@@ -48696,230 +49065,6 @@ function decodeBase64(base64) {
|
|
|
48696
49065
|
return null;
|
|
48697
49066
|
}
|
|
48698
49067
|
}
|
|
48699
|
-
/**
|
|
48700
|
-
* The VIDEO's playback rate — 4×, the same as {@link NC_GIF_SPEED}.
|
|
48701
|
-
*
|
|
48702
|
-
* This was 1, and the reasoning for 1 was sound as far as it went: `speed !== 1`
|
|
48703
|
-
* fails `clipCanCopy`, so a sped-up video cannot be the camera's own H.264
|
|
48704
|
-
* copied — it is a `libx264` burst. The OPERATOR priced that and took it. It is
|
|
48705
|
-
* one encode per event over a ~12 s window, not a permanent transcode child
|
|
48706
|
-
* ([D84](../../../../../../docs/decisions/adr-0084.md) is about the latter), and
|
|
48707
|
-
* it was measured on a real 615 720p cut before being chosen: **0.23 s of
|
|
48708
|
-
* encode, 254 KB out**, against the 922 KB the copy of the same window carried.
|
|
48709
|
-
* The re-encode is smaller than what it replaces.
|
|
48710
|
-
*
|
|
48711
|
-
* So both attachments now agree on the timeline as well as on the window: one
|
|
48712
|
-
* clip, one rate, two containers.
|
|
48713
|
-
*/
|
|
48714
|
-
var DEFAULT_SPEED = 4;
|
|
48715
|
-
/** The fallback mp4's requested width — the cap's own ceiling, so a rendition
|
|
48716
|
-
* at or below 1080p is an identity scale and the broker copies it. */
|
|
48717
|
-
var RING_MP4_MAX_WIDTH = 1920;
|
|
48718
|
-
/** MP4 keeps the source cadence; this only bounds the muxer. The cap caps at 15. */
|
|
48719
|
-
var RING_MP4_FPS = 15;
|
|
48720
|
-
var NO_MEDIA = {
|
|
48721
|
-
mp4: null,
|
|
48722
|
-
gif: null,
|
|
48723
|
-
source: "none",
|
|
48724
|
-
startOffsetMs: null,
|
|
48725
|
-
endOffsetMs: null,
|
|
48726
|
-
profile: null,
|
|
48727
|
-
video: null
|
|
48728
|
-
};
|
|
48729
|
-
var EventMediaService = class {
|
|
48730
|
-
deps;
|
|
48731
|
-
constructor(deps) {
|
|
48732
|
-
this.deps = deps;
|
|
48733
|
-
}
|
|
48734
|
-
/**
|
|
48735
|
-
* Cut this event. Never throws — a notification that lost its media is still
|
|
48736
|
-
* a notification, and every branch that drops it logs why.
|
|
48737
|
-
*/
|
|
48738
|
-
async cut(request) {
|
|
48739
|
-
if (!request.wantMp4 && !request.wantGif) return NO_MEDIA;
|
|
48740
|
-
const produced = await this.produce(request);
|
|
48741
|
-
if (produced !== null) return produced;
|
|
48742
|
-
return this.fallbackToRing(request);
|
|
48743
|
-
}
|
|
48744
|
-
async produce(request) {
|
|
48745
|
-
const deviceId = request.deviceId;
|
|
48746
|
-
const log = this.deps.logger;
|
|
48747
|
-
const kinds = [];
|
|
48748
|
-
if (request.wantMp4) kinds.push("mp4");
|
|
48749
|
-
if (request.wantGif) kinds.push("gif");
|
|
48750
|
-
let production;
|
|
48751
|
-
try {
|
|
48752
|
-
production = await this.deps.produce({
|
|
48753
|
-
deviceId,
|
|
48754
|
-
aroundMs: request.aroundMs,
|
|
48755
|
-
preSeconds: request.preRollSec,
|
|
48756
|
-
postSeconds: request.postRollSec,
|
|
48757
|
-
kinds,
|
|
48758
|
-
gifMaxWidth: 640,
|
|
48759
|
-
gifFps: 12,
|
|
48760
|
-
gifSpeed: 4,
|
|
48761
|
-
speed: request.speed ?? DEFAULT_SPEED,
|
|
48762
|
-
...request.profile !== void 0 ? { profile: request.profile } : {}
|
|
48763
|
-
});
|
|
48764
|
-
} catch (err) {
|
|
48765
|
-
log.warn("nc media: the broker could not produce this event — falling back to the clip ring", {
|
|
48766
|
-
tags: { deviceId },
|
|
48767
|
-
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
48768
|
-
});
|
|
48769
|
-
return null;
|
|
48770
|
-
}
|
|
48771
|
-
const mp4 = await this.redeem(deviceId, production.media, "mp4");
|
|
48772
|
-
const gif = await this.redeem(deviceId, production.media, "gif");
|
|
48773
|
-
if (mp4 === null && gif === null) {
|
|
48774
|
-
log.warn("nc media: the production carried no artifact — falling back to the clip ring", {
|
|
48775
|
-
tags: { deviceId },
|
|
48776
|
-
meta: {
|
|
48777
|
-
kinds: kinds.join(","),
|
|
48778
|
-
profile: production.profile
|
|
48779
|
-
}
|
|
48780
|
-
});
|
|
48781
|
-
return null;
|
|
48782
|
-
}
|
|
48783
|
-
const startOffsetMs = production.coverage.fromTs - request.aroundMs;
|
|
48784
|
-
const endOffsetMs = production.coverage.toTs - request.aroundMs;
|
|
48785
|
-
log.info("nc media: one production, every attachment", {
|
|
48786
|
-
tags: { deviceId },
|
|
48787
|
-
meta: {
|
|
48788
|
-
profile: production.profile,
|
|
48789
|
-
video: production.video,
|
|
48790
|
-
mp4Bytes: mp4?.byteLength ?? null,
|
|
48791
|
-
gifBytes: gif?.byteLength ?? null,
|
|
48792
|
-
startOffsetMs,
|
|
48793
|
-
endOffsetMs,
|
|
48794
|
-
sharedWindow: true
|
|
48795
|
-
}
|
|
48796
|
-
});
|
|
48797
|
-
return {
|
|
48798
|
-
mp4,
|
|
48799
|
-
gif,
|
|
48800
|
-
source: "produced",
|
|
48801
|
-
startOffsetMs,
|
|
48802
|
-
endOffsetMs,
|
|
48803
|
-
profile: production.profile,
|
|
48804
|
-
video: production.video
|
|
48805
|
-
};
|
|
48806
|
-
}
|
|
48807
|
-
/**
|
|
48808
|
-
* Redeem one artifact handle at the node that produced it.
|
|
48809
|
-
*
|
|
48810
|
-
* `null` is not an error here — a production simply may not carry the kind
|
|
48811
|
-
* (a gif whose derive failed says so on the broker's own log line). A handle
|
|
48812
|
-
* that FAILS to redeem is different and is logged, because it means the bytes
|
|
48813
|
-
* existed and did not arrive.
|
|
48814
|
-
*/
|
|
48815
|
-
async redeem(deviceId, media, kind) {
|
|
48816
|
-
const artifact = media.find((m) => m.kind === kind);
|
|
48817
|
-
if (artifact === void 0) return null;
|
|
48818
|
-
try {
|
|
48819
|
-
const res = await this.deps.fetch(artifact.handle, artifact.nodeId);
|
|
48820
|
-
if (res === null) {
|
|
48821
|
-
this.deps.logger.warn("nc media: an artifact handle expired before it could be fetched", {
|
|
48822
|
-
tags: { deviceId },
|
|
48823
|
-
meta: {
|
|
48824
|
-
kind,
|
|
48825
|
-
handle: artifact.handle,
|
|
48826
|
-
nodeId: artifact.nodeId
|
|
48827
|
-
}
|
|
48828
|
-
});
|
|
48829
|
-
return null;
|
|
48830
|
-
}
|
|
48831
|
-
const buf = Buffer.from(res.base64, "base64");
|
|
48832
|
-
if (buf.byteLength === 0) return null;
|
|
48833
|
-
const bytes = new Uint8Array(buf.byteLength);
|
|
48834
|
-
bytes.set(buf);
|
|
48835
|
-
return bytes;
|
|
48836
|
-
} catch (err) {
|
|
48837
|
-
this.deps.logger.warn("nc media: fetching an artifact failed — that attachment is dropped", {
|
|
48838
|
-
tags: { deviceId },
|
|
48839
|
-
meta: {
|
|
48840
|
-
kind,
|
|
48841
|
-
handle: artifact.handle,
|
|
48842
|
-
nodeId: artifact.nodeId,
|
|
48843
|
-
error: err instanceof Error ? err.message : String(err)
|
|
48844
|
-
}
|
|
48845
|
-
});
|
|
48846
|
-
return null;
|
|
48847
|
-
}
|
|
48848
|
-
}
|
|
48849
|
-
/**
|
|
48850
|
-
* The pre-`produceEventMedia` path: `renderPreBufferClip`, once per container.
|
|
48851
|
-
*
|
|
48852
|
-
* Weaker than a production and deliberately so — the coherence here rests on
|
|
48853
|
-
* the two calls being given IDENTICAL parameters rather than on there being
|
|
48854
|
-
* one render. That is defensible because the ring only ever grows forward and
|
|
48855
|
-
* `windowAround` selects by wall clock around the same instant, so two calls
|
|
48856
|
-
* seconds apart choose the same packets; the one thing that could differ is
|
|
48857
|
-
* the rendition, so the profile is pinned to whatever the FIRST call actually
|
|
48858
|
-
* used and handed to the second.
|
|
48859
|
-
*
|
|
48860
|
-
* It exists for one reason: a hub whose `@camstack/server` predates the
|
|
48861
|
-
* production method must not stop attaching footage, and three of the four
|
|
48862
|
-
* live rules on this install ask for a gif and nothing else.
|
|
48863
|
-
*/
|
|
48864
|
-
async fallbackToRing(request) {
|
|
48865
|
-
const deviceId = request.deviceId;
|
|
48866
|
-
const speed = request.speed ?? DEFAULT_SPEED;
|
|
48867
|
-
const window = {
|
|
48868
|
-
deviceId,
|
|
48869
|
-
aroundMs: request.aroundMs,
|
|
48870
|
-
preRollSec: request.preRollSec,
|
|
48871
|
-
postRollSec: request.postRollSec,
|
|
48872
|
-
speed,
|
|
48873
|
-
...request.profile !== void 0 ? { profile: request.profile } : {}
|
|
48874
|
-
};
|
|
48875
|
-
try {
|
|
48876
|
-
const mp4 = request.wantMp4 ? await this.deps.renderRingClip({
|
|
48877
|
-
...window,
|
|
48878
|
-
format: "mp4",
|
|
48879
|
-
maxWidth: RING_MP4_MAX_WIDTH,
|
|
48880
|
-
fps: RING_MP4_FPS
|
|
48881
|
-
}) : null;
|
|
48882
|
-
const gif = request.wantGif ? await this.deps.renderRingClip({
|
|
48883
|
-
...window,
|
|
48884
|
-
format: "gif",
|
|
48885
|
-
maxWidth: 640,
|
|
48886
|
-
fps: 12 / 4,
|
|
48887
|
-
speed: 4
|
|
48888
|
-
}) : null;
|
|
48889
|
-
if ((mp4 === null || mp4.byteLength === 0) && (gif === null || gif.byteLength === 0)) {
|
|
48890
|
-
this.deps.logger.warn("nc media: the clip ring covered nothing — no footage attached", {
|
|
48891
|
-
tags: { deviceId },
|
|
48892
|
-
meta: { aroundMs: request.aroundMs }
|
|
48893
|
-
});
|
|
48894
|
-
return NO_MEDIA;
|
|
48895
|
-
}
|
|
48896
|
-
this.deps.logger.info("nc media: served by the clip-ring FALLBACK", {
|
|
48897
|
-
tags: { deviceId },
|
|
48898
|
-
meta: {
|
|
48899
|
-
mp4Bytes: mp4?.byteLength ?? null,
|
|
48900
|
-
gifBytes: gif?.byteLength ?? null,
|
|
48901
|
-
profile: request.profile ?? null,
|
|
48902
|
-
sharedWindow: "by-parameters"
|
|
48903
|
-
}
|
|
48904
|
-
});
|
|
48905
|
-
return {
|
|
48906
|
-
mp4: mp4 !== null && mp4.byteLength > 0 ? mp4 : null,
|
|
48907
|
-
gif: gif !== null && gif.byteLength > 0 ? gif : null,
|
|
48908
|
-
source: "ring-fallback",
|
|
48909
|
-
startOffsetMs: -Math.max(0, request.preRollSec) * 1e3,
|
|
48910
|
-
endOffsetMs: Math.max(0, request.postRollSec) * 1e3,
|
|
48911
|
-
profile: request.profile ?? null,
|
|
48912
|
-
video: null
|
|
48913
|
-
};
|
|
48914
|
-
} catch (err) {
|
|
48915
|
-
this.deps.logger.warn("nc media: the clip ring failed too — this event ships no footage", {
|
|
48916
|
-
tags: { deviceId },
|
|
48917
|
-
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
48918
|
-
});
|
|
48919
|
-
return NO_MEDIA;
|
|
48920
|
-
}
|
|
48921
|
-
}
|
|
48922
|
-
};
|
|
48923
49068
|
//#endregion
|
|
48924
49069
|
//#region src/shared/frame/crop-extractor.ts
|
|
48925
49070
|
/**
|
|
@@ -57190,6 +57335,41 @@ function resolveMediaSettings(raw) {
|
|
|
57190
57335
|
snapshotMaxIdleMs: pick("snapshotMaxIdleMs")
|
|
57191
57336
|
};
|
|
57192
57337
|
}
|
|
57338
|
+
//#endregion
|
|
57339
|
+
//#region src/pipeline-analytics/our-recording-availability.ts
|
|
57340
|
+
/**
|
|
57341
|
+
* Coverage of OUR archive, asked of the `recording` collection by name.
|
|
57342
|
+
*
|
|
57343
|
+
* `recording` became a device collection on 2026-09-24 (D625): a camera may
|
|
57344
|
+
* have several sources of recorded coverage and every read names one. Every
|
|
57345
|
+
* read in this addon wants the same one — the footage OUR recorder wrote,
|
|
57346
|
+
* because everything downstream of it (a recorded clip cut, a timelapse, the
|
|
57347
|
+
* earliest-footage floor of a retention sweep) is about bytes we hold and can
|
|
57348
|
+
* export. A camera's own SD card is not an answer to any of those questions.
|
|
57349
|
+
*
|
|
57350
|
+
* It also owns the `read: 'unreadable'` rule, in one place: a read that FAILED
|
|
57351
|
+
* throws here rather than arriving downstream as an empty range list. Every
|
|
57352
|
+
* caller of this module treats "no ranges" as "recording is off for this
|
|
57353
|
+
* camera" and acts on it — dropping the attachment, skipping the render,
|
|
57354
|
+
* lowering the sweep floor — so folding a failed read into that shape would
|
|
57355
|
+
* make an unreachable recorder look exactly like a camera nobody records
|
|
57356
|
+
* (D393). The callers already handle a throw and log it as a read failure.
|
|
57357
|
+
*/
|
|
57358
|
+
/** Our coverage of `[fromMs, toMs)`, or a throw naming the failed read. */
|
|
57359
|
+
async function ourRecordingAvailability(api, input) {
|
|
57360
|
+
const answer = await api.recording.getAvailability.query({
|
|
57361
|
+
deviceId: input.deviceId,
|
|
57362
|
+
provider: require_dist.RECORDING_SOURCE_CAMSTACK_ADDON,
|
|
57363
|
+
fromMs: input.fromMs,
|
|
57364
|
+
toMs: input.toMs,
|
|
57365
|
+
...input.profile !== void 0 ? { profile: input.profile } : {}
|
|
57366
|
+
});
|
|
57367
|
+
if (answer.read === "unreadable") throw new Error(`recording.getAvailability: the CamStack archive was unreadable for device ${String(input.deviceId)} — no claim is made about its footage`);
|
|
57368
|
+
return {
|
|
57369
|
+
ranges: answer.ranges,
|
|
57370
|
+
profilesWithFootage: answer.profilesWithFootage
|
|
57371
|
+
};
|
|
57372
|
+
}
|
|
57193
57373
|
function toDetectionBbox(bbox) {
|
|
57194
57374
|
return {
|
|
57195
57375
|
x: bbox.x,
|
|
@@ -58481,6 +58661,17 @@ function resolveSearchThumbnailUrl(input) {
|
|
|
58481
58661
|
return `${input.baseUrl}/${encodeURIComponent(id)}`;
|
|
58482
58662
|
}
|
|
58483
58663
|
//#endregion
|
|
58664
|
+
//#region src/pipeline-analytics/pipeline/onboard-birth-authority.ts
|
|
58665
|
+
/**
|
|
58666
|
+
* May a suppression verdict RETRACT this birth?
|
|
58667
|
+
*
|
|
58668
|
+
* `false` means hold: keep the track, log the held verdict, and let the
|
|
58669
|
+
* ordinary lifetime decide. It never means "confirmed".
|
|
58670
|
+
*/
|
|
58671
|
+
function suppressionMayRetract(source) {
|
|
58672
|
+
return source !== "onboard";
|
|
58673
|
+
}
|
|
58674
|
+
//#endregion
|
|
58484
58675
|
//#region src/pipeline-analytics/pipeline/package/package-area-gate.ts
|
|
58485
58676
|
/**
|
|
58486
58677
|
* Decide whether a package-class box is plausibly parcel-sized.
|
|
@@ -61174,65 +61365,6 @@ var StationaryEvidenceLedger = class {
|
|
|
61174
61365
|
for (const row of excess) this.forgetKey(row.key);
|
|
61175
61366
|
}
|
|
61176
61367
|
};
|
|
61177
|
-
//#endregion
|
|
61178
|
-
//#region src/pipeline-analytics/pipeline/stationary/stationary-track-disposal.ts
|
|
61179
|
-
function planStationaryTrackDisposal(input) {
|
|
61180
|
-
const trackIds = [input.liveTrackId];
|
|
61181
|
-
if (input.sourceTrackId !== void 0 && input.sourceTrackId !== input.liveTrackId) trackIds.push(input.sourceTrackId);
|
|
61182
|
-
const keepMediaIds = /* @__PURE__ */ new Set();
|
|
61183
|
-
if (input.entryKeyFrameMediaId !== void 0) keepMediaIds.add(input.entryKeyFrameMediaId);
|
|
61184
|
-
return {
|
|
61185
|
-
trackIds,
|
|
61186
|
-
keepMediaIds
|
|
61187
|
-
};
|
|
61188
|
-
}
|
|
61189
|
-
/**
|
|
61190
|
-
* Run the plan. The live track is dropped from RAM FIRST so it can never be
|
|
61191
|
-
* persisted by a sweep racing this call; the cascade then removes what every
|
|
61192
|
-
* track owned, sparing the entry's key frame, and deletes the row (a no-op for
|
|
61193
|
-
* the never-persisted live track).
|
|
61194
|
-
*
|
|
61195
|
-
* Never silent: the line names the entity, what went and what was kept — a
|
|
61196
|
-
* deleted track is dropped work, and the operator asked for it by name.
|
|
61197
|
-
*/
|
|
61198
|
-
async function disposeStationaryTracks(deps, plan) {
|
|
61199
|
-
const [liveTrackId] = plan.trackIds;
|
|
61200
|
-
if (liveTrackId !== void 0) deps.trackStore.dropActive(liveTrackId);
|
|
61201
|
-
const sparingMedia = { deleteByTracks: (trackIds) => deps.media.deleteByTracks(trackIds, { spare: plan.keepMediaIds }) };
|
|
61202
|
-
const result = await runTrackCascadeBatch({
|
|
61203
|
-
registry: [...deps.leaves, sparingMedia],
|
|
61204
|
-
trackStore: deps.trackStore,
|
|
61205
|
-
deviceId: deps.deviceId,
|
|
61206
|
-
onTrackCleanup: deps.onTrackCleanup,
|
|
61207
|
-
onFailure: (trackId, err) => {
|
|
61208
|
-
deps.logger.warn("stationary promotion: a source track could not be disposed of", {
|
|
61209
|
-
tags: { deviceId: deps.deviceId },
|
|
61210
|
-
meta: {
|
|
61211
|
-
deviceId: deps.deviceId,
|
|
61212
|
-
entryId: deps.entryId,
|
|
61213
|
-
trackId,
|
|
61214
|
-
error: String(err)
|
|
61215
|
-
}
|
|
61216
|
-
});
|
|
61217
|
-
}
|
|
61218
|
-
}, plan.trackIds);
|
|
61219
|
-
deps.logger.info("stationary promotion: source tracks discarded — the entity keeps its key frame", {
|
|
61220
|
-
tags: { deviceId: deps.deviceId },
|
|
61221
|
-
meta: {
|
|
61222
|
-
deviceId: deps.deviceId,
|
|
61223
|
-
entryId: deps.entryId,
|
|
61224
|
-
trackIds: plan.trackIds,
|
|
61225
|
-
keptMediaIds: [...plan.keepMediaIds],
|
|
61226
|
-
deleted: result.deleted,
|
|
61227
|
-
failed: result.failed,
|
|
61228
|
-
mediaRows: result.tally.media
|
|
61229
|
-
}
|
|
61230
|
-
});
|
|
61231
|
-
return {
|
|
61232
|
-
deleted: result.deleted,
|
|
61233
|
-
failed: result.failed
|
|
61234
|
-
};
|
|
61235
|
-
}
|
|
61236
61368
|
/**
|
|
61237
61369
|
* How long the same weak box must keep being seen before it may re-anchor.
|
|
61238
61370
|
*
|
|
@@ -62174,6 +62306,97 @@ function rowToEntry(id, data) {
|
|
|
62174
62306
|
};
|
|
62175
62307
|
}
|
|
62176
62308
|
//#endregion
|
|
62309
|
+
//#region src/pipeline-analytics/pipeline/stationary/stationary-track-disposal.ts
|
|
62310
|
+
function planStationaryTrackDisposal(input) {
|
|
62311
|
+
const trackIds = [input.liveTrackId];
|
|
62312
|
+
if (input.sourceTrackId !== void 0 && input.sourceTrackId !== input.liveTrackId) trackIds.push(input.sourceTrackId);
|
|
62313
|
+
const keepMediaIds = /* @__PURE__ */ new Set();
|
|
62314
|
+
if (input.entryKeyFrameMediaId !== void 0) keepMediaIds.add(input.entryKeyFrameMediaId);
|
|
62315
|
+
return {
|
|
62316
|
+
trackIds,
|
|
62317
|
+
keepMediaIds
|
|
62318
|
+
};
|
|
62319
|
+
}
|
|
62320
|
+
/**
|
|
62321
|
+
* Run the plan. The live track is dropped from RAM FIRST so it can never be
|
|
62322
|
+
* persisted by a sweep racing this call; the cascade then removes what every
|
|
62323
|
+
* track owned, sparing the entry's key frame, and deletes the row (a no-op for
|
|
62324
|
+
* the never-persisted live track).
|
|
62325
|
+
*
|
|
62326
|
+
* Never silent: the line names the entity, what went and what was kept — a
|
|
62327
|
+
* deleted track is dropped work, and the operator asked for it by name.
|
|
62328
|
+
*/
|
|
62329
|
+
async function disposeStationaryTracks(deps, plan) {
|
|
62330
|
+
const [liveTrackId] = plan.trackIds;
|
|
62331
|
+
if (liveTrackId !== void 0) deps.trackStore.dropActive(liveTrackId);
|
|
62332
|
+
const sparingMedia = { deleteByTracks: (trackIds) => deps.media.deleteByTracks(trackIds, { spare: plan.keepMediaIds }) };
|
|
62333
|
+
const result = await runTrackCascadeBatch({
|
|
62334
|
+
registry: [...deps.leaves, sparingMedia],
|
|
62335
|
+
trackStore: deps.trackStore,
|
|
62336
|
+
deviceId: deps.deviceId,
|
|
62337
|
+
onTrackCleanup: deps.onTrackCleanup,
|
|
62338
|
+
onFailure: (trackId, err) => {
|
|
62339
|
+
deps.logger.warn("stationary promotion: a source track could not be disposed of", {
|
|
62340
|
+
tags: { deviceId: deps.deviceId },
|
|
62341
|
+
meta: {
|
|
62342
|
+
deviceId: deps.deviceId,
|
|
62343
|
+
entryId: deps.entryId,
|
|
62344
|
+
trackId,
|
|
62345
|
+
error: String(err)
|
|
62346
|
+
}
|
|
62347
|
+
});
|
|
62348
|
+
}
|
|
62349
|
+
}, plan.trackIds);
|
|
62350
|
+
deps.logger.info("stationary promotion: source tracks discarded — the entity keeps its key frame", {
|
|
62351
|
+
tags: { deviceId: deps.deviceId },
|
|
62352
|
+
meta: {
|
|
62353
|
+
deviceId: deps.deviceId,
|
|
62354
|
+
entryId: deps.entryId,
|
|
62355
|
+
trackIds: plan.trackIds,
|
|
62356
|
+
keptMediaIds: [...plan.keepMediaIds],
|
|
62357
|
+
deleted: result.deleted,
|
|
62358
|
+
failed: result.failed,
|
|
62359
|
+
mediaRows: result.tally.media
|
|
62360
|
+
}
|
|
62361
|
+
});
|
|
62362
|
+
return {
|
|
62363
|
+
deleted: result.deleted,
|
|
62364
|
+
failed: result.failed
|
|
62365
|
+
};
|
|
62366
|
+
}
|
|
62367
|
+
//#endregion
|
|
62368
|
+
//#region src/pipeline-analytics/pipeline/stationary-planes.ts
|
|
62369
|
+
/**
|
|
62370
|
+
* WHICH detection planes carry the stationary machinery.
|
|
62371
|
+
*
|
|
62372
|
+
* A plane qualifies when it produces OBJECT BOXES on decoded frames — because
|
|
62373
|
+
* that is what the registry needs to decide an object is parked: a box, a
|
|
62374
|
+
* class, and an observed clock that only advances while frames flow.
|
|
62375
|
+
*
|
|
62376
|
+
* Both root detectors qualify. `pipeline` is the ML tree; `onboard` is the
|
|
62377
|
+
* camera's own firmware, and the boxes it produces are paired with a decoded
|
|
62378
|
+
* frame in that frame's pixel space — the same plane, entered by a different
|
|
62379
|
+
* door. `sensor` and `audio` do not: they carry no geometry to park.
|
|
62380
|
+
*
|
|
62381
|
+
* It used to be `source === 'pipeline'`, in five places, each explaining that
|
|
62382
|
+
* the gate "runs on that plane". The reason given was WHERE the flood it was
|
|
62383
|
+
* written for had been measured (617, the ML plane), never an argument that the
|
|
62384
|
+
* firmware's boxes were unfit. The cost of the accident, on device 640: a still
|
|
62385
|
+
* object is never promoted, so it holds `hasLiveTrack` true for as long as it
|
|
62386
|
+
* sits there, and every detection session runs to its 120 s cap instead of
|
|
62387
|
+
* closing on cooldown — `closed at the max-hold cap with a subject still
|
|
62388
|
+
* present`. The camera's lazy stream is then torn down and rebuilt on the next
|
|
62389
|
+
* detection, on a battery camera.
|
|
62390
|
+
*
|
|
62391
|
+
* One predicate and not five comparisons, because five copies of a rule drift:
|
|
62392
|
+
* this repo has already paid for that with two authorities on one function
|
|
62393
|
+
* (D62).
|
|
62394
|
+
*/
|
|
62395
|
+
/** Does this detection plane run the stationary registry, gate and promotion? */
|
|
62396
|
+
function planeRunsStationary(source) {
|
|
62397
|
+
return source === "pipeline" || source === "onboard";
|
|
62398
|
+
}
|
|
62399
|
+
//#endregion
|
|
62177
62400
|
//#region src/pipeline-analytics/pipeline/suppressed-births.ts
|
|
62178
62401
|
/**
|
|
62179
62402
|
* Memory of births the confirmation gate rejected, per device.
|
|
@@ -63089,49 +63312,6 @@ function hasKeyFrame(source) {
|
|
|
63089
63312
|
return source.keyFrame !== null && source.bbox !== null;
|
|
63090
63313
|
}
|
|
63091
63314
|
//#endregion
|
|
63092
|
-
//#region src/pipeline-analytics/pipeline/stationary-planes.ts
|
|
63093
|
-
/**
|
|
63094
|
-
* WHICH detection planes carry the stationary machinery.
|
|
63095
|
-
*
|
|
63096
|
-
* A plane qualifies when it produces OBJECT BOXES on decoded frames — because
|
|
63097
|
-
* that is what the registry needs to decide an object is parked: a box, a
|
|
63098
|
-
* class, and an observed clock that only advances while frames flow.
|
|
63099
|
-
*
|
|
63100
|
-
* Both root detectors qualify. `pipeline` is the ML tree; `onboard` is the
|
|
63101
|
-
* camera's own firmware, and the boxes it produces are paired with a decoded
|
|
63102
|
-
* frame in that frame's pixel space — the same plane, entered by a different
|
|
63103
|
-
* door. `sensor` and `audio` do not: they carry no geometry to park.
|
|
63104
|
-
*
|
|
63105
|
-
* It used to be `source === 'pipeline'`, in five places, each explaining that
|
|
63106
|
-
* the gate "runs on that plane". The reason given was WHERE the flood it was
|
|
63107
|
-
* written for had been measured (617, the ML plane), never an argument that the
|
|
63108
|
-
* firmware's boxes were unfit. The cost of the accident, on device 640: a still
|
|
63109
|
-
* object is never promoted, so it holds `hasLiveTrack` true for as long as it
|
|
63110
|
-
* sits there, and every detection session runs to its 120 s cap instead of
|
|
63111
|
-
* closing on cooldown — `closed at the max-hold cap with a subject still
|
|
63112
|
-
* present`. The camera's lazy stream is then torn down and rebuilt on the next
|
|
63113
|
-
* detection, on a battery camera.
|
|
63114
|
-
*
|
|
63115
|
-
* One predicate and not five comparisons, because five copies of a rule drift:
|
|
63116
|
-
* this repo has already paid for that with two authorities on one function
|
|
63117
|
-
* (D62).
|
|
63118
|
-
*/
|
|
63119
|
-
/** Does this detection plane run the stationary registry, gate and promotion? */
|
|
63120
|
-
function planeRunsStationary(source) {
|
|
63121
|
-
return source === "pipeline" || source === "onboard";
|
|
63122
|
-
}
|
|
63123
|
-
//#endregion
|
|
63124
|
-
//#region src/pipeline-analytics/pipeline/onboard-birth-authority.ts
|
|
63125
|
-
/**
|
|
63126
|
-
* May a suppression verdict RETRACT this birth?
|
|
63127
|
-
*
|
|
63128
|
-
* `false` means hold: keep the track, log the held verdict, and let the
|
|
63129
|
-
* ordinary lifetime decide. It never means "confirmed".
|
|
63130
|
-
*/
|
|
63131
|
-
function suppressionMayRetract(source) {
|
|
63132
|
-
return source !== "onboard";
|
|
63133
|
-
}
|
|
63134
|
-
//#endregion
|
|
63135
63315
|
//#region src/pipeline-analytics/rebuild-source-loader.ts
|
|
63136
63316
|
/**
|
|
63137
63317
|
* Assemble one track's rebuild inputs, reading at most ONE blob.
|
|
@@ -71703,9 +71883,111 @@ async function projectSensorMarkers(deps, data, timestamp) {
|
|
|
71703
71883
|
}
|
|
71704
71884
|
return landed;
|
|
71705
71885
|
}
|
|
71886
|
+
//#endregion
|
|
71887
|
+
//#region src/pipeline-analytics/services/sensor-transition-gate.ts
|
|
71888
|
+
/**
|
|
71889
|
+
* The gate between "a `DeviceStateChanged` arrived for a mapped sensor cap"
|
|
71890
|
+
* and "the sensor actually CHANGED".
|
|
71891
|
+
*
|
|
71892
|
+
* ## What this fixes
|
|
71893
|
+
*
|
|
71894
|
+
* `handleSensorStateChanged` treated the ARRIVAL of the bus event as the
|
|
71895
|
+
* assertion, and never looked at the slice. It cannot: the kernel mirror
|
|
71896
|
+
* (`device-state-mirror.ts` `applySingleCapUpdate`) diffs the whole SLICE, and
|
|
71897
|
+
* a contact slice carries `lastChangedAt` beside `entryOpen`. Anything that
|
|
71898
|
+
* moves the timestamp without moving the boolean is a `DeviceStateChanged`,
|
|
71899
|
+
* and downstream it became a `SensorEvent` row and a synthetic `contact` track
|
|
71900
|
+
* on the linked camera's timeline.
|
|
71901
|
+
*
|
|
71902
|
+
* Measured on camera 615 / contact sensor 4131, 2026-08-29→30: **30 of 35**
|
|
71903
|
+
* contact events were one of two non-events.
|
|
71904
|
+
*
|
|
71905
|
+
* - The RECOVERY family. The Zigbee entity flaps `off → unavailable → off`
|
|
71906
|
+
* (03:22:37→03:23:28, 03:56:51→03:58:19, 04:40:05→04:45:45). The HA
|
|
71907
|
+
* provider drops the `unavailable` push and writes the recovery, whose
|
|
71908
|
+
* `entryOpen` is still `false` and whose `lastChangedAt` is HA's fresh
|
|
71909
|
+
* `last_changed`. The mirror diffs, and emits.
|
|
71910
|
+
* - The COLD-SLICE family. `HaBinarySensorDevice`'s constructor writes
|
|
71911
|
+
* `{ entryOpen: false, lastChangedAt: 0 }` on every device rebuild; the
|
|
71912
|
+
* real state lands seconds later with a `lastChangedAt` hours old. Two
|
|
71913
|
+
* emissions, no transition.
|
|
71914
|
+
*
|
|
71915
|
+
* ## Why the predicate is imported and not written here
|
|
71916
|
+
*
|
|
71917
|
+
* `evaluateSensorEdge` (`@camstack/types`) is already THE edge predicate: the
|
|
71918
|
+
* virtual doorbell feeds it (`builtins/doorbell/trigger-engine.ts`) and so does
|
|
71919
|
+
* the recorder's sensor trigger (`addon-pipeline` `recorder/sensor-trigger.ts`).
|
|
71920
|
+
* Post-analysis was the third consumer of the same bus event and the only one
|
|
71921
|
+
* that did not. A second predicate here would diverge on the first cap added —
|
|
71922
|
+
* the exact failure [D62](../../../../../docs/decisions/adr-0062.md) names.
|
|
71923
|
+
*
|
|
71924
|
+
* ## Where it deliberately differs from the other two
|
|
71925
|
+
*
|
|
71926
|
+
* The doorbell and the recorder want the RISING edge only. A timeline marker
|
|
71927
|
+
* is not a doorbell: a door CLOSING is an event an operator wants to see, and
|
|
71928
|
+
* the taxonomy has one `contact` kind for both directions. So this gate accepts
|
|
71929
|
+
* `edge: 'rising'` **and** the `falling-edge` verdict, and rejects only the
|
|
71930
|
+
* verdicts that mean "nothing moved" — which is precisely the 30.
|
|
71931
|
+
*
|
|
71932
|
+
* ## Caps with no boolean level pass through untouched
|
|
71933
|
+
*
|
|
71934
|
+
* `EVENT_KIND_BY_CAP` covers sixteen caps; `SOURCE_CAP_ACTIVE_FIELD` covers the
|
|
71935
|
+
* ten that have a boolean whose edge means something. `doorbell`, `button`,
|
|
71936
|
+
* `lock-control`, `presence`, `enum-sensor` and `event-emitter` are NOT levels
|
|
71937
|
+
* — their ARRIVAL is the event, and three presses in a row are three markers.
|
|
71938
|
+
* Gating them would re-ship the failure of 2026-08-08, when camera 615's press
|
|
71939
|
+
* produced a row, a ring, a matched rule and no marker.
|
|
71940
|
+
*
|
|
71941
|
+
* ## A cover is a level whose level is a WORD
|
|
71942
|
+
*
|
|
71943
|
+
* `cover` is the sixteenth cap, and it is neither of the above. Its level is
|
|
71944
|
+
* the `state` word (`open`, `opening`, `closing`, `closed`, `stopped`), and its
|
|
71945
|
+
* slice also carries `position`, which ticks all the way through a travel and
|
|
71946
|
+
* is republished unchanged by providers that poll. Passed through, every tick
|
|
71947
|
+
* and every republish would write a sensor-event row per linked camera and an
|
|
71948
|
+
* NC evaluation — so it transitions on the WORD only (`WORD_LEVEL_FIELD`):
|
|
71949
|
+
* `closed → opening → open` is two events, a position tick or a same-word
|
|
71950
|
+
* republish is a `no-change` drop.
|
|
71951
|
+
*
|
|
71952
|
+
* Its FIRST sighting follows the booleans' rule (`evaluateSensorEdge`), with
|
|
71953
|
+
* `closed` as the inactive word: `closed` seeds the baseline and is dropped
|
|
71954
|
+
* (`baseline-seeded-inactive`); any other word is an EVENT when the slice's
|
|
71955
|
+
* `lastChangedAt` clears the same floor, `max(startedAt, now - freshness)`,
|
|
71956
|
+
* and is otherwise seeded and dropped (`baseline-seeded-stale-active`). It has
|
|
71957
|
+
* to emit: after a runner respawn the memory is empty and the next slice is
|
|
71958
|
+
* usually the next REAL change, and on a two-word cover (Velux, most HA
|
|
71959
|
+
* covers: `closed`/`open` only) seeding it would lose the whole opening.
|
|
71960
|
+
*
|
|
71961
|
+
* Residual edges, both erring towards silence on the ambiguous case like the
|
|
71962
|
+
* booleans do: a cover that was already open and whose `lastChangedAt` is
|
|
71963
|
+
* older than the floor is not reported (a respawn cannot tell it from a
|
|
71964
|
+
* hydration); and a first sighting whose `lastChangedAt` is fresh only
|
|
71965
|
+
* because a POSITION tick moved it (the slice timestamp is not the word's)
|
|
71966
|
+
* is reported once as if the word had just changed. The alarm needs covers
|
|
71967
|
+
* to be eventful to see one open (D630).
|
|
71968
|
+
*
|
|
71969
|
+
* ## Nothing here logs, and no drop is silent
|
|
71970
|
+
*
|
|
71971
|
+
* The gate is pure towards the world (no bus, no logger, no clock of its own —
|
|
71972
|
+
* the caller brings `now`), like the recorder's router. But it sits on a path
|
|
71973
|
+
* that carries every camera's native `motion` slice at motion rate — ~0.6/s
|
|
71974
|
+
* fleet-wide — so a line per drop would be its own outage. It accumulates
|
|
71975
|
+
* instead, per `(deviceId, capName, reason)`, and the caller drains one summary
|
|
71976
|
+
* per window. A `debug` at 3.3/s that nobody read is how a 100 %-rejection
|
|
71977
|
+
* regime survived three months here.
|
|
71978
|
+
*/
|
|
71979
|
+
/**
|
|
71980
|
+
* Caps whose level is a string word rather than a boolean: cap → slice field.
|
|
71981
|
+
* See "A cover is a level whose level is a WORD" above.
|
|
71982
|
+
*/
|
|
71983
|
+
var WORD_LEVEL_FIELD = { cover: "state" };
|
|
71984
|
+
/** The word a word-level cap rests in — its `false`, for the first-sighting rule. */
|
|
71985
|
+
var WORD_LEVEL_INACTIVE = { cover: "closed" };
|
|
71706
71986
|
var SensorTransitionGate = class {
|
|
71707
71987
|
/** Last observed boolean, keyed by `${deviceId}:${capName}`. */
|
|
71708
71988
|
lastValues = /* @__PURE__ */ new Map();
|
|
71989
|
+
/** Last observed word of a word-level cap, keyed by `${deviceId}:${capName}`. */
|
|
71990
|
+
lastWords = /* @__PURE__ */ new Map();
|
|
71709
71991
|
/** Drops since the window opened, keyed by `${deviceId}:${capName}:${reason}`. */
|
|
71710
71992
|
drops = /* @__PURE__ */ new Map();
|
|
71711
71993
|
windowStartedAt;
|
|
@@ -71723,10 +72005,13 @@ var SensorTransitionGate = class {
|
|
|
71723
72005
|
/**
|
|
71724
72006
|
* Feed one `DeviceStateChanged` slice for a cap `EVENT_KIND_BY_CAP` maps.
|
|
71725
72007
|
*
|
|
71726
|
-
* A cap with no
|
|
71727
|
-
*
|
|
72008
|
+
* A cap with no level passes through unconditionally. A cap that HAS one
|
|
72009
|
+
* passes only when its level moved — a boolean in either direction, or a
|
|
72010
|
+
* word-level cap's word changing.
|
|
71728
72011
|
*/
|
|
71729
72012
|
observe(deviceId, capName, slice) {
|
|
72013
|
+
const wordField = WORD_LEVEL_FIELD[capName];
|
|
72014
|
+
if (wordField !== void 0) return this.observeWord(deviceId, capName, slice, wordField);
|
|
71730
72015
|
if (!require_dist.isSourceCap(capName)) return { changed: true };
|
|
71731
72016
|
const key = `${deviceId}:${capName}`;
|
|
71732
72017
|
const verdict = require_dist.evaluateSensorEdge({
|
|
@@ -71764,6 +72049,40 @@ var SensorTransitionGate = class {
|
|
|
71764
72049
|
this.drops.clear();
|
|
71765
72050
|
return out;
|
|
71766
72051
|
}
|
|
72052
|
+
/**
|
|
72053
|
+
* A word-level cap: a transition is the word changing. The memory is
|
|
72054
|
+
* written only when the slice CARRIED a word, for the same reason as the
|
|
72055
|
+
* booleans — a malformed slice must not become a baseline.
|
|
72056
|
+
*/
|
|
72057
|
+
observeWord(deviceId, capName, slice, wordField) {
|
|
72058
|
+
const raw = slice[wordField];
|
|
72059
|
+
if (typeof raw !== "string" || raw.length === 0) return this.drop(deviceId, capName, "non-word-value");
|
|
72060
|
+
const key = `${deviceId}:${capName}`;
|
|
72061
|
+
const prior = this.lastWords.get(key);
|
|
72062
|
+
this.lastWords.set(key, raw);
|
|
72063
|
+
if (prior === void 0) return this.firstWordSighting(deviceId, capName, slice, raw);
|
|
72064
|
+
if (prior === raw) return this.drop(deviceId, capName, "no-change");
|
|
72065
|
+
return { changed: true };
|
|
72066
|
+
}
|
|
72067
|
+
/**
|
|
72068
|
+
* The booleans' first-sighting rule, on a word: the resting word seeds; any
|
|
72069
|
+
* other word is an event only when the slice's own `lastChangedAt` clears
|
|
72070
|
+
* the SAME floor `evaluateSensorEdge` uses, from the same two knobs.
|
|
72071
|
+
*/
|
|
72072
|
+
firstWordSighting(deviceId, capName, slice, word) {
|
|
72073
|
+
if (word === WORD_LEVEL_INACTIVE[capName]) return this.drop(deviceId, capName, "baseline-seeded-inactive");
|
|
72074
|
+
const changedAt = slice["lastChangedAt"];
|
|
72075
|
+
const floor = Math.max(this.startedAtMs, this.now() - this.freshnessMs);
|
|
72076
|
+
if (!(typeof changedAt === "number" && Number.isFinite(changedAt) && changedAt > 0 && changedAt >= floor)) return this.drop(deviceId, capName, "baseline-seeded-stale-active");
|
|
72077
|
+
return { changed: true };
|
|
72078
|
+
}
|
|
72079
|
+
drop(deviceId, capName, reason) {
|
|
72080
|
+
this.noteDrop(deviceId, capName, reason);
|
|
72081
|
+
return {
|
|
72082
|
+
changed: false,
|
|
72083
|
+
reason
|
|
72084
|
+
};
|
|
72085
|
+
}
|
|
71767
72086
|
noteDrop(deviceId, capName, reason) {
|
|
71768
72087
|
const key = `${deviceId}:${capName}:${reason}`;
|
|
71769
72088
|
const prior = this.drops.get(key);
|
|
@@ -78342,7 +78661,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
78342
78661
|
injectTestEvent: (input) => center.injectTestEvent(input, { getSnapshot: (deviceId) => api.snapshot.getSnapshot.query({ deviceId }) }),
|
|
78343
78662
|
timelapse: {
|
|
78344
78663
|
store: center.timelapseStore,
|
|
78345
|
-
getRecordingConfig: (deviceId) => api.
|
|
78664
|
+
getRecordingConfig: (deviceId) => api.recordingArchive.getDeviceConfig.query({ deviceId }).catch(() => null),
|
|
78346
78665
|
runNow: (input) => center.runTimelapseNow(input)
|
|
78347
78666
|
},
|
|
78348
78667
|
summary: {
|
|
@@ -78350,6 +78669,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
78350
78669
|
runNow: (input) => center.runSummaryNow(input)
|
|
78351
78670
|
},
|
|
78352
78671
|
texts: center.textCatalogEditor,
|
|
78672
|
+
previewTexts: center.textCatalog,
|
|
78353
78673
|
...this.ncArtifactIndex !== null ? { artifacts: this.ncArtifactIndex } : {},
|
|
78354
78674
|
artifactShelf: { remove: async (id) => await this.ncArtifactStore?.remove(id) ?? false },
|
|
78355
78675
|
ruleStatus: {
|
|
@@ -79435,7 +79755,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
79435
79755
|
logger: logger.child("RecordedClip"),
|
|
79436
79756
|
now: () => Date.now(),
|
|
79437
79757
|
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
79438
|
-
getAvailability: (input) => api
|
|
79758
|
+
getAvailability: (input) => ourRecordingAvailability(api, input),
|
|
79439
79759
|
createExport: (input) => api.recordingExport.createExport.mutate({ ...input }),
|
|
79440
79760
|
getExport: (input) => api.recordingExport.getExport.query(input),
|
|
79441
79761
|
readExportBytes: (input) => api.recordingExport.readExportBytes.query(input),
|
|
@@ -79689,7 +80009,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
79689
80009
|
buildTimelapsePorts(api, stores) {
|
|
79690
80010
|
return {
|
|
79691
80011
|
getAvailability: async ({ deviceId, fromMs, toMs, profile }) => {
|
|
79692
|
-
const availability = await api
|
|
80012
|
+
const availability = await ourRecordingAvailability(api, {
|
|
79693
80013
|
deviceId,
|
|
79694
80014
|
fromMs,
|
|
79695
80015
|
toMs,
|
|
@@ -83880,7 +84200,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
83880
84200
|
if (cached && Date.now() - cached.at < 10 * 6e4) return cached.value;
|
|
83881
84201
|
let value = null;
|
|
83882
84202
|
try {
|
|
83883
|
-
const res = await this.ctx.api
|
|
84203
|
+
const res = await ourRecordingAvailability(this.ctx.api, {
|
|
83884
84204
|
deviceId,
|
|
83885
84205
|
fromMs: 0,
|
|
83886
84206
|
toMs: Date.now()
|
|
@@ -85189,7 +85509,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
85189
85509
|
* A cap with no boolean level (`doorbell`, `button`, `lock-control`,
|
|
85190
85510
|
* `presence`, `enum-sensor`, `event-emitter`) always passes — its ARRIVAL is
|
|
85191
85511
|
* the event, and gating it would re-ship the silence of 2026-08-08. A cap
|
|
85192
|
-
* that HAS one passes only when the boolean moved, in either direction
|
|
85512
|
+
* that HAS one passes only when the boolean moved, in either direction — or,
|
|
85513
|
+
* for `cover`, whose level is its `state` word, only when the word changed
|
|
85514
|
+
* (a position tick or a same-word republish is a drop).
|
|
85193
85515
|
*
|
|
85194
85516
|
* A slice that is not an object at all is refused rather than defaulted to
|
|
85195
85517
|
* `{}`: `evaluateSensorEdge` would read `non-boolean-value` from an empty
|
|
@@ -85926,7 +86248,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
85926
86248
|
} });
|
|
85927
86249
|
const deviceIds = input.deviceId !== void 0 ? [input.deviceId] : await trackStore.listDeviceIds();
|
|
85928
86250
|
const recordedStills = new RecordedStillService(buildRecordedStillPorts({
|
|
85929
|
-
api: this.ctx.api.
|
|
86251
|
+
api: this.ctx.api.recordingArchive,
|
|
85930
86252
|
logger: this.ctx.logger
|
|
85931
86253
|
}));
|
|
85932
86254
|
const deps = {
|