@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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as
|
|
1
|
+
import { $ as SCENE_DIVERGED, $t as hydrateSchema, A as NcConditionDescriptorSchema, At as occupancyScope, B as NcSystemEventKindSchema, Bt as storageOccupancyCapability, C as MediaFileKindEnum, Ct as isKnownTemplateVar, Dt as kebabToCamel, E as NC_DEFAULT_SNOOZE_MINUTES, Et as isSourceCap, F as NcRuleUpdateInputSchema, Ft as readDeviceStateFrom, G as OpsLogEntrySchema, Gt as zoneAnalyticsCapability, H as NcTemplatePreviewInputSchema, Ht as systemEventFilterApplies, I as NcScheduleSchema, It as readTimelapseGeneratedAt, J as RECORDING_SOURCE_CAMSTACK_ADDON, Jt as CamProfileSchema, Kt as errMsg$1, L as NcSnoozeInputSchema, Lt as resolveLocationMode, M as NcRulePatchSchema, Mt as pickClusterStepModels, N as NcRuleSchema, Nt as pipelineAnalyticsCapability, O as NC_TAXONOMY, Ot as mayWriteToLocation, P as NcRuleTargetSchema, Pt as plateGalleryCapability, Q as SCENE_DEFAULT_UNCOVERED_POLICY, Qt as defineCustomActions, R as NcSnoozeSchema, S as MOTION_CLOSE_AFTER_MS, St as isEventOwnerType, T as NC_CONDITION_CATALOG, Tt as isScheduleActive, U as NcTemplatePreviewSchema, Ut as templateVarsFor, V as NcTaxonomySchema, Vt as subKindsOf, W as NcTemplateVarDescriptorSchema, Wt as vectorDimFromBase64, Xt as createEvent, Y as RetrainStatusSchema, Yt as DeviceType, Z as SCENE_DEFAULT_ANCHOR_THRESHOLD, Zt as customAction, _ as FailureCounters, _t as evictionPolicyOfLocation, a as CLUSTER_MODEL_SCOPED_STEPS, an as boolean, at as TrackSourceSchema, b as MAX_BIRTH_DECISION_RECORDS, cn as number, ct as assertTimelapseCadences, dn as record, dt as buildEventKindDescriptor, en as isDeviceScopedCap, et as SceneMonitorSchema, f as DeclaredDevices, fn as string, ft as cosineSimilarity$1, g as FULL_IMAGE_BBOX, gt as evaluateSensorEdge, h as FIRST_LEVEL_MACRO_CLASSES, ht as encodeVectorBase64, i as BirthDecisionRecordSchema, in as array, it as TimelapseRuleSchema, j as NcRuleInputSchema, k as NC_TEMPLATE_VARS, kt as notificationRulesCapability, ln as object, lt as audioMetricsCapability, m as EVENT_OWNER_TYPES, mn as EventCategory, n as ArchivedDebugNoteSchema, nn as sleep$1, nt as TimelapseRuleInputSchema, o as COCO_TO_MACRO, on as discriminatedUnion, ot as addonWidgetsSourceCapability, p as EVENT_KIND_BY_CAP, pn as unknown, pt as deriveRecordingMode, q as RECORDING_EXPORT_MAX_READ_BYTES, qt as BaseAddon, r as BaseDevice, rn as _enum, rt as TimelapseRulePatchSchema, s as DEFAULT_EVENT_COLOR, sn as literal, st as alarmPanelCapability, t as AUDIO_MACRO_LABELS, tn as nodePin, tt as TIMELAPSE_DENSE_FLOOR_SEC, u as DETECTION_MACRO_CLASSES, un as partialRecord, ut as audioModeOf, v as LabelAttributionSchema, vt as faceGalleryCapability, w as NC_ALARM_SYSTEM_EVENT_KINDS, wt as isMediaOwnerType, xt as isDetectionMacroClass, y as MAX_ARCHIVED_DEBUG_NOTES, yt as failureContributionCapability, z as NcSnoozeSuppressedSchema, zt as sceneMonitorCapability } from "../dist-D3Yub96m.mjs";
|
|
2
2
|
import { t as __exportAll } from "../embedding-encoder/index.mjs";
|
|
3
3
|
import * as fs from "node:fs";
|
|
4
4
|
import { promises } from "node:fs";
|
|
@@ -1454,7 +1454,8 @@ var NcAlarmPanelDevice = class extends BaseDevice {
|
|
|
1454
1454
|
return {
|
|
1455
1455
|
...this.readDelays(),
|
|
1456
1456
|
announceArm: this.config.get("announceArm"),
|
|
1457
|
-
announceTargets: [...this.config.get("announceTargets")]
|
|
1457
|
+
announceTargets: [...this.config.get("announceTargets")],
|
|
1458
|
+
nonBlocking: []
|
|
1458
1459
|
};
|
|
1459
1460
|
}
|
|
1460
1461
|
/**
|
|
@@ -10815,6 +10816,23 @@ function requireCapCaller(method, caller) {
|
|
|
10815
10816
|
return caller;
|
|
10816
10817
|
}
|
|
10817
10818
|
//#endregion
|
|
10819
|
+
//#region src/notification-center/clock-text.ts
|
|
10820
|
+
/**
|
|
10821
|
+
* `{{time}}` — hours and minutes in the NOTIFICATION language.
|
|
10822
|
+
*
|
|
10823
|
+
* It used to be `toLocaleTimeString()` in the server's own locale, so an
|
|
10824
|
+
* Italian hub running an English container sent "8:05:13 PM" inside an
|
|
10825
|
+
* Italian sentence. The time zone is the host's unless a caller (a spec)
|
|
10826
|
+
* pins one.
|
|
10827
|
+
*/
|
|
10828
|
+
function ncClockText(language, atMs, timeZone) {
|
|
10829
|
+
return new Intl.DateTimeFormat(language, {
|
|
10830
|
+
hour: "2-digit",
|
|
10831
|
+
minute: "2-digit",
|
|
10832
|
+
...timeZone !== void 0 ? { timeZone } : {}
|
|
10833
|
+
}).format(new Date(atMs));
|
|
10834
|
+
}
|
|
10835
|
+
//#endregion
|
|
10818
10836
|
//#region src/notification-center/system-event-tap-url.ts
|
|
10819
10837
|
/** The admin UI's own routes, as `packages/addon-admin-ui/src/App.tsx` declares them. */
|
|
10820
10838
|
var ADMIN_ADDONS_PATH = "/system/addons";
|
|
@@ -12783,8 +12801,7 @@ var NcDispatcher = class {
|
|
|
12783
12801
|
async buildNotification(entry, target) {
|
|
12784
12802
|
const subject = entry.payload.subject;
|
|
12785
12803
|
const systemEvent = subject.systemEvent;
|
|
12786
|
-
const
|
|
12787
|
-
const deviceName = systemEvent !== void 0 && systemEvent.deviceId === void 0 ? "system" : await this.deps.getDeviceName(displayDeviceId).catch(() => null) ?? `camera ${displayDeviceId}`;
|
|
12804
|
+
const deviceName = await notificationDeviceName(entry, this.deps.getDeviceName);
|
|
12788
12805
|
const alarmSource = systemEvent?.alarmSource;
|
|
12789
12806
|
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) : [];
|
|
12790
12807
|
const vars = buildTemplateVars(this.texts, entry, deviceName, zoneLabels);
|
|
@@ -13538,17 +13555,25 @@ async function resolveZoneLabels(getZoneNames, deviceId, zoneIds) {
|
|
|
13538
13555
|
function percentOf(confidence) {
|
|
13539
13556
|
return confidence !== void 0 ? `${Math.round(confidence * 100)}%` : "";
|
|
13540
13557
|
}
|
|
13558
|
+
/** What `{{camera}}` renders on a system row that names no device. */
|
|
13559
|
+
var NC_DEVICELESS_CAMERA_NAME = "system";
|
|
13541
13560
|
/**
|
|
13542
|
-
* The
|
|
13543
|
-
*
|
|
13561
|
+
* The name `{{camera}}` renders for a row — the ONE derivation, shared by the
|
|
13562
|
+
* dispatcher and the template-variable parity spec.
|
|
13544
13563
|
*
|
|
13545
|
-
*
|
|
13546
|
-
*
|
|
13547
|
-
*
|
|
13548
|
-
*
|
|
13549
|
-
*
|
|
13550
|
-
* outrank the live vocabulary for exactly those names.
|
|
13564
|
+
* A system row that names no device (a node, a backup, an update) renders the
|
|
13565
|
+
* filler {@link NC_DEVICELESS_CAMERA_NAME}: there is no camera to name, which
|
|
13566
|
+
* is why the catalog offers `{{camera}}` on a system rule only for the kinds
|
|
13567
|
+
* that carry a device (template-vars.ts). Everything else asks the directory,
|
|
13568
|
+
* and falls back to `camera <id>` when it cannot answer.
|
|
13551
13569
|
*/
|
|
13570
|
+
async function notificationDeviceName(entry, getDeviceName) {
|
|
13571
|
+
const subject = entry.payload.subject;
|
|
13572
|
+
const systemEvent = subject.systemEvent;
|
|
13573
|
+
if (systemEvent !== void 0 && systemEvent.deviceId === void 0) return NC_DEVICELESS_CAMERA_NAME;
|
|
13574
|
+
const displayDeviceId = systemEvent?.deviceId ?? subject.deviceId;
|
|
13575
|
+
return await getDeviceName(displayDeviceId).catch(() => null) ?? `camera ${displayDeviceId}`;
|
|
13576
|
+
}
|
|
13552
13577
|
/** Class composition of a group, busiest classes in first-seen order. */
|
|
13553
13578
|
function classCounts(members) {
|
|
13554
13579
|
const counts = /* @__PURE__ */ new Map();
|
|
@@ -13558,7 +13583,18 @@ function classCounts(members) {
|
|
|
13558
13583
|
count
|
|
13559
13584
|
}));
|
|
13560
13585
|
}
|
|
13561
|
-
|
|
13586
|
+
/**
|
|
13587
|
+
* The variable map ONE render pass sees: scalars, then the composites built
|
|
13588
|
+
* from them, then the params a system-event row froze at intake.
|
|
13589
|
+
*
|
|
13590
|
+
* The three groups are layered in that order deliberately. A composite
|
|
13591
|
+
* (`{{subject}}`, `{{inZones}}`, `{{op}}`) is a finished phrase and must not be
|
|
13592
|
+
* visible to the fragments it was built from — that is the depth bound, and it
|
|
13593
|
+
* is enforced by `text-compose.ts` never being handed this map. The frozen
|
|
13594
|
+
* params go last because they are the row's own truth about a system event and
|
|
13595
|
+
* outrank the live vocabulary for exactly those names.
|
|
13596
|
+
*/
|
|
13597
|
+
function buildTemplateVars(texts, entry, deviceName, zoneLabels, timeZone) {
|
|
13562
13598
|
const subject = entry.payload.subject;
|
|
13563
13599
|
const occupancy = subject.occupancy;
|
|
13564
13600
|
const systemEvent = subject.systemEvent;
|
|
@@ -13566,7 +13602,7 @@ function buildTemplateVars(texts, entry, deviceName, zoneLabels) {
|
|
|
13566
13602
|
const group = entry.payload.group;
|
|
13567
13603
|
return {
|
|
13568
13604
|
camera: deviceName,
|
|
13569
|
-
class: detection?.className ?? subject.className,
|
|
13605
|
+
class: detection?.className ?? (systemEvent !== void 0 ? "" : subject.className),
|
|
13570
13606
|
label: detection?.label ?? subject.label ?? "",
|
|
13571
13607
|
zones: zoneLabels.join(", "),
|
|
13572
13608
|
subject: detection?.className !== void 0 ? ncSubjectText(texts, {
|
|
@@ -13579,10 +13615,14 @@ function buildTemplateVars(texts, entry, deviceName, zoneLabels) {
|
|
|
13579
13615
|
}),
|
|
13580
13616
|
detectionSummary: group !== void 0 && group.members.length > 1 ? ncDetectionSummary(texts, classCounts(group.members)) : "",
|
|
13581
13617
|
inZones: ncInZonesText(texts, zoneLabels),
|
|
13582
|
-
scope: occupancy
|
|
13618
|
+
scope: occupancy !== void 0 ? occupancy.zone ?? deviceName : "",
|
|
13583
13619
|
zone: occupancy?.zone ?? zoneLabels[0] ?? "",
|
|
13584
13620
|
confidence: percentOf(detection?.confidence ?? subject.confidence),
|
|
13585
|
-
time:
|
|
13621
|
+
time: ncClockText(texts.language, subject.timestamp, timeZone),
|
|
13622
|
+
sounds: subject.audio?.labels.join(", ") ?? "",
|
|
13623
|
+
hitPercent: subject.audio?.hitPercent !== void 0 ? `${Math.round(subject.audio.hitPercent)}` : "",
|
|
13624
|
+
db: subject.audio?.peakDbfs !== void 0 ? `${Math.round(subject.audio.peakDbfs)}` : "",
|
|
13625
|
+
eventType: subject.eventType ?? "",
|
|
13586
13626
|
rule: entry.payload.ruleName,
|
|
13587
13627
|
count: occupancy !== void 0 ? `${occupancy.count}` : group !== void 0 && group.members.length > 1 ? `${group.members.length}` : "",
|
|
13588
13628
|
capacity: occupancy !== void 0 ? `${occupancy.capacity}` : "",
|
|
@@ -17413,12 +17453,22 @@ var NcRuleStore = class {
|
|
|
17413
17453
|
await this.ledger.put(rule);
|
|
17414
17454
|
return rule;
|
|
17415
17455
|
}
|
|
17416
|
-
/**
|
|
17417
|
-
|
|
17456
|
+
/**
|
|
17457
|
+
* Apply a partial patch. Immutable: returns the NEW rule object.
|
|
17458
|
+
*
|
|
17459
|
+
* `clear` names optional fields to REMOVE before the merge — an omitted key
|
|
17460
|
+
* keeps its stored value, so this is how an editor empties one. Setting and
|
|
17461
|
+
* clearing the same key is a contradiction and is refused rather than
|
|
17462
|
+
* resolved by ordering.
|
|
17463
|
+
*/
|
|
17464
|
+
async update(ruleId, patch, clear = []) {
|
|
17418
17465
|
const existing = this.ledger.get(ruleId);
|
|
17419
17466
|
if (!existing) throw new Error(`notification rule not found: ${ruleId}`);
|
|
17467
|
+
const conflict = clear.find((key) => patch[key] !== void 0);
|
|
17468
|
+
if (conflict !== void 0) throw new Error(`notification rule patch both sets and clears '${conflict}'`);
|
|
17469
|
+
const cleared = new Set(clear);
|
|
17420
17470
|
const candidate = {
|
|
17421
|
-
...existing,
|
|
17471
|
+
...Object.fromEntries(Object.entries(existing).filter(([key]) => !cleared.has(key))),
|
|
17422
17472
|
...patch,
|
|
17423
17473
|
id: existing.id,
|
|
17424
17474
|
createdBy: existing.createdBy,
|
|
@@ -19326,13 +19376,18 @@ function clockOf$2(atMs) {
|
|
|
19326
19376
|
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
19327
19377
|
}
|
|
19328
19378
|
/** Template vocabulary. Deliberately the SAME names the detection and timelapse
|
|
19329
|
-
* bodies use (`rule`, `from`, `to`, `time`) plus the digest-only ones.
|
|
19330
|
-
|
|
19379
|
+
* bodies use (`rule`, `from`, `to`, `time`) plus the digest-only ones.
|
|
19380
|
+
*
|
|
19381
|
+
* Exported as `summaryTemplateVars` — not just an implementation detail of
|
|
19382
|
+
* {@link buildSummaryOutboxInputs} — so the parity spec
|
|
19383
|
+
* (`template-var-parity.spec.ts`) can assert this map directly against the
|
|
19384
|
+
* catalog, the same way `buildTemplateVars` is asserted for rule rows. */
|
|
19385
|
+
function summaryTemplateVars(input) {
|
|
19331
19386
|
return {
|
|
19332
19387
|
rule: input.ruleName,
|
|
19333
19388
|
from: clockOf$2(input.window.startMs),
|
|
19334
19389
|
to: clockOf$2(input.window.endMs),
|
|
19335
|
-
time:
|
|
19390
|
+
time: ncClockText(input.texts.language, input.generatedAt),
|
|
19336
19391
|
events: String(input.tileCount),
|
|
19337
19392
|
cameras: String(input.cameraCount),
|
|
19338
19393
|
matched: String(input.matchedCount),
|
|
@@ -19439,7 +19494,7 @@ function summaryCaptionText(input) {
|
|
|
19439
19494
|
* @returns rows ready for `NcOutbox.enqueue` — never sent from here.
|
|
19440
19495
|
*/
|
|
19441
19496
|
function buildSummaryOutboxInputs(input) {
|
|
19442
|
-
const vars =
|
|
19497
|
+
const vars = summaryTemplateVars(input);
|
|
19443
19498
|
const optedOut = new Set(input.disabledTargetIds ?? []);
|
|
19444
19499
|
const title = renderTemplate(input.template?.title, vars) ?? input.texts.text({
|
|
19445
19500
|
key: "summary.title",
|
|
@@ -21663,6 +21718,67 @@ async function deriveSyntheticMedia(jpeg, bbox, timestamp) {
|
|
|
21663
21718
|
return files;
|
|
21664
21719
|
}
|
|
21665
21720
|
//#endregion
|
|
21721
|
+
//#region src/notification-center/template-preview.ts
|
|
21722
|
+
/**
|
|
21723
|
+
* `previewTemplate` — render a rule's draft title/body against EXAMPLE values,
|
|
21724
|
+
* so an operator sees `{{scope}}: {{count}}/{{capacity}}` become
|
|
21725
|
+
* `Cancello: 3/2` before saving the rule, not after the first real event.
|
|
21726
|
+
*
|
|
21727
|
+
* Pure with respect to the caller: it never sends anything, and the only
|
|
21728
|
+
* side-channel it reads is the live `NcTextCatalog` handed in, so a preview
|
|
21729
|
+
* always speaks the hub's CURRENT language and CURRENT overrides — the same
|
|
21730
|
+
* words a delivered notification would use.
|
|
21731
|
+
*/
|
|
21732
|
+
/**
|
|
21733
|
+
* The instant `{{time}}` is previewed at — fixed, and in UTC, so the example
|
|
21734
|
+
* is the same on every host: "20:05" in Italian, "08:05 PM" in English.
|
|
21735
|
+
*/
|
|
21736
|
+
var PREVIEW_INSTANT_MS = Date.UTC(2026, 8, 24, 20, 5);
|
|
21737
|
+
var PREVIEW_TIME_ZONE = "UTC";
|
|
21738
|
+
/**
|
|
21739
|
+
* The example map: every declared example, with the COMPOSITES rendered by the
|
|
21740
|
+
* real composers so the preview speaks the hub's language exactly as a
|
|
21741
|
+
* delivered notification would.
|
|
21742
|
+
*/
|
|
21743
|
+
function exampleVars(texts) {
|
|
21744
|
+
return {
|
|
21745
|
+
...Object.fromEntries(NC_TEMPLATE_VARS.filter((d) => d.dynamic === void 0).map((d) => [d.name, d.example])),
|
|
21746
|
+
subject: ncSubjectText(texts, {
|
|
21747
|
+
className: "person",
|
|
21748
|
+
label: "Mario",
|
|
21749
|
+
labelKind: "identity"
|
|
21750
|
+
}),
|
|
21751
|
+
inZones: ncInZonesText(texts, ["Cancello"]),
|
|
21752
|
+
op: ncOccupancyOp(texts, true),
|
|
21753
|
+
time: ncClockText(texts.language, PREVIEW_INSTANT_MS, PREVIEW_TIME_ZONE),
|
|
21754
|
+
detectionSummary: ncDetectionSummary(texts, [{
|
|
21755
|
+
className: "person",
|
|
21756
|
+
count: 2
|
|
21757
|
+
}, {
|
|
21758
|
+
className: "car",
|
|
21759
|
+
count: 1
|
|
21760
|
+
}])
|
|
21761
|
+
};
|
|
21762
|
+
}
|
|
21763
|
+
var DYNAMIC_EXAMPLE = "3";
|
|
21764
|
+
function previewTemplate(texts, input) {
|
|
21765
|
+
const ctx = input.context;
|
|
21766
|
+
const allowed = new Set(templateVarsFor(ctx).map((d) => d.name));
|
|
21767
|
+
const examples = exampleVars(texts);
|
|
21768
|
+
const used = [input.template.title ?? "", input.template.body ?? ""].flatMap((t) => [...templatePlaceholders(t)]);
|
|
21769
|
+
const unknown = [...new Set(used.filter((name) => !isKnownTemplateVar(name, ctx)))];
|
|
21770
|
+
const vars = {};
|
|
21771
|
+
for (const name of used) {
|
|
21772
|
+
if (!isKnownTemplateVar(name, ctx)) continue;
|
|
21773
|
+
vars[name] = allowed.has(name) ? examples[name] ?? "" : DYNAMIC_EXAMPLE;
|
|
21774
|
+
}
|
|
21775
|
+
return {
|
|
21776
|
+
title: renderTemplate(input.template.title, vars),
|
|
21777
|
+
body: renderTemplate(input.template.body, vars),
|
|
21778
|
+
unknown
|
|
21779
|
+
};
|
|
21780
|
+
}
|
|
21781
|
+
//#endregion
|
|
21666
21782
|
//#region src/notification-center/text-catalog-view.ts
|
|
21667
21783
|
/**
|
|
21668
21784
|
* The text catalog, projected for an EDITOR — Phase 2's read model, pure.
|
|
@@ -22102,14 +22218,19 @@ function clockOf(atMs) {
|
|
|
22102
22218
|
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
22103
22219
|
}
|
|
22104
22220
|
/** Template vocabulary. Deliberately the same NAMES the rule templates use
|
|
22105
|
-
* (`camera`, `rule`, `time`) plus the timelapse-only ones.
|
|
22106
|
-
|
|
22221
|
+
* (`camera`, `rule`, `time`) plus the timelapse-only ones.
|
|
22222
|
+
*
|
|
22223
|
+
* Exported as `timelapseTemplateVars` — not just an implementation detail of
|
|
22224
|
+
* {@link buildTimelapseOutboxInputs} — so the parity spec
|
|
22225
|
+
* (`template-var-parity.spec.ts`) can assert this map directly against the
|
|
22226
|
+
* catalog, the same way `buildTemplateVars` is asserted for rule rows. */
|
|
22227
|
+
function timelapseTemplateVars(input) {
|
|
22107
22228
|
return {
|
|
22108
22229
|
camera: input.deviceName ?? `camera ${input.deviceId}`,
|
|
22109
22230
|
rule: input.ruleName,
|
|
22110
22231
|
from: clockOf(input.window.startMs),
|
|
22111
22232
|
to: clockOf(input.window.endMs),
|
|
22112
|
-
time:
|
|
22233
|
+
time: ncClockText(input.texts.language, input.generatedAt),
|
|
22113
22234
|
coverage: String(input.coverage.percent),
|
|
22114
22235
|
events: String(input.denseRanges),
|
|
22115
22236
|
...detectionTemplateVars(input.texts, input.detections ?? NO_DETECTIONS)
|
|
@@ -22190,7 +22311,7 @@ function timelapseRecordId(deviceId, windowEndMs) {
|
|
|
22190
22311
|
* @returns rows ready for `NcOutbox.enqueue` — never sent from here.
|
|
22191
22312
|
*/
|
|
22192
22313
|
function buildTimelapseOutboxInputs(input) {
|
|
22193
|
-
const vars =
|
|
22314
|
+
const vars = timelapseTemplateVars(input);
|
|
22194
22315
|
const optedOut = new Set(input.disabledTargetIds ?? []);
|
|
22195
22316
|
const title = renderTemplate(input.template?.title, vars) ?? input.texts.text({
|
|
22196
22317
|
key: "timelapse.title",
|
|
@@ -23173,7 +23294,8 @@ function consumablesSliceOf(event) {
|
|
|
23173
23294
|
var NC_ALARM_SETTINGS_FALLBACK = {
|
|
23174
23295
|
...DEFAULT_DELAYS,
|
|
23175
23296
|
announceArm: false,
|
|
23176
|
-
announceTargets: []
|
|
23297
|
+
announceTargets: [],
|
|
23298
|
+
nonBlocking: []
|
|
23177
23299
|
};
|
|
23178
23300
|
/**
|
|
23179
23301
|
* How often the "matched NO rule" report may fire per device. Long enough that
|
|
@@ -24423,6 +24545,10 @@ var NotificationCenter = class NotificationCenter {
|
|
|
24423
24545
|
get textCatalogEditor() {
|
|
24424
24546
|
return this.textEditor;
|
|
24425
24547
|
}
|
|
24548
|
+
/** The live catalog every producer renders with — the preview must use the same one. */
|
|
24549
|
+
get textCatalog() {
|
|
24550
|
+
return this.texts;
|
|
24551
|
+
}
|
|
24426
24552
|
/**
|
|
24427
24553
|
* Produce the last closed window for one camera of one timelapse rule, now.
|
|
24428
24554
|
*
|
|
@@ -25285,17 +25411,19 @@ var NotificationCenter = class NotificationCenter {
|
|
|
25285
25411
|
} });
|
|
25286
25412
|
return { rule: created };
|
|
25287
25413
|
},
|
|
25288
|
-
updateRule: async ({ ruleId, patch, caller }) => {
|
|
25414
|
+
updateRule: async ({ ruleId, patch, clear, caller }) => {
|
|
25289
25415
|
if (patch.targets !== void 0) await this.validateTargetRefs(patch.targets.map((t) => t.targetId));
|
|
25290
|
-
|
|
25416
|
+
const clearsUsers = clear?.includes("targetUsers") ?? false;
|
|
25417
|
+
if (patch.targets !== void 0 || patch.targetUsers !== void 0 || clearsUsers) {
|
|
25291
25418
|
const existing = this.rules.get(ruleId);
|
|
25292
|
-
NotificationCenter.assertHasAddressee(patch.targets ?? existing?.targets ?? [], patch.targetUsers ?? existing?.targetUsers);
|
|
25419
|
+
NotificationCenter.assertHasAddressee(patch.targets ?? existing?.targets ?? [], clearsUsers ? void 0 : patch.targetUsers ?? existing?.targetUsers);
|
|
25293
25420
|
}
|
|
25294
25421
|
const { disabledTargetIds: _optOut, ...safePatch } = patch;
|
|
25295
|
-
const updated = await this.rules.update(ruleId, safePatch);
|
|
25422
|
+
const updated = await this.rules.update(ruleId, safePatch, clear ?? []);
|
|
25296
25423
|
this.logger.info("notification rule updated", { meta: {
|
|
25297
25424
|
ruleId,
|
|
25298
|
-
by: caller.userId
|
|
25425
|
+
by: caller.userId,
|
|
25426
|
+
...clear && clear.length > 0 ? { cleared: clear } : {}
|
|
25299
25427
|
} });
|
|
25300
25428
|
return { rule: updated };
|
|
25301
25429
|
},
|
|
@@ -25312,6 +25440,8 @@ var NotificationCenter = class NotificationCenter {
|
|
|
25312
25440
|
catalog: [...NC_CONDITION_CATALOG],
|
|
25313
25441
|
taxonomy: NC_TAXONOMY
|
|
25314
25442
|
}),
|
|
25443
|
+
getTemplateCatalog: async () => ({ vars: [...NC_TEMPLATE_VARS] }),
|
|
25444
|
+
previewTemplate: async (input) => previewTemplate(this.texts, input),
|
|
25315
25445
|
getHistory: async ({ filter }) => {
|
|
25316
25446
|
const entries = await this.outbox.queryHistory({
|
|
25317
25447
|
...filter.ruleId !== void 0 ? { ruleId: filter.ruleId } : {},
|
|
@@ -26499,6 +26629,12 @@ var NotificationCenter = class NotificationCenter {
|
|
|
26499
26629
|
...subject.observedAt !== void 0 ? { observedAt: subject.observedAt } : {},
|
|
26500
26630
|
...subject.occupancy !== void 0 ? { occupancy: frozenOccupancy(rule, subject.occupancy) } : {},
|
|
26501
26631
|
...subject.packagePhase !== void 0 ? { packagePhase: subject.packagePhase } : {},
|
|
26632
|
+
...subject.audioWindow !== void 0 ? { audio: {
|
|
26633
|
+
labels: subject.audioWindow.labels,
|
|
26634
|
+
...subject.audioWindow.mode === "level" ? { hitPercent: subject.audioWindow.hitPercent } : {},
|
|
26635
|
+
...subject.audioWindow.peakDbfs !== void 0 ? { peakDbfs: subject.audioWindow.peakDbfs } : {}
|
|
26636
|
+
} } : {},
|
|
26637
|
+
...subject.eventType !== void 0 ? { eventType: subject.eventType } : {},
|
|
26502
26638
|
...subject.systemEvent !== void 0 ? { systemEvent: subject.systemEvent } : {}
|
|
26503
26639
|
}
|
|
26504
26640
|
};
|
|
@@ -27687,14 +27823,13 @@ var ncActions = defineCustomActions({
|
|
|
27687
27823
|
catalog: array(NcConditionDescriptorSchema),
|
|
27688
27824
|
taxonomy: NcTaxonomySchema
|
|
27689
27825
|
})),
|
|
27826
|
+
"nc.getTemplateCatalog": customAction(object({}), object({ vars: array(NcTemplateVarDescriptorSchema) })),
|
|
27827
|
+
"nc.previewTemplate": customAction(NcTemplatePreviewInputSchema, NcTemplatePreviewSchema, { kind: "mutation" }),
|
|
27690
27828
|
"nc.createRule": customAction(object({ rule: NcRuleInputSchema }), object({ rule: NcRuleSchema }), {
|
|
27691
27829
|
kind: "mutation",
|
|
27692
27830
|
caller: "required"
|
|
27693
27831
|
}),
|
|
27694
|
-
"nc.updateRule": customAction(object({
|
|
27695
|
-
ruleId: string(),
|
|
27696
|
-
patch: NcRulePatchSchema
|
|
27697
|
-
}), object({ rule: NcRuleSchema }), {
|
|
27832
|
+
"nc.updateRule": customAction(NcRuleUpdateInputSchema, object({ rule: NcRuleSchema }), {
|
|
27698
27833
|
kind: "mutation",
|
|
27699
27834
|
caller: "required"
|
|
27700
27835
|
}),
|
|
@@ -27713,14 +27848,17 @@ var ncActions = defineCustomActions({
|
|
|
27713
27848
|
/**
|
|
27714
27849
|
* THE AI SECTION of the ordinary rules — read and write.
|
|
27715
27850
|
*
|
|
27716
|
-
* A pair of its own rather than two more keys on
|
|
27717
|
-
*
|
|
27718
|
-
*
|
|
27719
|
-
*
|
|
27720
|
-
*
|
|
27721
|
-
*
|
|
27722
|
-
* (`
|
|
27723
|
-
* `
|
|
27851
|
+
* A pair of its own rather than two more keys on the rule CRUD, and that is
|
|
27852
|
+
* the whole point of the design. The addon builds self-contained and INLINES
|
|
27853
|
+
* `@camstack/types`, so its own copy of `NcRuleInputSchema` is never the
|
|
27854
|
+
* problem and nothing here fails at load. The coupling is the HUB: the cap
|
|
27855
|
+
* route (`notificationRules.createRule` / `updateRule`, which the admin
|
|
27856
|
+
* editor uses) validates input against the copy of `@camstack/types` that
|
|
27857
|
+
* `@camstack/system` inlines (`bundleTypesMainEntry`), so a new key on
|
|
27858
|
+
* `NcRuleInputSchema` is SILENTLY STRIPPED there until a `@camstack/server`
|
|
27859
|
+
* release carrying it is installed. These two validate only here, against a
|
|
27860
|
+
* schema that lives in the addon (`rule-ai.ts`), so a field added to it is
|
|
27861
|
+
* live on the next `camstack deploy packages/addon-post-analysis`.
|
|
27724
27862
|
*
|
|
27725
27863
|
* Scoped exactly as the rule CRUD is: reading needs a rule the caller can
|
|
27726
27864
|
* SEE, writing needs one they own (admins bypass ownership, never the caller
|
|
@@ -28212,6 +28350,12 @@ function makeNcActionHandlers(deps) {
|
|
|
28212
28350
|
catalog: [...NC_CONDITION_CATALOG],
|
|
28213
28351
|
taxonomy: NC_TAXONOMY
|
|
28214
28352
|
}),
|
|
28353
|
+
"nc.getTemplateCatalog": async () => ({ vars: [...NC_TEMPLATE_VARS] }),
|
|
28354
|
+
"nc.previewTemplate": async (input) => {
|
|
28355
|
+
const texts = deps.previewTexts;
|
|
28356
|
+
if (texts === void 0) throw new Error("nc.previewTemplate: text catalog not wired");
|
|
28357
|
+
return previewTemplate(texts, input);
|
|
28358
|
+
},
|
|
28215
28359
|
"nc.createRule": async (input, caller) => {
|
|
28216
28360
|
const c = requireCaller$1(caller);
|
|
28217
28361
|
const parsed = NcRuleInputSchema.parse(input.rule);
|
|
@@ -28232,10 +28376,11 @@ function makeNcActionHandlers(deps) {
|
|
|
28232
28376
|
const patch = NcRulePatchSchema.parse(input.patch);
|
|
28233
28377
|
if (patch.targets !== void 0) await assertTargetsOwned(patch.targets.map((t) => t.targetId), c);
|
|
28234
28378
|
const { ownerUserId: _owner, disabledTargetIds: _optOut, ...safe } = patch;
|
|
28235
|
-
const rule = await deps.ruleStore.update(input.ruleId, safe);
|
|
28379
|
+
const rule = await deps.ruleStore.update(input.ruleId, safe, input.clear ?? []);
|
|
28236
28380
|
deps.logger.info("nc rule updated", { meta: {
|
|
28237
28381
|
ruleId: rule.id,
|
|
28238
|
-
owner: c.userId
|
|
28382
|
+
owner: c.userId,
|
|
28383
|
+
...input.clear && input.clear.length > 0 ? { cleared: input.clear } : {}
|
|
28239
28384
|
} });
|
|
28240
28385
|
return { rule };
|
|
28241
28386
|
},
|
|
@@ -41691,6 +41836,160 @@ var TrackResidentState = class {
|
|
|
41691
41836
|
if (resident.rasterFallback === void 0) resident.rasterFallback = fallback;
|
|
41692
41837
|
}
|
|
41693
41838
|
};
|
|
41839
|
+
/**
|
|
41840
|
+
* The shipped limits.
|
|
41841
|
+
*
|
|
41842
|
+
* `minTracks` 12 / `minSamples` 200: the four live cameras sampled above
|
|
41843
|
+
* produced 697–8739 samples from 42–51 tracks in a single 60-track read, so a
|
|
41844
|
+
* camera that cannot clear these has genuinely not been watched enough.
|
|
41845
|
+
* `maxTracks` 200 bounds the read: the `positions` blob averages ~10 kB a row
|
|
41846
|
+
* (`track-store.ts`), so this pass is ~2 MB of JSON at its worst.
|
|
41847
|
+
*/
|
|
41848
|
+
var DEFAULT_STILLNESS_CALIBRATION_LIMITS = {
|
|
41849
|
+
minTracks: 12,
|
|
41850
|
+
minSamples: 200,
|
|
41851
|
+
maxTracks: 200,
|
|
41852
|
+
minIntervalSec: .02,
|
|
41853
|
+
maxIntervalSec: 3,
|
|
41854
|
+
quantile: .25,
|
|
41855
|
+
floorFactor: .02,
|
|
41856
|
+
ceilFactor: .25,
|
|
41857
|
+
minBar: .001,
|
|
41858
|
+
maxBar: 1
|
|
41859
|
+
};
|
|
41860
|
+
/** Linear-interpolated quantile. `NaN` for an empty list — never 0, which
|
|
41861
|
+
* would read as a measured zero (D393). */
|
|
41862
|
+
function quantileOf(values, p) {
|
|
41863
|
+
if (values.length === 0) return NaN;
|
|
41864
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
41865
|
+
const idx = (sorted.length - 1) * Math.min(Math.max(p, 0), 1);
|
|
41866
|
+
const lo = Math.floor(idx);
|
|
41867
|
+
const hi = Math.ceil(idx);
|
|
41868
|
+
const at = sorted[lo];
|
|
41869
|
+
const next = sorted[hi];
|
|
41870
|
+
if (at === void 0 || next === void 0) return NaN;
|
|
41871
|
+
return at + (next - at) * (idx - lo);
|
|
41872
|
+
}
|
|
41873
|
+
/**
|
|
41874
|
+
* Per-sample centroid speeds for a camera, in subject-box-diagonals per second.
|
|
41875
|
+
*
|
|
41876
|
+
* A pair of consecutive positions contributes a sample only when the interval
|
|
41877
|
+
* between them is inside `[minIntervalSec, maxIntervalSec]` and the later box
|
|
41878
|
+
* has a positive diagonal. A gap outside that band is a session boundary or a
|
|
41879
|
+
* duplicate read, not a measurement of how fast anything travelled; a box with
|
|
41880
|
+
* no size is unknown geometry, and unknown is never a sample.
|
|
41881
|
+
*/
|
|
41882
|
+
function collectStillnessSamples(tracks, limits) {
|
|
41883
|
+
const speeds = [];
|
|
41884
|
+
const intervals = [];
|
|
41885
|
+
const diagonals = [];
|
|
41886
|
+
let trackCount = 0;
|
|
41887
|
+
const read = tracks.slice(0, limits.maxTracks);
|
|
41888
|
+
for (const track of read) {
|
|
41889
|
+
const positions = [...track.positions].sort((a, b) => a.timestamp - b.timestamp);
|
|
41890
|
+
let contributed = false;
|
|
41891
|
+
for (let i = 1; i < positions.length; i++) {
|
|
41892
|
+
const prev = positions[i - 1];
|
|
41893
|
+
const cur = positions[i];
|
|
41894
|
+
if (prev === void 0 || cur === void 0) continue;
|
|
41895
|
+
const dtSec = (cur.timestamp - prev.timestamp) / 1e3;
|
|
41896
|
+
if (dtSec < limits.minIntervalSec || dtSec > limits.maxIntervalSec) continue;
|
|
41897
|
+
const diagonal = Math.hypot(cur.bbox.w, cur.bbox.h);
|
|
41898
|
+
if (!(diagonal > 0)) continue;
|
|
41899
|
+
const travelled = Math.hypot(cur.x - prev.x, cur.y - prev.y);
|
|
41900
|
+
speeds.push(travelled / diagonal / dtSec);
|
|
41901
|
+
intervals.push(dtSec);
|
|
41902
|
+
diagonals.push(diagonal);
|
|
41903
|
+
contributed = true;
|
|
41904
|
+
}
|
|
41905
|
+
if (contributed) trackCount++;
|
|
41906
|
+
}
|
|
41907
|
+
return {
|
|
41908
|
+
speeds,
|
|
41909
|
+
intervals,
|
|
41910
|
+
diagonals,
|
|
41911
|
+
trackCount,
|
|
41912
|
+
tracksRead: read.length
|
|
41913
|
+
};
|
|
41914
|
+
}
|
|
41915
|
+
function measure$1(samples) {
|
|
41916
|
+
return {
|
|
41917
|
+
trackCount: samples.trackCount,
|
|
41918
|
+
sampleCount: samples.speeds.length,
|
|
41919
|
+
p25: quantileOf(samples.speeds, .25),
|
|
41920
|
+
p50: quantileOf(samples.speeds, .5),
|
|
41921
|
+
p90: quantileOf(samples.speeds, .9),
|
|
41922
|
+
medianIntervalSec: quantileOf(samples.intervals, .5),
|
|
41923
|
+
medianSubjectDiagonalPx: quantileOf(samples.diagonals, .5)
|
|
41924
|
+
};
|
|
41925
|
+
}
|
|
41926
|
+
/**
|
|
41927
|
+
* Choose this camera's stillness speed bar, or refuse with a named reason.
|
|
41928
|
+
*
|
|
41929
|
+
* The order of the gates is the order of the questions: is there anything at
|
|
41930
|
+
* all, is it enough tracks, is it enough samples, and did anything on this
|
|
41931
|
+
* camera ever actually move. The last one is not pedantry — the whole method
|
|
41932
|
+
* scales the bar against the camera's own travel, so with no travel there is
|
|
41933
|
+
* nothing to scale against and the default is the honest answer.
|
|
41934
|
+
*/
|
|
41935
|
+
function calibrateStillnessSpeed(samples, limits) {
|
|
41936
|
+
const measurement = measure$1(samples);
|
|
41937
|
+
if (samples.tracksRead === 0) return {
|
|
41938
|
+
ok: false,
|
|
41939
|
+
reason: "no-tracks",
|
|
41940
|
+
measurement
|
|
41941
|
+
};
|
|
41942
|
+
if (samples.trackCount < limits.minTracks) return {
|
|
41943
|
+
ok: false,
|
|
41944
|
+
reason: "too-few-tracks",
|
|
41945
|
+
measurement
|
|
41946
|
+
};
|
|
41947
|
+
if (samples.speeds.length < limits.minSamples) return {
|
|
41948
|
+
ok: false,
|
|
41949
|
+
reason: "too-few-samples",
|
|
41950
|
+
measurement
|
|
41951
|
+
};
|
|
41952
|
+
if (!(measurement.p90 > 0)) return {
|
|
41953
|
+
ok: false,
|
|
41954
|
+
reason: "no-motion-evidence",
|
|
41955
|
+
measurement
|
|
41956
|
+
};
|
|
41957
|
+
const rawBar = quantileOf(samples.speeds, limits.quantile);
|
|
41958
|
+
const floor = measurement.p90 * limits.floorFactor;
|
|
41959
|
+
const ceiling = measurement.p90 * limits.ceilFactor;
|
|
41960
|
+
let bar = rawBar;
|
|
41961
|
+
let clampedBy = "none";
|
|
41962
|
+
if (bar < floor) {
|
|
41963
|
+
bar = floor;
|
|
41964
|
+
clampedBy = "floor";
|
|
41965
|
+
} else if (bar > ceiling) {
|
|
41966
|
+
bar = ceiling;
|
|
41967
|
+
clampedBy = "ceiling";
|
|
41968
|
+
}
|
|
41969
|
+
if (bar < limits.minBar) {
|
|
41970
|
+
bar = limits.minBar;
|
|
41971
|
+
clampedBy = "min";
|
|
41972
|
+
} else if (bar > limits.maxBar) {
|
|
41973
|
+
bar = limits.maxBar;
|
|
41974
|
+
clampedBy = "max";
|
|
41975
|
+
}
|
|
41976
|
+
return {
|
|
41977
|
+
ok: true,
|
|
41978
|
+
bar,
|
|
41979
|
+
rawBar,
|
|
41980
|
+
clampedBy,
|
|
41981
|
+
measurement
|
|
41982
|
+
};
|
|
41983
|
+
}
|
|
41984
|
+
/** One line an operator can read: what was measured, from how much, and what
|
|
41985
|
+
* rule produced the stored number. Kept short — it rides the settings blob. */
|
|
41986
|
+
function describeStillnessCalibration(outcome) {
|
|
41987
|
+
const m = outcome.measurement;
|
|
41988
|
+
const n = (v) => Number.isFinite(v) ? v.toFixed(4) : "unknown";
|
|
41989
|
+
const evidence = `${m.sampleCount} samples / ${m.trackCount} tracks, p25 ${n(m.p25)}, p50 ${n(m.p50)}, p90 ${n(m.p90)} subject-diagonals/s`;
|
|
41990
|
+
if (!outcome.ok) return `refused (${outcome.reason}) — ${evidence}`;
|
|
41991
|
+
return `bar ${n(outcome.bar)} (raw ${n(outcome.rawBar)}, clamp ${outcome.clampedBy}) — ${evidence}`;
|
|
41992
|
+
}
|
|
41694
41993
|
//#endregion
|
|
41695
41994
|
//#region src/pipeline-analytics/pipeline/plate-clustering.ts
|
|
41696
41995
|
/**
|
|
@@ -47641,6 +47940,182 @@ async function retentionScopeDeviceIds(deps, onError) {
|
|
|
47641
47940
|
return [...ids];
|
|
47642
47941
|
}
|
|
47643
47942
|
//#endregion
|
|
47943
|
+
//#region src/pipeline-analytics/stillness-calibration-actions.ts
|
|
47944
|
+
/**
|
|
47945
|
+
* stillness-calibration-actions — "measure this camera's own stillness scale".
|
|
47946
|
+
*
|
|
47947
|
+
* ## Why an `addons.custom` action and not a cap method
|
|
47948
|
+
*
|
|
47949
|
+
* Same reason as `face.rescoreTrack` beside it: a cap method's wire schema
|
|
47950
|
+
* lives in `@camstack/types`, which travels inside the `@camstack/server`
|
|
47951
|
+
* closure, so a new one is not callable until a server train ships. Here the
|
|
47952
|
+
* schema lives in this addon's own dist and the hub forwards an opaque
|
|
47953
|
+
* envelope, so `camstack deploy` is the whole delivery.
|
|
47954
|
+
*
|
|
47955
|
+
* ## Why `apply` is explicit and not a fallback
|
|
47956
|
+
*
|
|
47957
|
+
* A calibration run READS recorded tracks and does arithmetic. Applying it
|
|
47958
|
+
* WRITES the camera's settings and changes what the tracker calls settled from
|
|
47959
|
+
* the next frame on. Those are different enough in consequence that a button
|
|
47960
|
+
* labelled "calibrate" must not choose between them on the operator's behalf —
|
|
47961
|
+
* the run reports, the operator applies. A REFUSED run never writes the bar
|
|
47962
|
+
* whatever `apply` says; it writes only the note, so the refusal and its reason
|
|
47963
|
+
* are visible in the form instead of being a toast that scrolls away.
|
|
47964
|
+
*/
|
|
47965
|
+
/** Mirrors `StillnessCalibrationRefusal` — kept literal so the wire schema is
|
|
47966
|
+
* self-describing and a new reason is a deliberate edit in both places. */
|
|
47967
|
+
var StillnessRefusalSchema = _enum([
|
|
47968
|
+
"no-tracks",
|
|
47969
|
+
"too-few-tracks",
|
|
47970
|
+
"too-few-samples",
|
|
47971
|
+
"no-motion-evidence"
|
|
47972
|
+
]);
|
|
47973
|
+
var StillnessMeasurementSchema = object({
|
|
47974
|
+
/** Tracks that contributed at least one speed sample. */
|
|
47975
|
+
trackCount: number().int(),
|
|
47976
|
+
/** Tracks the pass actually read, after the cost bound. */
|
|
47977
|
+
tracksRead: number().int(),
|
|
47978
|
+
sampleCount: number().int(),
|
|
47979
|
+
/** Speed quantiles, subject-box-diagonals per second. `null` when there was
|
|
47980
|
+
* nothing to measure — never 0, which would read as a measured stillness
|
|
47981
|
+
* (D393). */
|
|
47982
|
+
p25: number().nullable(),
|
|
47983
|
+
p50: number().nullable(),
|
|
47984
|
+
p90: number().nullable(),
|
|
47985
|
+
/** Median gap between stored positions (s) — the camera's real cadence. */
|
|
47986
|
+
medianIntervalSec: number().nullable(),
|
|
47987
|
+
/** Median subject box diagonal (px) the speeds were normalised by. */
|
|
47988
|
+
medianSubjectDiagonalPx: number().nullable()
|
|
47989
|
+
});
|
|
47990
|
+
var stillnessCalibrationActions = defineCustomActions({
|
|
47991
|
+
/**
|
|
47992
|
+
* `admin` because it WRITES a per-camera detection threshold when `apply` is
|
|
47993
|
+
* set, and because the report describes how the camera's subjects move,
|
|
47994
|
+
* which is not a viewer-level fact.
|
|
47995
|
+
*/
|
|
47996
|
+
"stillness.calibrate": customAction(object({
|
|
47997
|
+
deviceId: number().int(),
|
|
47998
|
+
/** How far back to read. Bounded: this pass parses ~10 kB of trajectory a
|
|
47999
|
+
* row, so the window is a suggestion and `maxTracks` is the real bound. */
|
|
48000
|
+
sinceHours: number().min(.25).max(720).default(72),
|
|
48001
|
+
/** Hard cost bound on tracks read, newest first. */
|
|
48002
|
+
maxTracks: number().int().min(1).max(1e3).default(200),
|
|
48003
|
+
/**
|
|
48004
|
+
* Write the chosen bar onto the camera. Never implicit, and never done at
|
|
48005
|
+
* all for a refused run.
|
|
48006
|
+
*/
|
|
48007
|
+
apply: boolean().default(false)
|
|
48008
|
+
}), object({
|
|
48009
|
+
deviceId: number().int(),
|
|
48010
|
+
/** The window actually read (epoch ms). */
|
|
48011
|
+
since: number().int(),
|
|
48012
|
+
until: number().int(),
|
|
48013
|
+
accepted: boolean(),
|
|
48014
|
+
/** Why it refused. Absent on an accepted run. */
|
|
48015
|
+
reason: StillnessRefusalSchema.optional(),
|
|
48016
|
+
/** The chosen bar, subject-box-diagonals per second. Absent on a refusal —
|
|
48017
|
+
* never 0, which the settings layer reads as "uncalibrated". */
|
|
48018
|
+
bar: number().optional(),
|
|
48019
|
+
/** The raw quantile before the rails, so the clamp is arguable. */
|
|
48020
|
+
rawBar: number().optional(),
|
|
48021
|
+
clampedBy: _enum([
|
|
48022
|
+
"none",
|
|
48023
|
+
"floor",
|
|
48024
|
+
"ceiling",
|
|
48025
|
+
"min",
|
|
48026
|
+
"max"
|
|
48027
|
+
]).optional(),
|
|
48028
|
+
measurement: StillnessMeasurementSchema,
|
|
48029
|
+
/** The bar this camera was on BEFORE the run — `null` = uncalibrated. Read
|
|
48030
|
+
* before any write, so the operator sees both numbers. */
|
|
48031
|
+
previousBar: number().nullable(),
|
|
48032
|
+
/** Whether the camera's settings were actually written. */
|
|
48033
|
+
applied: boolean(),
|
|
48034
|
+
/** The one-line note stored on the camera (accepted or refused). */
|
|
48035
|
+
note: string()
|
|
48036
|
+
}), {
|
|
48037
|
+
kind: "mutation",
|
|
48038
|
+
auth: "admin"
|
|
48039
|
+
}) });
|
|
48040
|
+
//#endregion
|
|
48041
|
+
//#region src/pipeline-analytics/stillness-settings.ts
|
|
48042
|
+
/**
|
|
48043
|
+
* Per-device STILLNESS SCALE — the one number that says how fast a subject on
|
|
48044
|
+
* THIS camera has to move before it is travelling.
|
|
48045
|
+
*
|
|
48046
|
+
* A threshold belongs to whoever GENERATES the event, per device, never baked
|
|
48047
|
+
* into a classifier default. The judgement this feeds is
|
|
48048
|
+
* `StateAnalyzer.speedSaysSettled`; the measurement behind the number is
|
|
48049
|
+
* `pipeline/tracker/stillness-scale.ts`, which also carries the live
|
|
48050
|
+
* measurement showing why one number cannot serve a 30 m driveway and a 2 m
|
|
48051
|
+
* doorway.
|
|
48052
|
+
*
|
|
48053
|
+
* Cascade and shape mirror `stationary-settings` / `media-settings`: a
|
|
48054
|
+
* per-device override on top of the default, resolved per FIELD, and a parse
|
|
48055
|
+
* that never throws on a bad blob.
|
|
48056
|
+
*
|
|
48057
|
+
* **Absent behaves exactly as today.** The device store is a flat numeric blob
|
|
48058
|
+
* with no room for "unset", so the stored form of absent is `0` — and `0` is
|
|
48059
|
+
* not a bar anyone could want (it would mean nothing on this camera is ever
|
|
48060
|
+
* still) and not a value the calibration can produce (`minBar`). The decision
|
|
48061
|
+
* layer never sees it: {@link resolveStillSpeedFracPerSec} maps it to
|
|
48062
|
+
* `undefined`, and `undefined` is what leaves `StateAnalyzer` on its original
|
|
48063
|
+
* absolute `velocityThreshold` path.
|
|
48064
|
+
*/
|
|
48065
|
+
var StillnessSettingsSchema = object({
|
|
48066
|
+
/**
|
|
48067
|
+
* The camera's "not travelling" bar: centroid speed as a fraction of the
|
|
48068
|
+
* SUBJECT's own box diagonal, per second. `0` = not calibrated (see above).
|
|
48069
|
+
*
|
|
48070
|
+
* Measured p50 on the live hub 2026-09-19, for scale: 0.0136 (device 592,
|
|
48071
|
+
* close view, mostly parked subjects) to 0.3081 (device 615, far view, only
|
|
48072
|
+
* transits).
|
|
48073
|
+
*/
|
|
48074
|
+
stillnessSpeedFracPerSec: number().min(0).max(1).default(0),
|
|
48075
|
+
/**
|
|
48076
|
+
* When the number above was last CHOSEN — by a calibration run or by the
|
|
48077
|
+
* operator. `0` = never. Purely a readout; nothing gates on it.
|
|
48078
|
+
*/
|
|
48079
|
+
stillnessCalibratedAt: number().int().min(0).default(0),
|
|
48080
|
+
/**
|
|
48081
|
+
* What the last calibration run measured and decided, in one line
|
|
48082
|
+
* (`describeStillnessCalibration`) — including a REFUSAL and its named
|
|
48083
|
+
* reason, because "why is this camera still on the default" has to be
|
|
48084
|
+
* answerable without re-running anything.
|
|
48085
|
+
*/
|
|
48086
|
+
stillnessCalibrationNote: string().max(400).default("")
|
|
48087
|
+
});
|
|
48088
|
+
var STILLNESS_DEFAULTS = StillnessSettingsSchema.parse({});
|
|
48089
|
+
/**
|
|
48090
|
+
* Resolve a per-device store blob into typed stillness settings. Unknown or
|
|
48091
|
+
* invalid fields fall back to the default for that field — including a bar
|
|
48092
|
+
* outside the calibration's own rails, which is refused rather than clamped:
|
|
48093
|
+
* a number the calibration could never have produced is not evidence about
|
|
48094
|
+
* this camera, and silently bending it into range would present it as if it
|
|
48095
|
+
* were.
|
|
48096
|
+
*/
|
|
48097
|
+
function resolveStillnessSettings(raw) {
|
|
48098
|
+
const pick = (key) => {
|
|
48099
|
+
const parsed = StillnessSettingsSchema.shape[key].safeParse(raw[key]);
|
|
48100
|
+
return parsed.success ? parsed.data : STILLNESS_DEFAULTS[key];
|
|
48101
|
+
};
|
|
48102
|
+
return {
|
|
48103
|
+
stillnessSpeedFracPerSec: pick("stillnessSpeedFracPerSec"),
|
|
48104
|
+
stillnessCalibratedAt: pick("stillnessCalibratedAt"),
|
|
48105
|
+
stillnessCalibrationNote: pick("stillnessCalibrationNote")
|
|
48106
|
+
};
|
|
48107
|
+
}
|
|
48108
|
+
/**
|
|
48109
|
+
* The bar the decision layer gets: a positive, in-rails number, or
|
|
48110
|
+
* `undefined` for "this camera is not calibrated — judge it exactly as
|
|
48111
|
+
* before". The ONE place the stored-zero convention is interpreted.
|
|
48112
|
+
*/
|
|
48113
|
+
function resolveStillSpeedFracPerSec(settings) {
|
|
48114
|
+
const bar = settings.stillnessSpeedFracPerSec;
|
|
48115
|
+
if (!(bar >= .001) || bar > 1) return void 0;
|
|
48116
|
+
return bar;
|
|
48117
|
+
}
|
|
48118
|
+
//#endregion
|
|
47644
48119
|
//#region src/pipeline-analytics/viewer-settings-actions.ts
|
|
47645
48120
|
/**
|
|
47646
48121
|
* Viewer settings snapshots — hub-shared named blobs of the viewer's stores.
|
|
@@ -47860,336 +48335,6 @@ function makeViewerSettingsActionHandlers(store) {
|
|
|
47860
48335
|
};
|
|
47861
48336
|
}
|
|
47862
48337
|
//#endregion
|
|
47863
|
-
//#region src/pipeline-analytics/stillness-calibration-actions.ts
|
|
47864
|
-
/**
|
|
47865
|
-
* stillness-calibration-actions — "measure this camera's own stillness scale".
|
|
47866
|
-
*
|
|
47867
|
-
* ## Why an `addons.custom` action and not a cap method
|
|
47868
|
-
*
|
|
47869
|
-
* Same reason as `face.rescoreTrack` beside it: a cap method's wire schema
|
|
47870
|
-
* lives in `@camstack/types`, which travels inside the `@camstack/server`
|
|
47871
|
-
* closure, so a new one is not callable until a server train ships. Here the
|
|
47872
|
-
* schema lives in this addon's own dist and the hub forwards an opaque
|
|
47873
|
-
* envelope, so `camstack deploy` is the whole delivery.
|
|
47874
|
-
*
|
|
47875
|
-
* ## Why `apply` is explicit and not a fallback
|
|
47876
|
-
*
|
|
47877
|
-
* A calibration run READS recorded tracks and does arithmetic. Applying it
|
|
47878
|
-
* WRITES the camera's settings and changes what the tracker calls settled from
|
|
47879
|
-
* the next frame on. Those are different enough in consequence that a button
|
|
47880
|
-
* labelled "calibrate" must not choose between them on the operator's behalf —
|
|
47881
|
-
* the run reports, the operator applies. A REFUSED run never writes the bar
|
|
47882
|
-
* whatever `apply` says; it writes only the note, so the refusal and its reason
|
|
47883
|
-
* are visible in the form instead of being a toast that scrolls away.
|
|
47884
|
-
*/
|
|
47885
|
-
/** Mirrors `StillnessCalibrationRefusal` — kept literal so the wire schema is
|
|
47886
|
-
* self-describing and a new reason is a deliberate edit in both places. */
|
|
47887
|
-
var StillnessRefusalSchema = _enum([
|
|
47888
|
-
"no-tracks",
|
|
47889
|
-
"too-few-tracks",
|
|
47890
|
-
"too-few-samples",
|
|
47891
|
-
"no-motion-evidence"
|
|
47892
|
-
]);
|
|
47893
|
-
var StillnessMeasurementSchema = object({
|
|
47894
|
-
/** Tracks that contributed at least one speed sample. */
|
|
47895
|
-
trackCount: number().int(),
|
|
47896
|
-
/** Tracks the pass actually read, after the cost bound. */
|
|
47897
|
-
tracksRead: number().int(),
|
|
47898
|
-
sampleCount: number().int(),
|
|
47899
|
-
/** Speed quantiles, subject-box-diagonals per second. `null` when there was
|
|
47900
|
-
* nothing to measure — never 0, which would read as a measured stillness
|
|
47901
|
-
* (D393). */
|
|
47902
|
-
p25: number().nullable(),
|
|
47903
|
-
p50: number().nullable(),
|
|
47904
|
-
p90: number().nullable(),
|
|
47905
|
-
/** Median gap between stored positions (s) — the camera's real cadence. */
|
|
47906
|
-
medianIntervalSec: number().nullable(),
|
|
47907
|
-
/** Median subject box diagonal (px) the speeds were normalised by. */
|
|
47908
|
-
medianSubjectDiagonalPx: number().nullable()
|
|
47909
|
-
});
|
|
47910
|
-
var stillnessCalibrationActions = defineCustomActions({
|
|
47911
|
-
/**
|
|
47912
|
-
* `admin` because it WRITES a per-camera detection threshold when `apply` is
|
|
47913
|
-
* set, and because the report describes how the camera's subjects move,
|
|
47914
|
-
* which is not a viewer-level fact.
|
|
47915
|
-
*/
|
|
47916
|
-
"stillness.calibrate": customAction(object({
|
|
47917
|
-
deviceId: number().int(),
|
|
47918
|
-
/** How far back to read. Bounded: this pass parses ~10 kB of trajectory a
|
|
47919
|
-
* row, so the window is a suggestion and `maxTracks` is the real bound. */
|
|
47920
|
-
sinceHours: number().min(.25).max(720).default(72),
|
|
47921
|
-
/** Hard cost bound on tracks read, newest first. */
|
|
47922
|
-
maxTracks: number().int().min(1).max(1e3).default(200),
|
|
47923
|
-
/**
|
|
47924
|
-
* Write the chosen bar onto the camera. Never implicit, and never done at
|
|
47925
|
-
* all for a refused run.
|
|
47926
|
-
*/
|
|
47927
|
-
apply: boolean().default(false)
|
|
47928
|
-
}), object({
|
|
47929
|
-
deviceId: number().int(),
|
|
47930
|
-
/** The window actually read (epoch ms). */
|
|
47931
|
-
since: number().int(),
|
|
47932
|
-
until: number().int(),
|
|
47933
|
-
accepted: boolean(),
|
|
47934
|
-
/** Why it refused. Absent on an accepted run. */
|
|
47935
|
-
reason: StillnessRefusalSchema.optional(),
|
|
47936
|
-
/** The chosen bar, subject-box-diagonals per second. Absent on a refusal —
|
|
47937
|
-
* never 0, which the settings layer reads as "uncalibrated". */
|
|
47938
|
-
bar: number().optional(),
|
|
47939
|
-
/** The raw quantile before the rails, so the clamp is arguable. */
|
|
47940
|
-
rawBar: number().optional(),
|
|
47941
|
-
clampedBy: _enum([
|
|
47942
|
-
"none",
|
|
47943
|
-
"floor",
|
|
47944
|
-
"ceiling",
|
|
47945
|
-
"min",
|
|
47946
|
-
"max"
|
|
47947
|
-
]).optional(),
|
|
47948
|
-
measurement: StillnessMeasurementSchema,
|
|
47949
|
-
/** The bar this camera was on BEFORE the run — `null` = uncalibrated. Read
|
|
47950
|
-
* before any write, so the operator sees both numbers. */
|
|
47951
|
-
previousBar: number().nullable(),
|
|
47952
|
-
/** Whether the camera's settings were actually written. */
|
|
47953
|
-
applied: boolean(),
|
|
47954
|
-
/** The one-line note stored on the camera (accepted or refused). */
|
|
47955
|
-
note: string()
|
|
47956
|
-
}), {
|
|
47957
|
-
kind: "mutation",
|
|
47958
|
-
auth: "admin"
|
|
47959
|
-
}) });
|
|
47960
|
-
/**
|
|
47961
|
-
* The shipped limits.
|
|
47962
|
-
*
|
|
47963
|
-
* `minTracks` 12 / `minSamples` 200: the four live cameras sampled above
|
|
47964
|
-
* produced 697–8739 samples from 42–51 tracks in a single 60-track read, so a
|
|
47965
|
-
* camera that cannot clear these has genuinely not been watched enough.
|
|
47966
|
-
* `maxTracks` 200 bounds the read: the `positions` blob averages ~10 kB a row
|
|
47967
|
-
* (`track-store.ts`), so this pass is ~2 MB of JSON at its worst.
|
|
47968
|
-
*/
|
|
47969
|
-
var DEFAULT_STILLNESS_CALIBRATION_LIMITS = {
|
|
47970
|
-
minTracks: 12,
|
|
47971
|
-
minSamples: 200,
|
|
47972
|
-
maxTracks: 200,
|
|
47973
|
-
minIntervalSec: .02,
|
|
47974
|
-
maxIntervalSec: 3,
|
|
47975
|
-
quantile: .25,
|
|
47976
|
-
floorFactor: .02,
|
|
47977
|
-
ceilFactor: .25,
|
|
47978
|
-
minBar: .001,
|
|
47979
|
-
maxBar: 1
|
|
47980
|
-
};
|
|
47981
|
-
/** Linear-interpolated quantile. `NaN` for an empty list — never 0, which
|
|
47982
|
-
* would read as a measured zero (D393). */
|
|
47983
|
-
function quantileOf(values, p) {
|
|
47984
|
-
if (values.length === 0) return NaN;
|
|
47985
|
-
const sorted = [...values].sort((a, b) => a - b);
|
|
47986
|
-
const idx = (sorted.length - 1) * Math.min(Math.max(p, 0), 1);
|
|
47987
|
-
const lo = Math.floor(idx);
|
|
47988
|
-
const hi = Math.ceil(idx);
|
|
47989
|
-
const at = sorted[lo];
|
|
47990
|
-
const next = sorted[hi];
|
|
47991
|
-
if (at === void 0 || next === void 0) return NaN;
|
|
47992
|
-
return at + (next - at) * (idx - lo);
|
|
47993
|
-
}
|
|
47994
|
-
/**
|
|
47995
|
-
* Per-sample centroid speeds for a camera, in subject-box-diagonals per second.
|
|
47996
|
-
*
|
|
47997
|
-
* A pair of consecutive positions contributes a sample only when the interval
|
|
47998
|
-
* between them is inside `[minIntervalSec, maxIntervalSec]` and the later box
|
|
47999
|
-
* has a positive diagonal. A gap outside that band is a session boundary or a
|
|
48000
|
-
* duplicate read, not a measurement of how fast anything travelled; a box with
|
|
48001
|
-
* no size is unknown geometry, and unknown is never a sample.
|
|
48002
|
-
*/
|
|
48003
|
-
function collectStillnessSamples(tracks, limits) {
|
|
48004
|
-
const speeds = [];
|
|
48005
|
-
const intervals = [];
|
|
48006
|
-
const diagonals = [];
|
|
48007
|
-
let trackCount = 0;
|
|
48008
|
-
const read = tracks.slice(0, limits.maxTracks);
|
|
48009
|
-
for (const track of read) {
|
|
48010
|
-
const positions = [...track.positions].sort((a, b) => a.timestamp - b.timestamp);
|
|
48011
|
-
let contributed = false;
|
|
48012
|
-
for (let i = 1; i < positions.length; i++) {
|
|
48013
|
-
const prev = positions[i - 1];
|
|
48014
|
-
const cur = positions[i];
|
|
48015
|
-
if (prev === void 0 || cur === void 0) continue;
|
|
48016
|
-
const dtSec = (cur.timestamp - prev.timestamp) / 1e3;
|
|
48017
|
-
if (dtSec < limits.minIntervalSec || dtSec > limits.maxIntervalSec) continue;
|
|
48018
|
-
const diagonal = Math.hypot(cur.bbox.w, cur.bbox.h);
|
|
48019
|
-
if (!(diagonal > 0)) continue;
|
|
48020
|
-
const travelled = Math.hypot(cur.x - prev.x, cur.y - prev.y);
|
|
48021
|
-
speeds.push(travelled / diagonal / dtSec);
|
|
48022
|
-
intervals.push(dtSec);
|
|
48023
|
-
diagonals.push(diagonal);
|
|
48024
|
-
contributed = true;
|
|
48025
|
-
}
|
|
48026
|
-
if (contributed) trackCount++;
|
|
48027
|
-
}
|
|
48028
|
-
return {
|
|
48029
|
-
speeds,
|
|
48030
|
-
intervals,
|
|
48031
|
-
diagonals,
|
|
48032
|
-
trackCount,
|
|
48033
|
-
tracksRead: read.length
|
|
48034
|
-
};
|
|
48035
|
-
}
|
|
48036
|
-
function measure$1(samples) {
|
|
48037
|
-
return {
|
|
48038
|
-
trackCount: samples.trackCount,
|
|
48039
|
-
sampleCount: samples.speeds.length,
|
|
48040
|
-
p25: quantileOf(samples.speeds, .25),
|
|
48041
|
-
p50: quantileOf(samples.speeds, .5),
|
|
48042
|
-
p90: quantileOf(samples.speeds, .9),
|
|
48043
|
-
medianIntervalSec: quantileOf(samples.intervals, .5),
|
|
48044
|
-
medianSubjectDiagonalPx: quantileOf(samples.diagonals, .5)
|
|
48045
|
-
};
|
|
48046
|
-
}
|
|
48047
|
-
/**
|
|
48048
|
-
* Choose this camera's stillness speed bar, or refuse with a named reason.
|
|
48049
|
-
*
|
|
48050
|
-
* The order of the gates is the order of the questions: is there anything at
|
|
48051
|
-
* all, is it enough tracks, is it enough samples, and did anything on this
|
|
48052
|
-
* camera ever actually move. The last one is not pedantry — the whole method
|
|
48053
|
-
* scales the bar against the camera's own travel, so with no travel there is
|
|
48054
|
-
* nothing to scale against and the default is the honest answer.
|
|
48055
|
-
*/
|
|
48056
|
-
function calibrateStillnessSpeed(samples, limits) {
|
|
48057
|
-
const measurement = measure$1(samples);
|
|
48058
|
-
if (samples.tracksRead === 0) return {
|
|
48059
|
-
ok: false,
|
|
48060
|
-
reason: "no-tracks",
|
|
48061
|
-
measurement
|
|
48062
|
-
};
|
|
48063
|
-
if (samples.trackCount < limits.minTracks) return {
|
|
48064
|
-
ok: false,
|
|
48065
|
-
reason: "too-few-tracks",
|
|
48066
|
-
measurement
|
|
48067
|
-
};
|
|
48068
|
-
if (samples.speeds.length < limits.minSamples) return {
|
|
48069
|
-
ok: false,
|
|
48070
|
-
reason: "too-few-samples",
|
|
48071
|
-
measurement
|
|
48072
|
-
};
|
|
48073
|
-
if (!(measurement.p90 > 0)) return {
|
|
48074
|
-
ok: false,
|
|
48075
|
-
reason: "no-motion-evidence",
|
|
48076
|
-
measurement
|
|
48077
|
-
};
|
|
48078
|
-
const rawBar = quantileOf(samples.speeds, limits.quantile);
|
|
48079
|
-
const floor = measurement.p90 * limits.floorFactor;
|
|
48080
|
-
const ceiling = measurement.p90 * limits.ceilFactor;
|
|
48081
|
-
let bar = rawBar;
|
|
48082
|
-
let clampedBy = "none";
|
|
48083
|
-
if (bar < floor) {
|
|
48084
|
-
bar = floor;
|
|
48085
|
-
clampedBy = "floor";
|
|
48086
|
-
} else if (bar > ceiling) {
|
|
48087
|
-
bar = ceiling;
|
|
48088
|
-
clampedBy = "ceiling";
|
|
48089
|
-
}
|
|
48090
|
-
if (bar < limits.minBar) {
|
|
48091
|
-
bar = limits.minBar;
|
|
48092
|
-
clampedBy = "min";
|
|
48093
|
-
} else if (bar > limits.maxBar) {
|
|
48094
|
-
bar = limits.maxBar;
|
|
48095
|
-
clampedBy = "max";
|
|
48096
|
-
}
|
|
48097
|
-
return {
|
|
48098
|
-
ok: true,
|
|
48099
|
-
bar,
|
|
48100
|
-
rawBar,
|
|
48101
|
-
clampedBy,
|
|
48102
|
-
measurement
|
|
48103
|
-
};
|
|
48104
|
-
}
|
|
48105
|
-
/** One line an operator can read: what was measured, from how much, and what
|
|
48106
|
-
* rule produced the stored number. Kept short — it rides the settings blob. */
|
|
48107
|
-
function describeStillnessCalibration(outcome) {
|
|
48108
|
-
const m = outcome.measurement;
|
|
48109
|
-
const n = (v) => Number.isFinite(v) ? v.toFixed(4) : "unknown";
|
|
48110
|
-
const evidence = `${m.sampleCount} samples / ${m.trackCount} tracks, p25 ${n(m.p25)}, p50 ${n(m.p50)}, p90 ${n(m.p90)} subject-diagonals/s`;
|
|
48111
|
-
if (!outcome.ok) return `refused (${outcome.reason}) — ${evidence}`;
|
|
48112
|
-
return `bar ${n(outcome.bar)} (raw ${n(outcome.rawBar)}, clamp ${outcome.clampedBy}) — ${evidence}`;
|
|
48113
|
-
}
|
|
48114
|
-
//#endregion
|
|
48115
|
-
//#region src/pipeline-analytics/stillness-settings.ts
|
|
48116
|
-
/**
|
|
48117
|
-
* Per-device STILLNESS SCALE — the one number that says how fast a subject on
|
|
48118
|
-
* THIS camera has to move before it is travelling.
|
|
48119
|
-
*
|
|
48120
|
-
* A threshold belongs to whoever GENERATES the event, per device, never baked
|
|
48121
|
-
* into a classifier default. The judgement this feeds is
|
|
48122
|
-
* `StateAnalyzer.speedSaysSettled`; the measurement behind the number is
|
|
48123
|
-
* `pipeline/tracker/stillness-scale.ts`, which also carries the live
|
|
48124
|
-
* measurement showing why one number cannot serve a 30 m driveway and a 2 m
|
|
48125
|
-
* doorway.
|
|
48126
|
-
*
|
|
48127
|
-
* Cascade and shape mirror `stationary-settings` / `media-settings`: a
|
|
48128
|
-
* per-device override on top of the default, resolved per FIELD, and a parse
|
|
48129
|
-
* that never throws on a bad blob.
|
|
48130
|
-
*
|
|
48131
|
-
* **Absent behaves exactly as today.** The device store is a flat numeric blob
|
|
48132
|
-
* with no room for "unset", so the stored form of absent is `0` — and `0` is
|
|
48133
|
-
* not a bar anyone could want (it would mean nothing on this camera is ever
|
|
48134
|
-
* still) and not a value the calibration can produce (`minBar`). The decision
|
|
48135
|
-
* layer never sees it: {@link resolveStillSpeedFracPerSec} maps it to
|
|
48136
|
-
* `undefined`, and `undefined` is what leaves `StateAnalyzer` on its original
|
|
48137
|
-
* absolute `velocityThreshold` path.
|
|
48138
|
-
*/
|
|
48139
|
-
var StillnessSettingsSchema = object({
|
|
48140
|
-
/**
|
|
48141
|
-
* The camera's "not travelling" bar: centroid speed as a fraction of the
|
|
48142
|
-
* SUBJECT's own box diagonal, per second. `0` = not calibrated (see above).
|
|
48143
|
-
*
|
|
48144
|
-
* Measured p50 on the live hub 2026-09-19, for scale: 0.0136 (device 592,
|
|
48145
|
-
* close view, mostly parked subjects) to 0.3081 (device 615, far view, only
|
|
48146
|
-
* transits).
|
|
48147
|
-
*/
|
|
48148
|
-
stillnessSpeedFracPerSec: number().min(0).max(1).default(0),
|
|
48149
|
-
/**
|
|
48150
|
-
* When the number above was last CHOSEN — by a calibration run or by the
|
|
48151
|
-
* operator. `0` = never. Purely a readout; nothing gates on it.
|
|
48152
|
-
*/
|
|
48153
|
-
stillnessCalibratedAt: number().int().min(0).default(0),
|
|
48154
|
-
/**
|
|
48155
|
-
* What the last calibration run measured and decided, in one line
|
|
48156
|
-
* (`describeStillnessCalibration`) — including a REFUSAL and its named
|
|
48157
|
-
* reason, because "why is this camera still on the default" has to be
|
|
48158
|
-
* answerable without re-running anything.
|
|
48159
|
-
*/
|
|
48160
|
-
stillnessCalibrationNote: string().max(400).default("")
|
|
48161
|
-
});
|
|
48162
|
-
var STILLNESS_DEFAULTS = StillnessSettingsSchema.parse({});
|
|
48163
|
-
/**
|
|
48164
|
-
* Resolve a per-device store blob into typed stillness settings. Unknown or
|
|
48165
|
-
* invalid fields fall back to the default for that field — including a bar
|
|
48166
|
-
* outside the calibration's own rails, which is refused rather than clamped:
|
|
48167
|
-
* a number the calibration could never have produced is not evidence about
|
|
48168
|
-
* this camera, and silently bending it into range would present it as if it
|
|
48169
|
-
* were.
|
|
48170
|
-
*/
|
|
48171
|
-
function resolveStillnessSettings(raw) {
|
|
48172
|
-
const pick = (key) => {
|
|
48173
|
-
const parsed = StillnessSettingsSchema.shape[key].safeParse(raw[key]);
|
|
48174
|
-
return parsed.success ? parsed.data : STILLNESS_DEFAULTS[key];
|
|
48175
|
-
};
|
|
48176
|
-
return {
|
|
48177
|
-
stillnessSpeedFracPerSec: pick("stillnessSpeedFracPerSec"),
|
|
48178
|
-
stillnessCalibratedAt: pick("stillnessCalibratedAt"),
|
|
48179
|
-
stillnessCalibrationNote: pick("stillnessCalibrationNote")
|
|
48180
|
-
};
|
|
48181
|
-
}
|
|
48182
|
-
/**
|
|
48183
|
-
* The bar the decision layer gets: a positive, in-rails number, or
|
|
48184
|
-
* `undefined` for "this camera is not calibrated — judge it exactly as
|
|
48185
|
-
* before". The ONE place the stored-zero convention is interpreted.
|
|
48186
|
-
*/
|
|
48187
|
-
function resolveStillSpeedFracPerSec(settings) {
|
|
48188
|
-
const bar = settings.stillnessSpeedFracPerSec;
|
|
48189
|
-
if (!(bar >= .001) || bar > 1) return void 0;
|
|
48190
|
-
return bar;
|
|
48191
|
-
}
|
|
48192
|
-
//#endregion
|
|
48193
48338
|
//#region src/pipeline-analytics/viewer-settings-legacy-adoption.ts
|
|
48194
48339
|
/**
|
|
48195
48340
|
* @durable class=config owner=pipeline-analytics
|
|
@@ -48367,6 +48512,230 @@ async function adoptLegacyViewerSettingsSnapshots(deps) {
|
|
|
48367
48512
|
return retired;
|
|
48368
48513
|
}
|
|
48369
48514
|
/**
|
|
48515
|
+
* The VIDEO's playback rate — 4×, the same as {@link NC_GIF_SPEED}.
|
|
48516
|
+
*
|
|
48517
|
+
* This was 1, and the reasoning for 1 was sound as far as it went: `speed !== 1`
|
|
48518
|
+
* fails `clipCanCopy`, so a sped-up video cannot be the camera's own H.264
|
|
48519
|
+
* copied — it is a `libx264` burst. The OPERATOR priced that and took it. It is
|
|
48520
|
+
* one encode per event over a ~12 s window, not a permanent transcode child
|
|
48521
|
+
* ([D84](../../../../../../docs/decisions/adr-0084.md) is about the latter), and
|
|
48522
|
+
* it was measured on a real 615 720p cut before being chosen: **0.23 s of
|
|
48523
|
+
* encode, 254 KB out**, against the 922 KB the copy of the same window carried.
|
|
48524
|
+
* The re-encode is smaller than what it replaces.
|
|
48525
|
+
*
|
|
48526
|
+
* So both attachments now agree on the timeline as well as on the window: one
|
|
48527
|
+
* clip, one rate, two containers.
|
|
48528
|
+
*/
|
|
48529
|
+
var DEFAULT_SPEED = 4;
|
|
48530
|
+
/** The fallback mp4's requested width — the cap's own ceiling, so a rendition
|
|
48531
|
+
* at or below 1080p is an identity scale and the broker copies it. */
|
|
48532
|
+
var RING_MP4_MAX_WIDTH = 1920;
|
|
48533
|
+
/** MP4 keeps the source cadence; this only bounds the muxer. The cap caps at 15. */
|
|
48534
|
+
var RING_MP4_FPS = 15;
|
|
48535
|
+
var NO_MEDIA = {
|
|
48536
|
+
mp4: null,
|
|
48537
|
+
gif: null,
|
|
48538
|
+
source: "none",
|
|
48539
|
+
startOffsetMs: null,
|
|
48540
|
+
endOffsetMs: null,
|
|
48541
|
+
profile: null,
|
|
48542
|
+
video: null
|
|
48543
|
+
};
|
|
48544
|
+
var EventMediaService = class {
|
|
48545
|
+
deps;
|
|
48546
|
+
constructor(deps) {
|
|
48547
|
+
this.deps = deps;
|
|
48548
|
+
}
|
|
48549
|
+
/**
|
|
48550
|
+
* Cut this event. Never throws — a notification that lost its media is still
|
|
48551
|
+
* a notification, and every branch that drops it logs why.
|
|
48552
|
+
*/
|
|
48553
|
+
async cut(request) {
|
|
48554
|
+
if (!request.wantMp4 && !request.wantGif) return NO_MEDIA;
|
|
48555
|
+
const produced = await this.produce(request);
|
|
48556
|
+
if (produced !== null) return produced;
|
|
48557
|
+
return this.fallbackToRing(request);
|
|
48558
|
+
}
|
|
48559
|
+
async produce(request) {
|
|
48560
|
+
const deviceId = request.deviceId;
|
|
48561
|
+
const log = this.deps.logger;
|
|
48562
|
+
const kinds = [];
|
|
48563
|
+
if (request.wantMp4) kinds.push("mp4");
|
|
48564
|
+
if (request.wantGif) kinds.push("gif");
|
|
48565
|
+
let production;
|
|
48566
|
+
try {
|
|
48567
|
+
production = await this.deps.produce({
|
|
48568
|
+
deviceId,
|
|
48569
|
+
aroundMs: request.aroundMs,
|
|
48570
|
+
preSeconds: request.preRollSec,
|
|
48571
|
+
postSeconds: request.postRollSec,
|
|
48572
|
+
kinds,
|
|
48573
|
+
gifMaxWidth: 640,
|
|
48574
|
+
gifFps: 12,
|
|
48575
|
+
gifSpeed: 4,
|
|
48576
|
+
speed: request.speed ?? DEFAULT_SPEED,
|
|
48577
|
+
...request.profile !== void 0 ? { profile: request.profile } : {}
|
|
48578
|
+
});
|
|
48579
|
+
} catch (err) {
|
|
48580
|
+
log.warn("nc media: the broker could not produce this event — falling back to the clip ring", {
|
|
48581
|
+
tags: { deviceId },
|
|
48582
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
48583
|
+
});
|
|
48584
|
+
return null;
|
|
48585
|
+
}
|
|
48586
|
+
const mp4 = await this.redeem(deviceId, production.media, "mp4");
|
|
48587
|
+
const gif = await this.redeem(deviceId, production.media, "gif");
|
|
48588
|
+
if (mp4 === null && gif === null) {
|
|
48589
|
+
log.warn("nc media: the production carried no artifact — falling back to the clip ring", {
|
|
48590
|
+
tags: { deviceId },
|
|
48591
|
+
meta: {
|
|
48592
|
+
kinds: kinds.join(","),
|
|
48593
|
+
profile: production.profile
|
|
48594
|
+
}
|
|
48595
|
+
});
|
|
48596
|
+
return null;
|
|
48597
|
+
}
|
|
48598
|
+
const startOffsetMs = production.coverage.fromTs - request.aroundMs;
|
|
48599
|
+
const endOffsetMs = production.coverage.toTs - request.aroundMs;
|
|
48600
|
+
log.info("nc media: one production, every attachment", {
|
|
48601
|
+
tags: { deviceId },
|
|
48602
|
+
meta: {
|
|
48603
|
+
profile: production.profile,
|
|
48604
|
+
video: production.video,
|
|
48605
|
+
mp4Bytes: mp4?.byteLength ?? null,
|
|
48606
|
+
gifBytes: gif?.byteLength ?? null,
|
|
48607
|
+
startOffsetMs,
|
|
48608
|
+
endOffsetMs,
|
|
48609
|
+
sharedWindow: true
|
|
48610
|
+
}
|
|
48611
|
+
});
|
|
48612
|
+
return {
|
|
48613
|
+
mp4,
|
|
48614
|
+
gif,
|
|
48615
|
+
source: "produced",
|
|
48616
|
+
startOffsetMs,
|
|
48617
|
+
endOffsetMs,
|
|
48618
|
+
profile: production.profile,
|
|
48619
|
+
video: production.video
|
|
48620
|
+
};
|
|
48621
|
+
}
|
|
48622
|
+
/**
|
|
48623
|
+
* Redeem one artifact handle at the node that produced it.
|
|
48624
|
+
*
|
|
48625
|
+
* `null` is not an error here — a production simply may not carry the kind
|
|
48626
|
+
* (a gif whose derive failed says so on the broker's own log line). A handle
|
|
48627
|
+
* that FAILS to redeem is different and is logged, because it means the bytes
|
|
48628
|
+
* existed and did not arrive.
|
|
48629
|
+
*/
|
|
48630
|
+
async redeem(deviceId, media, kind) {
|
|
48631
|
+
const artifact = media.find((m) => m.kind === kind);
|
|
48632
|
+
if (artifact === void 0) return null;
|
|
48633
|
+
try {
|
|
48634
|
+
const res = await this.deps.fetch(artifact.handle, artifact.nodeId);
|
|
48635
|
+
if (res === null) {
|
|
48636
|
+
this.deps.logger.warn("nc media: an artifact handle expired before it could be fetched", {
|
|
48637
|
+
tags: { deviceId },
|
|
48638
|
+
meta: {
|
|
48639
|
+
kind,
|
|
48640
|
+
handle: artifact.handle,
|
|
48641
|
+
nodeId: artifact.nodeId
|
|
48642
|
+
}
|
|
48643
|
+
});
|
|
48644
|
+
return null;
|
|
48645
|
+
}
|
|
48646
|
+
const buf = Buffer.from(res.base64, "base64");
|
|
48647
|
+
if (buf.byteLength === 0) return null;
|
|
48648
|
+
const bytes = new Uint8Array(buf.byteLength);
|
|
48649
|
+
bytes.set(buf);
|
|
48650
|
+
return bytes;
|
|
48651
|
+
} catch (err) {
|
|
48652
|
+
this.deps.logger.warn("nc media: fetching an artifact failed — that attachment is dropped", {
|
|
48653
|
+
tags: { deviceId },
|
|
48654
|
+
meta: {
|
|
48655
|
+
kind,
|
|
48656
|
+
handle: artifact.handle,
|
|
48657
|
+
nodeId: artifact.nodeId,
|
|
48658
|
+
error: err instanceof Error ? err.message : String(err)
|
|
48659
|
+
}
|
|
48660
|
+
});
|
|
48661
|
+
return null;
|
|
48662
|
+
}
|
|
48663
|
+
}
|
|
48664
|
+
/**
|
|
48665
|
+
* The pre-`produceEventMedia` path: `renderPreBufferClip`, once per container.
|
|
48666
|
+
*
|
|
48667
|
+
* Weaker than a production and deliberately so — the coherence here rests on
|
|
48668
|
+
* the two calls being given IDENTICAL parameters rather than on there being
|
|
48669
|
+
* one render. That is defensible because the ring only ever grows forward and
|
|
48670
|
+
* `windowAround` selects by wall clock around the same instant, so two calls
|
|
48671
|
+
* seconds apart choose the same packets; the one thing that could differ is
|
|
48672
|
+
* the rendition, so the profile is pinned to whatever the FIRST call actually
|
|
48673
|
+
* used and handed to the second.
|
|
48674
|
+
*
|
|
48675
|
+
* It exists for one reason: a hub whose `@camstack/server` predates the
|
|
48676
|
+
* production method must not stop attaching footage, and three of the four
|
|
48677
|
+
* live rules on this install ask for a gif and nothing else.
|
|
48678
|
+
*/
|
|
48679
|
+
async fallbackToRing(request) {
|
|
48680
|
+
const deviceId = request.deviceId;
|
|
48681
|
+
const speed = request.speed ?? DEFAULT_SPEED;
|
|
48682
|
+
const window = {
|
|
48683
|
+
deviceId,
|
|
48684
|
+
aroundMs: request.aroundMs,
|
|
48685
|
+
preRollSec: request.preRollSec,
|
|
48686
|
+
postRollSec: request.postRollSec,
|
|
48687
|
+
speed,
|
|
48688
|
+
...request.profile !== void 0 ? { profile: request.profile } : {}
|
|
48689
|
+
};
|
|
48690
|
+
try {
|
|
48691
|
+
const mp4 = request.wantMp4 ? await this.deps.renderRingClip({
|
|
48692
|
+
...window,
|
|
48693
|
+
format: "mp4",
|
|
48694
|
+
maxWidth: RING_MP4_MAX_WIDTH,
|
|
48695
|
+
fps: RING_MP4_FPS
|
|
48696
|
+
}) : null;
|
|
48697
|
+
const gif = request.wantGif ? await this.deps.renderRingClip({
|
|
48698
|
+
...window,
|
|
48699
|
+
format: "gif",
|
|
48700
|
+
maxWidth: 640,
|
|
48701
|
+
fps: 12 / 4,
|
|
48702
|
+
speed: 4
|
|
48703
|
+
}) : null;
|
|
48704
|
+
if ((mp4 === null || mp4.byteLength === 0) && (gif === null || gif.byteLength === 0)) {
|
|
48705
|
+
this.deps.logger.warn("nc media: the clip ring covered nothing — no footage attached", {
|
|
48706
|
+
tags: { deviceId },
|
|
48707
|
+
meta: { aroundMs: request.aroundMs }
|
|
48708
|
+
});
|
|
48709
|
+
return NO_MEDIA;
|
|
48710
|
+
}
|
|
48711
|
+
this.deps.logger.info("nc media: served by the clip-ring FALLBACK", {
|
|
48712
|
+
tags: { deviceId },
|
|
48713
|
+
meta: {
|
|
48714
|
+
mp4Bytes: mp4?.byteLength ?? null,
|
|
48715
|
+
gifBytes: gif?.byteLength ?? null,
|
|
48716
|
+
profile: request.profile ?? null,
|
|
48717
|
+
sharedWindow: "by-parameters"
|
|
48718
|
+
}
|
|
48719
|
+
});
|
|
48720
|
+
return {
|
|
48721
|
+
mp4: mp4 !== null && mp4.byteLength > 0 ? mp4 : null,
|
|
48722
|
+
gif: gif !== null && gif.byteLength > 0 ? gif : null,
|
|
48723
|
+
source: "ring-fallback",
|
|
48724
|
+
startOffsetMs: -Math.max(0, request.preRollSec) * 1e3,
|
|
48725
|
+
endOffsetMs: Math.max(0, request.postRollSec) * 1e3,
|
|
48726
|
+
profile: request.profile ?? null,
|
|
48727
|
+
video: null
|
|
48728
|
+
};
|
|
48729
|
+
} catch (err) {
|
|
48730
|
+
this.deps.logger.warn("nc media: the clip ring failed too — this event ships no footage", {
|
|
48731
|
+
tags: { deviceId },
|
|
48732
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
48733
|
+
});
|
|
48734
|
+
return NO_MEDIA;
|
|
48735
|
+
}
|
|
48736
|
+
}
|
|
48737
|
+
};
|
|
48738
|
+
/**
|
|
48370
48739
|
* How long the recorder keeps the file we are about to read and delete.
|
|
48371
48740
|
*
|
|
48372
48741
|
* `maxLifeMs` is `z.number().int().positive()` on the capability, so "do not
|
|
@@ -48620,230 +48989,6 @@ function decodeBase64(base64) {
|
|
|
48620
48989
|
return null;
|
|
48621
48990
|
}
|
|
48622
48991
|
}
|
|
48623
|
-
/**
|
|
48624
|
-
* The VIDEO's playback rate — 4×, the same as {@link NC_GIF_SPEED}.
|
|
48625
|
-
*
|
|
48626
|
-
* This was 1, and the reasoning for 1 was sound as far as it went: `speed !== 1`
|
|
48627
|
-
* fails `clipCanCopy`, so a sped-up video cannot be the camera's own H.264
|
|
48628
|
-
* copied — it is a `libx264` burst. The OPERATOR priced that and took it. It is
|
|
48629
|
-
* one encode per event over a ~12 s window, not a permanent transcode child
|
|
48630
|
-
* ([D84](../../../../../../docs/decisions/adr-0084.md) is about the latter), and
|
|
48631
|
-
* it was measured on a real 615 720p cut before being chosen: **0.23 s of
|
|
48632
|
-
* encode, 254 KB out**, against the 922 KB the copy of the same window carried.
|
|
48633
|
-
* The re-encode is smaller than what it replaces.
|
|
48634
|
-
*
|
|
48635
|
-
* So both attachments now agree on the timeline as well as on the window: one
|
|
48636
|
-
* clip, one rate, two containers.
|
|
48637
|
-
*/
|
|
48638
|
-
var DEFAULT_SPEED = 4;
|
|
48639
|
-
/** The fallback mp4's requested width — the cap's own ceiling, so a rendition
|
|
48640
|
-
* at or below 1080p is an identity scale and the broker copies it. */
|
|
48641
|
-
var RING_MP4_MAX_WIDTH = 1920;
|
|
48642
|
-
/** MP4 keeps the source cadence; this only bounds the muxer. The cap caps at 15. */
|
|
48643
|
-
var RING_MP4_FPS = 15;
|
|
48644
|
-
var NO_MEDIA = {
|
|
48645
|
-
mp4: null,
|
|
48646
|
-
gif: null,
|
|
48647
|
-
source: "none",
|
|
48648
|
-
startOffsetMs: null,
|
|
48649
|
-
endOffsetMs: null,
|
|
48650
|
-
profile: null,
|
|
48651
|
-
video: null
|
|
48652
|
-
};
|
|
48653
|
-
var EventMediaService = class {
|
|
48654
|
-
deps;
|
|
48655
|
-
constructor(deps) {
|
|
48656
|
-
this.deps = deps;
|
|
48657
|
-
}
|
|
48658
|
-
/**
|
|
48659
|
-
* Cut this event. Never throws — a notification that lost its media is still
|
|
48660
|
-
* a notification, and every branch that drops it logs why.
|
|
48661
|
-
*/
|
|
48662
|
-
async cut(request) {
|
|
48663
|
-
if (!request.wantMp4 && !request.wantGif) return NO_MEDIA;
|
|
48664
|
-
const produced = await this.produce(request);
|
|
48665
|
-
if (produced !== null) return produced;
|
|
48666
|
-
return this.fallbackToRing(request);
|
|
48667
|
-
}
|
|
48668
|
-
async produce(request) {
|
|
48669
|
-
const deviceId = request.deviceId;
|
|
48670
|
-
const log = this.deps.logger;
|
|
48671
|
-
const kinds = [];
|
|
48672
|
-
if (request.wantMp4) kinds.push("mp4");
|
|
48673
|
-
if (request.wantGif) kinds.push("gif");
|
|
48674
|
-
let production;
|
|
48675
|
-
try {
|
|
48676
|
-
production = await this.deps.produce({
|
|
48677
|
-
deviceId,
|
|
48678
|
-
aroundMs: request.aroundMs,
|
|
48679
|
-
preSeconds: request.preRollSec,
|
|
48680
|
-
postSeconds: request.postRollSec,
|
|
48681
|
-
kinds,
|
|
48682
|
-
gifMaxWidth: 640,
|
|
48683
|
-
gifFps: 12,
|
|
48684
|
-
gifSpeed: 4,
|
|
48685
|
-
speed: request.speed ?? DEFAULT_SPEED,
|
|
48686
|
-
...request.profile !== void 0 ? { profile: request.profile } : {}
|
|
48687
|
-
});
|
|
48688
|
-
} catch (err) {
|
|
48689
|
-
log.warn("nc media: the broker could not produce this event — falling back to the clip ring", {
|
|
48690
|
-
tags: { deviceId },
|
|
48691
|
-
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
48692
|
-
});
|
|
48693
|
-
return null;
|
|
48694
|
-
}
|
|
48695
|
-
const mp4 = await this.redeem(deviceId, production.media, "mp4");
|
|
48696
|
-
const gif = await this.redeem(deviceId, production.media, "gif");
|
|
48697
|
-
if (mp4 === null && gif === null) {
|
|
48698
|
-
log.warn("nc media: the production carried no artifact — falling back to the clip ring", {
|
|
48699
|
-
tags: { deviceId },
|
|
48700
|
-
meta: {
|
|
48701
|
-
kinds: kinds.join(","),
|
|
48702
|
-
profile: production.profile
|
|
48703
|
-
}
|
|
48704
|
-
});
|
|
48705
|
-
return null;
|
|
48706
|
-
}
|
|
48707
|
-
const startOffsetMs = production.coverage.fromTs - request.aroundMs;
|
|
48708
|
-
const endOffsetMs = production.coverage.toTs - request.aroundMs;
|
|
48709
|
-
log.info("nc media: one production, every attachment", {
|
|
48710
|
-
tags: { deviceId },
|
|
48711
|
-
meta: {
|
|
48712
|
-
profile: production.profile,
|
|
48713
|
-
video: production.video,
|
|
48714
|
-
mp4Bytes: mp4?.byteLength ?? null,
|
|
48715
|
-
gifBytes: gif?.byteLength ?? null,
|
|
48716
|
-
startOffsetMs,
|
|
48717
|
-
endOffsetMs,
|
|
48718
|
-
sharedWindow: true
|
|
48719
|
-
}
|
|
48720
|
-
});
|
|
48721
|
-
return {
|
|
48722
|
-
mp4,
|
|
48723
|
-
gif,
|
|
48724
|
-
source: "produced",
|
|
48725
|
-
startOffsetMs,
|
|
48726
|
-
endOffsetMs,
|
|
48727
|
-
profile: production.profile,
|
|
48728
|
-
video: production.video
|
|
48729
|
-
};
|
|
48730
|
-
}
|
|
48731
|
-
/**
|
|
48732
|
-
* Redeem one artifact handle at the node that produced it.
|
|
48733
|
-
*
|
|
48734
|
-
* `null` is not an error here — a production simply may not carry the kind
|
|
48735
|
-
* (a gif whose derive failed says so on the broker's own log line). A handle
|
|
48736
|
-
* that FAILS to redeem is different and is logged, because it means the bytes
|
|
48737
|
-
* existed and did not arrive.
|
|
48738
|
-
*/
|
|
48739
|
-
async redeem(deviceId, media, kind) {
|
|
48740
|
-
const artifact = media.find((m) => m.kind === kind);
|
|
48741
|
-
if (artifact === void 0) return null;
|
|
48742
|
-
try {
|
|
48743
|
-
const res = await this.deps.fetch(artifact.handle, artifact.nodeId);
|
|
48744
|
-
if (res === null) {
|
|
48745
|
-
this.deps.logger.warn("nc media: an artifact handle expired before it could be fetched", {
|
|
48746
|
-
tags: { deviceId },
|
|
48747
|
-
meta: {
|
|
48748
|
-
kind,
|
|
48749
|
-
handle: artifact.handle,
|
|
48750
|
-
nodeId: artifact.nodeId
|
|
48751
|
-
}
|
|
48752
|
-
});
|
|
48753
|
-
return null;
|
|
48754
|
-
}
|
|
48755
|
-
const buf = Buffer.from(res.base64, "base64");
|
|
48756
|
-
if (buf.byteLength === 0) return null;
|
|
48757
|
-
const bytes = new Uint8Array(buf.byteLength);
|
|
48758
|
-
bytes.set(buf);
|
|
48759
|
-
return bytes;
|
|
48760
|
-
} catch (err) {
|
|
48761
|
-
this.deps.logger.warn("nc media: fetching an artifact failed — that attachment is dropped", {
|
|
48762
|
-
tags: { deviceId },
|
|
48763
|
-
meta: {
|
|
48764
|
-
kind,
|
|
48765
|
-
handle: artifact.handle,
|
|
48766
|
-
nodeId: artifact.nodeId,
|
|
48767
|
-
error: err instanceof Error ? err.message : String(err)
|
|
48768
|
-
}
|
|
48769
|
-
});
|
|
48770
|
-
return null;
|
|
48771
|
-
}
|
|
48772
|
-
}
|
|
48773
|
-
/**
|
|
48774
|
-
* The pre-`produceEventMedia` path: `renderPreBufferClip`, once per container.
|
|
48775
|
-
*
|
|
48776
|
-
* Weaker than a production and deliberately so — the coherence here rests on
|
|
48777
|
-
* the two calls being given IDENTICAL parameters rather than on there being
|
|
48778
|
-
* one render. That is defensible because the ring only ever grows forward and
|
|
48779
|
-
* `windowAround` selects by wall clock around the same instant, so two calls
|
|
48780
|
-
* seconds apart choose the same packets; the one thing that could differ is
|
|
48781
|
-
* the rendition, so the profile is pinned to whatever the FIRST call actually
|
|
48782
|
-
* used and handed to the second.
|
|
48783
|
-
*
|
|
48784
|
-
* It exists for one reason: a hub whose `@camstack/server` predates the
|
|
48785
|
-
* production method must not stop attaching footage, and three of the four
|
|
48786
|
-
* live rules on this install ask for a gif and nothing else.
|
|
48787
|
-
*/
|
|
48788
|
-
async fallbackToRing(request) {
|
|
48789
|
-
const deviceId = request.deviceId;
|
|
48790
|
-
const speed = request.speed ?? DEFAULT_SPEED;
|
|
48791
|
-
const window = {
|
|
48792
|
-
deviceId,
|
|
48793
|
-
aroundMs: request.aroundMs,
|
|
48794
|
-
preRollSec: request.preRollSec,
|
|
48795
|
-
postRollSec: request.postRollSec,
|
|
48796
|
-
speed,
|
|
48797
|
-
...request.profile !== void 0 ? { profile: request.profile } : {}
|
|
48798
|
-
};
|
|
48799
|
-
try {
|
|
48800
|
-
const mp4 = request.wantMp4 ? await this.deps.renderRingClip({
|
|
48801
|
-
...window,
|
|
48802
|
-
format: "mp4",
|
|
48803
|
-
maxWidth: RING_MP4_MAX_WIDTH,
|
|
48804
|
-
fps: RING_MP4_FPS
|
|
48805
|
-
}) : null;
|
|
48806
|
-
const gif = request.wantGif ? await this.deps.renderRingClip({
|
|
48807
|
-
...window,
|
|
48808
|
-
format: "gif",
|
|
48809
|
-
maxWidth: 640,
|
|
48810
|
-
fps: 12 / 4,
|
|
48811
|
-
speed: 4
|
|
48812
|
-
}) : null;
|
|
48813
|
-
if ((mp4 === null || mp4.byteLength === 0) && (gif === null || gif.byteLength === 0)) {
|
|
48814
|
-
this.deps.logger.warn("nc media: the clip ring covered nothing — no footage attached", {
|
|
48815
|
-
tags: { deviceId },
|
|
48816
|
-
meta: { aroundMs: request.aroundMs }
|
|
48817
|
-
});
|
|
48818
|
-
return NO_MEDIA;
|
|
48819
|
-
}
|
|
48820
|
-
this.deps.logger.info("nc media: served by the clip-ring FALLBACK", {
|
|
48821
|
-
tags: { deviceId },
|
|
48822
|
-
meta: {
|
|
48823
|
-
mp4Bytes: mp4?.byteLength ?? null,
|
|
48824
|
-
gifBytes: gif?.byteLength ?? null,
|
|
48825
|
-
profile: request.profile ?? null,
|
|
48826
|
-
sharedWindow: "by-parameters"
|
|
48827
|
-
}
|
|
48828
|
-
});
|
|
48829
|
-
return {
|
|
48830
|
-
mp4: mp4 !== null && mp4.byteLength > 0 ? mp4 : null,
|
|
48831
|
-
gif: gif !== null && gif.byteLength > 0 ? gif : null,
|
|
48832
|
-
source: "ring-fallback",
|
|
48833
|
-
startOffsetMs: -Math.max(0, request.preRollSec) * 1e3,
|
|
48834
|
-
endOffsetMs: Math.max(0, request.postRollSec) * 1e3,
|
|
48835
|
-
profile: request.profile ?? null,
|
|
48836
|
-
video: null
|
|
48837
|
-
};
|
|
48838
|
-
} catch (err) {
|
|
48839
|
-
this.deps.logger.warn("nc media: the clip ring failed too — this event ships no footage", {
|
|
48840
|
-
tags: { deviceId },
|
|
48841
|
-
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
48842
|
-
});
|
|
48843
|
-
return NO_MEDIA;
|
|
48844
|
-
}
|
|
48845
|
-
}
|
|
48846
|
-
};
|
|
48847
48992
|
//#endregion
|
|
48848
48993
|
//#region src/shared/frame/crop-extractor.ts
|
|
48849
48994
|
/**
|
|
@@ -57114,6 +57259,41 @@ function resolveMediaSettings(raw) {
|
|
|
57114
57259
|
snapshotMaxIdleMs: pick("snapshotMaxIdleMs")
|
|
57115
57260
|
};
|
|
57116
57261
|
}
|
|
57262
|
+
//#endregion
|
|
57263
|
+
//#region src/pipeline-analytics/our-recording-availability.ts
|
|
57264
|
+
/**
|
|
57265
|
+
* Coverage of OUR archive, asked of the `recording` collection by name.
|
|
57266
|
+
*
|
|
57267
|
+
* `recording` became a device collection on 2026-09-24 (D625): a camera may
|
|
57268
|
+
* have several sources of recorded coverage and every read names one. Every
|
|
57269
|
+
* read in this addon wants the same one — the footage OUR recorder wrote,
|
|
57270
|
+
* because everything downstream of it (a recorded clip cut, a timelapse, the
|
|
57271
|
+
* earliest-footage floor of a retention sweep) is about bytes we hold and can
|
|
57272
|
+
* export. A camera's own SD card is not an answer to any of those questions.
|
|
57273
|
+
*
|
|
57274
|
+
* It also owns the `read: 'unreadable'` rule, in one place: a read that FAILED
|
|
57275
|
+
* throws here rather than arriving downstream as an empty range list. Every
|
|
57276
|
+
* caller of this module treats "no ranges" as "recording is off for this
|
|
57277
|
+
* camera" and acts on it — dropping the attachment, skipping the render,
|
|
57278
|
+
* lowering the sweep floor — so folding a failed read into that shape would
|
|
57279
|
+
* make an unreachable recorder look exactly like a camera nobody records
|
|
57280
|
+
* (D393). The callers already handle a throw and log it as a read failure.
|
|
57281
|
+
*/
|
|
57282
|
+
/** Our coverage of `[fromMs, toMs)`, or a throw naming the failed read. */
|
|
57283
|
+
async function ourRecordingAvailability(api, input) {
|
|
57284
|
+
const answer = await api.recording.getAvailability.query({
|
|
57285
|
+
deviceId: input.deviceId,
|
|
57286
|
+
provider: RECORDING_SOURCE_CAMSTACK_ADDON,
|
|
57287
|
+
fromMs: input.fromMs,
|
|
57288
|
+
toMs: input.toMs,
|
|
57289
|
+
...input.profile !== void 0 ? { profile: input.profile } : {}
|
|
57290
|
+
});
|
|
57291
|
+
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`);
|
|
57292
|
+
return {
|
|
57293
|
+
ranges: answer.ranges,
|
|
57294
|
+
profilesWithFootage: answer.profilesWithFootage
|
|
57295
|
+
};
|
|
57296
|
+
}
|
|
57117
57297
|
function toDetectionBbox(bbox) {
|
|
57118
57298
|
return {
|
|
57119
57299
|
x: bbox.x,
|
|
@@ -58405,6 +58585,17 @@ function resolveSearchThumbnailUrl(input) {
|
|
|
58405
58585
|
return `${input.baseUrl}/${encodeURIComponent(id)}`;
|
|
58406
58586
|
}
|
|
58407
58587
|
//#endregion
|
|
58588
|
+
//#region src/pipeline-analytics/pipeline/onboard-birth-authority.ts
|
|
58589
|
+
/**
|
|
58590
|
+
* May a suppression verdict RETRACT this birth?
|
|
58591
|
+
*
|
|
58592
|
+
* `false` means hold: keep the track, log the held verdict, and let the
|
|
58593
|
+
* ordinary lifetime decide. It never means "confirmed".
|
|
58594
|
+
*/
|
|
58595
|
+
function suppressionMayRetract(source) {
|
|
58596
|
+
return source !== "onboard";
|
|
58597
|
+
}
|
|
58598
|
+
//#endregion
|
|
58408
58599
|
//#region src/pipeline-analytics/pipeline/package/package-area-gate.ts
|
|
58409
58600
|
/**
|
|
58410
58601
|
* Decide whether a package-class box is plausibly parcel-sized.
|
|
@@ -61098,65 +61289,6 @@ var StationaryEvidenceLedger = class {
|
|
|
61098
61289
|
for (const row of excess) this.forgetKey(row.key);
|
|
61099
61290
|
}
|
|
61100
61291
|
};
|
|
61101
|
-
//#endregion
|
|
61102
|
-
//#region src/pipeline-analytics/pipeline/stationary/stationary-track-disposal.ts
|
|
61103
|
-
function planStationaryTrackDisposal(input) {
|
|
61104
|
-
const trackIds = [input.liveTrackId];
|
|
61105
|
-
if (input.sourceTrackId !== void 0 && input.sourceTrackId !== input.liveTrackId) trackIds.push(input.sourceTrackId);
|
|
61106
|
-
const keepMediaIds = /* @__PURE__ */ new Set();
|
|
61107
|
-
if (input.entryKeyFrameMediaId !== void 0) keepMediaIds.add(input.entryKeyFrameMediaId);
|
|
61108
|
-
return {
|
|
61109
|
-
trackIds,
|
|
61110
|
-
keepMediaIds
|
|
61111
|
-
};
|
|
61112
|
-
}
|
|
61113
|
-
/**
|
|
61114
|
-
* Run the plan. The live track is dropped from RAM FIRST so it can never be
|
|
61115
|
-
* persisted by a sweep racing this call; the cascade then removes what every
|
|
61116
|
-
* track owned, sparing the entry's key frame, and deletes the row (a no-op for
|
|
61117
|
-
* the never-persisted live track).
|
|
61118
|
-
*
|
|
61119
|
-
* Never silent: the line names the entity, what went and what was kept — a
|
|
61120
|
-
* deleted track is dropped work, and the operator asked for it by name.
|
|
61121
|
-
*/
|
|
61122
|
-
async function disposeStationaryTracks(deps, plan) {
|
|
61123
|
-
const [liveTrackId] = plan.trackIds;
|
|
61124
|
-
if (liveTrackId !== void 0) deps.trackStore.dropActive(liveTrackId);
|
|
61125
|
-
const sparingMedia = { deleteByTracks: (trackIds) => deps.media.deleteByTracks(trackIds, { spare: plan.keepMediaIds }) };
|
|
61126
|
-
const result = await runTrackCascadeBatch({
|
|
61127
|
-
registry: [...deps.leaves, sparingMedia],
|
|
61128
|
-
trackStore: deps.trackStore,
|
|
61129
|
-
deviceId: deps.deviceId,
|
|
61130
|
-
onTrackCleanup: deps.onTrackCleanup,
|
|
61131
|
-
onFailure: (trackId, err) => {
|
|
61132
|
-
deps.logger.warn("stationary promotion: a source track could not be disposed of", {
|
|
61133
|
-
tags: { deviceId: deps.deviceId },
|
|
61134
|
-
meta: {
|
|
61135
|
-
deviceId: deps.deviceId,
|
|
61136
|
-
entryId: deps.entryId,
|
|
61137
|
-
trackId,
|
|
61138
|
-
error: String(err)
|
|
61139
|
-
}
|
|
61140
|
-
});
|
|
61141
|
-
}
|
|
61142
|
-
}, plan.trackIds);
|
|
61143
|
-
deps.logger.info("stationary promotion: source tracks discarded — the entity keeps its key frame", {
|
|
61144
|
-
tags: { deviceId: deps.deviceId },
|
|
61145
|
-
meta: {
|
|
61146
|
-
deviceId: deps.deviceId,
|
|
61147
|
-
entryId: deps.entryId,
|
|
61148
|
-
trackIds: plan.trackIds,
|
|
61149
|
-
keptMediaIds: [...plan.keepMediaIds],
|
|
61150
|
-
deleted: result.deleted,
|
|
61151
|
-
failed: result.failed,
|
|
61152
|
-
mediaRows: result.tally.media
|
|
61153
|
-
}
|
|
61154
|
-
});
|
|
61155
|
-
return {
|
|
61156
|
-
deleted: result.deleted,
|
|
61157
|
-
failed: result.failed
|
|
61158
|
-
};
|
|
61159
|
-
}
|
|
61160
61292
|
/**
|
|
61161
61293
|
* How long the same weak box must keep being seen before it may re-anchor.
|
|
61162
61294
|
*
|
|
@@ -62098,6 +62230,97 @@ function rowToEntry(id, data) {
|
|
|
62098
62230
|
};
|
|
62099
62231
|
}
|
|
62100
62232
|
//#endregion
|
|
62233
|
+
//#region src/pipeline-analytics/pipeline/stationary/stationary-track-disposal.ts
|
|
62234
|
+
function planStationaryTrackDisposal(input) {
|
|
62235
|
+
const trackIds = [input.liveTrackId];
|
|
62236
|
+
if (input.sourceTrackId !== void 0 && input.sourceTrackId !== input.liveTrackId) trackIds.push(input.sourceTrackId);
|
|
62237
|
+
const keepMediaIds = /* @__PURE__ */ new Set();
|
|
62238
|
+
if (input.entryKeyFrameMediaId !== void 0) keepMediaIds.add(input.entryKeyFrameMediaId);
|
|
62239
|
+
return {
|
|
62240
|
+
trackIds,
|
|
62241
|
+
keepMediaIds
|
|
62242
|
+
};
|
|
62243
|
+
}
|
|
62244
|
+
/**
|
|
62245
|
+
* Run the plan. The live track is dropped from RAM FIRST so it can never be
|
|
62246
|
+
* persisted by a sweep racing this call; the cascade then removes what every
|
|
62247
|
+
* track owned, sparing the entry's key frame, and deletes the row (a no-op for
|
|
62248
|
+
* the never-persisted live track).
|
|
62249
|
+
*
|
|
62250
|
+
* Never silent: the line names the entity, what went and what was kept — a
|
|
62251
|
+
* deleted track is dropped work, and the operator asked for it by name.
|
|
62252
|
+
*/
|
|
62253
|
+
async function disposeStationaryTracks(deps, plan) {
|
|
62254
|
+
const [liveTrackId] = plan.trackIds;
|
|
62255
|
+
if (liveTrackId !== void 0) deps.trackStore.dropActive(liveTrackId);
|
|
62256
|
+
const sparingMedia = { deleteByTracks: (trackIds) => deps.media.deleteByTracks(trackIds, { spare: plan.keepMediaIds }) };
|
|
62257
|
+
const result = await runTrackCascadeBatch({
|
|
62258
|
+
registry: [...deps.leaves, sparingMedia],
|
|
62259
|
+
trackStore: deps.trackStore,
|
|
62260
|
+
deviceId: deps.deviceId,
|
|
62261
|
+
onTrackCleanup: deps.onTrackCleanup,
|
|
62262
|
+
onFailure: (trackId, err) => {
|
|
62263
|
+
deps.logger.warn("stationary promotion: a source track could not be disposed of", {
|
|
62264
|
+
tags: { deviceId: deps.deviceId },
|
|
62265
|
+
meta: {
|
|
62266
|
+
deviceId: deps.deviceId,
|
|
62267
|
+
entryId: deps.entryId,
|
|
62268
|
+
trackId,
|
|
62269
|
+
error: String(err)
|
|
62270
|
+
}
|
|
62271
|
+
});
|
|
62272
|
+
}
|
|
62273
|
+
}, plan.trackIds);
|
|
62274
|
+
deps.logger.info("stationary promotion: source tracks discarded — the entity keeps its key frame", {
|
|
62275
|
+
tags: { deviceId: deps.deviceId },
|
|
62276
|
+
meta: {
|
|
62277
|
+
deviceId: deps.deviceId,
|
|
62278
|
+
entryId: deps.entryId,
|
|
62279
|
+
trackIds: plan.trackIds,
|
|
62280
|
+
keptMediaIds: [...plan.keepMediaIds],
|
|
62281
|
+
deleted: result.deleted,
|
|
62282
|
+
failed: result.failed,
|
|
62283
|
+
mediaRows: result.tally.media
|
|
62284
|
+
}
|
|
62285
|
+
});
|
|
62286
|
+
return {
|
|
62287
|
+
deleted: result.deleted,
|
|
62288
|
+
failed: result.failed
|
|
62289
|
+
};
|
|
62290
|
+
}
|
|
62291
|
+
//#endregion
|
|
62292
|
+
//#region src/pipeline-analytics/pipeline/stationary-planes.ts
|
|
62293
|
+
/**
|
|
62294
|
+
* WHICH detection planes carry the stationary machinery.
|
|
62295
|
+
*
|
|
62296
|
+
* A plane qualifies when it produces OBJECT BOXES on decoded frames — because
|
|
62297
|
+
* that is what the registry needs to decide an object is parked: a box, a
|
|
62298
|
+
* class, and an observed clock that only advances while frames flow.
|
|
62299
|
+
*
|
|
62300
|
+
* Both root detectors qualify. `pipeline` is the ML tree; `onboard` is the
|
|
62301
|
+
* camera's own firmware, and the boxes it produces are paired with a decoded
|
|
62302
|
+
* frame in that frame's pixel space — the same plane, entered by a different
|
|
62303
|
+
* door. `sensor` and `audio` do not: they carry no geometry to park.
|
|
62304
|
+
*
|
|
62305
|
+
* It used to be `source === 'pipeline'`, in five places, each explaining that
|
|
62306
|
+
* the gate "runs on that plane". The reason given was WHERE the flood it was
|
|
62307
|
+
* written for had been measured (617, the ML plane), never an argument that the
|
|
62308
|
+
* firmware's boxes were unfit. The cost of the accident, on device 640: a still
|
|
62309
|
+
* object is never promoted, so it holds `hasLiveTrack` true for as long as it
|
|
62310
|
+
* sits there, and every detection session runs to its 120 s cap instead of
|
|
62311
|
+
* closing on cooldown — `closed at the max-hold cap with a subject still
|
|
62312
|
+
* present`. The camera's lazy stream is then torn down and rebuilt on the next
|
|
62313
|
+
* detection, on a battery camera.
|
|
62314
|
+
*
|
|
62315
|
+
* One predicate and not five comparisons, because five copies of a rule drift:
|
|
62316
|
+
* this repo has already paid for that with two authorities on one function
|
|
62317
|
+
* (D62).
|
|
62318
|
+
*/
|
|
62319
|
+
/** Does this detection plane run the stationary registry, gate and promotion? */
|
|
62320
|
+
function planeRunsStationary(source) {
|
|
62321
|
+
return source === "pipeline" || source === "onboard";
|
|
62322
|
+
}
|
|
62323
|
+
//#endregion
|
|
62101
62324
|
//#region src/pipeline-analytics/pipeline/suppressed-births.ts
|
|
62102
62325
|
/**
|
|
62103
62326
|
* Memory of births the confirmation gate rejected, per device.
|
|
@@ -63013,49 +63236,6 @@ function hasKeyFrame(source) {
|
|
|
63013
63236
|
return source.keyFrame !== null && source.bbox !== null;
|
|
63014
63237
|
}
|
|
63015
63238
|
//#endregion
|
|
63016
|
-
//#region src/pipeline-analytics/pipeline/stationary-planes.ts
|
|
63017
|
-
/**
|
|
63018
|
-
* WHICH detection planes carry the stationary machinery.
|
|
63019
|
-
*
|
|
63020
|
-
* A plane qualifies when it produces OBJECT BOXES on decoded frames — because
|
|
63021
|
-
* that is what the registry needs to decide an object is parked: a box, a
|
|
63022
|
-
* class, and an observed clock that only advances while frames flow.
|
|
63023
|
-
*
|
|
63024
|
-
* Both root detectors qualify. `pipeline` is the ML tree; `onboard` is the
|
|
63025
|
-
* camera's own firmware, and the boxes it produces are paired with a decoded
|
|
63026
|
-
* frame in that frame's pixel space — the same plane, entered by a different
|
|
63027
|
-
* door. `sensor` and `audio` do not: they carry no geometry to park.
|
|
63028
|
-
*
|
|
63029
|
-
* It used to be `source === 'pipeline'`, in five places, each explaining that
|
|
63030
|
-
* the gate "runs on that plane". The reason given was WHERE the flood it was
|
|
63031
|
-
* written for had been measured (617, the ML plane), never an argument that the
|
|
63032
|
-
* firmware's boxes were unfit. The cost of the accident, on device 640: a still
|
|
63033
|
-
* object is never promoted, so it holds `hasLiveTrack` true for as long as it
|
|
63034
|
-
* sits there, and every detection session runs to its 120 s cap instead of
|
|
63035
|
-
* closing on cooldown — `closed at the max-hold cap with a subject still
|
|
63036
|
-
* present`. The camera's lazy stream is then torn down and rebuilt on the next
|
|
63037
|
-
* detection, on a battery camera.
|
|
63038
|
-
*
|
|
63039
|
-
* One predicate and not five comparisons, because five copies of a rule drift:
|
|
63040
|
-
* this repo has already paid for that with two authorities on one function
|
|
63041
|
-
* (D62).
|
|
63042
|
-
*/
|
|
63043
|
-
/** Does this detection plane run the stationary registry, gate and promotion? */
|
|
63044
|
-
function planeRunsStationary(source) {
|
|
63045
|
-
return source === "pipeline" || source === "onboard";
|
|
63046
|
-
}
|
|
63047
|
-
//#endregion
|
|
63048
|
-
//#region src/pipeline-analytics/pipeline/onboard-birth-authority.ts
|
|
63049
|
-
/**
|
|
63050
|
-
* May a suppression verdict RETRACT this birth?
|
|
63051
|
-
*
|
|
63052
|
-
* `false` means hold: keep the track, log the held verdict, and let the
|
|
63053
|
-
* ordinary lifetime decide. It never means "confirmed".
|
|
63054
|
-
*/
|
|
63055
|
-
function suppressionMayRetract(source) {
|
|
63056
|
-
return source !== "onboard";
|
|
63057
|
-
}
|
|
63058
|
-
//#endregion
|
|
63059
63239
|
//#region src/pipeline-analytics/rebuild-source-loader.ts
|
|
63060
63240
|
/**
|
|
63061
63241
|
* Assemble one track's rebuild inputs, reading at most ONE blob.
|
|
@@ -71627,9 +71807,111 @@ async function projectSensorMarkers(deps, data, timestamp) {
|
|
|
71627
71807
|
}
|
|
71628
71808
|
return landed;
|
|
71629
71809
|
}
|
|
71810
|
+
//#endregion
|
|
71811
|
+
//#region src/pipeline-analytics/services/sensor-transition-gate.ts
|
|
71812
|
+
/**
|
|
71813
|
+
* The gate between "a `DeviceStateChanged` arrived for a mapped sensor cap"
|
|
71814
|
+
* and "the sensor actually CHANGED".
|
|
71815
|
+
*
|
|
71816
|
+
* ## What this fixes
|
|
71817
|
+
*
|
|
71818
|
+
* `handleSensorStateChanged` treated the ARRIVAL of the bus event as the
|
|
71819
|
+
* assertion, and never looked at the slice. It cannot: the kernel mirror
|
|
71820
|
+
* (`device-state-mirror.ts` `applySingleCapUpdate`) diffs the whole SLICE, and
|
|
71821
|
+
* a contact slice carries `lastChangedAt` beside `entryOpen`. Anything that
|
|
71822
|
+
* moves the timestamp without moving the boolean is a `DeviceStateChanged`,
|
|
71823
|
+
* and downstream it became a `SensorEvent` row and a synthetic `contact` track
|
|
71824
|
+
* on the linked camera's timeline.
|
|
71825
|
+
*
|
|
71826
|
+
* Measured on camera 615 / contact sensor 4131, 2026-08-29→30: **30 of 35**
|
|
71827
|
+
* contact events were one of two non-events.
|
|
71828
|
+
*
|
|
71829
|
+
* - The RECOVERY family. The Zigbee entity flaps `off → unavailable → off`
|
|
71830
|
+
* (03:22:37→03:23:28, 03:56:51→03:58:19, 04:40:05→04:45:45). The HA
|
|
71831
|
+
* provider drops the `unavailable` push and writes the recovery, whose
|
|
71832
|
+
* `entryOpen` is still `false` and whose `lastChangedAt` is HA's fresh
|
|
71833
|
+
* `last_changed`. The mirror diffs, and emits.
|
|
71834
|
+
* - The COLD-SLICE family. `HaBinarySensorDevice`'s constructor writes
|
|
71835
|
+
* `{ entryOpen: false, lastChangedAt: 0 }` on every device rebuild; the
|
|
71836
|
+
* real state lands seconds later with a `lastChangedAt` hours old. Two
|
|
71837
|
+
* emissions, no transition.
|
|
71838
|
+
*
|
|
71839
|
+
* ## Why the predicate is imported and not written here
|
|
71840
|
+
*
|
|
71841
|
+
* `evaluateSensorEdge` (`@camstack/types`) is already THE edge predicate: the
|
|
71842
|
+
* virtual doorbell feeds it (`builtins/doorbell/trigger-engine.ts`) and so does
|
|
71843
|
+
* the recorder's sensor trigger (`addon-pipeline` `recorder/sensor-trigger.ts`).
|
|
71844
|
+
* Post-analysis was the third consumer of the same bus event and the only one
|
|
71845
|
+
* that did not. A second predicate here would diverge on the first cap added —
|
|
71846
|
+
* the exact failure [D62](../../../../../docs/decisions/adr-0062.md) names.
|
|
71847
|
+
*
|
|
71848
|
+
* ## Where it deliberately differs from the other two
|
|
71849
|
+
*
|
|
71850
|
+
* The doorbell and the recorder want the RISING edge only. A timeline marker
|
|
71851
|
+
* is not a doorbell: a door CLOSING is an event an operator wants to see, and
|
|
71852
|
+
* the taxonomy has one `contact` kind for both directions. So this gate accepts
|
|
71853
|
+
* `edge: 'rising'` **and** the `falling-edge` verdict, and rejects only the
|
|
71854
|
+
* verdicts that mean "nothing moved" — which is precisely the 30.
|
|
71855
|
+
*
|
|
71856
|
+
* ## Caps with no boolean level pass through untouched
|
|
71857
|
+
*
|
|
71858
|
+
* `EVENT_KIND_BY_CAP` covers sixteen caps; `SOURCE_CAP_ACTIVE_FIELD` covers the
|
|
71859
|
+
* ten that have a boolean whose edge means something. `doorbell`, `button`,
|
|
71860
|
+
* `lock-control`, `presence`, `enum-sensor` and `event-emitter` are NOT levels
|
|
71861
|
+
* — their ARRIVAL is the event, and three presses in a row are three markers.
|
|
71862
|
+
* Gating them would re-ship the failure of 2026-08-08, when camera 615's press
|
|
71863
|
+
* produced a row, a ring, a matched rule and no marker.
|
|
71864
|
+
*
|
|
71865
|
+
* ## A cover is a level whose level is a WORD
|
|
71866
|
+
*
|
|
71867
|
+
* `cover` is the sixteenth cap, and it is neither of the above. Its level is
|
|
71868
|
+
* the `state` word (`open`, `opening`, `closing`, `closed`, `stopped`), and its
|
|
71869
|
+
* slice also carries `position`, which ticks all the way through a travel and
|
|
71870
|
+
* is republished unchanged by providers that poll. Passed through, every tick
|
|
71871
|
+
* and every republish would write a sensor-event row per linked camera and an
|
|
71872
|
+
* NC evaluation — so it transitions on the WORD only (`WORD_LEVEL_FIELD`):
|
|
71873
|
+
* `closed → opening → open` is two events, a position tick or a same-word
|
|
71874
|
+
* republish is a `no-change` drop.
|
|
71875
|
+
*
|
|
71876
|
+
* Its FIRST sighting follows the booleans' rule (`evaluateSensorEdge`), with
|
|
71877
|
+
* `closed` as the inactive word: `closed` seeds the baseline and is dropped
|
|
71878
|
+
* (`baseline-seeded-inactive`); any other word is an EVENT when the slice's
|
|
71879
|
+
* `lastChangedAt` clears the same floor, `max(startedAt, now - freshness)`,
|
|
71880
|
+
* and is otherwise seeded and dropped (`baseline-seeded-stale-active`). It has
|
|
71881
|
+
* to emit: after a runner respawn the memory is empty and the next slice is
|
|
71882
|
+
* usually the next REAL change, and on a two-word cover (Velux, most HA
|
|
71883
|
+
* covers: `closed`/`open` only) seeding it would lose the whole opening.
|
|
71884
|
+
*
|
|
71885
|
+
* Residual edges, both erring towards silence on the ambiguous case like the
|
|
71886
|
+
* booleans do: a cover that was already open and whose `lastChangedAt` is
|
|
71887
|
+
* older than the floor is not reported (a respawn cannot tell it from a
|
|
71888
|
+
* hydration); and a first sighting whose `lastChangedAt` is fresh only
|
|
71889
|
+
* because a POSITION tick moved it (the slice timestamp is not the word's)
|
|
71890
|
+
* is reported once as if the word had just changed. The alarm needs covers
|
|
71891
|
+
* to be eventful to see one open (D630).
|
|
71892
|
+
*
|
|
71893
|
+
* ## Nothing here logs, and no drop is silent
|
|
71894
|
+
*
|
|
71895
|
+
* The gate is pure towards the world (no bus, no logger, no clock of its own —
|
|
71896
|
+
* the caller brings `now`), like the recorder's router. But it sits on a path
|
|
71897
|
+
* that carries every camera's native `motion` slice at motion rate — ~0.6/s
|
|
71898
|
+
* fleet-wide — so a line per drop would be its own outage. It accumulates
|
|
71899
|
+
* instead, per `(deviceId, capName, reason)`, and the caller drains one summary
|
|
71900
|
+
* per window. A `debug` at 3.3/s that nobody read is how a 100 %-rejection
|
|
71901
|
+
* regime survived three months here.
|
|
71902
|
+
*/
|
|
71903
|
+
/**
|
|
71904
|
+
* Caps whose level is a string word rather than a boolean: cap → slice field.
|
|
71905
|
+
* See "A cover is a level whose level is a WORD" above.
|
|
71906
|
+
*/
|
|
71907
|
+
var WORD_LEVEL_FIELD = { cover: "state" };
|
|
71908
|
+
/** The word a word-level cap rests in — its `false`, for the first-sighting rule. */
|
|
71909
|
+
var WORD_LEVEL_INACTIVE = { cover: "closed" };
|
|
71630
71910
|
var SensorTransitionGate = class {
|
|
71631
71911
|
/** Last observed boolean, keyed by `${deviceId}:${capName}`. */
|
|
71632
71912
|
lastValues = /* @__PURE__ */ new Map();
|
|
71913
|
+
/** Last observed word of a word-level cap, keyed by `${deviceId}:${capName}`. */
|
|
71914
|
+
lastWords = /* @__PURE__ */ new Map();
|
|
71633
71915
|
/** Drops since the window opened, keyed by `${deviceId}:${capName}:${reason}`. */
|
|
71634
71916
|
drops = /* @__PURE__ */ new Map();
|
|
71635
71917
|
windowStartedAt;
|
|
@@ -71647,10 +71929,13 @@ var SensorTransitionGate = class {
|
|
|
71647
71929
|
/**
|
|
71648
71930
|
* Feed one `DeviceStateChanged` slice for a cap `EVENT_KIND_BY_CAP` maps.
|
|
71649
71931
|
*
|
|
71650
|
-
* A cap with no
|
|
71651
|
-
*
|
|
71932
|
+
* A cap with no level passes through unconditionally. A cap that HAS one
|
|
71933
|
+
* passes only when its level moved — a boolean in either direction, or a
|
|
71934
|
+
* word-level cap's word changing.
|
|
71652
71935
|
*/
|
|
71653
71936
|
observe(deviceId, capName, slice) {
|
|
71937
|
+
const wordField = WORD_LEVEL_FIELD[capName];
|
|
71938
|
+
if (wordField !== void 0) return this.observeWord(deviceId, capName, slice, wordField);
|
|
71654
71939
|
if (!isSourceCap(capName)) return { changed: true };
|
|
71655
71940
|
const key = `${deviceId}:${capName}`;
|
|
71656
71941
|
const verdict = evaluateSensorEdge({
|
|
@@ -71688,6 +71973,40 @@ var SensorTransitionGate = class {
|
|
|
71688
71973
|
this.drops.clear();
|
|
71689
71974
|
return out;
|
|
71690
71975
|
}
|
|
71976
|
+
/**
|
|
71977
|
+
* A word-level cap: a transition is the word changing. The memory is
|
|
71978
|
+
* written only when the slice CARRIED a word, for the same reason as the
|
|
71979
|
+
* booleans — a malformed slice must not become a baseline.
|
|
71980
|
+
*/
|
|
71981
|
+
observeWord(deviceId, capName, slice, wordField) {
|
|
71982
|
+
const raw = slice[wordField];
|
|
71983
|
+
if (typeof raw !== "string" || raw.length === 0) return this.drop(deviceId, capName, "non-word-value");
|
|
71984
|
+
const key = `${deviceId}:${capName}`;
|
|
71985
|
+
const prior = this.lastWords.get(key);
|
|
71986
|
+
this.lastWords.set(key, raw);
|
|
71987
|
+
if (prior === void 0) return this.firstWordSighting(deviceId, capName, slice, raw);
|
|
71988
|
+
if (prior === raw) return this.drop(deviceId, capName, "no-change");
|
|
71989
|
+
return { changed: true };
|
|
71990
|
+
}
|
|
71991
|
+
/**
|
|
71992
|
+
* The booleans' first-sighting rule, on a word: the resting word seeds; any
|
|
71993
|
+
* other word is an event only when the slice's own `lastChangedAt` clears
|
|
71994
|
+
* the SAME floor `evaluateSensorEdge` uses, from the same two knobs.
|
|
71995
|
+
*/
|
|
71996
|
+
firstWordSighting(deviceId, capName, slice, word) {
|
|
71997
|
+
if (word === WORD_LEVEL_INACTIVE[capName]) return this.drop(deviceId, capName, "baseline-seeded-inactive");
|
|
71998
|
+
const changedAt = slice["lastChangedAt"];
|
|
71999
|
+
const floor = Math.max(this.startedAtMs, this.now() - this.freshnessMs);
|
|
72000
|
+
if (!(typeof changedAt === "number" && Number.isFinite(changedAt) && changedAt > 0 && changedAt >= floor)) return this.drop(deviceId, capName, "baseline-seeded-stale-active");
|
|
72001
|
+
return { changed: true };
|
|
72002
|
+
}
|
|
72003
|
+
drop(deviceId, capName, reason) {
|
|
72004
|
+
this.noteDrop(deviceId, capName, reason);
|
|
72005
|
+
return {
|
|
72006
|
+
changed: false,
|
|
72007
|
+
reason
|
|
72008
|
+
};
|
|
72009
|
+
}
|
|
71691
72010
|
noteDrop(deviceId, capName, reason) {
|
|
71692
72011
|
const key = `${deviceId}:${capName}:${reason}`;
|
|
71693
72012
|
const prior = this.drops.get(key);
|
|
@@ -78266,7 +78585,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
78266
78585
|
injectTestEvent: (input) => center.injectTestEvent(input, { getSnapshot: (deviceId) => api.snapshot.getSnapshot.query({ deviceId }) }),
|
|
78267
78586
|
timelapse: {
|
|
78268
78587
|
store: center.timelapseStore,
|
|
78269
|
-
getRecordingConfig: (deviceId) => api.
|
|
78588
|
+
getRecordingConfig: (deviceId) => api.recordingArchive.getDeviceConfig.query({ deviceId }).catch(() => null),
|
|
78270
78589
|
runNow: (input) => center.runTimelapseNow(input)
|
|
78271
78590
|
},
|
|
78272
78591
|
summary: {
|
|
@@ -78274,6 +78593,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
78274
78593
|
runNow: (input) => center.runSummaryNow(input)
|
|
78275
78594
|
},
|
|
78276
78595
|
texts: center.textCatalogEditor,
|
|
78596
|
+
previewTexts: center.textCatalog,
|
|
78277
78597
|
...this.ncArtifactIndex !== null ? { artifacts: this.ncArtifactIndex } : {},
|
|
78278
78598
|
artifactShelf: { remove: async (id) => await this.ncArtifactStore?.remove(id) ?? false },
|
|
78279
78599
|
ruleStatus: {
|
|
@@ -79359,7 +79679,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
79359
79679
|
logger: logger.child("RecordedClip"),
|
|
79360
79680
|
now: () => Date.now(),
|
|
79361
79681
|
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
79362
|
-
getAvailability: (input) => api
|
|
79682
|
+
getAvailability: (input) => ourRecordingAvailability(api, input),
|
|
79363
79683
|
createExport: (input) => api.recordingExport.createExport.mutate({ ...input }),
|
|
79364
79684
|
getExport: (input) => api.recordingExport.getExport.query(input),
|
|
79365
79685
|
readExportBytes: (input) => api.recordingExport.readExportBytes.query(input),
|
|
@@ -79613,7 +79933,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
79613
79933
|
buildTimelapsePorts(api, stores) {
|
|
79614
79934
|
return {
|
|
79615
79935
|
getAvailability: async ({ deviceId, fromMs, toMs, profile }) => {
|
|
79616
|
-
const availability = await api
|
|
79936
|
+
const availability = await ourRecordingAvailability(api, {
|
|
79617
79937
|
deviceId,
|
|
79618
79938
|
fromMs,
|
|
79619
79939
|
toMs,
|
|
@@ -83804,7 +84124,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
83804
84124
|
if (cached && Date.now() - cached.at < 10 * 6e4) return cached.value;
|
|
83805
84125
|
let value = null;
|
|
83806
84126
|
try {
|
|
83807
|
-
const res = await this.ctx.api
|
|
84127
|
+
const res = await ourRecordingAvailability(this.ctx.api, {
|
|
83808
84128
|
deviceId,
|
|
83809
84129
|
fromMs: 0,
|
|
83810
84130
|
toMs: Date.now()
|
|
@@ -85113,7 +85433,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
85113
85433
|
* A cap with no boolean level (`doorbell`, `button`, `lock-control`,
|
|
85114
85434
|
* `presence`, `enum-sensor`, `event-emitter`) always passes — its ARRIVAL is
|
|
85115
85435
|
* the event, and gating it would re-ship the silence of 2026-08-08. A cap
|
|
85116
|
-
* that HAS one passes only when the boolean moved, in either direction
|
|
85436
|
+
* that HAS one passes only when the boolean moved, in either direction — or,
|
|
85437
|
+
* for `cover`, whose level is its `state` word, only when the word changed
|
|
85438
|
+
* (a position tick or a same-word republish is a drop).
|
|
85117
85439
|
*
|
|
85118
85440
|
* A slice that is not an object at all is refused rather than defaulted to
|
|
85119
85441
|
* `{}`: `evaluateSensorEdge` would read `non-boolean-value` from an empty
|
|
@@ -85850,7 +86172,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
85850
86172
|
} });
|
|
85851
86173
|
const deviceIds = input.deviceId !== void 0 ? [input.deviceId] : await trackStore.listDeviceIds();
|
|
85852
86174
|
const recordedStills = new RecordedStillService(buildRecordedStillPorts({
|
|
85853
|
-
api: this.ctx.api.
|
|
86175
|
+
api: this.ctx.api.recordingArchive,
|
|
85854
86176
|
logger: this.ctx.logger
|
|
85855
86177
|
}));
|
|
85856
86178
|
const deps = {
|