@camstack/addon-post-analysis 1.2.90 → 1.2.92
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-BBIhkNDY.js → dist-BfrtqGyh.js} +63 -19
- package/dist/{dist-Bac7ww1C.mjs → dist-VnaTfwAL.mjs} +58 -20
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-DDC0Fqfb.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-BIDGLXRs.mjs} +1 -1
- package/dist/pipeline-analytics/{hostInit-DhP09VMX.mjs → hostInit-CpWvyhen.mjs} +1 -1
- package/dist/pipeline-analytics/index.js +1059 -237
- package/dist/pipeline-analytics/index.mjs +1021 -200
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as faceGalleryCapability, At as
|
|
1
|
+
import { $ as faceGalleryCapability, At as partialRecord, B as VISIT_MERGE_GAP_MS, C as NcSnoozeSchema, Ct as _enum, D as OpsLogEntrySchema, Dt as literal, E as NcTaxonomySchema, Et as discriminatedUnion, F as TIMELAPSE_DENSE_FLOOR_SEC, G as audioModeOf, H as alarmPanelCapability, I as TimelapseRuleInputSchema, J as customAction, K as buildEventKindDescriptor, L as TimelapseRulePatchSchema, M as SCENE_DEFAULT_UNCOVERED_POLICY, Mt as string, N as SCENE_DIVERGED, Nt as unknown, O as RECORDING_EXPORT_MAX_READ_BYTES, Ot as number, P as SceneMonitorSchema, Pt as EventCategory, Q as encodeVectorBase64, R as TimelapseRuleSchema, S as NcSnoozeInputSchema, St as sleep, T as NcSystemEventKindSchema, Tt as boolean, U as assertTimelapseCadences, V as addonWidgetsSourceCapability, W as audioMetricsCapability, X as deriveRecordingMode, Y as defineCustomActions, _ as NcRuleInputSchema, _t as DeviceType, at as pipelineAnalyticsCapability, b as NcRuleTargetSchema, bt as isDeviceScopedCap, c as EVENT_PAD_MS, ct as readTimelapseGeneratedAt, d as NC_ALARM_SYSTEM_EVENT_KINDS, dt as systemEventFilterApplies, f as NC_CONDITION_CATALOG, ft as vectorDimFromBase64, g as NcConditionDescriptorSchema, gt as BaseAddon, h as NC_TAXONOMY, ht as errMsg, i as DETECTION_MACRO_CLASSES, it as notificationRulesCapability, j as SCENE_DEFAULT_ANCHOR_THRESHOLD, jt as record, k as RetrainStatusSchema, kt as object, l as LabelAttributionSchema, lt as sceneMonitorCapability, mt as zoneAnalyticsCapability, n as DEFAULT_EVENT_COLOR, nt as isScheduleActive, o as DeclaredDevices, ot as plateGalleryCapability, p as NC_DEFAULT_SNOOZE_MINUTES, pt as videoclipsCapability, q as cosineSimilarity$1, rt as kebabToCamel, s as EVENT_KIND_BY_CAP, st as readDeviceStateFrom, t as BaseDevice, tt as isDetectionMacroClass, u as MACRO_LABELS, ut as subKindsOf, v as NcRulePatchSchema, vt as createEvent, w as NcSnoozeSuppressedSchema, wt as array, x as NcScheduleSchema, xt as nodePin, y as NcRuleSchema, yt as hydrateSchema, z as TrackSourceSchema } from "../dist-VnaTfwAL.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";
|
|
@@ -7779,17 +7779,40 @@ function referencesAudioClass(classes) {
|
|
|
7779
7779
|
if (classes === void 0) return false;
|
|
7780
7780
|
return classes.some((c) => c.trim().toLowerCase().startsWith("audio-"));
|
|
7781
7781
|
}
|
|
7782
|
-
|
|
7783
|
-
|
|
7784
|
-
|
|
7785
|
-
|
|
7786
|
-
|
|
7787
|
-
|
|
7782
|
+
/**
|
|
7783
|
+
* Match one system subject against one rule's system-event condition.
|
|
7784
|
+
*
|
|
7785
|
+
* EVERY NARROWING FILTER IS SCOPED TO ITS KIND FAMILY, and the scoping is not
|
|
7786
|
+
* decided here: `systemEventFilterApplies` (in `@camstack/types`) is the one
|
|
7787
|
+
* statement of it, and both rule editors compute their filter visibility from
|
|
7788
|
+
* the same predicate. A filter outside a kind's family is IGNORED — never an
|
|
7789
|
+
* exclusion. It used to be an exclusion, and that made the operator's
|
|
7790
|
+
* eleven-kind rule with `deviceTypes: ['camera']` deliverable for exactly the
|
|
7791
|
+
* two kinds that carry a device type; the other nine failed closed and logged
|
|
7792
|
+
* nothing for four days.
|
|
7793
|
+
*
|
|
7794
|
+
* Inside its family a filter still narrows, and still fails CLOSED when the
|
|
7795
|
+
* subject cannot produce the identity it tests.
|
|
7796
|
+
*
|
|
7797
|
+
* Returns `undefined` on a match.
|
|
7798
|
+
*/
|
|
7799
|
+
function systemEventExclusion(condition, subject) {
|
|
7800
|
+
if (!condition.kinds.includes(subject.kind)) return "kinds";
|
|
7801
|
+
const applies = (filter) => systemEventFilterApplies(subject.kind, filter);
|
|
7802
|
+
if (condition.deviceIds !== void 0 && applies("deviceIds")) {
|
|
7803
|
+
if (subject.deviceId === void 0 || !condition.deviceIds.includes(subject.deviceId)) return "deviceIds";
|
|
7788
7804
|
}
|
|
7789
|
-
if (condition.
|
|
7790
|
-
if (
|
|
7805
|
+
if (condition.deviceTypes !== void 0 && applies("deviceTypes")) {
|
|
7806
|
+
if (subject.deviceType === void 0 || !condition.deviceTypes.includes(subject.deviceType)) return "deviceTypes";
|
|
7807
|
+
}
|
|
7808
|
+
const wantedNodes = condition.nodeIds;
|
|
7809
|
+
if (wantedNodes !== void 0 && applies("nodeIds")) {
|
|
7810
|
+
if (!subjectNodes(subject).some((id) => wantedNodes.includes(id))) return "nodeIds";
|
|
7811
|
+
}
|
|
7812
|
+
const wantedPackages = condition.packageNames;
|
|
7813
|
+
if (wantedPackages !== void 0 && applies("packageNames")) {
|
|
7814
|
+
if (!subjectPackages(subject).some((name) => wantedPackages.includes(name))) return "packageNames";
|
|
7791
7815
|
}
|
|
7792
|
-
return true;
|
|
7793
7816
|
}
|
|
7794
7817
|
function subjectNodes(subject) {
|
|
7795
7818
|
const ids = [...subject.nodeIds ?? []];
|
|
@@ -7805,7 +7828,14 @@ function evaluateRule(rule, subject, deviceState, zoneOwners, sceneStates) {
|
|
|
7805
7828
|
if (subject.kind === "system-event") {
|
|
7806
7829
|
if (rule.delivery !== "system-event") return fail("delivery");
|
|
7807
7830
|
const condition = rule.conditions.systemEvent;
|
|
7808
|
-
|
|
7831
|
+
const systemEvent = subject.systemEvent;
|
|
7832
|
+
if (condition === void 0 || systemEvent === void 0) return fail("systemEvent");
|
|
7833
|
+
const excluded = systemEventExclusion(condition, systemEvent);
|
|
7834
|
+
if (excluded !== void 0) return excluded === "kinds" ? fail("systemEvent") : {
|
|
7835
|
+
matched: false,
|
|
7836
|
+
failedCondition: "systemEvent",
|
|
7837
|
+
excludedBySystemEventFilter: excluded
|
|
7838
|
+
};
|
|
7809
7839
|
if (rule.conditions.occupancy !== void 0) return fail("occupancy");
|
|
7810
7840
|
if (rule.conditions.audio !== void 0) return fail("audio");
|
|
7811
7841
|
} else if (subject.kind === "occupancy-event") {
|
|
@@ -20038,6 +20068,39 @@ var NotificationCenter = class NotificationCenter {
|
|
|
20038
20068
|
if (identity.isCamera && identity.detectionActive === false) return "detection-off";
|
|
20039
20069
|
}
|
|
20040
20070
|
/**
|
|
20071
|
+
* "Your rule covers this kind, and your own filter dropped it."
|
|
20072
|
+
*
|
|
20073
|
+
* INFO, not debug, and unconditional. The alternative is what shipped: a
|
|
20074
|
+
* rule that selected eleven system-event kinds delivered two of them for
|
|
20075
|
+
* four days, and the nine it silently refused produced no line anywhere —
|
|
20076
|
+
* indistinguishable from an intake that had stopped emitting. The filter is
|
|
20077
|
+
* NAMED, and so is the value that failed it, because the fix is always an
|
|
20078
|
+
* edit to that one list.
|
|
20079
|
+
*
|
|
20080
|
+
* `tags.deviceId` carries the SUBJECT's numeric device id (the system
|
|
20081
|
+
* subject's own `deviceId` is the sentinel {@link NC_SYSTEM_DEVICE_ID}), so
|
|
20082
|
+
* "why is 617 louder than 2018?" is answerable by grouping. A node- or
|
|
20083
|
+
* package-scoped exclusion is about no device and carries no tag.
|
|
20084
|
+
*/
|
|
20085
|
+
reportSystemFilterExclusion(rule, subject, filter) {
|
|
20086
|
+
const system = subject.systemEvent;
|
|
20087
|
+
if (system === void 0) return;
|
|
20088
|
+
this.logger.info("system event excluded by a rule filter", {
|
|
20089
|
+
...system.deviceId !== void 0 ? { tags: { deviceId: system.deviceId } } : {},
|
|
20090
|
+
meta: {
|
|
20091
|
+
ruleId: rule.id,
|
|
20092
|
+
rule: rule.name,
|
|
20093
|
+
kind: system.kind,
|
|
20094
|
+
filter,
|
|
20095
|
+
eventId: subject.recordId,
|
|
20096
|
+
subject: system.subject,
|
|
20097
|
+
...system.deviceType !== void 0 ? { deviceType: system.deviceType } : {},
|
|
20098
|
+
...system.nodeId !== void 0 ? { nodeId: system.nodeId } : {},
|
|
20099
|
+
...system.packageName !== void 0 ? { packageName: system.packageName } : {}
|
|
20100
|
+
}
|
|
20101
|
+
});
|
|
20102
|
+
}
|
|
20103
|
+
/**
|
|
20041
20104
|
* SAY that the scene gate suppressed this rule.
|
|
20042
20105
|
*
|
|
20043
20106
|
* A branch that drops work silently logs it — the three-hour media blackout
|
|
@@ -20457,6 +20520,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
20457
20520
|
const evaluation = evaluateRule(rule, subject, (id) => this.deviceStates.get(id), (id) => this.zoneOwners.get(id), (sceneId) => this.sceneStates.get(sceneId));
|
|
20458
20521
|
if (!evaluation.matched) {
|
|
20459
20522
|
if (evaluation.failedCondition === "scene") this.reportSceneSuppression(rule, subject);
|
|
20523
|
+
if (evaluation.excludedBySystemEventFilter !== void 0) this.reportSystemFilterExclusion(rule, subject, evaluation.excludedBySystemEventFilter);
|
|
20460
20524
|
rejections.push(`${rule.name}:${evaluation.failedCondition ?? "unknown"}`);
|
|
20461
20525
|
outcomes.push({
|
|
20462
20526
|
ruleId: rule.id,
|
|
@@ -22357,7 +22421,7 @@ var ncActions = defineCustomActions({
|
|
|
22357
22421
|
"nc.getArtifact": customAction(object({ artifactId: string().min(1) }), object({ artifact: NcArtifactRecordSchema.nullable() }), { caller: "required" })
|
|
22358
22422
|
});
|
|
22359
22423
|
/** Fail-closed caller resolution — an absent forwarded caller is NEVER admin. */
|
|
22360
|
-
function requireCaller(caller) {
|
|
22424
|
+
function requireCaller$1(caller) {
|
|
22361
22425
|
if (!caller || typeof caller.userId !== "string" || caller.userId.length === 0) throw new Error("forbidden: authenticated caller required");
|
|
22362
22426
|
return caller;
|
|
22363
22427
|
}
|
|
@@ -22455,14 +22519,14 @@ function makeNcActionHandlers(deps) {
|
|
|
22455
22519
|
};
|
|
22456
22520
|
return {
|
|
22457
22521
|
"nc.listRules": async (_input, caller) => {
|
|
22458
|
-
const c = requireCaller(caller);
|
|
22522
|
+
const c = requireCaller$1(caller);
|
|
22459
22523
|
return { rules: (c.isAdmin ? deps.ruleStore.list() : deps.ruleStore.listForOwner(c.userId)).map((r) => ({
|
|
22460
22524
|
...r,
|
|
22461
22525
|
readOnly: !c.isAdmin && r.ownerUserId !== c.userId
|
|
22462
22526
|
})) };
|
|
22463
22527
|
},
|
|
22464
22528
|
"nc.listTrackGroups": async (input, caller) => {
|
|
22465
|
-
requireCaller(caller);
|
|
22529
|
+
requireCaller$1(caller);
|
|
22466
22530
|
const list = deps.listTrackGroups;
|
|
22467
22531
|
if (list === void 0) return { groups: [] };
|
|
22468
22532
|
return { groups: [...await list(input)] };
|
|
@@ -22472,7 +22536,7 @@ function makeNcActionHandlers(deps) {
|
|
|
22472
22536
|
taxonomy: NC_TAXONOMY
|
|
22473
22537
|
}),
|
|
22474
22538
|
"nc.createRule": async (input, caller) => {
|
|
22475
|
-
const c = requireCaller(caller);
|
|
22539
|
+
const c = requireCaller$1(caller);
|
|
22476
22540
|
const parsed = NcRuleInputSchema.parse(input.rule);
|
|
22477
22541
|
await assertTargetsOwned(parsed.targets.map((t) => t.targetId), c);
|
|
22478
22542
|
const rule = await deps.ruleStore.create({
|
|
@@ -22486,7 +22550,7 @@ function makeNcActionHandlers(deps) {
|
|
|
22486
22550
|
return { rule };
|
|
22487
22551
|
},
|
|
22488
22552
|
"nc.updateRule": async (input, caller) => {
|
|
22489
|
-
const c = requireCaller(caller);
|
|
22553
|
+
const c = requireCaller$1(caller);
|
|
22490
22554
|
assertRuleEditable(input.ruleId, c);
|
|
22491
22555
|
const patch = NcRulePatchSchema.parse(input.patch);
|
|
22492
22556
|
if (patch.targets !== void 0) await assertTargetsOwned(patch.targets.map((t) => t.targetId), c);
|
|
@@ -22499,7 +22563,7 @@ function makeNcActionHandlers(deps) {
|
|
|
22499
22563
|
return { rule };
|
|
22500
22564
|
},
|
|
22501
22565
|
"nc.deleteRule": async (input, caller) => {
|
|
22502
|
-
const c = requireCaller(caller);
|
|
22566
|
+
const c = requireCaller$1(caller);
|
|
22503
22567
|
assertRuleEditable(input.ruleId, c);
|
|
22504
22568
|
await deps.ruleStore.delete(input.ruleId);
|
|
22505
22569
|
deps.logger.info("nc rule deleted", { meta: {
|
|
@@ -22509,11 +22573,11 @@ function makeNcActionHandlers(deps) {
|
|
|
22509
22573
|
return { success: true };
|
|
22510
22574
|
},
|
|
22511
22575
|
"nc.listSnoozes": async (_input, caller) => {
|
|
22512
|
-
const c = requireCaller(caller);
|
|
22576
|
+
const c = requireCaller$1(caller);
|
|
22513
22577
|
return { snoozes: [...deps.snooze.list(c.userId, c.isAdmin)] };
|
|
22514
22578
|
},
|
|
22515
22579
|
"nc.createSnooze": async (input, caller) => {
|
|
22516
|
-
const c = requireCaller(caller);
|
|
22580
|
+
const c = requireCaller$1(caller);
|
|
22517
22581
|
const parsed = NcSnoozeInputSchema.parse(input.snooze);
|
|
22518
22582
|
if (parsed.ruleId !== void 0) assertRuleVisible(parsed.ruleId, c);
|
|
22519
22583
|
const snooze = await deps.snooze.create(parsed, c);
|
|
@@ -22526,12 +22590,12 @@ function makeNcActionHandlers(deps) {
|
|
|
22526
22590
|
return { snooze };
|
|
22527
22591
|
},
|
|
22528
22592
|
"nc.cancelSnooze": async (input, caller) => {
|
|
22529
|
-
const c = requireCaller(caller);
|
|
22593
|
+
const c = requireCaller$1(caller);
|
|
22530
22594
|
await deps.snooze.cancel(input.snoozeId, c);
|
|
22531
22595
|
return { success: true };
|
|
22532
22596
|
},
|
|
22533
22597
|
"nc.injectTestEvent": async (input, caller) => {
|
|
22534
|
-
const c = requireCaller(caller);
|
|
22598
|
+
const c = requireCaller$1(caller);
|
|
22535
22599
|
if (!c.isAdmin) throw new Error("forbidden: admin required to inject a test event");
|
|
22536
22600
|
const inject = deps.injectTestEvent;
|
|
22537
22601
|
if (inject === void 0) throw new Error("this node does not host the notification centre — inject on the hub");
|
|
@@ -22547,7 +22611,7 @@ function makeNcActionHandlers(deps) {
|
|
|
22547
22611
|
return inject(parsed);
|
|
22548
22612
|
},
|
|
22549
22613
|
"nc.testRule": async (input, caller) => {
|
|
22550
|
-
const c = requireCaller(caller);
|
|
22614
|
+
const c = requireCaller$1(caller);
|
|
22551
22615
|
if (!c.isAdmin) throw new Error("forbidden: admin required to test a rule");
|
|
22552
22616
|
const inject = deps.injectTestEvent;
|
|
22553
22617
|
if (inject === void 0) throw new Error("this node does not host the notification centre — test on the hub");
|
|
@@ -22579,7 +22643,7 @@ function makeNcActionHandlers(deps) {
|
|
|
22579
22643
|
};
|
|
22580
22644
|
},
|
|
22581
22645
|
"nc.getRuleStatus": async (input, caller) => {
|
|
22582
|
-
const c = requireCaller(caller);
|
|
22646
|
+
const c = requireCaller$1(caller);
|
|
22583
22647
|
const status = deps.ruleStatus;
|
|
22584
22648
|
if (status === void 0) throw new Error("this node does not host the notification centre — ask the hub");
|
|
22585
22649
|
const detection = deps.ruleStore.get(input.ruleId);
|
|
@@ -22595,7 +22659,7 @@ function makeNcActionHandlers(deps) {
|
|
|
22595
22659
|
throw new Error(`rule not found: ${input.ruleId}`);
|
|
22596
22660
|
},
|
|
22597
22661
|
"nc.listTimelapseRules": async (_input, caller) => {
|
|
22598
|
-
const c = requireCaller(caller);
|
|
22662
|
+
const c = requireCaller$1(caller);
|
|
22599
22663
|
const tl = requireTimelapse();
|
|
22600
22664
|
return { rules: (c.isAdmin ? tl.store.list() : tl.store.listForOwner(c.userId)).map((r) => ({
|
|
22601
22665
|
...r,
|
|
@@ -22603,7 +22667,7 @@ function makeNcActionHandlers(deps) {
|
|
|
22603
22667
|
})) };
|
|
22604
22668
|
},
|
|
22605
22669
|
"nc.upsertTimelapseRule": async (input, caller) => {
|
|
22606
|
-
const c = requireCaller(caller);
|
|
22670
|
+
const c = requireCaller$1(caller);
|
|
22607
22671
|
const tl = requireTimelapse();
|
|
22608
22672
|
if (input.ruleId === void 0) {
|
|
22609
22673
|
if (input.rule === void 0) throw new Error("upsertTimelapseRule: rule or ruleId required");
|
|
@@ -22639,7 +22703,7 @@ function makeNcActionHandlers(deps) {
|
|
|
22639
22703
|
return { rule };
|
|
22640
22704
|
},
|
|
22641
22705
|
"nc.deleteTimelapseRule": async (input, caller) => {
|
|
22642
|
-
const c = requireCaller(caller);
|
|
22706
|
+
const c = requireCaller$1(caller);
|
|
22643
22707
|
const tl = requireTimelapse();
|
|
22644
22708
|
assertTimelapseEditable(input.ruleId, c, tl);
|
|
22645
22709
|
await tl.store.delete(input.ruleId);
|
|
@@ -22650,7 +22714,7 @@ function makeNcActionHandlers(deps) {
|
|
|
22650
22714
|
return { success: true };
|
|
22651
22715
|
},
|
|
22652
22716
|
"nc.testTimelapseRule": async (input, caller) => {
|
|
22653
|
-
const c = requireCaller(caller);
|
|
22717
|
+
const c = requireCaller$1(caller);
|
|
22654
22718
|
if (!c.isAdmin) throw new Error("forbidden: admin required to produce a test timelapse");
|
|
22655
22719
|
const tl = requireTimelapse();
|
|
22656
22720
|
deps.logger.info("timelapse test requested", {
|
|
@@ -22677,14 +22741,14 @@ function makeNcActionHandlers(deps) {
|
|
|
22677
22741
|
};
|
|
22678
22742
|
},
|
|
22679
22743
|
"nc.setRuleTargetEnabled": async (input, caller) => {
|
|
22680
|
-
const c = requireCaller(caller);
|
|
22744
|
+
const c = requireCaller$1(caller);
|
|
22681
22745
|
assertRuleVisible(input.ruleId, c);
|
|
22682
22746
|
await assertTargetsOwned([input.targetId], c);
|
|
22683
22747
|
await deps.ruleStore.setRuleTargetEnabled(input.ruleId, input.targetId, input.enabled);
|
|
22684
22748
|
return { success: true };
|
|
22685
22749
|
},
|
|
22686
22750
|
"nc.listSummaryRules": async (_input, caller) => {
|
|
22687
|
-
const c = requireCaller(caller);
|
|
22751
|
+
const c = requireCaller$1(caller);
|
|
22688
22752
|
const sum = requireSummary();
|
|
22689
22753
|
return { rules: (c.isAdmin ? sum.store.list() : sum.store.listForOwner(c.userId)).map((r) => ({
|
|
22690
22754
|
...r,
|
|
@@ -22692,7 +22756,7 @@ function makeNcActionHandlers(deps) {
|
|
|
22692
22756
|
})) };
|
|
22693
22757
|
},
|
|
22694
22758
|
"nc.upsertSummaryRule": async (input, caller) => {
|
|
22695
|
-
const c = requireCaller(caller);
|
|
22759
|
+
const c = requireCaller$1(caller);
|
|
22696
22760
|
if (!c.isAdmin) throw new Error("forbidden: admin required to author a summary rule");
|
|
22697
22761
|
const sum = requireSummary();
|
|
22698
22762
|
if (input.ruleId === void 0) {
|
|
@@ -22725,7 +22789,7 @@ function makeNcActionHandlers(deps) {
|
|
|
22725
22789
|
return { rule };
|
|
22726
22790
|
},
|
|
22727
22791
|
"nc.deleteSummaryRule": async (input, caller) => {
|
|
22728
|
-
const c = requireCaller(caller);
|
|
22792
|
+
const c = requireCaller$1(caller);
|
|
22729
22793
|
if (!c.isAdmin) throw new Error("forbidden: admin required to delete a summary rule");
|
|
22730
22794
|
await requireSummary().store.delete(input.ruleId);
|
|
22731
22795
|
deps.logger.info("summary rule deleted", { meta: {
|
|
@@ -22735,7 +22799,7 @@ function makeNcActionHandlers(deps) {
|
|
|
22735
22799
|
return { success: true };
|
|
22736
22800
|
},
|
|
22737
22801
|
"nc.testSummaryRule": async (input, caller) => {
|
|
22738
|
-
const c = requireCaller(caller);
|
|
22802
|
+
const c = requireCaller$1(caller);
|
|
22739
22803
|
if (!c.isAdmin) throw new Error("forbidden: admin required to produce a test summary");
|
|
22740
22804
|
const sum = requireSummary();
|
|
22741
22805
|
const startMs = input.windowStartMs;
|
|
@@ -22782,7 +22846,7 @@ function makeNcActionHandlers(deps) {
|
|
|
22782
22846
|
"nc.setLanguage": async (input) => requireTexts().setLanguage(input.language),
|
|
22783
22847
|
"nc.setTextOverrides": async (input) => requireTexts().setOverrides(input.language, input.overrides),
|
|
22784
22848
|
"nc.listArtifacts": async (input, caller) => {
|
|
22785
|
-
const c = requireCaller(caller);
|
|
22849
|
+
const c = requireCaller$1(caller);
|
|
22786
22850
|
const page = await requireArtifacts().list(input, {
|
|
22787
22851
|
userId: c.userId,
|
|
22788
22852
|
isAdmin: c.isAdmin
|
|
@@ -22794,7 +22858,7 @@ function makeNcActionHandlers(deps) {
|
|
|
22794
22858
|
};
|
|
22795
22859
|
},
|
|
22796
22860
|
"nc.getArtifact": async (input, caller) => {
|
|
22797
|
-
const c = requireCaller(caller);
|
|
22861
|
+
const c = requireCaller$1(caller);
|
|
22798
22862
|
return { artifact: await requireArtifacts().get(input.artifactId, {
|
|
22799
22863
|
userId: c.userId,
|
|
22800
22864
|
isAdmin: c.isAdmin
|
|
@@ -24443,11 +24507,11 @@ var WipeResultSchema = object({ deleted: number() });
|
|
|
24443
24507
|
* and can never be called: the bridge insists on `{}` and the schema rejects
|
|
24444
24508
|
* it. Verified on the live hub, both directions.
|
|
24445
24509
|
*/
|
|
24446
|
-
var NoInputSchema = object({});
|
|
24510
|
+
var NoInputSchema$1 = object({});
|
|
24447
24511
|
var embeddingActions = defineCustomActions({
|
|
24448
24512
|
"embedding.rebuild": customAction(RebuildInputSchema, RebuildStartedSchema, { auth: "admin" }),
|
|
24449
|
-
"embedding.rebuildStatus": customAction(NoInputSchema, RebuildStatusSchema),
|
|
24450
|
-
"embedding.wipe": customAction(NoInputSchema, WipeResultSchema, { auth: "admin" })
|
|
24513
|
+
"embedding.rebuildStatus": customAction(NoInputSchema$1, RebuildStatusSchema),
|
|
24514
|
+
"embedding.wipe": customAction(NoInputSchema$1, WipeResultSchema, { auth: "admin" })
|
|
24451
24515
|
});
|
|
24452
24516
|
//#endregion
|
|
24453
24517
|
//#region src/pipeline-analytics/map-with-concurrency.ts
|
|
@@ -28769,51 +28833,6 @@ var PlateGalleryProvider = class {
|
|
|
28769
28833
|
};
|
|
28770
28834
|
}
|
|
28771
28835
|
};
|
|
28772
|
-
var OrphanAuditActionInputSchema = object({
|
|
28773
|
-
mode: _enum(["report", "reclaim"]).default("report"),
|
|
28774
|
-
/**
|
|
28775
|
-
* Start every scope from the top instead of resuming its persisted cursor.
|
|
28776
|
-
*
|
|
28777
|
-
* What draining a backlog wants, and never the default — the cursor exists
|
|
28778
|
-
* so a 6-hourly pass makes progress instead of re-walking the same head.
|
|
28779
|
-
*/
|
|
28780
|
-
restart: boolean().optional(),
|
|
28781
|
-
/** Rows read per statement. Smaller = more yields = a gentler pass. */
|
|
28782
|
-
pageSize: number().int().min(50).max(5e3).optional(),
|
|
28783
|
-
/** Rows EXAMINED per collection in this pass — the pacing bound. */
|
|
28784
|
-
maxRowsPerScope: number().int().min(1).max(2e5).optional(),
|
|
28785
|
-
/** Rows DELETED per collection in this pass — the blast-radius bound. */
|
|
28786
|
-
maxReclaimPerScope: number().int().min(1).max(1e5).optional(),
|
|
28787
|
-
/**
|
|
28788
|
-
* Minutes of immunity for a recent row. Protective ONLY — raising it can
|
|
28789
|
-
* exclusively save rows, never select them, which is what keeps this off
|
|
28790
|
-
* the one-age-keyed-sweep guard's books.
|
|
28791
|
-
*/
|
|
28792
|
-
graceMinutes: number().int().min(1).max(10080).optional()
|
|
28793
|
-
});
|
|
28794
|
-
var OrphanAuditScopeReportSchema = object({
|
|
28795
|
-
collection: string(),
|
|
28796
|
-
examined: number(),
|
|
28797
|
-
orphans: number(),
|
|
28798
|
-
reclaimed: number(),
|
|
28799
|
-
bytesReclaimed: number(),
|
|
28800
|
-
exempt: number(),
|
|
28801
|
-
withinGrace: number(),
|
|
28802
|
-
unresolvable: number(),
|
|
28803
|
-
stoppedBy: string(),
|
|
28804
|
-
/** Where the NEXT pass resumes. `0` = the scope was walked to the end. */
|
|
28805
|
-
nextCursor: number()
|
|
28806
|
-
});
|
|
28807
|
-
var orphanAuditActions = defineCustomActions({ "retention.orphanAudit": customAction(OrphanAuditActionInputSchema, object({
|
|
28808
|
-
/** `false` when a pass was already in flight — nothing was done. */
|
|
28809
|
-
ran: boolean(),
|
|
28810
|
-
mode: _enum(["report", "reclaim"]),
|
|
28811
|
-
scopes: array(OrphanAuditScopeReportSchema),
|
|
28812
|
-
totalOrphans: number(),
|
|
28813
|
-
totalReclaimed: number(),
|
|
28814
|
-
totalBytesReclaimed: number(),
|
|
28815
|
-
durationMs: number()
|
|
28816
|
-
}), { auth: "admin" }) });
|
|
28817
28836
|
//#endregion
|
|
28818
28837
|
//#region src/pipeline-analytics/store/storage-pressure.ts
|
|
28819
28838
|
/**
|
|
@@ -28981,6 +29000,11 @@ var FACE_MEDIA_OWNER_PREFIX = "face-";
|
|
|
28981
29000
|
* `plate-<trackId>`. Used by `deleteByTracks` to derive the plate crop owners
|
|
28982
29001
|
* of a set of tracks without a `trackId` column on media rows. */
|
|
28983
29002
|
var PLATE_MEDIA_OWNER_PREFIX = "plate-";
|
|
29003
|
+
/** Index rows per page inside one presence chunk. Must stay under
|
|
29004
|
+
* `SETTINGS_QUERY_DEFAULT_ROW_CAP` (2 000) so the engine's backstop can never
|
|
29005
|
+
* silently truncate a page — an owner reported absent because a cap bit would
|
|
29006
|
+
* DELETE a live thumbnail from the surface. */
|
|
29007
|
+
var MEDIA_PRESENCE_PAGE_SIZE = 1e3;
|
|
28984
29008
|
/**
|
|
28985
29009
|
* Kinds that hold exactly ONE row per `(ownerKind, ownerId)` — the current
|
|
28986
29010
|
* "best"/rolling/first artefact, never a filmstrip. Their row id + blob path are
|
|
@@ -29408,6 +29432,59 @@ var MediaStore = class {
|
|
|
29408
29432
|
});
|
|
29409
29433
|
}
|
|
29410
29434
|
/**
|
|
29435
|
+
* Which of `ownerIds` hold at least ONE media row — index only, no blob is
|
|
29436
|
+
* opened, no base64 is allocated.
|
|
29437
|
+
*
|
|
29438
|
+
* This is the licence `videoclips` needs to publish a `thumbnail`. The
|
|
29439
|
+
* event-media URL is stamped from a template for every event in the system,
|
|
29440
|
+
* so "the plane is up" was being served as "there are bytes": 64 of one
|
|
29441
|
+
* camera's 179 clips pointed at a 404 on 2026-08-17.
|
|
29442
|
+
*
|
|
29443
|
+
* Batched by `whereIn` and paged by offset. NOT `listInfoByOwner` in a loop:
|
|
29444
|
+
* that is one query per owner, which is the N+1 this exists to prevent (and
|
|
29445
|
+
* `queryObjectByTrackIds` already records what believing `whereIn` was
|
|
29446
|
+
* unavailable cost the search path).
|
|
29447
|
+
*
|
|
29448
|
+
* `deviceId` is the AUTHORIZATION filter, exactly as in `listByOwner`.
|
|
29449
|
+
*
|
|
29450
|
+
* THROWS on a query failure rather than answering partially — a probe that
|
|
29451
|
+
* quietly under-reports reads as "these owners have nothing", and the caller
|
|
29452
|
+
* drops thumbnails that exist.
|
|
29453
|
+
*/
|
|
29454
|
+
async ownersWithMedia(ownerKind, ownerIds, deviceId) {
|
|
29455
|
+
const found = /* @__PURE__ */ new Set();
|
|
29456
|
+
const unique = [...new Set(ownerIds)];
|
|
29457
|
+
for (let i = 0; i < unique.length; i += 200) {
|
|
29458
|
+
const chunk = unique.slice(i, i + 200);
|
|
29459
|
+
const answered = /* @__PURE__ */ new Set();
|
|
29460
|
+
for (let offset = 0;; offset += MEDIA_PRESENCE_PAGE_SIZE) {
|
|
29461
|
+
const rows = await this.store.query.query({
|
|
29462
|
+
collection: MEDIA_COLLECTION,
|
|
29463
|
+
filter: {
|
|
29464
|
+
where: {
|
|
29465
|
+
ownerKind,
|
|
29466
|
+
deviceId
|
|
29467
|
+
},
|
|
29468
|
+
whereIn: { ownerId: chunk },
|
|
29469
|
+
orderBy: {
|
|
29470
|
+
field: "ownerId",
|
|
29471
|
+
direction: "asc"
|
|
29472
|
+
},
|
|
29473
|
+
limit: MEDIA_PRESENCE_PAGE_SIZE,
|
|
29474
|
+
offset
|
|
29475
|
+
}
|
|
29476
|
+
});
|
|
29477
|
+
for (const row of rows) {
|
|
29478
|
+
const ownerId = row.data["ownerId"];
|
|
29479
|
+
if (typeof ownerId === "string") answered.add(ownerId);
|
|
29480
|
+
}
|
|
29481
|
+
if (rows.length < 1e3 || answered.size >= chunk.length) break;
|
|
29482
|
+
}
|
|
29483
|
+
for (const ownerId of answered) found.add(ownerId);
|
|
29484
|
+
}
|
|
29485
|
+
return found;
|
|
29486
|
+
}
|
|
29487
|
+
/**
|
|
29411
29488
|
* Delete all media (blobs + index rows) for the given owners of one kind.
|
|
29412
29489
|
* Best-effort per row; returns the number of index rows removed.
|
|
29413
29490
|
*/
|
|
@@ -29754,6 +29831,51 @@ async function reconcileDeviceFromDisk(deps, deviceId) {
|
|
|
29754
29831
|
events: doomedEvents.length
|
|
29755
29832
|
};
|
|
29756
29833
|
}
|
|
29834
|
+
var OrphanAuditActionInputSchema = object({
|
|
29835
|
+
mode: _enum(["report", "reclaim"]).default("report"),
|
|
29836
|
+
/**
|
|
29837
|
+
* Start every scope from the top instead of resuming its persisted cursor.
|
|
29838
|
+
*
|
|
29839
|
+
* What draining a backlog wants, and never the default — the cursor exists
|
|
29840
|
+
* so a 6-hourly pass makes progress instead of re-walking the same head.
|
|
29841
|
+
*/
|
|
29842
|
+
restart: boolean().optional(),
|
|
29843
|
+
/** Rows read per statement. Smaller = more yields = a gentler pass. */
|
|
29844
|
+
pageSize: number().int().min(50).max(5e3).optional(),
|
|
29845
|
+
/** Rows EXAMINED per collection in this pass — the pacing bound. */
|
|
29846
|
+
maxRowsPerScope: number().int().min(1).max(2e5).optional(),
|
|
29847
|
+
/** Rows DELETED per collection in this pass — the blast-radius bound. */
|
|
29848
|
+
maxReclaimPerScope: number().int().min(1).max(1e5).optional(),
|
|
29849
|
+
/**
|
|
29850
|
+
* Minutes of immunity for a recent row. Protective ONLY — raising it can
|
|
29851
|
+
* exclusively save rows, never select them, which is what keeps this off
|
|
29852
|
+
* the one-age-keyed-sweep guard's books.
|
|
29853
|
+
*/
|
|
29854
|
+
graceMinutes: number().int().min(1).max(10080).optional()
|
|
29855
|
+
});
|
|
29856
|
+
var OrphanAuditScopeReportSchema = object({
|
|
29857
|
+
collection: string(),
|
|
29858
|
+
examined: number(),
|
|
29859
|
+
orphans: number(),
|
|
29860
|
+
reclaimed: number(),
|
|
29861
|
+
bytesReclaimed: number(),
|
|
29862
|
+
exempt: number(),
|
|
29863
|
+
withinGrace: number(),
|
|
29864
|
+
unresolvable: number(),
|
|
29865
|
+
stoppedBy: string(),
|
|
29866
|
+
/** Where the NEXT pass resumes. `0` = the scope was walked to the end. */
|
|
29867
|
+
nextCursor: number()
|
|
29868
|
+
});
|
|
29869
|
+
var orphanAuditActions = defineCustomActions({ "retention.orphanAudit": customAction(OrphanAuditActionInputSchema, object({
|
|
29870
|
+
/** `false` when a pass was already in flight — nothing was done. */
|
|
29871
|
+
ran: boolean(),
|
|
29872
|
+
mode: _enum(["report", "reclaim"]),
|
|
29873
|
+
scopes: array(OrphanAuditScopeReportSchema),
|
|
29874
|
+
totalOrphans: number(),
|
|
29875
|
+
totalReclaimed: number(),
|
|
29876
|
+
totalBytesReclaimed: number(),
|
|
29877
|
+
durationMs: number()
|
|
29878
|
+
}), { auth: "admin" }) });
|
|
29757
29879
|
//#endregion
|
|
29758
29880
|
//#region src/pipeline-analytics/videoclip-visits.ts
|
|
29759
29881
|
/**
|
|
@@ -29821,7 +29943,22 @@ function dominantKind(attached) {
|
|
|
29821
29943
|
for (const kind of KIND_ORDER) if (attached.some((a) => a.kind === kind)) return kind;
|
|
29822
29944
|
return "motion";
|
|
29823
29945
|
}
|
|
29824
|
-
|
|
29946
|
+
/**
|
|
29947
|
+
* The instant a recorded still can actually be decoded inside a visit: the
|
|
29948
|
+
* midpoint of its LONGEST contiguous range.
|
|
29949
|
+
*
|
|
29950
|
+
* NOT the midpoint of the visit. A visit spans its holes by construction (it is
|
|
29951
|
+
* ranges glued across gaps up to `VISIT_MERGE_GAP_MS`), so the naive midpoint
|
|
29952
|
+
* lands in dead air roughly as often as not and the recorder's still route
|
|
29953
|
+
* answers 204 for a visit with plenty of footage.
|
|
29954
|
+
*/
|
|
29955
|
+
function stillInstantMs(ranges) {
|
|
29956
|
+
let best;
|
|
29957
|
+
for (const range of ranges) if (best === void 0 || range.endMs - range.startMs > best.endMs - best.startMs) best = range;
|
|
29958
|
+
if (best === void 0) return void 0;
|
|
29959
|
+
return Math.floor((best.startMs + best.endMs) / 2);
|
|
29960
|
+
}
|
|
29961
|
+
function visitsToClips(visits, events, pad, vouched) {
|
|
29825
29962
|
const tagged = [
|
|
29826
29963
|
...events.object.map((event) => ({
|
|
29827
29964
|
kind: "object",
|
|
@@ -29839,7 +29976,8 @@ function visitsToClips(visits, events, pad) {
|
|
|
29839
29976
|
return visits.map((visit) => {
|
|
29840
29977
|
const attached = tagged.filter((t) => eventOverlapsVisit(t.event, visit, pad));
|
|
29841
29978
|
const kind = dominantKind(attached);
|
|
29842
|
-
const withThumb = attached.find((a) => a.kind === kind && a.event.mediaUrl) ?? attached.find((a) => a.event.mediaUrl);
|
|
29979
|
+
const withThumb = attached.find((a) => a.kind === kind && a.event.mediaUrl && vouched.has(a.event.id)) ?? attached.find((a) => a.event.mediaUrl && vouched.has(a.event.id));
|
|
29980
|
+
const stillAtMs = stillInstantMs(visit.ranges);
|
|
29843
29981
|
return {
|
|
29844
29982
|
id: `analytics:${visit.startMs}:${visit.endMs}:${VISIT_EVENT_ID}`,
|
|
29845
29983
|
source: "analytics",
|
|
@@ -29848,8 +29986,10 @@ function visitsToClips(visits, events, pad) {
|
|
|
29848
29986
|
startMs: visit.startMs,
|
|
29849
29987
|
endMs: visit.endMs
|
|
29850
29988
|
},
|
|
29851
|
-
thumbnail: withThumb
|
|
29852
|
-
|
|
29989
|
+
...withThumb?.event.mediaUrl !== void 0 ? { thumbnail: withThumb.event.mediaUrl } : {},
|
|
29990
|
+
...stillAtMs !== void 0 ? { stillAtMs } : {},
|
|
29991
|
+
eventIds: attached.slice(0, 24).map((a) => a.event.id),
|
|
29992
|
+
eventCount: attached.length,
|
|
29853
29993
|
holes: [...visit.holes]
|
|
29854
29994
|
};
|
|
29855
29995
|
}).toSorted((a, b) => b.timeRange.startMs - a.timeRange.startMs);
|
|
@@ -29873,6 +30013,17 @@ function visitsToClips(visits, events, pad) {
|
|
|
29873
30013
|
* injected fetch/resolve deps so the compose logic is testable without I/O.
|
|
29874
30014
|
*/
|
|
29875
30015
|
var SOURCE = "analytics";
|
|
30016
|
+
/**
|
|
30017
|
+
* Backstop on the ANNOTATION read — never on the clip list.
|
|
30018
|
+
*
|
|
30019
|
+
* A visit is annotated by every event inside it, so the clip `limit` must not
|
|
30020
|
+
* reach this fetch (one 615 visit legitimately spans 2 345 motion rows). This
|
|
30021
|
+
* bound exists only so a pathological camera-day cannot pull an unbounded page:
|
|
30022
|
+
* the store orders `timestamp DESC`, so a truncation drops the OLDEST rows and
|
|
30023
|
+
* the EARLIEST visits silently lose their annotations — which is why hitting it
|
|
30024
|
+
* is logged, per camera, rather than absorbed.
|
|
30025
|
+
*/
|
|
30026
|
+
var VISIT_EVENT_FETCH_LIMIT = 5e3;
|
|
29876
30027
|
function clipIdFor(eventId, startMs, endMs) {
|
|
29877
30028
|
return `${SOURCE}:${startMs}:${endMs}:${eventId}`;
|
|
29878
30029
|
}
|
|
@@ -29893,7 +30044,7 @@ function parseClipId(clipId) {
|
|
|
29893
30044
|
};
|
|
29894
30045
|
}
|
|
29895
30046
|
/** Map per-kind events into time-ranged clips, newest-first. Pure. */
|
|
29896
|
-
function eventsToClips(events, pad) {
|
|
30047
|
+
function eventsToClips(events, pad, vouched) {
|
|
29897
30048
|
const make = (e, kind) => {
|
|
29898
30049
|
const startMs = e.timestamp - pad.preMs;
|
|
29899
30050
|
const endMs = e.timestamp + pad.postMs;
|
|
@@ -29905,7 +30056,7 @@ function eventsToClips(events, pad) {
|
|
|
29905
30056
|
startMs,
|
|
29906
30057
|
endMs
|
|
29907
30058
|
},
|
|
29908
|
-
thumbnail: e.mediaUrl
|
|
30059
|
+
...e.mediaUrl !== void 0 && vouched.has(e.id) ? { thumbnail: e.mediaUrl } : {}
|
|
29909
30060
|
};
|
|
29910
30061
|
};
|
|
29911
30062
|
return [
|
|
@@ -29914,29 +30065,106 @@ function eventsToClips(events, pad) {
|
|
|
29914
30065
|
...events.audio.map((e) => make(e, "audio"))
|
|
29915
30066
|
].toSorted((a, b) => b.timeRange.startMs - a.timeRange.startMs);
|
|
29916
30067
|
}
|
|
30068
|
+
/** Every id in the three buckets, in the order the composers tag them. */
|
|
30069
|
+
function allEventIds(events) {
|
|
30070
|
+
return [
|
|
30071
|
+
...events.motion,
|
|
30072
|
+
...events.object,
|
|
30073
|
+
...events.audio
|
|
30074
|
+
].map((e) => e.id);
|
|
30075
|
+
}
|
|
30076
|
+
/**
|
|
30077
|
+
* Resolve the event ids whose `mediaUrl` is backed by real bytes, mirroring
|
|
30078
|
+
* `resolveDefaultEventMedia`: the event's OWN media when it has any, else its
|
|
30079
|
+
* owning TRACK's. Two batched index reads at most, and the second only over the
|
|
30080
|
+
* object events the first did not already answer for — motion and audio are
|
|
30081
|
+
* trackless roots, so they never need it.
|
|
30082
|
+
*
|
|
30083
|
+
* THROWS on a failed read. The caller fails closed on the CLAIM and open on the
|
|
30084
|
+
* LIST; swallowing here would collapse those two opposite directions into one.
|
|
30085
|
+
*/
|
|
30086
|
+
async function resolveVouchedEvents(deviceId, events, fetchMediaOwners) {
|
|
30087
|
+
const eventIds = allEventIds(events);
|
|
30088
|
+
if (eventIds.length === 0) return /* @__PURE__ */ new Set();
|
|
30089
|
+
const ownMedia = await fetchMediaOwners({
|
|
30090
|
+
deviceId,
|
|
30091
|
+
ownerKind: "event",
|
|
30092
|
+
ownerIds: eventIds
|
|
30093
|
+
});
|
|
30094
|
+
const residualTracks = [];
|
|
30095
|
+
const seenTracks = /* @__PURE__ */ new Set();
|
|
30096
|
+
for (const event of events.object) {
|
|
30097
|
+
const trackId = event.trackId;
|
|
30098
|
+
if (trackId === void 0 || ownMedia.has(event.id) || seenTracks.has(trackId)) continue;
|
|
30099
|
+
seenTracks.add(trackId);
|
|
30100
|
+
residualTracks.push(trackId);
|
|
30101
|
+
}
|
|
30102
|
+
const trackMedia = residualTracks.length === 0 ? /* @__PURE__ */ new Set() : await fetchMediaOwners({
|
|
30103
|
+
deviceId,
|
|
30104
|
+
ownerKind: "track",
|
|
30105
|
+
ownerIds: residualTracks
|
|
30106
|
+
});
|
|
30107
|
+
const vouched = new Set(ownMedia);
|
|
30108
|
+
for (const event of events.object) {
|
|
30109
|
+
const trackId = event.trackId;
|
|
30110
|
+
if (trackId !== void 0 && trackMedia.has(trackId)) vouched.add(event.id);
|
|
30111
|
+
}
|
|
30112
|
+
return vouched;
|
|
30113
|
+
}
|
|
30114
|
+
/** Keep only clips that actually touch the caller's window. The reads are
|
|
30115
|
+
* widened by {@link leftOverhangMs}; the ANSWER is not. */
|
|
30116
|
+
function intersectsWindow(clip, since, until) {
|
|
30117
|
+
return clip.timeRange.startMs < until && clip.timeRange.endMs > since;
|
|
30118
|
+
}
|
|
30119
|
+
/**
|
|
30120
|
+
* How far BEFORE `since` the event + availability reads must reach.
|
|
30121
|
+
*
|
|
30122
|
+
* A visit straddling `since` keeps its heartbeat rows and loses the transition
|
|
30123
|
+
* row — the only one that ever carries a snapshot — so the same visit shows a
|
|
30124
|
+
* picture or doesn't depending on where the operator scrolled. One wider range
|
|
30125
|
+
* scan on an indexed column buys a list that stops changing under the cursor.
|
|
30126
|
+
*/
|
|
30127
|
+
function leftOverhangMs(pad) {
|
|
30128
|
+
return VISIT_MERGE_GAP_MS + pad.preMs;
|
|
30129
|
+
}
|
|
29917
30130
|
function createVideoclipsProvider(deps) {
|
|
29918
30131
|
return {
|
|
29919
30132
|
listClips: async ({ deviceId, since, until, limit }) => {
|
|
30133
|
+
const readSince = since - leftOverhangMs(deps.defaultPad);
|
|
29920
30134
|
const events = await deps.fetchEvents({
|
|
29921
30135
|
deviceId,
|
|
29922
|
-
since,
|
|
30136
|
+
since: readSince,
|
|
29923
30137
|
until,
|
|
29924
|
-
|
|
30138
|
+
limit: deps.fetchAvailability ? VISIT_EVENT_FETCH_LIMIT : limit
|
|
29925
30139
|
});
|
|
29926
|
-
|
|
30140
|
+
warnOnTruncatedFetch(deps, deviceId, events);
|
|
30141
|
+
let vouched = /* @__PURE__ */ new Set();
|
|
30142
|
+
try {
|
|
30143
|
+
vouched = await resolveVouchedEvents(deviceId, events, deps.fetchMediaOwners);
|
|
30144
|
+
} catch (err) {
|
|
30145
|
+
deps.logWarn?.("videoclips: media presence read failed — listing without thumbnails", {
|
|
30146
|
+
deviceId,
|
|
30147
|
+
events: allEventIds(events).length,
|
|
30148
|
+
error: err instanceof Error ? err.message : String(err)
|
|
30149
|
+
});
|
|
30150
|
+
}
|
|
30151
|
+
let clips = eventsToClips(events, deps.defaultPad, vouched);
|
|
29927
30152
|
if (deps.fetchAvailability) try {
|
|
29928
30153
|
clips = visitsToClips(clusterFootageVisits(await deps.fetchAvailability({
|
|
29929
30154
|
deviceId,
|
|
29930
|
-
since,
|
|
30155
|
+
since: readSince,
|
|
29931
30156
|
until
|
|
29932
|
-
}), VISIT_MERGE_GAP_MS), events, deps.defaultPad);
|
|
30157
|
+
}), VISIT_MERGE_GAP_MS), events, deps.defaultPad, vouched);
|
|
29933
30158
|
} catch (err) {
|
|
29934
30159
|
deps.logWarn?.("videoclips: availability read failed — listing unfiltered", {
|
|
29935
30160
|
deviceId,
|
|
29936
30161
|
error: err instanceof Error ? err.message : String(err)
|
|
29937
30162
|
});
|
|
29938
30163
|
}
|
|
29939
|
-
|
|
30164
|
+
const inWindow = clips.filter((c) => intersectsWindow(c, since, until));
|
|
30165
|
+
const answer = limit != null ? inWindow.slice(0, limit) : inWindow;
|
|
30166
|
+
reportUnvouchedClips(deps, deviceId, answer);
|
|
30167
|
+
return answer;
|
|
29940
30168
|
},
|
|
29941
30169
|
getClipPlayback: async ({ deviceId, clipId }) => {
|
|
29942
30170
|
const parsed = parseClipId(clipId);
|
|
@@ -29949,6 +30177,246 @@ function createVideoclipsProvider(deps) {
|
|
|
29949
30177
|
}
|
|
29950
30178
|
};
|
|
29951
30179
|
}
|
|
30180
|
+
/** The annotation backstop biting is a SILENT loss of the earliest visits'
|
|
30181
|
+
* events — say so, per camera, instead of shipping a thinner list. */
|
|
30182
|
+
function warnOnTruncatedFetch(deps, deviceId, events) {
|
|
30183
|
+
if (!deps.fetchAvailability) return;
|
|
30184
|
+
const truncated = [
|
|
30185
|
+
"motion",
|
|
30186
|
+
"object",
|
|
30187
|
+
"audio"
|
|
30188
|
+
].filter((kind) => events[kind].length >= VISIT_EVENT_FETCH_LIMIT);
|
|
30189
|
+
if (truncated.length === 0) return;
|
|
30190
|
+
deps.logWarn?.("videoclips: annotation fetch truncated — earliest visits lose their events", {
|
|
30191
|
+
deviceId,
|
|
30192
|
+
kinds: truncated,
|
|
30193
|
+
limit: VISIT_EVENT_FETCH_LIMIT
|
|
30194
|
+
});
|
|
30195
|
+
}
|
|
30196
|
+
/** The operator's acceptance criterion as a number: a rising count on one
|
|
30197
|
+
* camera is its motion-snapshot capturer failing, not a quiet day. */
|
|
30198
|
+
function reportUnvouchedClips(deps, deviceId, clips) {
|
|
30199
|
+
const clipsWithoutStill = clips.filter((c) => c.thumbnail === void 0).length;
|
|
30200
|
+
if (clipsWithoutStill === 0) return;
|
|
30201
|
+
deps.logInfo?.("videoclips: visit has no vouched still", {
|
|
30202
|
+
deviceId,
|
|
30203
|
+
clipsWithoutStill,
|
|
30204
|
+
clips: clips.length
|
|
30205
|
+
});
|
|
30206
|
+
}
|
|
30207
|
+
//#endregion
|
|
30208
|
+
//#region src/pipeline-analytics/viewer-settings-actions.ts
|
|
30209
|
+
/**
|
|
30210
|
+
* Viewer settings snapshots — hub-shared named blobs of the viewer's stores.
|
|
30211
|
+
*
|
|
30212
|
+
* Rides `addons.custom` (`viewerSettings.*`) so it ships with an addon deploy
|
|
30213
|
+
* and does not wait on a cap/codegen/server image. Ownership is the
|
|
30214
|
+
* authenticated caller (`caller: 'required'`); snapshots are visible to every
|
|
30215
|
+
* hub user, overwrite/delete only of one's own.
|
|
30216
|
+
*/
|
|
30217
|
+
/**
|
|
30218
|
+
* @durable class=config owner=pipeline-analytics
|
|
30219
|
+
* write="a signed-in viewer user saves a named snapshot of their local stores"
|
|
30220
|
+
* retention="none — a row goes only when its owner deletes it"
|
|
30221
|
+
*/
|
|
30222
|
+
var VIEWER_SETTINGS_SNAPSHOTS_COLLECTION = "viewer-settings-snapshots";
|
|
30223
|
+
var COLUMNS = [
|
|
30224
|
+
{
|
|
30225
|
+
name: "id",
|
|
30226
|
+
type: "TEXT",
|
|
30227
|
+
primaryKey: true,
|
|
30228
|
+
notNull: true
|
|
30229
|
+
},
|
|
30230
|
+
{
|
|
30231
|
+
name: "name",
|
|
30232
|
+
type: "TEXT",
|
|
30233
|
+
notNull: true
|
|
30234
|
+
},
|
|
30235
|
+
{
|
|
30236
|
+
name: "ownerUserId",
|
|
30237
|
+
type: "TEXT",
|
|
30238
|
+
notNull: true
|
|
30239
|
+
},
|
|
30240
|
+
{
|
|
30241
|
+
name: "ownerUsername",
|
|
30242
|
+
type: "TEXT",
|
|
30243
|
+
notNull: true
|
|
30244
|
+
},
|
|
30245
|
+
{
|
|
30246
|
+
name: "createdAt",
|
|
30247
|
+
type: "INTEGER",
|
|
30248
|
+
notNull: true
|
|
30249
|
+
},
|
|
30250
|
+
{
|
|
30251
|
+
name: "updatedAt",
|
|
30252
|
+
type: "INTEGER",
|
|
30253
|
+
notNull: true
|
|
30254
|
+
},
|
|
30255
|
+
{
|
|
30256
|
+
name: "blob",
|
|
30257
|
+
type: "JSON",
|
|
30258
|
+
notNull: true
|
|
30259
|
+
}
|
|
30260
|
+
];
|
|
30261
|
+
var NameSchema = string().trim().min(1).max(80);
|
|
30262
|
+
var BlobSchema = record(string(), unknown());
|
|
30263
|
+
var ViewerSettingsSnapshotSummarySchema = object({
|
|
30264
|
+
id: string(),
|
|
30265
|
+
name: string(),
|
|
30266
|
+
ownerUserId: string(),
|
|
30267
|
+
ownerUsername: string(),
|
|
30268
|
+
createdAt: number().int(),
|
|
30269
|
+
updatedAt: number().int(),
|
|
30270
|
+
mine: boolean()
|
|
30271
|
+
});
|
|
30272
|
+
var ViewerSettingsSnapshotSchema = ViewerSettingsSnapshotSummarySchema.extend({ blob: BlobSchema });
|
|
30273
|
+
var viewerSettingsActions = defineCustomActions({
|
|
30274
|
+
"viewerSettings.list": customAction(object({}), object({ snapshots: array(ViewerSettingsSnapshotSummarySchema) }), { caller: "required" }),
|
|
30275
|
+
"viewerSettings.save": customAction(object({
|
|
30276
|
+
name: NameSchema,
|
|
30277
|
+
blob: BlobSchema,
|
|
30278
|
+
ownerUsername: string().min(1).max(80)
|
|
30279
|
+
}), object({ snapshot: ViewerSettingsSnapshotSummarySchema }), {
|
|
30280
|
+
kind: "mutation",
|
|
30281
|
+
caller: "required"
|
|
30282
|
+
}),
|
|
30283
|
+
"viewerSettings.get": customAction(object({ id: string().min(1) }), object({ snapshot: ViewerSettingsSnapshotSchema.nullable() }), { caller: "required" }),
|
|
30284
|
+
"viewerSettings.delete": customAction(object({ id: string().min(1) }), object({ success: literal(true) }), {
|
|
30285
|
+
kind: "mutation",
|
|
30286
|
+
caller: "required"
|
|
30287
|
+
})
|
|
30288
|
+
});
|
|
30289
|
+
function requireCaller(caller) {
|
|
30290
|
+
if (!caller) throw new Error("viewerSettings: authenticated caller required");
|
|
30291
|
+
return caller;
|
|
30292
|
+
}
|
|
30293
|
+
function asRecord(data) {
|
|
30294
|
+
return typeof data === "object" && data !== null ? data : {};
|
|
30295
|
+
}
|
|
30296
|
+
function toSummary(id, data, callerUserId) {
|
|
30297
|
+
const parsed = ViewerSettingsSnapshotSummarySchema.omit({ mine: true }).safeParse({
|
|
30298
|
+
id,
|
|
30299
|
+
name: data.name,
|
|
30300
|
+
ownerUserId: data.ownerUserId,
|
|
30301
|
+
ownerUsername: data.ownerUsername,
|
|
30302
|
+
createdAt: data.createdAt,
|
|
30303
|
+
updatedAt: data.updatedAt
|
|
30304
|
+
});
|
|
30305
|
+
if (!parsed.success) return null;
|
|
30306
|
+
return {
|
|
30307
|
+
...parsed.data,
|
|
30308
|
+
mine: parsed.data.ownerUserId === callerUserId
|
|
30309
|
+
};
|
|
30310
|
+
}
|
|
30311
|
+
var ViewerSettingsSnapshotStore = class {
|
|
30312
|
+
store;
|
|
30313
|
+
constructor(store) {
|
|
30314
|
+
this.store = store;
|
|
30315
|
+
}
|
|
30316
|
+
static async declare(store) {
|
|
30317
|
+
await store.declareCollection.mutate({
|
|
30318
|
+
collection: VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
|
|
30319
|
+
columns: [...COLUMNS],
|
|
30320
|
+
indexes: [{
|
|
30321
|
+
name: "idx_viewer_settings_owner",
|
|
30322
|
+
columns: ["ownerUserId"]
|
|
30323
|
+
}]
|
|
30324
|
+
});
|
|
30325
|
+
}
|
|
30326
|
+
async list(caller) {
|
|
30327
|
+
const rows = await this.store.query.query({
|
|
30328
|
+
collection: VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
|
|
30329
|
+
filter: {
|
|
30330
|
+
orderBy: {
|
|
30331
|
+
field: "updatedAt",
|
|
30332
|
+
direction: "desc"
|
|
30333
|
+
},
|
|
30334
|
+
limit: 200
|
|
30335
|
+
}
|
|
30336
|
+
});
|
|
30337
|
+
const out = [];
|
|
30338
|
+
for (const row of rows) {
|
|
30339
|
+
const summary = toSummary(row.id, asRecord(row.data), caller.userId);
|
|
30340
|
+
if (summary) out.push(summary);
|
|
30341
|
+
}
|
|
30342
|
+
return out;
|
|
30343
|
+
}
|
|
30344
|
+
async get(id, caller) {
|
|
30345
|
+
const row = (await this.store.query.query({
|
|
30346
|
+
collection: VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
|
|
30347
|
+
filter: {
|
|
30348
|
+
where: { id },
|
|
30349
|
+
limit: 1
|
|
30350
|
+
}
|
|
30351
|
+
}))[0];
|
|
30352
|
+
if (!row) return null;
|
|
30353
|
+
const data = asRecord(row.data);
|
|
30354
|
+
const summary = toSummary(row.id, data, caller.userId);
|
|
30355
|
+
if (!summary) return null;
|
|
30356
|
+
const blob = BlobSchema.safeParse(data.blob);
|
|
30357
|
+
if (!blob.success) return null;
|
|
30358
|
+
return {
|
|
30359
|
+
...summary,
|
|
30360
|
+
blob: blob.data
|
|
30361
|
+
};
|
|
30362
|
+
}
|
|
30363
|
+
async save(input, caller) {
|
|
30364
|
+
const now = Date.now();
|
|
30365
|
+
const prior = (await this.store.query.query({
|
|
30366
|
+
collection: VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
|
|
30367
|
+
filter: {
|
|
30368
|
+
where: {
|
|
30369
|
+
ownerUserId: caller.userId,
|
|
30370
|
+
name: input.name
|
|
30371
|
+
},
|
|
30372
|
+
limit: 1
|
|
30373
|
+
}
|
|
30374
|
+
}))[0];
|
|
30375
|
+
const id = prior?.id ?? randomUUID();
|
|
30376
|
+
const createdAt = typeof asRecord(prior?.data).createdAt === "number" ? asRecord(prior?.data).createdAt : now;
|
|
30377
|
+
await this.store.set.mutate({
|
|
30378
|
+
collection: VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
|
|
30379
|
+
key: id,
|
|
30380
|
+
value: {
|
|
30381
|
+
name: input.name,
|
|
30382
|
+
ownerUserId: caller.userId,
|
|
30383
|
+
ownerUsername: input.ownerUsername,
|
|
30384
|
+
createdAt,
|
|
30385
|
+
updatedAt: now,
|
|
30386
|
+
blob: input.blob
|
|
30387
|
+
}
|
|
30388
|
+
});
|
|
30389
|
+
return {
|
|
30390
|
+
id,
|
|
30391
|
+
name: input.name,
|
|
30392
|
+
ownerUserId: caller.userId,
|
|
30393
|
+
ownerUsername: input.ownerUsername,
|
|
30394
|
+
createdAt,
|
|
30395
|
+
updatedAt: now,
|
|
30396
|
+
mine: true
|
|
30397
|
+
};
|
|
30398
|
+
}
|
|
30399
|
+
async delete(id, caller) {
|
|
30400
|
+
const row = await this.get(id, caller);
|
|
30401
|
+
if (!row) throw new Error("viewerSettings: snapshot not found");
|
|
30402
|
+
if (row.ownerUserId !== caller.userId && !caller.isAdmin) throw new Error("viewerSettings: only the owner can delete this snapshot");
|
|
30403
|
+
await this.store.delete.mutate({
|
|
30404
|
+
collection: VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
|
|
30405
|
+
key: id
|
|
30406
|
+
});
|
|
30407
|
+
}
|
|
30408
|
+
};
|
|
30409
|
+
function makeViewerSettingsActionHandlers(store) {
|
|
30410
|
+
return {
|
|
30411
|
+
"viewerSettings.list": async (_input, caller) => ({ snapshots: await store.list(requireCaller(caller)) }),
|
|
30412
|
+
"viewerSettings.save": async (input, caller) => ({ snapshot: await store.save(input, requireCaller(caller)) }),
|
|
30413
|
+
"viewerSettings.get": async (input, caller) => ({ snapshot: await store.get(input.id, requireCaller(caller)) }),
|
|
30414
|
+
"viewerSettings.delete": async (input, caller) => {
|
|
30415
|
+
await store.delete(input.id, requireCaller(caller));
|
|
30416
|
+
return { success: true };
|
|
30417
|
+
}
|
|
30418
|
+
};
|
|
30419
|
+
}
|
|
29952
30420
|
/**
|
|
29953
30421
|
* The VIDEO's playback rate — 4×, the same as {@link NC_GIF_SPEED}.
|
|
29954
30422
|
*
|
|
@@ -30755,7 +31223,15 @@ var AudioDetectionSettingsSchema = object({
|
|
|
30755
31223
|
/** Flood ceiling: markers per camera per rolling 24 h. This, not the
|
|
30756
31224
|
* thresholds, is what bounds the worst case — a mis-tuned threshold can
|
|
30757
31225
|
* only ever cost the operator this many markers a day. */
|
|
30758
|
-
audioMarkerMaxPerDay: number().int().positive().default(12)
|
|
31226
|
+
audioMarkerMaxPerDay: number().int().positive().default(12),
|
|
31227
|
+
/** Materialize one event + one synthetic track per classified episode. */
|
|
31228
|
+
audioEpisodeEnabled: boolean().default(true),
|
|
31229
|
+
/** Class must be heard continuously this long before the episode opens. */
|
|
31230
|
+
audioEpisodeOpenHoldSec: number().nonnegative().default(1.5),
|
|
31231
|
+
/** Class must be absent this long before the episode closes. */
|
|
31232
|
+
audioEpisodeCloseGapSec: number().positive().default(12),
|
|
31233
|
+
/** After close, the same class cannot reopen until this elapses. */
|
|
31234
|
+
audioEpisodeCooldownSec: number().nonnegative().default(30)
|
|
30759
31235
|
});
|
|
30760
31236
|
var AUDIO_DETECTION_DEFAULTS = AudioDetectionSettingsSchema.parse({});
|
|
30761
31237
|
/**
|
|
@@ -30779,6 +31255,159 @@ function resolveAudioDetectionSettings(raw) {
|
|
|
30779
31255
|
return resolved.success ? resolved.data : AUDIO_DETECTION_DEFAULTS;
|
|
30780
31256
|
}
|
|
30781
31257
|
//#endregion
|
|
31258
|
+
//#region src/pipeline-analytics/audio-episode-hysteresis.ts
|
|
31259
|
+
var EMPTY = {
|
|
31260
|
+
pendingClass: null,
|
|
31261
|
+
pendingSinceMs: 0,
|
|
31262
|
+
openClass: null,
|
|
31263
|
+
episodeId: null,
|
|
31264
|
+
lastHeardMs: 0,
|
|
31265
|
+
lastClosedClass: null,
|
|
31266
|
+
lastClosedMs: 0
|
|
31267
|
+
};
|
|
31268
|
+
function emptyAudioEpisodeState() {
|
|
31269
|
+
return EMPTY;
|
|
31270
|
+
}
|
|
31271
|
+
function episodeIdFor(className, timestampMs) {
|
|
31272
|
+
return `${className}:${timestampMs}`;
|
|
31273
|
+
}
|
|
31274
|
+
function inCooldown(state, className, timestampMs, knobs) {
|
|
31275
|
+
return state.lastClosedClass === className && timestampMs - state.lastClosedMs < knobs.cooldownMs;
|
|
31276
|
+
}
|
|
31277
|
+
function closedState(className, atMs) {
|
|
31278
|
+
return {
|
|
31279
|
+
...EMPTY,
|
|
31280
|
+
lastClosedClass: className,
|
|
31281
|
+
lastClosedMs: atMs
|
|
31282
|
+
};
|
|
31283
|
+
}
|
|
31284
|
+
function observeAudioEpisode(state, sample, knobs) {
|
|
31285
|
+
if (!knobs.enabled) return {
|
|
31286
|
+
nextState: EMPTY,
|
|
31287
|
+
action: { kind: "none" }
|
|
31288
|
+
};
|
|
31289
|
+
const { timestampMs, className } = sample;
|
|
31290
|
+
const openClass = state.openClass;
|
|
31291
|
+
const openId = state.episodeId;
|
|
31292
|
+
if (openClass !== null && openId !== null) {
|
|
31293
|
+
if (className === openClass) return {
|
|
31294
|
+
nextState: {
|
|
31295
|
+
...state,
|
|
31296
|
+
pendingClass: null,
|
|
31297
|
+
pendingSinceMs: 0,
|
|
31298
|
+
lastHeardMs: timestampMs
|
|
31299
|
+
},
|
|
31300
|
+
action: {
|
|
31301
|
+
kind: "hold",
|
|
31302
|
+
className: openClass,
|
|
31303
|
+
episodeId: openId
|
|
31304
|
+
}
|
|
31305
|
+
};
|
|
31306
|
+
if (className === null) {
|
|
31307
|
+
if (timestampMs - state.lastHeardMs < knobs.closeGapMs) return {
|
|
31308
|
+
nextState: state,
|
|
31309
|
+
action: { kind: "none" }
|
|
31310
|
+
};
|
|
31311
|
+
return {
|
|
31312
|
+
nextState: closedState(openClass, timestampMs),
|
|
31313
|
+
action: {
|
|
31314
|
+
kind: "close",
|
|
31315
|
+
className: openClass,
|
|
31316
|
+
episodeId: openId
|
|
31317
|
+
}
|
|
31318
|
+
};
|
|
31319
|
+
}
|
|
31320
|
+
if (inCooldown(state, className, timestampMs, knobs)) return {
|
|
31321
|
+
nextState: state,
|
|
31322
|
+
action: { kind: "none" }
|
|
31323
|
+
};
|
|
31324
|
+
const pendingSince = state.pendingClass === className ? state.pendingSinceMs : timestampMs;
|
|
31325
|
+
if (knobs.openHoldMs === 0 || state.pendingClass === className && timestampMs - pendingSince >= knobs.openHoldMs) {
|
|
31326
|
+
const openedId = episodeIdFor(className, timestampMs);
|
|
31327
|
+
return {
|
|
31328
|
+
nextState: {
|
|
31329
|
+
pendingClass: null,
|
|
31330
|
+
pendingSinceMs: 0,
|
|
31331
|
+
openClass: className,
|
|
31332
|
+
episodeId: openedId,
|
|
31333
|
+
lastHeardMs: timestampMs,
|
|
31334
|
+
lastClosedClass: openClass,
|
|
31335
|
+
lastClosedMs: timestampMs
|
|
31336
|
+
},
|
|
31337
|
+
action: {
|
|
31338
|
+
kind: "replace",
|
|
31339
|
+
closed: {
|
|
31340
|
+
className: openClass,
|
|
31341
|
+
episodeId: openId
|
|
31342
|
+
},
|
|
31343
|
+
opened: {
|
|
31344
|
+
className,
|
|
31345
|
+
episodeId: openedId
|
|
31346
|
+
}
|
|
31347
|
+
}
|
|
31348
|
+
};
|
|
31349
|
+
}
|
|
31350
|
+
return {
|
|
31351
|
+
nextState: {
|
|
31352
|
+
...state,
|
|
31353
|
+
pendingClass: className,
|
|
31354
|
+
pendingSinceMs: pendingSince
|
|
31355
|
+
},
|
|
31356
|
+
action: { kind: "none" }
|
|
31357
|
+
};
|
|
31358
|
+
}
|
|
31359
|
+
if (className === null) {
|
|
31360
|
+
if (state.pendingClass === null) return {
|
|
31361
|
+
nextState: state,
|
|
31362
|
+
action: { kind: "none" }
|
|
31363
|
+
};
|
|
31364
|
+
return {
|
|
31365
|
+
nextState: {
|
|
31366
|
+
...state,
|
|
31367
|
+
pendingClass: null,
|
|
31368
|
+
pendingSinceMs: 0
|
|
31369
|
+
},
|
|
31370
|
+
action: { kind: "none" }
|
|
31371
|
+
};
|
|
31372
|
+
}
|
|
31373
|
+
if (inCooldown(state, className, timestampMs, knobs)) return {
|
|
31374
|
+
nextState: state,
|
|
31375
|
+
action: { kind: "none" }
|
|
31376
|
+
};
|
|
31377
|
+
const pendingSince = state.pendingClass === className ? state.pendingSinceMs : timestampMs;
|
|
31378
|
+
if (knobs.openHoldMs === 0 || state.pendingClass === className && timestampMs - pendingSince >= knobs.openHoldMs) {
|
|
31379
|
+
const episodeId = episodeIdFor(className, timestampMs);
|
|
31380
|
+
return {
|
|
31381
|
+
nextState: {
|
|
31382
|
+
pendingClass: null,
|
|
31383
|
+
pendingSinceMs: 0,
|
|
31384
|
+
openClass: className,
|
|
31385
|
+
episodeId,
|
|
31386
|
+
lastHeardMs: timestampMs,
|
|
31387
|
+
lastClosedClass: state.lastClosedClass,
|
|
31388
|
+
lastClosedMs: state.lastClosedMs
|
|
31389
|
+
},
|
|
31390
|
+
action: {
|
|
31391
|
+
kind: "open",
|
|
31392
|
+
className,
|
|
31393
|
+
episodeId
|
|
31394
|
+
}
|
|
31395
|
+
};
|
|
31396
|
+
}
|
|
31397
|
+
if (state.pendingClass === className) return {
|
|
31398
|
+
nextState: state,
|
|
31399
|
+
action: { kind: "none" }
|
|
31400
|
+
};
|
|
31401
|
+
return {
|
|
31402
|
+
nextState: {
|
|
31403
|
+
...state,
|
|
31404
|
+
pendingClass: className,
|
|
31405
|
+
pendingSinceMs: timestampMs
|
|
31406
|
+
},
|
|
31407
|
+
action: { kind: "none" }
|
|
31408
|
+
};
|
|
31409
|
+
}
|
|
31410
|
+
//#endregion
|
|
30782
31411
|
//#region src/pipeline-analytics/audio-event-router.ts
|
|
30783
31412
|
/**
|
|
30784
31413
|
* Decide whether an audio frame is a (meaningful) classification event or a
|
|
@@ -46764,6 +47393,66 @@ var EventMediaDispatcher = class {
|
|
|
46764
47393
|
}
|
|
46765
47394
|
}
|
|
46766
47395
|
};
|
|
47396
|
+
var SnapshotTimeoutError$1 = class extends Error {
|
|
47397
|
+
constructor(ms) {
|
|
47398
|
+
super(`snapshot cap timed out after ${ms}ms`);
|
|
47399
|
+
this.name = "SnapshotTimeoutError";
|
|
47400
|
+
}
|
|
47401
|
+
};
|
|
47402
|
+
function withTimeout$1(promise, ms) {
|
|
47403
|
+
return new Promise((resolve, reject) => {
|
|
47404
|
+
const timer = setTimeout(() => reject(new SnapshotTimeoutError$1(ms)), ms);
|
|
47405
|
+
promise.then((value) => {
|
|
47406
|
+
clearTimeout(timer);
|
|
47407
|
+
resolve(value);
|
|
47408
|
+
}, (err) => {
|
|
47409
|
+
clearTimeout(timer);
|
|
47410
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
47411
|
+
});
|
|
47412
|
+
});
|
|
47413
|
+
}
|
|
47414
|
+
var MotionEventSnapshotCapturer = class {
|
|
47415
|
+
deps;
|
|
47416
|
+
snapshotTimeoutMs;
|
|
47417
|
+
constructor(deps) {
|
|
47418
|
+
this.deps = deps;
|
|
47419
|
+
this.snapshotTimeoutMs = deps.snapshotTimeoutMs ?? 2500;
|
|
47420
|
+
}
|
|
47421
|
+
/**
|
|
47422
|
+
* Fetch a still and persist it as the motion event's snapshot. Never
|
|
47423
|
+
* throws — the durable record is the motion row, already inserted by
|
|
47424
|
+
* the caller.
|
|
47425
|
+
*/
|
|
47426
|
+
async capture(input) {
|
|
47427
|
+
let snap;
|
|
47428
|
+
try {
|
|
47429
|
+
snap = await withTimeout$1(this.deps.snapshot.getSnapshot({ deviceId: input.deviceId }), this.snapshotTimeoutMs);
|
|
47430
|
+
} catch (err) {
|
|
47431
|
+
this.deps.onError?.(err instanceof SnapshotTimeoutError$1 ? "snapshotTimeout" : "snapshotFetch", err, input.deviceId);
|
|
47432
|
+
return;
|
|
47433
|
+
}
|
|
47434
|
+
if (snap === null) return;
|
|
47435
|
+
const raw = Buffer.from(snap.base64, "base64");
|
|
47436
|
+
let data = raw;
|
|
47437
|
+
try {
|
|
47438
|
+
data = await downscaleFullFrameJpeg(raw, 960, 540);
|
|
47439
|
+
} catch (err) {
|
|
47440
|
+
this.deps.onError?.("downscaleSnapshot", err, input.deviceId);
|
|
47441
|
+
}
|
|
47442
|
+
try {
|
|
47443
|
+
await this.deps.media.put({
|
|
47444
|
+
deviceId: input.deviceId,
|
|
47445
|
+
ownerKind: "event",
|
|
47446
|
+
ownerId: input.eventId,
|
|
47447
|
+
kind: "snapshot",
|
|
47448
|
+
timestamp: input.timestamp,
|
|
47449
|
+
data
|
|
47450
|
+
});
|
|
47451
|
+
} catch (err) {
|
|
47452
|
+
this.deps.onError?.("snapshotMedia", err, input.deviceId);
|
|
47453
|
+
}
|
|
47454
|
+
}
|
|
47455
|
+
};
|
|
46767
47456
|
/**
|
|
46768
47457
|
* Project one `DeviceStateChanged` of a mapped sensor/control cap into a
|
|
46769
47458
|
* synthetic track per camera the event belongs to. Returns the number of
|
|
@@ -46860,16 +47549,16 @@ var DEFAULT_DEBOUNCE_MS = 1e4;
|
|
|
46860
47549
|
/** A hanging snapshot cap must not stall the sensor state-change path. */
|
|
46861
47550
|
var DEFAULT_SNAPSHOT_TIMEOUT_MS = 1e4;
|
|
46862
47551
|
/** Distinguishes a snapshot-cap timeout from a fetch failure for reporting. */
|
|
46863
|
-
var SnapshotTimeoutError
|
|
47552
|
+
var SnapshotTimeoutError = class extends Error {
|
|
46864
47553
|
constructor(ms) {
|
|
46865
47554
|
super(`snapshot cap timed out after ${ms}ms`);
|
|
46866
47555
|
this.name = "SnapshotTimeoutError";
|
|
46867
47556
|
}
|
|
46868
47557
|
};
|
|
46869
47558
|
/** Reject with `SnapshotTimeoutError` if `promise` doesn't settle within `ms`. */
|
|
46870
|
-
function withTimeout
|
|
47559
|
+
function withTimeout(promise, ms) {
|
|
46871
47560
|
return new Promise((resolve, reject) => {
|
|
46872
|
-
const timer = setTimeout(() => reject(new SnapshotTimeoutError
|
|
47561
|
+
const timer = setTimeout(() => reject(new SnapshotTimeoutError(ms)), ms);
|
|
46873
47562
|
promise.then((value) => {
|
|
46874
47563
|
clearTimeout(timer);
|
|
46875
47564
|
resolve(value);
|
|
@@ -46924,11 +47613,11 @@ var SyntheticTrackMaterializer = class {
|
|
|
46924
47613
|
*/
|
|
46925
47614
|
async fetchSnapshot(cameraId, timeoutMs) {
|
|
46926
47615
|
try {
|
|
46927
|
-
const snap = await withTimeout
|
|
47616
|
+
const snap = await withTimeout(this.deps.snapshot.getSnapshot({ deviceId: cameraId }), timeoutMs);
|
|
46928
47617
|
if (snap === null) this.deps.onError?.("getSnapshot", /* @__PURE__ */ new Error("snapshot returned null"), cameraId);
|
|
46929
47618
|
return snap;
|
|
46930
47619
|
} catch (err) {
|
|
46931
|
-
this.deps.onError?.(err instanceof SnapshotTimeoutError
|
|
47620
|
+
this.deps.onError?.(err instanceof SnapshotTimeoutError ? "snapshotTimeout" : "snapshotMedia", err, cameraId);
|
|
46932
47621
|
return null;
|
|
46933
47622
|
}
|
|
46934
47623
|
}
|
|
@@ -46994,66 +47683,6 @@ var SyntheticTrackMaterializer = class {
|
|
|
46994
47683
|
return track;
|
|
46995
47684
|
}
|
|
46996
47685
|
};
|
|
46997
|
-
var SnapshotTimeoutError = class extends Error {
|
|
46998
|
-
constructor(ms) {
|
|
46999
|
-
super(`snapshot cap timed out after ${ms}ms`);
|
|
47000
|
-
this.name = "SnapshotTimeoutError";
|
|
47001
|
-
}
|
|
47002
|
-
};
|
|
47003
|
-
function withTimeout(promise, ms) {
|
|
47004
|
-
return new Promise((resolve, reject) => {
|
|
47005
|
-
const timer = setTimeout(() => reject(new SnapshotTimeoutError(ms)), ms);
|
|
47006
|
-
promise.then((value) => {
|
|
47007
|
-
clearTimeout(timer);
|
|
47008
|
-
resolve(value);
|
|
47009
|
-
}, (err) => {
|
|
47010
|
-
clearTimeout(timer);
|
|
47011
|
-
reject(err instanceof Error ? err : new Error(String(err)));
|
|
47012
|
-
});
|
|
47013
|
-
});
|
|
47014
|
-
}
|
|
47015
|
-
var MotionEventSnapshotCapturer = class {
|
|
47016
|
-
deps;
|
|
47017
|
-
snapshotTimeoutMs;
|
|
47018
|
-
constructor(deps) {
|
|
47019
|
-
this.deps = deps;
|
|
47020
|
-
this.snapshotTimeoutMs = deps.snapshotTimeoutMs ?? 2500;
|
|
47021
|
-
}
|
|
47022
|
-
/**
|
|
47023
|
-
* Fetch a still and persist it as the motion event's snapshot. Never
|
|
47024
|
-
* throws — the durable record is the motion row, already inserted by
|
|
47025
|
-
* the caller.
|
|
47026
|
-
*/
|
|
47027
|
-
async capture(input) {
|
|
47028
|
-
let snap;
|
|
47029
|
-
try {
|
|
47030
|
-
snap = await withTimeout(this.deps.snapshot.getSnapshot({ deviceId: input.deviceId }), this.snapshotTimeoutMs);
|
|
47031
|
-
} catch (err) {
|
|
47032
|
-
this.deps.onError?.(err instanceof SnapshotTimeoutError ? "snapshotTimeout" : "snapshotFetch", err, input.deviceId);
|
|
47033
|
-
return;
|
|
47034
|
-
}
|
|
47035
|
-
if (snap === null) return;
|
|
47036
|
-
const raw = Buffer.from(snap.base64, "base64");
|
|
47037
|
-
let data = raw;
|
|
47038
|
-
try {
|
|
47039
|
-
data = await downscaleFullFrameJpeg(raw, 960, 540);
|
|
47040
|
-
} catch (err) {
|
|
47041
|
-
this.deps.onError?.("downscaleSnapshot", err, input.deviceId);
|
|
47042
|
-
}
|
|
47043
|
-
try {
|
|
47044
|
-
await this.deps.media.put({
|
|
47045
|
-
deviceId: input.deviceId,
|
|
47046
|
-
ownerKind: "event",
|
|
47047
|
-
ownerId: input.eventId,
|
|
47048
|
-
kind: "snapshot",
|
|
47049
|
-
timestamp: input.timestamp,
|
|
47050
|
-
data
|
|
47051
|
-
});
|
|
47052
|
-
} catch (err) {
|
|
47053
|
-
this.deps.onError?.("snapshotMedia", err, input.deviceId);
|
|
47054
|
-
}
|
|
47055
|
-
}
|
|
47056
|
-
};
|
|
47057
47686
|
var TrackingSettingsSchema = object({
|
|
47058
47687
|
/**
|
|
47059
47688
|
* How much of a detection's box must lie inside a zone (0-1 fraction of the
|
|
@@ -48343,6 +48972,51 @@ function buildGlobalSettingsSchema() {
|
|
|
48343
48972
|
}
|
|
48344
48973
|
]
|
|
48345
48974
|
},
|
|
48975
|
+
{
|
|
48976
|
+
id: "audio-episodes",
|
|
48977
|
+
title: "Audio episodes",
|
|
48978
|
+
description: "One timeline track and one event per classified sound, held open with hysteresis so a long cry is not four tracks. No class filter: speech, crying, a bark — the knobs below are what stop repeats. Leave on for a day or two and judge the spam, then tighten per camera.",
|
|
48979
|
+
columns: 2,
|
|
48980
|
+
fields: [
|
|
48981
|
+
{
|
|
48982
|
+
type: "boolean",
|
|
48983
|
+
key: "audioEpisodeEnabled",
|
|
48984
|
+
label: "Create audio episodes",
|
|
48985
|
+
description: "When off, classified audio still notifies from other paths but does not open a synthetic track or coalesce classification events.",
|
|
48986
|
+
default: AUDIO_DETECTION_DEFAULTS.audioEpisodeEnabled
|
|
48987
|
+
},
|
|
48988
|
+
{
|
|
48989
|
+
type: "number",
|
|
48990
|
+
key: "audioEpisodeOpenHoldSec",
|
|
48991
|
+
label: "Open hold",
|
|
48992
|
+
description: "The class must be heard continuously this long before an episode opens. Filters 200 ms blips.",
|
|
48993
|
+
min: 0,
|
|
48994
|
+
step: .5,
|
|
48995
|
+
default: AUDIO_DETECTION_DEFAULTS.audioEpisodeOpenHoldSec,
|
|
48996
|
+
unit: "s"
|
|
48997
|
+
},
|
|
48998
|
+
{
|
|
48999
|
+
type: "number",
|
|
49000
|
+
key: "audioEpisodeCloseGapSec",
|
|
49001
|
+
label: "Close gap",
|
|
49002
|
+
description: "The class must be absent this long before the episode closes. A pause inside a cry does not start a second track.",
|
|
49003
|
+
min: 1,
|
|
49004
|
+
step: 1,
|
|
49005
|
+
default: AUDIO_DETECTION_DEFAULTS.audioEpisodeCloseGapSec,
|
|
49006
|
+
unit: "s"
|
|
49007
|
+
},
|
|
49008
|
+
{
|
|
49009
|
+
type: "number",
|
|
49010
|
+
key: "audioEpisodeCooldownSec",
|
|
49011
|
+
label: "Cooldown",
|
|
49012
|
+
description: "After an episode closes, the same class cannot reopen until this elapses.",
|
|
49013
|
+
min: 0,
|
|
49014
|
+
step: 5,
|
|
49015
|
+
default: AUDIO_DETECTION_DEFAULTS.audioEpisodeCooldownSec,
|
|
49016
|
+
unit: "s"
|
|
49017
|
+
}
|
|
49018
|
+
]
|
|
49019
|
+
},
|
|
48346
49020
|
{
|
|
48347
49021
|
id: "face-recognition",
|
|
48348
49022
|
title: "Face recognition",
|
|
@@ -49828,7 +50502,8 @@ var customActions = {
|
|
|
49828
50502
|
...ncActions,
|
|
49829
50503
|
...embeddingActions,
|
|
49830
50504
|
...debugActions,
|
|
49831
|
-
...orphanAuditActions
|
|
50505
|
+
...orphanAuditActions,
|
|
50506
|
+
...viewerSettingsActions
|
|
49832
50507
|
};
|
|
49833
50508
|
/**
|
|
49834
50509
|
* Assist threshold when the caller does not pin one. Packages have their own
|
|
@@ -49930,7 +50605,12 @@ var LTS_FLUSH_INTERVAL_MS = 6e4;
|
|
|
49930
50605
|
var OPS_LOG_RETENTION_MS = 720 * 60 * 6e4;
|
|
49931
50606
|
/** Throttle for the "overlay synthesis failed" warn — one line / minute / device. */
|
|
49932
50607
|
var OVERLAY_SYNTHESIS_WARN_THROTTLE_MS = 6e4;
|
|
49933
|
-
|
|
50608
|
+
/** How recently a spatial track must have been alive to count as "visual
|
|
50609
|
+
* happening" — stamp audio labels on that track instead of opening a
|
|
50610
|
+
* synthetic audio episode. */
|
|
50611
|
+
var AUDIO_EPISODE_VISUAL_QUIET_MS = 2e3;
|
|
50612
|
+
/** Persist lastSeen on an open synthetic audio track at most this often. */
|
|
50613
|
+
var AUDIO_EPISODE_PERSIST_MS = 2e3;
|
|
49934
50614
|
var MOTION_EVENT_HEARTBEAT_MS = 5e3;
|
|
49935
50615
|
/**
|
|
49936
50616
|
* Stored media kinds that carry NO drawn bounding box, in fallback preference
|
|
@@ -50402,6 +51082,11 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
50402
51082
|
* fallible read on the decision path.
|
|
50403
51083
|
*/
|
|
50404
51084
|
lastTrackActivityMs = /* @__PURE__ */ new Map();
|
|
51085
|
+
/** Per-device classified-audio episode hysteresis. */
|
|
51086
|
+
audioEpisodeByDevice = /* @__PURE__ */ new Map();
|
|
51087
|
+
/** Open synthetic tracks keyed by hysteresis episodeId. */
|
|
51088
|
+
audioEpisodeTracks = /* @__PURE__ */ new Map();
|
|
51089
|
+
audioEpisodeLastPersistMs = /* @__PURE__ */ new Map();
|
|
50405
51090
|
settingsCaches = new DeviceSettingsCacheHub();
|
|
50406
51091
|
/** Avoids a store read every ~30 Hz audio frame. */
|
|
50407
51092
|
audioSettingsCache = this.settingsCaches.registerDeviceCache("audio-detection", SETTINGS_CACHE_TTL_MS);
|
|
@@ -50685,6 +51370,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
50685
51370
|
* NAME — its scope list is derived from the classification registry, not
|
|
50686
51371
|
* from a set of store instances. */
|
|
50687
51372
|
settingsStore = null;
|
|
51373
|
+
viewerSettingsSnapshots = null;
|
|
50688
51374
|
/** Per-scope resume cursors, so a run interrupted by a restart costs one
|
|
50689
51375
|
* page rather than a full rescan. In RAM: losing them costs a rescan, and
|
|
50690
51376
|
* persisting them would be a durable record of a purely advisory position. */
|
|
@@ -50822,12 +51508,14 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
50822
51508
|
...ncActions,
|
|
50823
51509
|
...embeddingActions,
|
|
50824
51510
|
...debugActions,
|
|
50825
|
-
...orphanAuditActions
|
|
51511
|
+
...orphanAuditActions,
|
|
51512
|
+
...viewerSettingsActions
|
|
50826
51513
|
},
|
|
50827
51514
|
actionHandlers: {
|
|
50828
51515
|
...ncHandlers,
|
|
50829
51516
|
...this.buildEmbeddingActionHandlers(),
|
|
50830
|
-
...this.buildDebugActionHandlers()
|
|
51517
|
+
...this.buildDebugActionHandlers(),
|
|
51518
|
+
...this.viewerSettingsSnapshots ? makeViewerSettingsActionHandlers(this.viewerSettingsSnapshots) : {}
|
|
50831
51519
|
}
|
|
50832
51520
|
} : {}
|
|
50833
51521
|
};
|
|
@@ -51002,8 +51690,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
51002
51690
|
OpsLogStore.declare(api.settingsStore),
|
|
51003
51691
|
AnalyticsLts.declare(api.settingsStore),
|
|
51004
51692
|
SceneStore.declare(api.settingsStore),
|
|
51005
|
-
NotificationCenter.declare(api.settingsStore)
|
|
51693
|
+
NotificationCenter.declare(api.settingsStore),
|
|
51694
|
+
ViewerSettingsSnapshotStore.declare(api.settingsStore)
|
|
51006
51695
|
]);
|
|
51696
|
+
this.viewerSettingsSnapshots = new ViewerSettingsSnapshotStore(api.settingsStore);
|
|
51007
51697
|
runLabelTierMigration({
|
|
51008
51698
|
store: api.settingsStore,
|
|
51009
51699
|
logger: this.ctx.logger,
|
|
@@ -52263,6 +52953,11 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
52263
52953
|
this.forgetDeviceProcessors(deviceId);
|
|
52264
52954
|
this.levelStateByDevice.delete(deviceId);
|
|
52265
52955
|
this.lastTrackActivityMs.delete(deviceId);
|
|
52956
|
+
this.audioEpisodeByDevice.delete(deviceId);
|
|
52957
|
+
for (const [episodeId, track] of this.audioEpisodeTracks) if (track.deviceId === deviceId) {
|
|
52958
|
+
this.audioEpisodeTracks.delete(episodeId);
|
|
52959
|
+
this.audioEpisodeLastPersistMs.delete(episodeId);
|
|
52960
|
+
}
|
|
52266
52961
|
this.audioMarkers?.forgetDevice(deviceId);
|
|
52267
52962
|
this.settingsCaches.clearDevice(deviceId);
|
|
52268
52963
|
this.bindingCache?.invalidate(deviceId);
|
|
@@ -52527,6 +53222,9 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
52527
53222
|
audio
|
|
52528
53223
|
};
|
|
52529
53224
|
},
|
|
53225
|
+
fetchMediaOwners: async ({ deviceId, ownerKind, ownerIds }) => {
|
|
53226
|
+
return stores.mediaStore.ownersWithMedia(ownerKind, ownerIds, deviceId);
|
|
53227
|
+
},
|
|
52530
53228
|
fetchAvailability: async ({ deviceId, since, until }) => {
|
|
52531
53229
|
return (await api.recording.getAvailability.query({
|
|
52532
53230
|
deviceId,
|
|
@@ -52538,6 +53236,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
52538
53236
|
tags: { deviceId: Number(meta["deviceId"]) },
|
|
52539
53237
|
meta
|
|
52540
53238
|
}),
|
|
53239
|
+
logInfo: (message, meta) => this.ctx.logger.info(message, {
|
|
53240
|
+
tags: { deviceId: Number(meta["deviceId"]) },
|
|
53241
|
+
meta
|
|
53242
|
+
}),
|
|
52541
53243
|
resolvePlayback: async ({ deviceId, startMs, endMs }) => {
|
|
52542
53244
|
const manifest = await api.recording.getPlaybackManifest.query({
|
|
52543
53245
|
deviceId,
|
|
@@ -54159,6 +54861,146 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
54159
54861
|
maxPerDay: settings.audioMarkerMaxPerDay
|
|
54160
54862
|
};
|
|
54161
54863
|
}
|
|
54864
|
+
audioEpisodeKnobs(settings) {
|
|
54865
|
+
return {
|
|
54866
|
+
enabled: settings.audioEpisodeEnabled,
|
|
54867
|
+
openHoldMs: settings.audioEpisodeOpenHoldSec * 1e3,
|
|
54868
|
+
closeGapMs: settings.audioEpisodeCloseGapSec * 1e3,
|
|
54869
|
+
cooldownMs: settings.audioEpisodeCooldownSec * 1e3
|
|
54870
|
+
};
|
|
54871
|
+
}
|
|
54872
|
+
hasLiveVisualTrack(deviceId, atMs) {
|
|
54873
|
+
const at = this.lastTrackActivityMs.get(deviceId);
|
|
54874
|
+
return at !== void 0 && atMs - at < AUDIO_EPISODE_VISUAL_QUIET_MS;
|
|
54875
|
+
}
|
|
54876
|
+
async driveAudioEpisode(input) {
|
|
54877
|
+
const { nextState, action } = observeAudioEpisode(this.audioEpisodeByDevice.get(input.deviceId) ?? emptyAudioEpisodeState(), {
|
|
54878
|
+
timestampMs: input.timestamp,
|
|
54879
|
+
className: input.className
|
|
54880
|
+
}, input.knobs);
|
|
54881
|
+
this.audioEpisodeByDevice.set(input.deviceId, nextState);
|
|
54882
|
+
switch (action.kind) {
|
|
54883
|
+
case "none": return;
|
|
54884
|
+
case "open":
|
|
54885
|
+
await this.openAudioEpisode(input, action);
|
|
54886
|
+
return;
|
|
54887
|
+
case "hold":
|
|
54888
|
+
await this.holdAudioEpisode(input, action);
|
|
54889
|
+
return;
|
|
54890
|
+
case "close":
|
|
54891
|
+
await this.persistAudioEpisodeLastSeen(action, input.timestamp, true);
|
|
54892
|
+
return;
|
|
54893
|
+
case "replace":
|
|
54894
|
+
await this.persistAudioEpisodeLastSeen(action.closed, input.timestamp, true);
|
|
54895
|
+
await this.openAudioEpisode(input, action.opened);
|
|
54896
|
+
return;
|
|
54897
|
+
}
|
|
54898
|
+
}
|
|
54899
|
+
async openAudioEpisode(input, ref) {
|
|
54900
|
+
const ev = {
|
|
54901
|
+
id: randomUUID(),
|
|
54902
|
+
deviceId: input.deviceId,
|
|
54903
|
+
timestamp: input.timestamp,
|
|
54904
|
+
kind: "audio",
|
|
54905
|
+
rms: input.rms,
|
|
54906
|
+
dbfs: input.dbfs,
|
|
54907
|
+
classification: {
|
|
54908
|
+
className: ref.className,
|
|
54909
|
+
...input.originalClass !== void 0 ? { originalClass: input.originalClass } : {},
|
|
54910
|
+
score: input.score
|
|
54911
|
+
}
|
|
54912
|
+
};
|
|
54913
|
+
this.lastAudioInsertByDevice.set(input.deviceId, {
|
|
54914
|
+
className: ref.className,
|
|
54915
|
+
atMs: input.timestamp
|
|
54916
|
+
});
|
|
54917
|
+
await this.eventStore.insertAudio(ev);
|
|
54918
|
+
this.notificationCenter?.onAudioEventPersisted(ev);
|
|
54919
|
+
this.trackStore?.addAudioLabelEpisode(input.deviceId, ref.className, input.score, input.timestamp);
|
|
54920
|
+
this.ctx.eventBus.emit({
|
|
54921
|
+
id: `pa-${ev.id}`,
|
|
54922
|
+
timestamp: new Date(ev.timestamp),
|
|
54923
|
+
source: {
|
|
54924
|
+
type: "addon",
|
|
54925
|
+
id: "pipeline-analytics",
|
|
54926
|
+
addonId: "pipeline-analytics"
|
|
54927
|
+
},
|
|
54928
|
+
category: EventCategory.PipelineAnalyticsDetectionEvent,
|
|
54929
|
+
data: {
|
|
54930
|
+
deviceId: input.deviceId,
|
|
54931
|
+
kind: "audio",
|
|
54932
|
+
eventId: ev.id,
|
|
54933
|
+
timestamp: ev.timestamp
|
|
54934
|
+
}
|
|
54935
|
+
});
|
|
54936
|
+
if (this.hasLiveVisualTrack(input.deviceId, input.timestamp)) return;
|
|
54937
|
+
const materializer = this.syntheticTracks;
|
|
54938
|
+
if (materializer === null) return;
|
|
54939
|
+
try {
|
|
54940
|
+
const track = await materializer.materialize({
|
|
54941
|
+
cameraId: input.deviceId,
|
|
54942
|
+
sourceDeviceId: input.deviceId,
|
|
54943
|
+
source: "audio",
|
|
54944
|
+
kind: ref.className,
|
|
54945
|
+
timestamp: input.timestamp,
|
|
54946
|
+
audioLabels: [{
|
|
54947
|
+
label: ref.className,
|
|
54948
|
+
peakScore: input.score,
|
|
54949
|
+
count: 1,
|
|
54950
|
+
firstAt: input.timestamp,
|
|
54951
|
+
lastAt: input.timestamp
|
|
54952
|
+
}]
|
|
54953
|
+
});
|
|
54954
|
+
if (track) {
|
|
54955
|
+
this.audioEpisodeTracks.set(ref.episodeId, track);
|
|
54956
|
+
this.audioEpisodeLastPersistMs.set(ref.episodeId, input.timestamp);
|
|
54957
|
+
}
|
|
54958
|
+
} catch (err) {
|
|
54959
|
+
this.ctx.logger.warn("audio-episode synthetic track failed", {
|
|
54960
|
+
tags: { deviceId: input.deviceId },
|
|
54961
|
+
meta: {
|
|
54962
|
+
className: ref.className,
|
|
54963
|
+
error: errMsg(err)
|
|
54964
|
+
}
|
|
54965
|
+
});
|
|
54966
|
+
}
|
|
54967
|
+
}
|
|
54968
|
+
async holdAudioEpisode(input, ref) {
|
|
54969
|
+
await this.persistAudioEpisodeLastSeen(ref, input.timestamp, false);
|
|
54970
|
+
}
|
|
54971
|
+
async persistAudioEpisodeLastSeen(ref, timestamp, force) {
|
|
54972
|
+
const track = this.audioEpisodeTracks.get(ref.episodeId);
|
|
54973
|
+
if (!track) {
|
|
54974
|
+
if (force) {
|
|
54975
|
+
this.audioEpisodeTracks.delete(ref.episodeId);
|
|
54976
|
+
this.audioEpisodeLastPersistMs.delete(ref.episodeId);
|
|
54977
|
+
}
|
|
54978
|
+
return;
|
|
54979
|
+
}
|
|
54980
|
+
const last = this.audioEpisodeLastPersistMs.get(ref.episodeId) ?? 0;
|
|
54981
|
+
if (!force && timestamp - last < AUDIO_EPISODE_PERSIST_MS) return;
|
|
54982
|
+
const updated = {
|
|
54983
|
+
...track,
|
|
54984
|
+
lastSeen: timestamp
|
|
54985
|
+
};
|
|
54986
|
+
this.audioEpisodeTracks.set(ref.episodeId, updated);
|
|
54987
|
+
this.audioEpisodeLastPersistMs.set(ref.episodeId, timestamp);
|
|
54988
|
+
try {
|
|
54989
|
+
await this.trackStore?.persistSyntheticTrack(updated);
|
|
54990
|
+
} catch (err) {
|
|
54991
|
+
this.ctx.logger.warn("audio-episode lastSeen persist failed", {
|
|
54992
|
+
tags: { deviceId: track.deviceId },
|
|
54993
|
+
meta: {
|
|
54994
|
+
episodeId: ref.episodeId,
|
|
54995
|
+
error: errMsg(err)
|
|
54996
|
+
}
|
|
54997
|
+
});
|
|
54998
|
+
}
|
|
54999
|
+
if (force) {
|
|
55000
|
+
this.audioEpisodeTracks.delete(ref.episodeId);
|
|
55001
|
+
this.audioEpisodeLastPersistMs.delete(ref.episodeId);
|
|
55002
|
+
}
|
|
55003
|
+
}
|
|
54162
55004
|
async handleAudioResult(data) {
|
|
54163
55005
|
if (this.shuttingDown) return;
|
|
54164
55006
|
const { deviceId, frame } = data;
|
|
@@ -54202,48 +55044,27 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
54202
55044
|
});
|
|
54203
55045
|
return;
|
|
54204
55046
|
}
|
|
54205
|
-
|
|
54206
|
-
const sameClass = last?.className === route.className;
|
|
54207
|
-
const heartbeatDue = !last || timestamp - last.atMs >= AUDIO_EVENT_HEARTBEAT_MS;
|
|
54208
|
-
if (sameClass && !heartbeatDue) return;
|
|
54209
|
-
const ev = {
|
|
54210
|
-
id: randomUUID(),
|
|
55047
|
+
await this.driveAudioEpisode({
|
|
54211
55048
|
deviceId,
|
|
54212
55049
|
timestamp,
|
|
54213
|
-
|
|
55050
|
+
className: route.className,
|
|
55051
|
+
score: topClassification.score,
|
|
55052
|
+
originalClass: topClassification.debug?.originalClass,
|
|
54214
55053
|
rms: level?.rms ?? 0,
|
|
54215
55054
|
dbfs: level?.dbfs ?? 0,
|
|
54216
|
-
|
|
54217
|
-
className: topClassification.macroClass,
|
|
54218
|
-
...topClassification.debug?.originalClass !== void 0 ? { originalClass: topClassification.debug.originalClass } : {},
|
|
54219
|
-
score: topClassification.score
|
|
54220
|
-
}
|
|
54221
|
-
};
|
|
54222
|
-
this.lastAudioInsertByDevice.set(deviceId, {
|
|
54223
|
-
className: route.className,
|
|
54224
|
-
atMs: timestamp
|
|
54225
|
-
});
|
|
54226
|
-
await this.eventStore.insertAudio(ev);
|
|
54227
|
-
this.notificationCenter?.onAudioEventPersisted(ev);
|
|
54228
|
-
this.trackStore?.addAudioLabelEpisode(deviceId, route.className, topClassification.score, timestamp);
|
|
54229
|
-
this.ctx.eventBus.emit({
|
|
54230
|
-
id: `pa-${ev.id}`,
|
|
54231
|
-
timestamp: new Date(ev.timestamp),
|
|
54232
|
-
source: {
|
|
54233
|
-
type: "addon",
|
|
54234
|
-
id: "pipeline-analytics",
|
|
54235
|
-
addonId: "pipeline-analytics"
|
|
54236
|
-
},
|
|
54237
|
-
category: EventCategory.PipelineAnalyticsDetectionEvent,
|
|
54238
|
-
data: {
|
|
54239
|
-
deviceId,
|
|
54240
|
-
kind: "audio",
|
|
54241
|
-
eventId: ev.id,
|
|
54242
|
-
timestamp: ev.timestamp
|
|
54243
|
-
}
|
|
55055
|
+
knobs: this.audioEpisodeKnobs(settings)
|
|
54244
55056
|
});
|
|
54245
55057
|
return;
|
|
54246
55058
|
}
|
|
55059
|
+
await this.driveAudioEpisode({
|
|
55060
|
+
deviceId,
|
|
55061
|
+
timestamp,
|
|
55062
|
+
className: null,
|
|
55063
|
+
score: 0,
|
|
55064
|
+
rms: level?.rms ?? 0,
|
|
55065
|
+
dbfs: level?.dbfs ?? 0,
|
|
55066
|
+
knobs: this.audioEpisodeKnobs(settings)
|
|
55067
|
+
});
|
|
54247
55068
|
const res = observeLevel(this.levelStateByDevice.get(deviceId) ?? emptyLevelState(), {
|
|
54248
55069
|
timestampMs: timestamp,
|
|
54249
55070
|
dbfs: level?.dbfs ?? -Infinity
|