@camstack/addon-post-analysis 1.2.76 → 1.2.78
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-CYfG8ulU.mjs → dist-CA2KdMY9.mjs} +141 -8
- package/dist/{dist-BsBjSNIo.js → dist-CFOT1Yoz.js} +146 -7
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/pipeline-analytics/_stub.js +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-DCcRtqVf.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-DteUZxxp.mjs} +2 -2
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-B4LMjxKe.mjs +26 -0
- package/dist/pipeline-analytics/{hostInit-sdxJJeWR.mjs → hostInit-BTue_6nN.mjs} +2 -2
- package/dist/pipeline-analytics/index.js +1548 -428
- package/dist/pipeline-analytics/index.mjs +1511 -428
- 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_ui_mf_2_library__loadShare__.js-DFckpC3K.mjs +0 -26
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { At as string, B as addonWidgetsSourceCapability, C as NcSnoozeSchema, Ct as boolean, D as OpsLogEntrySchema, Dt as object, E as NcTaxonomySchema, Et as number, F as TIMELAPSE_DENSE_FLOOR_SEC, G as buildEventKindDescriptor, H as assertTimelapseCadences, I as TimelapseRuleInputSchema, J as defineCustomActions, K as cosineSimilarity$1, L as TimelapseRulePatchSchema, M as SCENE_DEFAULT_UNCOVERED_POLICY, Mt as EventCategory, N as SCENE_DIVERGED, O as RECORDING_EXPORT_MAX_READ_BYTES, Ot as partialRecord, P as SceneMonitorSchema, Q as faceGalleryCapability, R as TimelapseRuleSchema, S as NcSnoozeInputSchema, St as array, T as NcSystemEventKindSchema, Tt as literal, U as audioMetricsCapability, V as alarmPanelCapability, W as audioModeOf, Y as deriveRecordingMode, Z as encodeVectorBase64, _ as NcRuleInputSchema, _t as hydrateSchema, at as plateGalleryCapability, b as NcRuleTargetSchema, bt as sleep, c as EVENT_PAD_MS, ct as sceneMonitorCapability, d as NC_ALARM_SYSTEM_EVENT_KINDS, dt as videoclipsCapability, et as isDetectionMacroClass, f as NC_CONDITION_CATALOG, ft as zoneAnalyticsCapability, g as NcConditionDescriptorSchema, gt as createEvent, h as NC_TAXONOMY, ht as DeviceType, i as DETECTION_MACRO_CLASSES, it as pipelineAnalyticsCapability, j as SCENE_DEFAULT_ANCHOR_THRESHOLD, jt as unknown, k as RetrainStatusSchema, kt as record, l as LabelAttributionSchema, lt as subKindsOf, mt as BaseAddon, n as DEFAULT_EVENT_COLOR, nt as kebabToCamel, o as DeclaredDevices, ot as readDeviceStateFrom, p as NC_DEFAULT_SNOOZE_MINUTES, pt as errMsg, q as customAction, rt as notificationRulesCapability, s as EVENT_KIND_BY_CAP, st as readTimelapseGeneratedAt, t as BaseDevice, tt as isScheduleActive, u as MACRO_LABELS, ut as vectorDimFromBase64, v as NcRulePatchSchema, vt as isDeviceScopedCap, w as NcSnoozeSuppressedSchema, wt as discriminatedUnion, x as NcScheduleSchema, xt as _enum, y as NcRuleSchema, yt as nodePin, z as TrackSourceSchema } from "../dist-CA2KdMY9.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";
|
|
@@ -3384,19 +3384,26 @@ function cosineSimilarity(a, b) {
|
|
|
3384
3384
|
}
|
|
3385
3385
|
/**
|
|
3386
3386
|
* Select the references a live crop is scored against for a given condition —
|
|
3387
|
-
* **exact condition match,
|
|
3388
|
-
*
|
|
3389
|
-
* There used to be
|
|
3390
|
-
* condition had none. For a named-states monitor that was a defensible
|
|
3391
|
-
* than blind". For a latched alarm it is a false positive every single
|
|
3392
|
-
* the day reference is compared against an IR frame, the cosine
|
|
3393
|
-
* scene latches `diverged`, and the operator is told the bin was
|
|
3394
|
-
* 21:40. Cross-condition cosines are not comparable, so the honest
|
|
3395
|
-
* have never seen this scene in this light" is *I don't know* —
|
|
3396
|
-
* in the direction that raises an alarm.
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3387
|
+
* **exact condition match, and by default NO fallback.**
|
|
3388
|
+
*
|
|
3389
|
+
* There used to be an unconditional silent fallback to every reference when the
|
|
3390
|
+
* current condition had none. For a named-states monitor that was a defensible
|
|
3391
|
+
* "better than blind". For a latched alarm it is a false positive every single
|
|
3392
|
+
* night: the day reference is compared against an IR frame, the cosine
|
|
3393
|
+
* collapses, the scene latches `diverged`, and the operator is told the bin was
|
|
3394
|
+
* taken at 21:40. Cross-condition cosines are not comparable, so the honest
|
|
3395
|
+
* answer to "I have never seen this scene in this light" is *I don't know* —
|
|
3396
|
+
* never a guess in the direction that raises an alarm.
|
|
3397
|
+
*
|
|
3398
|
+
* The fallback survives only as an explicit per-scene opt-in, and even then it
|
|
3399
|
+
* is a LAST resort: when the exact condition has references of its own they
|
|
3400
|
+
* win outright, so opting in never widens a comparison the operator already
|
|
3401
|
+
* covered properly.
|
|
3402
|
+
*/
|
|
3403
|
+
function selectConditionRefs(references, currentCondition, crossConditionFallback) {
|
|
3404
|
+
const exact = references.filter((r) => r.condition === currentCondition);
|
|
3405
|
+
if (exact.length > 0 || !crossConditionFallback) return exact;
|
|
3406
|
+
return references;
|
|
3400
3407
|
}
|
|
3401
3408
|
/** A reference is comparable only when its `modelId` equals the live encoder's
|
|
3402
3409
|
* model AND its dimension equals the encoder's embedding dim (face-matcher
|
|
@@ -3412,11 +3419,12 @@ function comparable(r, encoderModelId, encoderDim) {
|
|
|
3412
3419
|
* Returns `{ bestStateId: null, bestScore: null }` when NOTHING was comparable
|
|
3413
3420
|
* — see `ScoreResult.bestScore`.
|
|
3414
3421
|
*/
|
|
3415
|
-
function scoreStates(states, liveEmbedding, currentCondition, encoderModelId, encoderDim) {
|
|
3422
|
+
function scoreStates(states, liveEmbedding, currentCondition, encoderModelId, encoderDim, options) {
|
|
3416
3423
|
let bestStateId = null;
|
|
3417
3424
|
let bestScore = null;
|
|
3425
|
+
const crossConditionFallback = options?.crossConditionFallback === true;
|
|
3418
3426
|
for (const s of states) {
|
|
3419
|
-
const refs = selectConditionRefs(s.references, currentCondition);
|
|
3427
|
+
const refs = selectConditionRefs(s.references, currentCondition, crossConditionFallback);
|
|
3420
3428
|
let stateScore = null;
|
|
3421
3429
|
for (const r of refs) {
|
|
3422
3430
|
if (!comparable(r, encoderModelId, encoderDim)) continue;
|
|
@@ -3602,7 +3610,8 @@ var SceneEngine = class {
|
|
|
3602
3610
|
lastCountedAt: 0,
|
|
3603
3611
|
lastAvailability: "ok",
|
|
3604
3612
|
lastVerdict: currentStateId === null ? "unknown" : "matched",
|
|
3605
|
-
lastUnavailable: null
|
|
3613
|
+
lastUnavailable: null,
|
|
3614
|
+
suspendedCondition: null
|
|
3606
3615
|
});
|
|
3607
3616
|
}
|
|
3608
3617
|
/** Force an out-of-cycle check for a single monitor. */
|
|
@@ -3625,7 +3634,8 @@ var SceneEngine = class {
|
|
|
3625
3634
|
lastCountedAt: 0,
|
|
3626
3635
|
lastAvailability: monitor.availability,
|
|
3627
3636
|
lastVerdict: monitor.verdict,
|
|
3628
|
-
lastUnavailable: monitor.unavailable
|
|
3637
|
+
lastUnavailable: monitor.unavailable,
|
|
3638
|
+
suspendedCondition: monitor.suspendedCondition
|
|
3629
3639
|
};
|
|
3630
3640
|
rt.lastCheckAt = now;
|
|
3631
3641
|
this.runtime.set(k, rt);
|
|
@@ -3676,6 +3686,7 @@ var SceneEngine = class {
|
|
|
3676
3686
|
availability: "unavailable",
|
|
3677
3687
|
unavailableReason: llmResult.reason,
|
|
3678
3688
|
coveredConditions: [],
|
|
3689
|
+
suspendedCondition: null,
|
|
3679
3690
|
transitioned: false,
|
|
3680
3691
|
checkedAt: now
|
|
3681
3692
|
});
|
|
@@ -3729,12 +3740,20 @@ var SceneEngine = class {
|
|
|
3729
3740
|
if (monitor.check.mode !== "similarity") return;
|
|
3730
3741
|
const { embedding } = await this.deps.encoder.encode(crop, cropWidth, cropHeight);
|
|
3731
3742
|
const info = await this.encoderInfo();
|
|
3732
|
-
const { bestStateId, bestScore } = scoreStates(monitor.states, embedding, condition, info.modelId, info.embeddingDim);
|
|
3743
|
+
const { bestStateId, bestScore } = scoreStates(monitor.states, embedding, condition, info.modelId, info.embeddingDim, { crossConditionFallback: monitor.onUncoveredCondition === "judge-anyway" });
|
|
3733
3744
|
const covered = [...coveredConditions(monitor.states, info.modelId, info.embeddingDim)];
|
|
3734
3745
|
if (bestScore === null) {
|
|
3735
3746
|
const cause = unknownCause(monitor, condition);
|
|
3736
3747
|
rt.lastAvailability = "ok";
|
|
3737
3748
|
this.runtime.set(k, rt);
|
|
3749
|
+
if (cause === "no-reference-for-condition" && covered.length > 0 && monitor.onUncoveredCondition === "skip") {
|
|
3750
|
+
await this.reportSuspended(deviceId, monitor, rt, {
|
|
3751
|
+
condition,
|
|
3752
|
+
covered,
|
|
3753
|
+
now
|
|
3754
|
+
});
|
|
3755
|
+
return;
|
|
3756
|
+
}
|
|
3738
3757
|
await this.reportUnknown(deviceId, monitor, rt, {
|
|
3739
3758
|
condition,
|
|
3740
3759
|
cause,
|
|
@@ -3802,9 +3821,11 @@ var SceneEngine = class {
|
|
|
3802
3821
|
rt.lastAvailability = "ok";
|
|
3803
3822
|
this.runtime.set(k, rt);
|
|
3804
3823
|
const verdict = projectVerdict(step.currentStateId, monitor.baselineStateId);
|
|
3805
|
-
const
|
|
3824
|
+
const resumed = rt.suspendedCondition !== null;
|
|
3825
|
+
const changed = step.transitioned || verdict !== rt.lastVerdict || rt.lastAvailability !== monitor.availability || rt.lastUnavailable !== null || resumed;
|
|
3806
3826
|
rt.lastVerdict = verdict;
|
|
3807
3827
|
rt.lastUnavailable = null;
|
|
3828
|
+
rt.suspendedCondition = null;
|
|
3808
3829
|
this.runtime.set(k, rt);
|
|
3809
3830
|
if (changed) await this.deps.applyEngineResult(deviceId, monitor.id, {
|
|
3810
3831
|
currentStateId: step.currentStateId,
|
|
@@ -3816,20 +3837,61 @@ var SceneEngine = class {
|
|
|
3816
3837
|
availability: "ok",
|
|
3817
3838
|
unavailableReason: null,
|
|
3818
3839
|
coveredConditions: covered,
|
|
3840
|
+
suspendedCondition: null,
|
|
3819
3841
|
transitioned: step.transitioned,
|
|
3820
3842
|
checkedAt: now
|
|
3821
3843
|
});
|
|
3822
3844
|
}
|
|
3823
3845
|
/**
|
|
3846
|
+
* Announce that this scene is SITTING OUT the current light — once per
|
|
3847
|
+
* transition into it, never once per tick. At a 60 s cadence a per-tick line
|
|
3848
|
+
* would be ~480 lines a night per scene, which is how a log stops being read
|
|
3849
|
+
* at all.
|
|
3850
|
+
*
|
|
3851
|
+
* `info`, not `warn`: nothing is wrong. The operator chose not to capture
|
|
3852
|
+
* this light, or has not got round to it, and either way the scene is doing
|
|
3853
|
+
* exactly what it was configured to do. A branch that drops work still has to
|
|
3854
|
+
* SAY so — silence reads as "never happened" — but saying it at warning level
|
|
3855
|
+
* would train the operator to ignore the one channel that reports real
|
|
3856
|
+
* faults.
|
|
3857
|
+
*/
|
|
3858
|
+
async reportSuspended(deviceId, monitor, rt, input) {
|
|
3859
|
+
if (rt.suspendedCondition === input.condition) return;
|
|
3860
|
+
rt.suspendedCondition = input.condition;
|
|
3861
|
+
this.deps.logger.info("scene checks paused — nothing captured in this light", {
|
|
3862
|
+
tags: { deviceId },
|
|
3863
|
+
meta: {
|
|
3864
|
+
monitorId: monitor.id,
|
|
3865
|
+
condition: input.condition,
|
|
3866
|
+
covered: input.covered.join(",")
|
|
3867
|
+
}
|
|
3868
|
+
});
|
|
3869
|
+
await this.deps.applyEngineResult(deviceId, monitor.id, {
|
|
3870
|
+
currentStateId: rt.hysteresis.currentStateId,
|
|
3871
|
+
previousStateId: rt.hysteresis.currentStateId,
|
|
3872
|
+
lastConfidence: null,
|
|
3873
|
+
currentCondition: input.condition,
|
|
3874
|
+
verdict: rt.lastVerdict,
|
|
3875
|
+
unavailable: rt.lastUnavailable,
|
|
3876
|
+
availability: "ok",
|
|
3877
|
+
unavailableReason: null,
|
|
3878
|
+
coveredConditions: input.covered,
|
|
3879
|
+
suspendedCondition: input.condition,
|
|
3880
|
+
transitioned: false,
|
|
3881
|
+
checkedAt: input.now
|
|
3882
|
+
});
|
|
3883
|
+
}
|
|
3884
|
+
/**
|
|
3824
3885
|
* Push an `unknown` verdict — once per transition, not once per poll. The
|
|
3825
3886
|
* held state is deliberately NOT destroyed: a scene that cannot judge is a
|
|
3826
3887
|
* scene that has not observed anything, and an observation it could not make
|
|
3827
3888
|
* must never spend or clear hysteresis credit in either direction.
|
|
3828
3889
|
*/
|
|
3829
3890
|
async reportUnknown(deviceId, monitor, rt, input) {
|
|
3830
|
-
if (rt.lastVerdict === "unknown" && rt.lastUnavailable === input.cause) return;
|
|
3891
|
+
if (rt.lastVerdict === "unknown" && rt.lastUnavailable === input.cause && rt.suspendedCondition === null) return;
|
|
3831
3892
|
rt.lastVerdict = "unknown";
|
|
3832
3893
|
rt.lastUnavailable = input.cause;
|
|
3894
|
+
rt.suspendedCondition = null;
|
|
3833
3895
|
this.deps.logger.warn("scene cannot judge — reporting unknown", {
|
|
3834
3896
|
tags: { deviceId },
|
|
3835
3897
|
meta: {
|
|
@@ -3849,6 +3911,7 @@ var SceneEngine = class {
|
|
|
3849
3911
|
availability: "ok",
|
|
3850
3912
|
unavailableReason: `no comparable reference for "${input.condition}"`,
|
|
3851
3913
|
coveredConditions: input.covered,
|
|
3914
|
+
suspendedCondition: null,
|
|
3852
3915
|
transitioned: false,
|
|
3853
3916
|
checkedAt: input.now
|
|
3854
3917
|
});
|
|
@@ -4571,6 +4634,8 @@ var SceneMonitorProvider = class {
|
|
|
4571
4634
|
minObservationSpacingSec: 120,
|
|
4572
4635
|
anchorThreshold: SCENE_DEFAULT_ANCHOR_THRESHOLD,
|
|
4573
4636
|
autoRestore: false,
|
|
4637
|
+
onUncoveredCondition: SCENE_DEFAULT_UNCOVERED_POLICY,
|
|
4638
|
+
suspendedCondition: null,
|
|
4574
4639
|
unavailable: "no-reference-for-condition",
|
|
4575
4640
|
coveredConditions: []
|
|
4576
4641
|
};
|
|
@@ -4600,7 +4665,9 @@ var SceneMonitorProvider = class {
|
|
|
4600
4665
|
this.refresh(input.deviceId);
|
|
4601
4666
|
}
|
|
4602
4667
|
async deleteScene(input) {
|
|
4668
|
+
const doomed = this.deps.store.getScene(input.deviceId, input.monitorId);
|
|
4603
4669
|
await this.deps.store.deleteScene(input.deviceId, input.monitorId);
|
|
4670
|
+
await this.dropOrphanedMedia(doomed?.monitor.states.flatMap((s) => s.references) ?? []);
|
|
4604
4671
|
this.deps.logger.info("scene deleted", {
|
|
4605
4672
|
tags: { deviceId: input.deviceId },
|
|
4606
4673
|
meta: { monitorId: input.monitorId }
|
|
@@ -4623,7 +4690,9 @@ var SceneMonitorProvider = class {
|
|
|
4623
4690
|
const stateId = input.stateId ?? monitor.states[0]?.id ?? randomUUID();
|
|
4624
4691
|
const existing = monitor.states.find((s) => s.id === stateId);
|
|
4625
4692
|
const label = input.label ?? existing?.label ?? monitor.label;
|
|
4626
|
-
const
|
|
4693
|
+
const kept = [...existing?.references ?? [], reference];
|
|
4694
|
+
const references = boundReferences(kept, condition);
|
|
4695
|
+
await this.dropOrphanedMedia(kept.filter((r) => !references.includes(r)));
|
|
4627
4696
|
const state = {
|
|
4628
4697
|
id: stateId,
|
|
4629
4698
|
label,
|
|
@@ -4657,6 +4726,7 @@ var SceneMonitorProvider = class {
|
|
|
4657
4726
|
}
|
|
4658
4727
|
async deleteReference(input) {
|
|
4659
4728
|
const monitor = this.requireScene(input.deviceId, input.monitorId);
|
|
4729
|
+
const removed = monitor.states.find((s) => s.id === input.stateId)?.references[input.index];
|
|
4660
4730
|
const states = monitor.states.map((s) => {
|
|
4661
4731
|
if (s.id !== input.stateId) return s;
|
|
4662
4732
|
const references = s.references.filter((_, i) => i !== input.index);
|
|
@@ -4670,6 +4740,17 @@ var SceneMonitorProvider = class {
|
|
|
4670
4740
|
...monitor,
|
|
4671
4741
|
states
|
|
4672
4742
|
}, this.now());
|
|
4743
|
+
await this.dropOrphanedMedia(removed === void 0 ? [] : [removed]);
|
|
4744
|
+
this.deps.logger.info("scene reference deleted", {
|
|
4745
|
+
tags: { deviceId: input.deviceId },
|
|
4746
|
+
meta: {
|
|
4747
|
+
monitorId: input.monitorId,
|
|
4748
|
+
stateId: input.stateId,
|
|
4749
|
+
index: input.index,
|
|
4750
|
+
condition: removed?.condition ?? "(none)",
|
|
4751
|
+
remaining: states.reduce((n, s) => n + s.references.length, 0)
|
|
4752
|
+
}
|
|
4753
|
+
});
|
|
4673
4754
|
this.refresh(input.deviceId);
|
|
4674
4755
|
}
|
|
4675
4756
|
async recheckNow(input) {
|
|
@@ -4754,6 +4835,15 @@ var SceneMonitorProvider = class {
|
|
|
4754
4835
|
const row = this.deps.store.getScene(deviceId, monitorId);
|
|
4755
4836
|
if (row === void 0) return;
|
|
4756
4837
|
const monitor = row.monitor;
|
|
4838
|
+
if (result.suspendedCondition !== null) {
|
|
4839
|
+
await this.deps.store.putScene(deviceId, {
|
|
4840
|
+
...monitor,
|
|
4841
|
+
suspendedCondition: result.suspendedCondition,
|
|
4842
|
+
coveredConditions: [...result.coveredConditions]
|
|
4843
|
+
}, result.checkedAt);
|
|
4844
|
+
this.refresh(deviceId);
|
|
4845
|
+
return;
|
|
4846
|
+
}
|
|
4757
4847
|
const previous = this.deps.store.getLatch(deviceId, monitorId);
|
|
4758
4848
|
const wasLatched = previous?.latched ?? monitor.latched;
|
|
4759
4849
|
const wasVerdict = previous?.verdict ?? monitor.verdict;
|
|
@@ -4780,6 +4870,7 @@ var SceneMonitorProvider = class {
|
|
|
4780
4870
|
const merged = {
|
|
4781
4871
|
...monitor,
|
|
4782
4872
|
coveredConditions: [...result.coveredConditions],
|
|
4873
|
+
suspendedCondition: null,
|
|
4783
4874
|
availability: result.availability,
|
|
4784
4875
|
unavailableReason: result.unavailableReason
|
|
4785
4876
|
};
|
|
@@ -4815,6 +4906,7 @@ var SceneMonitorProvider = class {
|
|
|
4815
4906
|
direction,
|
|
4816
4907
|
verdict: result.verdict,
|
|
4817
4908
|
latched,
|
|
4909
|
+
emit: monitor.emit,
|
|
4818
4910
|
condition: result.currentCondition,
|
|
4819
4911
|
confidence: result.lastConfidence,
|
|
4820
4912
|
timestamp: result.checkedAt
|
|
@@ -4882,8 +4974,29 @@ var SceneMonitorProvider = class {
|
|
|
4882
4974
|
updatedAt: now
|
|
4883
4975
|
};
|
|
4884
4976
|
}
|
|
4977
|
+
/**
|
|
4978
|
+
* Drop the pictures of references that are no longer stored anywhere.
|
|
4979
|
+
*
|
|
4980
|
+
* Best-effort per blob and never throwing: the reference row is already gone,
|
|
4981
|
+
* and refusing the operator's delete because a JPEG would not unlink would be
|
|
4982
|
+
* failing the request over the follower rather than the fact.
|
|
4983
|
+
*/
|
|
4984
|
+
async dropOrphanedMedia(references) {
|
|
4985
|
+
const drop = this.deps.dropMedia;
|
|
4986
|
+
if (drop === void 0) return;
|
|
4987
|
+
for (const r of references) {
|
|
4988
|
+
if (r.thumbnailMediaId === void 0) continue;
|
|
4989
|
+
await drop(r.thumbnailMediaId).catch((err) => {
|
|
4990
|
+
this.deps.logger.debug("scene reference thumbnail delete failed", { meta: {
|
|
4991
|
+
mediaId: r.thumbnailMediaId,
|
|
4992
|
+
error: String(err)
|
|
4993
|
+
} });
|
|
4994
|
+
});
|
|
4995
|
+
}
|
|
4996
|
+
}
|
|
4885
4997
|
/** Snapshot → ROI crop + whole-frame anchor → two embeddings + a thumbnail. */
|
|
4886
4998
|
async encodeReference(deviceId, monitor, condition) {
|
|
4999
|
+
const capturedAt = this.now();
|
|
4887
5000
|
const snap = await this.deps.getSnapshot(deviceId, true);
|
|
4888
5001
|
if (snap === null) throw new Error("no snapshot available for this camera");
|
|
4889
5002
|
const encoded = Buffer.from(snap.base64, "base64");
|
|
@@ -4901,15 +5014,15 @@ var SceneMonitorProvider = class {
|
|
|
4901
5014
|
});
|
|
4902
5015
|
const thumbnailMediaId = await this.deps.putMedia?.({
|
|
4903
5016
|
deviceId,
|
|
4904
|
-
ownerId: `scene-${monitor.id}-${condition}`,
|
|
5017
|
+
ownerId: `scene-${monitor.id}-${condition}-${capturedAt}`,
|
|
4905
5018
|
data: roi.crop,
|
|
4906
|
-
timestamp:
|
|
5019
|
+
timestamp: capturedAt
|
|
4907
5020
|
}).catch(() => void 0);
|
|
4908
5021
|
return {
|
|
4909
5022
|
embedding,
|
|
4910
5023
|
modelId: info.modelId,
|
|
4911
5024
|
condition,
|
|
4912
|
-
capturedAt
|
|
5025
|
+
capturedAt,
|
|
4913
5026
|
...thumbnailMediaId !== void 0 ? { thumbnailMediaId } : {},
|
|
4914
5027
|
...anchor !== null ? { anchorEmbedding: anchor } : {}
|
|
4915
5028
|
};
|
|
@@ -5405,6 +5518,256 @@ function observeLabel(deviceId, spec, sample, now) {
|
|
|
5405
5518
|
spec
|
|
5406
5519
|
};
|
|
5407
5520
|
}
|
|
5521
|
+
/** JPEG quality for the downscaled full frame — matches the crop path. */
|
|
5522
|
+
var FULL_FRAME_QUALITY = 80;
|
|
5523
|
+
/**
|
|
5524
|
+
* Downscale an already-encoded JPEG full frame to FIT WITHIN
|
|
5525
|
+
* {@link FULL_FRAME_MAX_WIDTH}×{@link FULL_FRAME_MAX_HEIGHT}, preserving aspect
|
|
5526
|
+
* ratio (`fit: 'inside'`) and never enlarging a source already smaller than the
|
|
5527
|
+
* box. Re-encodes as JPEG. Used before persisting a synthetic sensor/control
|
|
5528
|
+
* track's whole-scene snapshot so a raw native-resolution frame (a 4K bedroom
|
|
5529
|
+
* at night) is never stored or served — the privacy fix moved to CAPTURE time.
|
|
5530
|
+
*/
|
|
5531
|
+
async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
|
|
5532
|
+
return sharp(Buffer.from(jpeg)).resize(maxWidth, maxHeight, {
|
|
5533
|
+
fit: "inside",
|
|
5534
|
+
withoutEnlargement: true
|
|
5535
|
+
}).jpeg({ quality: FULL_FRAME_QUALITY }).toBuffer();
|
|
5536
|
+
}
|
|
5537
|
+
//#endregion
|
|
5538
|
+
//#region src/notification-center/audio-still.ts
|
|
5539
|
+
/**
|
|
5540
|
+
* The audio still shelf — the PHOTOGRAPH a sound rule's notification carries.
|
|
5541
|
+
*
|
|
5542
|
+
* ## Why a sound needs one at all
|
|
5543
|
+
*
|
|
5544
|
+
* The attachment ladder resolves media by OWNER, and every other trigger has
|
|
5545
|
+
* one: an object or package event owns its crops, a closed track owns its best
|
|
5546
|
+
* shot, a doorbell press owns the marker track the same press projected
|
|
5547
|
+
* (`sensor-marker-projector.ts`), an occupancy edge names one of the objects it
|
|
5548
|
+
* counted (`chooseOccupancyMediaOwner`). An audio match owns nothing. Nothing
|
|
5549
|
+
* was boxed, nothing was tracked, and — deliberately — nothing is persisted at
|
|
5550
|
+
* all: a confirmed window is a claim about sound that has already stopped, and
|
|
5551
|
+
* `event-intake.ts` states why replaying it later would be wrong.
|
|
5552
|
+
*
|
|
5553
|
+
* So the only honest picture is a PHOTOGRAPH of the camera taken at the moment
|
|
5554
|
+
* of the match. Not of the sound — of what the camera could see while it was
|
|
5555
|
+
* heard.
|
|
5556
|
+
*
|
|
5557
|
+
* ## Three properties, and each one is a decision
|
|
5558
|
+
*
|
|
5559
|
+
* **It is not a record.** The bytes live here, in RAM, under an owner id and a
|
|
5560
|
+
* TTL that covers the outbox's whole retry horizon — and nowhere else. The
|
|
5561
|
+
* alternative was the doorbell's: materialise a synthetic marker track through
|
|
5562
|
+
* `SyntheticTrackMaterializer` and let the notification name it. That would put
|
|
5563
|
+
* a durable Track on the camera's timeline for every confirmed window, feeding
|
|
5564
|
+
* the digest's `listTracks`, retention, and the audio-marker feature's own
|
|
5565
|
+
* operator ceilings (`audio-marker-projector.ts` exists precisely to bound how
|
|
5566
|
+
* many audio markers a camera may emit). A notification must not manufacture
|
|
5567
|
+
* timeline history as a side effect of wanting a picture.
|
|
5568
|
+
*
|
|
5569
|
+
* **The capture STARTS immediately and is never awaited.** A sound is transient
|
|
5570
|
+
* — a scream is over before a snapshot round-trip completes — so the fetch is
|
|
5571
|
+
* kicked off at the confirmation, before the rule evaluation runs, and the
|
|
5572
|
+
* owner id is minted synchronously so the outbox row can name it. The bytes
|
|
5573
|
+
* land while the row waits in the queue, and the dispatcher's existing bounded
|
|
5574
|
+
* still-wait (or the pause its own footage render already costs) picks them up.
|
|
5575
|
+
* A camera that never answers costs the picture and never the notification.
|
|
5576
|
+
*
|
|
5577
|
+
* **Two confirmations seconds apart share ONE capture.** A barking dog confirms
|
|
5578
|
+
* repeatedly and a label-mode rule has no re-arm timer at all (D157) — the
|
|
5579
|
+
* rule's cooldown is its only brake, and the cooldown is applied AFTER this.
|
|
5580
|
+
* Without a reuse window this would photograph a camera at whatever rate the
|
|
5581
|
+
* sound happens to occur. Each confirmation still gets its OWN owner id, so two
|
|
5582
|
+
* outbox rows are never mistaken for one subject; they merely point at the same
|
|
5583
|
+
* frame, which is the truth — the scene did not change in ten seconds.
|
|
5584
|
+
*
|
|
5585
|
+
* Nothing here is silent: a capture that lands and a camera that refuses each
|
|
5586
|
+
* emit one line carrying `tags: { deviceId }`, because "why did 617 get a photo
|
|
5587
|
+
* and 615 not" is the only form that question is ever asked in.
|
|
5588
|
+
*/
|
|
5589
|
+
/**
|
|
5590
|
+
* The owner-id namespace. It is what routes a lookup here instead of to the
|
|
5591
|
+
* media store, and it is the reason the dispatcher needs no audio branch —
|
|
5592
|
+
* `getMediaForOwner` answers for both under one signature.
|
|
5593
|
+
*/
|
|
5594
|
+
var NC_AUDIO_STILL_PREFIX = "nc-audio-still:";
|
|
5595
|
+
/** True for an owner id this shelf minted. */
|
|
5596
|
+
function isAudioStillId(id) {
|
|
5597
|
+
return id.startsWith(NC_AUDIO_STILL_PREFIX);
|
|
5598
|
+
}
|
|
5599
|
+
/**
|
|
5600
|
+
* How long the bytes are held.
|
|
5601
|
+
*
|
|
5602
|
+
* The outbox retries 8 times with a 5 s → 300 s backoff, which tops out around
|
|
5603
|
+
* ten minutes; fifteen covers that with room for a slow drain. Past it the row
|
|
5604
|
+
* ships text-only, which is the correct degradation for a photograph of a scene
|
|
5605
|
+
* that is a quarter of an hour stale anyway.
|
|
5606
|
+
*/
|
|
5607
|
+
var NC_AUDIO_STILL_TTL_MS = 15 * 6e4;
|
|
5608
|
+
/** A hanging snapshot cap must not pin a capture slot forever. */
|
|
5609
|
+
var SNAPSHOT_TIMEOUT_MS = 8e3;
|
|
5610
|
+
/**
|
|
5611
|
+
* Hard bound on held captures. Reached only if every camera on the hub confirms
|
|
5612
|
+
* an audio rule inside one TTL; the oldest is dropped first, which costs a
|
|
5613
|
+
* fifteen-minute-old picture nobody is waiting for.
|
|
5614
|
+
*/
|
|
5615
|
+
var MAX_CAPTURES = 64;
|
|
5616
|
+
var NcAudioStillShelf = class {
|
|
5617
|
+
deps;
|
|
5618
|
+
/** captureId → the photograph. */
|
|
5619
|
+
captures = /* @__PURE__ */ new Map();
|
|
5620
|
+
/** ownerId → captureId. Several owners may name one capture (the reuse window). */
|
|
5621
|
+
owners = /* @__PURE__ */ new Map();
|
|
5622
|
+
/** deviceId → its newest capture, for the reuse window. */
|
|
5623
|
+
newest = /* @__PURE__ */ new Map();
|
|
5624
|
+
constructor(deps) {
|
|
5625
|
+
this.deps = deps;
|
|
5626
|
+
}
|
|
5627
|
+
/**
|
|
5628
|
+
* Photograph `deviceId` for a match at `atMs`, and return the owner id the
|
|
5629
|
+
* subject should name. SYNCHRONOUS by contract: the caller is on the audio
|
|
5630
|
+
* confirmation path and the outbox row is built from what this returns.
|
|
5631
|
+
*/
|
|
5632
|
+
capture(deviceId, atMs) {
|
|
5633
|
+
const now = this.deps.now();
|
|
5634
|
+
this.prune(now);
|
|
5635
|
+
const ownerId = `${NC_AUDIO_STILL_PREFIX}${randomUUID()}`;
|
|
5636
|
+
const recent = this.newest.get(deviceId);
|
|
5637
|
+
if (recent !== void 0 && now - recent.at < 1e4 && this.captures.has(recent.captureId)) {
|
|
5638
|
+
this.owners.set(ownerId, recent.captureId);
|
|
5639
|
+
return ownerId;
|
|
5640
|
+
}
|
|
5641
|
+
const captureId = randomUUID();
|
|
5642
|
+
this.captures.set(captureId, {
|
|
5643
|
+
deviceId,
|
|
5644
|
+
startedAt: now,
|
|
5645
|
+
expiresAt: now + NC_AUDIO_STILL_TTL_MS,
|
|
5646
|
+
files: []
|
|
5647
|
+
});
|
|
5648
|
+
this.newest.set(deviceId, {
|
|
5649
|
+
captureId,
|
|
5650
|
+
at: now
|
|
5651
|
+
});
|
|
5652
|
+
this.owners.set(ownerId, captureId);
|
|
5653
|
+
this.fetch(captureId, deviceId, atMs);
|
|
5654
|
+
return ownerId;
|
|
5655
|
+
}
|
|
5656
|
+
/**
|
|
5657
|
+
* The media an owner id holds, or `undefined` when this shelf never minted
|
|
5658
|
+
* it. An EMPTY array is a different answer: the capture exists and has not
|
|
5659
|
+
* landed (or never will), which is exactly the case the dispatcher's bounded
|
|
5660
|
+
* wait and its `no still could be resolved` line are for.
|
|
5661
|
+
*/
|
|
5662
|
+
get(ownerId) {
|
|
5663
|
+
const captureId = this.owners.get(ownerId);
|
|
5664
|
+
if (captureId === void 0) return void 0;
|
|
5665
|
+
return this.captures.get(captureId)?.files ?? [];
|
|
5666
|
+
}
|
|
5667
|
+
/** Drop expired captures and the owners that named them. */
|
|
5668
|
+
prune(now) {
|
|
5669
|
+
for (const [captureId, held] of this.captures) {
|
|
5670
|
+
if (held.expiresAt > now) continue;
|
|
5671
|
+
this.captures.delete(captureId);
|
|
5672
|
+
if (this.newest.get(held.deviceId)?.captureId === captureId) this.newest.delete(held.deviceId);
|
|
5673
|
+
}
|
|
5674
|
+
while (this.captures.size > MAX_CAPTURES) {
|
|
5675
|
+
const oldest = this.captures.keys().next();
|
|
5676
|
+
if (oldest.done === true) break;
|
|
5677
|
+
const held = this.captures.get(oldest.value);
|
|
5678
|
+
this.captures.delete(oldest.value);
|
|
5679
|
+
if (held !== void 0 && this.newest.get(held.deviceId)?.captureId === oldest.value) this.newest.delete(held.deviceId);
|
|
5680
|
+
}
|
|
5681
|
+
for (const [ownerId, captureId] of this.owners) if (!this.captures.has(captureId)) this.owners.delete(ownerId);
|
|
5682
|
+
}
|
|
5683
|
+
/** Drop everything (shutdown). */
|
|
5684
|
+
clear() {
|
|
5685
|
+
this.captures.clear();
|
|
5686
|
+
this.owners.clear();
|
|
5687
|
+
this.newest.clear();
|
|
5688
|
+
}
|
|
5689
|
+
async fetch(captureId, deviceId, atMs) {
|
|
5690
|
+
try {
|
|
5691
|
+
const shot = await withTimeout$2(this.deps.getSnapshot(deviceId), SNAPSHOT_TIMEOUT_MS);
|
|
5692
|
+
if (shot === null || shot.base64.length === 0) {
|
|
5693
|
+
this.reportMiss(deviceId, "the camera returned no snapshot");
|
|
5694
|
+
return;
|
|
5695
|
+
}
|
|
5696
|
+
const raw = Buffer.from(shot.base64, "base64");
|
|
5697
|
+
let data = raw;
|
|
5698
|
+
try {
|
|
5699
|
+
data = await downscaleFullFrameJpeg(raw, 960, 540);
|
|
5700
|
+
} catch {}
|
|
5701
|
+
const held = this.captures.get(captureId);
|
|
5702
|
+
if (held === void 0) return;
|
|
5703
|
+
this.captures.set(captureId, {
|
|
5704
|
+
...held,
|
|
5705
|
+
files: audioStillMedia(data, atMs)
|
|
5706
|
+
});
|
|
5707
|
+
this.deps.logger.info("audio still captured", {
|
|
5708
|
+
tags: { deviceId },
|
|
5709
|
+
meta: {
|
|
5710
|
+
bytes: data.byteLength,
|
|
5711
|
+
tookMs: this.deps.now() - held.startedAt
|
|
5712
|
+
}
|
|
5713
|
+
});
|
|
5714
|
+
} catch (err) {
|
|
5715
|
+
this.reportMiss(deviceId, err instanceof Error ? err.message : String(err));
|
|
5716
|
+
}
|
|
5717
|
+
}
|
|
5718
|
+
/**
|
|
5719
|
+
* A branch that drops work says so. This one costs the operator the picture
|
|
5720
|
+
* on a notification they DID receive, so it is a warn and it carries the
|
|
5721
|
+
* camera — the only key the question is ever asked with.
|
|
5722
|
+
*/
|
|
5723
|
+
reportMiss(deviceId, reason) {
|
|
5724
|
+
this.deps.logger.warn("the camera did not answer the audio still — this notification ships text-only", {
|
|
5725
|
+
tags: { deviceId },
|
|
5726
|
+
meta: { reason }
|
|
5727
|
+
});
|
|
5728
|
+
}
|
|
5729
|
+
};
|
|
5730
|
+
/**
|
|
5731
|
+
* The photograph, in the three kinds the CLEAN-SCENE ladders ask for.
|
|
5732
|
+
*
|
|
5733
|
+
* `keyFrameSmall` leads because it is the first rung of both `attach: 'best'`
|
|
5734
|
+
* and `attach: 'keyFrame'` on a track owner; `keyFrame` and `fullFrame` answer
|
|
5735
|
+
* `frame: 'full'` and `frame: 'boxed'`'s honest degrade. There is deliberately
|
|
5736
|
+
* no `crop` / `thumbnail`: nothing was boxed, so `frame: 'cropped'` resolves
|
|
5737
|
+
* NOTHING and the notification ships text-only with the ordinary line saying
|
|
5738
|
+
* so. Inventing a centre crop would answer a different question from the one
|
|
5739
|
+
* the operator asked.
|
|
5740
|
+
*/
|
|
5741
|
+
function audioStillMedia(data, timestamp) {
|
|
5742
|
+
const base64 = data.toString("base64");
|
|
5743
|
+
const file = (kind) => ({
|
|
5744
|
+
key: `${NC_AUDIO_STILL_PREFIX}${kind}`,
|
|
5745
|
+
kind,
|
|
5746
|
+
base64,
|
|
5747
|
+
sizeBytes: data.byteLength,
|
|
5748
|
+
timestamp
|
|
5749
|
+
});
|
|
5750
|
+
return [
|
|
5751
|
+
file("keyFrameSmall"),
|
|
5752
|
+
file("keyFrame"),
|
|
5753
|
+
file("fullFrame")
|
|
5754
|
+
];
|
|
5755
|
+
}
|
|
5756
|
+
/** Reject if `promise` does not settle within `ms`. */
|
|
5757
|
+
function withTimeout$2(promise, ms) {
|
|
5758
|
+
return new Promise((resolve, reject) => {
|
|
5759
|
+
const timer = setTimeout(() => {
|
|
5760
|
+
reject(/* @__PURE__ */ new Error(`snapshot cap timed out after ${String(ms)}ms`));
|
|
5761
|
+
}, ms);
|
|
5762
|
+
promise.then((value) => {
|
|
5763
|
+
clearTimeout(timer);
|
|
5764
|
+
resolve(value);
|
|
5765
|
+
}, (err) => {
|
|
5766
|
+
clearTimeout(timer);
|
|
5767
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
5768
|
+
});
|
|
5769
|
+
});
|
|
5770
|
+
}
|
|
5408
5771
|
//#endregion
|
|
5409
5772
|
//#region src/shared/llm-vision/prompt-hygiene.ts
|
|
5410
5773
|
/**
|
|
@@ -6015,6 +6378,298 @@ var NcDeviceStateCache = class {
|
|
|
6015
6378
|
}
|
|
6016
6379
|
};
|
|
6017
6380
|
//#endregion
|
|
6381
|
+
//#region src/shared/frame/box-drawer.ts
|
|
6382
|
+
var DEFAULT_COLOR = DEFAULT_EVENT_COLOR;
|
|
6383
|
+
var DEFAULT_QUALITY = 80;
|
|
6384
|
+
var STROKE_WIDTH = 3;
|
|
6385
|
+
function escapeXml$2(s) {
|
|
6386
|
+
return s.replace(/[<>&'"]/g, (ch) => {
|
|
6387
|
+
switch (ch) {
|
|
6388
|
+
case "<": return "<";
|
|
6389
|
+
case ">": return ">";
|
|
6390
|
+
case "&": return "&";
|
|
6391
|
+
case "'": return "'";
|
|
6392
|
+
default: return """;
|
|
6393
|
+
}
|
|
6394
|
+
});
|
|
6395
|
+
}
|
|
6396
|
+
/** Clamp a pixel box to the frame so the rect always stays inside [0,W]×[0,H]. */
|
|
6397
|
+
function clampBox(b, frameWidth, frameHeight) {
|
|
6398
|
+
const x = Math.max(0, Math.min(Math.round(b.x), frameWidth - 1));
|
|
6399
|
+
const y = Math.max(0, Math.min(Math.round(b.y), frameHeight - 1));
|
|
6400
|
+
return {
|
|
6401
|
+
x,
|
|
6402
|
+
y,
|
|
6403
|
+
w: Math.max(1, Math.min(Math.round(b.w), frameWidth - x)),
|
|
6404
|
+
h: Math.max(1, Math.min(Math.round(b.h), frameHeight - y))
|
|
6405
|
+
};
|
|
6406
|
+
}
|
|
6407
|
+
/**
|
|
6408
|
+
* Draw bounding boxes over a raw RGB frame and JPEG-encode it.
|
|
6409
|
+
*
|
|
6410
|
+
* The frame is composited with an SVG overlay (one `<rect>` per box, plus an
|
|
6411
|
+
* optional caption) at full resolution, then optionally downscaled — so the
|
|
6412
|
+
* box stays crisp relative to the scene. Boxes are clamped to frame bounds to
|
|
6413
|
+
* avoid sharp `extract`/region errors on slightly-out-of-range detections.
|
|
6414
|
+
*/
|
|
6415
|
+
async function drawBoxedFrame(frameData, frameWidth, frameHeight, boxes, opts = {}) {
|
|
6416
|
+
const quality = opts.quality ?? DEFAULT_QUALITY;
|
|
6417
|
+
let base = frameData;
|
|
6418
|
+
let baseIsRaw = true;
|
|
6419
|
+
if (boxes.length > 0) {
|
|
6420
|
+
const fontSize = Math.max(12, Math.round(frameHeight / 30));
|
|
6421
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}">${boxes.map((b) => {
|
|
6422
|
+
const c = clampBox(b, frameWidth, frameHeight);
|
|
6423
|
+
const color = b.color ?? DEFAULT_COLOR;
|
|
6424
|
+
const rect = `<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${c.h}" fill="none" stroke="${color}" stroke-width="${STROKE_WIDTH}"/>`;
|
|
6425
|
+
if (!b.label) return rect;
|
|
6426
|
+
const ty = Math.max(fontSize, c.y - 4);
|
|
6427
|
+
return rect + `<text x="${c.x}" y="${ty}" font-family="sans-serif" font-size="${fontSize}" fill="${color}" stroke="black" stroke-width="0.5">${escapeXml$2(b.label)}</text>`;
|
|
6428
|
+
}).join("")}</svg>`;
|
|
6429
|
+
base = await sharp(frameData, { raw: {
|
|
6430
|
+
width: frameWidth,
|
|
6431
|
+
height: frameHeight,
|
|
6432
|
+
channels: 3
|
|
6433
|
+
} }).composite([{
|
|
6434
|
+
input: Buffer.from(svg),
|
|
6435
|
+
top: 0,
|
|
6436
|
+
left: 0
|
|
6437
|
+
}]).png().toBuffer();
|
|
6438
|
+
baseIsRaw = false;
|
|
6439
|
+
}
|
|
6440
|
+
let pipeline = baseIsRaw ? sharp(base, { raw: {
|
|
6441
|
+
width: frameWidth,
|
|
6442
|
+
height: frameHeight,
|
|
6443
|
+
channels: 3
|
|
6444
|
+
} }) : sharp(base);
|
|
6445
|
+
if (opts.maxWidth !== void 0) pipeline = pipeline.resize({
|
|
6446
|
+
width: opts.maxWidth,
|
|
6447
|
+
withoutEnlargement: true
|
|
6448
|
+
});
|
|
6449
|
+
return pipeline.jpeg({ quality }).toBuffer();
|
|
6450
|
+
}
|
|
6451
|
+
/** JPEG quality of the composed artefact — the mosaic's number, same reasons. */
|
|
6452
|
+
var GROUP_FRAME_QUALITY = 82;
|
|
6453
|
+
/**
|
|
6454
|
+
* Draw the members onto the frame. Returns `null` when there is nothing to
|
|
6455
|
+
* draw, or when anything at all goes wrong — the caller then ships the
|
|
6456
|
+
* unannotated frame unchanged.
|
|
6457
|
+
*/
|
|
6458
|
+
async function composeGroupFrame(input) {
|
|
6459
|
+
const freshnessMs = input.freshnessMs ?? 3e3;
|
|
6460
|
+
const fresh = input.boxes.filter((b) => Math.abs(input.at - b.observedAt) <= freshnessMs);
|
|
6461
|
+
const withheld = input.boxes.length - fresh.length;
|
|
6462
|
+
if (fresh.length < 2) {
|
|
6463
|
+
if (withheld > 0) input.logger.info("group frame not drawn — too few members with fresh geometry", {
|
|
6464
|
+
tags: { deviceId: input.deviceId },
|
|
6465
|
+
meta: {
|
|
6466
|
+
groupKey: input.groupKey,
|
|
6467
|
+
members: input.boxes.length,
|
|
6468
|
+
fresh: fresh.length,
|
|
6469
|
+
withheld
|
|
6470
|
+
}
|
|
6471
|
+
});
|
|
6472
|
+
return null;
|
|
6473
|
+
}
|
|
6474
|
+
try {
|
|
6475
|
+
const decoded = await sharp(input.jpeg).raw().toBuffer({ resolveWithObject: true });
|
|
6476
|
+
const { width, height, channels } = decoded.info;
|
|
6477
|
+
if (!(width > 0 && height > 0) || channels !== 3) {
|
|
6478
|
+
input.logger.warn("group frame not drawn — unexpected raster shape", {
|
|
6479
|
+
tags: { deviceId: input.deviceId },
|
|
6480
|
+
meta: {
|
|
6481
|
+
groupKey: input.groupKey,
|
|
6482
|
+
width,
|
|
6483
|
+
height,
|
|
6484
|
+
channels
|
|
6485
|
+
}
|
|
6486
|
+
});
|
|
6487
|
+
return null;
|
|
6488
|
+
}
|
|
6489
|
+
const boxes = fresh.map((b) => ({
|
|
6490
|
+
x: b.bbox.x * width,
|
|
6491
|
+
y: b.bbox.y * height,
|
|
6492
|
+
w: b.bbox.w * width,
|
|
6493
|
+
h: b.bbox.h * height,
|
|
6494
|
+
...b.label !== void 0 && b.label.length > 0 ? { label: b.label } : {}
|
|
6495
|
+
}));
|
|
6496
|
+
const bytes = await drawBoxedFrame(decoded.data, width, height, boxes, { quality: GROUP_FRAME_QUALITY });
|
|
6497
|
+
input.logger.debug("group frame composed", {
|
|
6498
|
+
tags: { deviceId: input.deviceId },
|
|
6499
|
+
meta: {
|
|
6500
|
+
groupKey: input.groupKey,
|
|
6501
|
+
drawn: boxes.length,
|
|
6502
|
+
withheld,
|
|
6503
|
+
width,
|
|
6504
|
+
height
|
|
6505
|
+
}
|
|
6506
|
+
});
|
|
6507
|
+
return {
|
|
6508
|
+
bytes,
|
|
6509
|
+
drawn: boxes.length,
|
|
6510
|
+
withheld
|
|
6511
|
+
};
|
|
6512
|
+
} catch (err) {
|
|
6513
|
+
input.logger.warn("group frame compose failed — shipping the unannotated frame", {
|
|
6514
|
+
tags: { deviceId: input.deviceId },
|
|
6515
|
+
meta: {
|
|
6516
|
+
groupKey: input.groupKey,
|
|
6517
|
+
error: err instanceof Error ? err.message : String(err)
|
|
6518
|
+
}
|
|
6519
|
+
});
|
|
6520
|
+
return null;
|
|
6521
|
+
}
|
|
6522
|
+
}
|
|
6523
|
+
//#endregion
|
|
6524
|
+
//#region src/notification-center/text-compose.ts
|
|
6525
|
+
/** One normalisation, used on both sides of every class lookup — a pipeline
|
|
6526
|
+
* that emitted `Person` must find the key written `class.person`. */
|
|
6527
|
+
function normaliseClass(className) {
|
|
6528
|
+
return className.trim().toLowerCase();
|
|
6529
|
+
}
|
|
6530
|
+
/**
|
|
6531
|
+
* The noun for a class, in the count's plural category.
|
|
6532
|
+
*
|
|
6533
|
+
* A class no locale names falls through to `class.other`, which prints the RAW
|
|
6534
|
+
* name: a custom model's class must appear in the sentence rather than vanish
|
|
6535
|
+
* out of it, and a missing translation is a smaller defect than a missing
|
|
6536
|
+
* detection.
|
|
6537
|
+
*/
|
|
6538
|
+
function ncClassNoun(texts, className, count) {
|
|
6539
|
+
const key = `class.${normaliseClass(className)}`;
|
|
6540
|
+
if (texts.has(key)) return texts.text({
|
|
6541
|
+
key,
|
|
6542
|
+
count,
|
|
6543
|
+
vars: { name: className }
|
|
6544
|
+
});
|
|
6545
|
+
return texts.text({
|
|
6546
|
+
key: "class.other",
|
|
6547
|
+
count,
|
|
6548
|
+
vars: { name: className }
|
|
6549
|
+
});
|
|
6550
|
+
}
|
|
6551
|
+
/** `person (John)` / `persona (John)` — the thing the notification is about. */
|
|
6552
|
+
function ncSubjectText(texts, input) {
|
|
6553
|
+
const classNoun = ncClassNoun(texts, input.className, 1);
|
|
6554
|
+
if (input.label === void 0) return texts.text({
|
|
6555
|
+
key: "detection.subject",
|
|
6556
|
+
vars: { classNoun }
|
|
6557
|
+
});
|
|
6558
|
+
return texts.text({
|
|
6559
|
+
key: subjectLabelKey(input.labelKind),
|
|
6560
|
+
vars: {
|
|
6561
|
+
classNoun,
|
|
6562
|
+
label: input.label
|
|
6563
|
+
}
|
|
6564
|
+
});
|
|
6565
|
+
}
|
|
6566
|
+
/**
|
|
6567
|
+
* Which labelled-subject key a recognition gets. A kind the catalog does not
|
|
6568
|
+
* name falls back to the generic key rather than printing nothing — the same
|
|
6569
|
+
* direction `ncClassNoun` degrades in, and for the same reason.
|
|
6570
|
+
*/
|
|
6571
|
+
function subjectLabelKey(kind) {
|
|
6572
|
+
if (kind === "identity") return "detection.subject.identity";
|
|
6573
|
+
if (kind === "plate") return "detection.subject.plate";
|
|
6574
|
+
return "detection.subject.labelled";
|
|
6575
|
+
}
|
|
6576
|
+
/**
|
|
6577
|
+
* ` in gate, drive` — INCLUDING its leading space, and empty when there are no
|
|
6578
|
+
* zones.
|
|
6579
|
+
*
|
|
6580
|
+
* The space belongs to the fragment rather than to the body key because the
|
|
6581
|
+
* alternative is a body that ends in a trailing space on every zone-less
|
|
6582
|
+
* notification. A translator moving the clause moves the space with it.
|
|
6583
|
+
*/
|
|
6584
|
+
function ncInZonesText(texts, zoneLabels) {
|
|
6585
|
+
if (zoneLabels.length === 0) return "";
|
|
6586
|
+
return texts.text({
|
|
6587
|
+
key: "detection.inZones",
|
|
6588
|
+
vars: { zones: zoneLabels.join(", ") }
|
|
6589
|
+
});
|
|
6590
|
+
}
|
|
6591
|
+
/** The human-readable edge polarity behind `{{op}}` and the occupancy body. */
|
|
6592
|
+
function ncOccupancyOp(texts, occupied) {
|
|
6593
|
+
return texts.text({ key: occupied ? "occupancy.op.occupied" : "occupancy.op.free" });
|
|
6594
|
+
}
|
|
6595
|
+
/**
|
|
6596
|
+
* `1 persona, 2 veicoli e 3 animali` — the language's own conjunction, its own
|
|
6597
|
+
* plural rules, and its own nouns.
|
|
6598
|
+
*
|
|
6599
|
+
* Zero-count classes are dropped: a selected class the window never contained
|
|
6600
|
+
* is a legitimate `0` in `{{count_vehicle}}`, but "0 veicoli" inside a summary
|
|
6601
|
+
* sentence is a line nobody reads. Order is the caller's (busiest first) and is
|
|
6602
|
+
* preserved, so the same night always produces the same sentence.
|
|
6603
|
+
*/
|
|
6604
|
+
function ncDetectionSummary(texts, counts) {
|
|
6605
|
+
const parts = counts.filter((entry) => entry.count > 0).map((entry) => `${entry.count} ${ncClassNoun(texts, entry.className, entry.count)}`);
|
|
6606
|
+
return texts.join(parts);
|
|
6607
|
+
}
|
|
6608
|
+
//#endregion
|
|
6609
|
+
//#region src/notification-center/group/nc-group-text.ts
|
|
6610
|
+
var DEFAULT_MAX_NAMES = 2;
|
|
6611
|
+
/**
|
|
6612
|
+
* `N altre persone` — the remainder phrase for one class, in its own gender.
|
|
6613
|
+
*
|
|
6614
|
+
* Mirrors {@link ncClassNoun} byte for byte, including the fallback: a class no
|
|
6615
|
+
* locale names falls through to `group.others.other`, which prints the raw class
|
|
6616
|
+
* name rather than vanishing. A custom model's class must appear in the sentence.
|
|
6617
|
+
*/
|
|
6618
|
+
function ncGroupOthers(texts, className, count) {
|
|
6619
|
+
const classNoun = ncClassNoun(texts, className, count);
|
|
6620
|
+
const key = `group.others.${className.trim().toLowerCase()}`;
|
|
6621
|
+
if (texts.has(key)) return texts.text({
|
|
6622
|
+
key,
|
|
6623
|
+
count,
|
|
6624
|
+
vars: {
|
|
6625
|
+
count: `${count}`,
|
|
6626
|
+
classNoun
|
|
6627
|
+
}
|
|
6628
|
+
});
|
|
6629
|
+
return texts.text({
|
|
6630
|
+
key: "group.others.other",
|
|
6631
|
+
count,
|
|
6632
|
+
vars: {
|
|
6633
|
+
count: `${count}`,
|
|
6634
|
+
classNoun
|
|
6635
|
+
}
|
|
6636
|
+
});
|
|
6637
|
+
}
|
|
6638
|
+
/** Counts per class, in first-seen order so the same burst always reads the same. */
|
|
6639
|
+
function countByClass(members) {
|
|
6640
|
+
const counts = /* @__PURE__ */ new Map();
|
|
6641
|
+
for (const m of members) counts.set(m.className, (counts.get(m.className) ?? 0) + 1);
|
|
6642
|
+
return [...counts].map(([className, count]) => ({
|
|
6643
|
+
className,
|
|
6644
|
+
count
|
|
6645
|
+
}));
|
|
6646
|
+
}
|
|
6647
|
+
/**
|
|
6648
|
+
* The finished `{{subject}}` phrase for a group.
|
|
6649
|
+
*
|
|
6650
|
+
* Pure: a catalog in, a string out. No clock, no store, no I/O — like every
|
|
6651
|
+
* other composer in `text-compose.ts`, and for the same reason.
|
|
6652
|
+
*/
|
|
6653
|
+
function ncGroupSubjectText(texts, input) {
|
|
6654
|
+
const members = input.members;
|
|
6655
|
+
const only = members.length === 1 ? members[0] : void 0;
|
|
6656
|
+
if (only !== void 0) return ncSubjectText(texts, {
|
|
6657
|
+
className: only.className,
|
|
6658
|
+
...only.label !== void 0 ? { label: only.label } : {},
|
|
6659
|
+
...only.labelKind !== void 0 ? { labelKind: only.labelKind } : {}
|
|
6660
|
+
});
|
|
6661
|
+
if (members.length === 0) return "";
|
|
6662
|
+
const maxNames = input.maxNames !== void 0 && input.maxNames >= 0 ? input.maxNames : DEFAULT_MAX_NAMES;
|
|
6663
|
+
const named = members.filter((m) => m.label !== void 0 && m.label.length > 0);
|
|
6664
|
+
const names = named.slice(0, maxNames).map((m) => m.label ?? "");
|
|
6665
|
+
if (names.length === 0) return ncDetectionSummary(texts, countByClass(members));
|
|
6666
|
+
const spoken = new Set(named.slice(0, maxNames));
|
|
6667
|
+
const remainder = members.filter((m) => !spoken.has(m));
|
|
6668
|
+
if (remainder.length === 0) return texts.join(names);
|
|
6669
|
+
const others = countByClass(remainder).map((c) => ncGroupOthers(texts, c.className, c.count));
|
|
6670
|
+
return texts.join([...names, ...others]);
|
|
6671
|
+
}
|
|
6672
|
+
//#endregion
|
|
6018
6673
|
//#region src/notification-center/render-template.ts
|
|
6019
6674
|
/**
|
|
6020
6675
|
* The ONE `{{var}}` renderer of the notification centre.
|
|
@@ -6479,6 +7134,110 @@ function maskPolygonOverlap(mask, maskWidth, maskHeight, bbox, polygon, _frameWi
|
|
|
6479
7134
|
if (totalMaskPixels === 0) return 0;
|
|
6480
7135
|
return insidePolygon / totalMaskPixels;
|
|
6481
7136
|
}
|
|
7137
|
+
var NcSceneStateCache = class {
|
|
7138
|
+
deps;
|
|
7139
|
+
entries = /* @__PURE__ */ new Map();
|
|
7140
|
+
noted = /* @__PURE__ */ new Map();
|
|
7141
|
+
ids = [];
|
|
7142
|
+
now;
|
|
7143
|
+
constructor(deps) {
|
|
7144
|
+
this.deps = deps;
|
|
7145
|
+
this.now = deps.now ?? (() => Date.now());
|
|
7146
|
+
}
|
|
7147
|
+
/**
|
|
7148
|
+
* The scene ids worth reading — recomputed from the rule set, so a cluster
|
|
7149
|
+
* with no scene-gated rule does no work at all and logs nothing.
|
|
7150
|
+
*/
|
|
7151
|
+
setWatched(ids) {
|
|
7152
|
+
this.ids = [...new Set(ids)].sort();
|
|
7153
|
+
}
|
|
7154
|
+
watched() {
|
|
7155
|
+
return this.ids;
|
|
7156
|
+
}
|
|
7157
|
+
/**
|
|
7158
|
+
* "This scene, in this process, is in this state, NOW."
|
|
7159
|
+
*
|
|
7160
|
+
* Synchronous by contract — the caller is the scene provider's own edge tap
|
|
7161
|
+
* and the next rule evaluation must already see it.
|
|
7162
|
+
*/
|
|
7163
|
+
note(observation) {
|
|
7164
|
+
this.noted.set(observation.sceneId, {
|
|
7165
|
+
observation,
|
|
7166
|
+
at: this.now()
|
|
7167
|
+
});
|
|
7168
|
+
}
|
|
7169
|
+
/** Hot-path read. No I/O, no await — this is called per event. */
|
|
7170
|
+
get(sceneId) {
|
|
7171
|
+
const now = this.now();
|
|
7172
|
+
const noted = this.noted.get(sceneId);
|
|
7173
|
+
if (noted !== void 0) {
|
|
7174
|
+
if (now - noted.at <= 3e5) return noted.observation;
|
|
7175
|
+
this.noted.delete(sceneId);
|
|
7176
|
+
}
|
|
7177
|
+
const entry = this.entries.get(sceneId);
|
|
7178
|
+
if (entry === void 0) return void 0;
|
|
7179
|
+
if (now - entry.at > 3e5) return void 0;
|
|
7180
|
+
return entry.observation;
|
|
7181
|
+
}
|
|
7182
|
+
/**
|
|
7183
|
+
* Re-read every watched scene. Never throws: a gate that could fail the
|
|
7184
|
+
* evaluation tick would take the notification pipeline with it.
|
|
7185
|
+
*
|
|
7186
|
+
* On failure the previous entries are KEPT — their timestamps are not
|
|
7187
|
+
* touched, so they age out on their own rather than vanishing at once.
|
|
7188
|
+
*/
|
|
7189
|
+
refresh() {
|
|
7190
|
+
if (this.ids.length === 0) {
|
|
7191
|
+
this.entries = /* @__PURE__ */ new Map();
|
|
7192
|
+
this.noted.clear();
|
|
7193
|
+
return;
|
|
7194
|
+
}
|
|
7195
|
+
let observations;
|
|
7196
|
+
try {
|
|
7197
|
+
observations = this.deps.readScenes();
|
|
7198
|
+
} catch (err) {
|
|
7199
|
+
this.deps.logger.warn("scene-state refresh failed — keeping the previous states until they age out", { meta: {
|
|
7200
|
+
watched: this.ids.length,
|
|
7201
|
+
error: String(err)
|
|
7202
|
+
} });
|
|
7203
|
+
return;
|
|
7204
|
+
}
|
|
7205
|
+
const at = this.now();
|
|
7206
|
+
const wanted = new Set(this.ids);
|
|
7207
|
+
const next = /* @__PURE__ */ new Map();
|
|
7208
|
+
for (const observation of observations) {
|
|
7209
|
+
if (!wanted.has(observation.sceneId)) continue;
|
|
7210
|
+
next.set(observation.sceneId, {
|
|
7211
|
+
observation,
|
|
7212
|
+
at
|
|
7213
|
+
});
|
|
7214
|
+
}
|
|
7215
|
+
this.entries = next;
|
|
7216
|
+
for (const sceneId of this.ids) {
|
|
7217
|
+
if (next.has(sceneId)) continue;
|
|
7218
|
+
this.deps.logger.info("scene gate: no state for a watched scene", { meta: { sceneId } });
|
|
7219
|
+
}
|
|
7220
|
+
}
|
|
7221
|
+
};
|
|
7222
|
+
/**
|
|
7223
|
+
* Does this observation satisfy the rule's scene condition?
|
|
7224
|
+
*
|
|
7225
|
+
* Exported and pure so the semantics can be tested without a cache, a provider
|
|
7226
|
+
* or a rule — and so there is exactly ONE place that decides what `latched`
|
|
7227
|
+
* absent means. See `NcSceneConditionSchema` for the reasoning behind each
|
|
7228
|
+
* branch.
|
|
7229
|
+
*/
|
|
7230
|
+
function sceneConditionSatisfied(condition, observation) {
|
|
7231
|
+
if (condition.latched ?? observation.emit === "latched") return observation.latched === (condition.requiredState === "diverged");
|
|
7232
|
+
return observation.verdict === condition.requiredState;
|
|
7233
|
+
}
|
|
7234
|
+
/** One-line "why", for the suppression log. Never a bare boolean: a drop the
|
|
7235
|
+
* operator cannot explain is the failure this whole feature is one misstep
|
|
7236
|
+
* away from. */
|
|
7237
|
+
function sceneGateReason(condition, observation) {
|
|
7238
|
+
if (observation === void 0) return "no mirrored state for this scene";
|
|
7239
|
+
return condition.latched ?? observation.emit === "latched" ? `latched=${observation.latched} (wanted ${condition.requiredState})` : `verdict=${observation.verdict} (wanted ${condition.requiredState})`;
|
|
7240
|
+
}
|
|
6482
7241
|
//#endregion
|
|
6483
7242
|
//#region src/notification-center/zone-ownership.ts
|
|
6484
7243
|
var UNCHANGED = { kind: "unchanged" };
|
|
@@ -6634,10 +7393,17 @@ function subjectFromSensorEvent(ev, markerTrackId) {
|
|
|
6634
7393
|
* `minConfidence` condition composes naturally). A level-path audio event (no
|
|
6635
7394
|
* `classification`) yields NO class ⇒ it can never satisfy the audio opt-in
|
|
6636
7395
|
* gate, so only classified audio ever notifies (documented boundary). Audio has
|
|
6637
|
-
* no zones / bbox / label
|
|
6638
|
-
*
|
|
7396
|
+
* no zones / bbox / label, so the object-specific conditions all fail closed
|
|
7397
|
+
* (see the {@link evaluateRule} audio gate + the matchers below).
|
|
7398
|
+
*
|
|
7399
|
+
* `stillOwnerId` is the audio still shelf's owner id — the photograph of the
|
|
7400
|
+
* camera taken at the match (`audio-still.ts`). It rides `trackId` because that
|
|
7401
|
+
* is the field the attachment ladder reads, exactly as a doorbell press rides
|
|
7402
|
+
* its marker track's id; nothing else about the subject is track-scoped, and
|
|
7403
|
+
* `buildEntries` keys an audio row on its record id, never on this. Absent when
|
|
7404
|
+
* no rule that could fire wants a still.
|
|
6639
7405
|
*/
|
|
6640
|
-
function subjectFromAudioEvent(ev) {
|
|
7406
|
+
function subjectFromAudioEvent(ev, stillOwnerId) {
|
|
6641
7407
|
const macro = ev.classification?.className;
|
|
6642
7408
|
return {
|
|
6643
7409
|
kind: "audio-event",
|
|
@@ -6647,6 +7413,7 @@ function subjectFromAudioEvent(ev) {
|
|
|
6647
7413
|
classNames: macro !== void 0 ? [`audio-${macro}`] : [],
|
|
6648
7414
|
...ev.classification?.score !== void 0 ? { confidence: ev.classification.score } : {},
|
|
6649
7415
|
zones: [],
|
|
7416
|
+
...stillOwnerId !== void 0 ? { trackId: stillOwnerId } : {},
|
|
6650
7417
|
source: "pipeline"
|
|
6651
7418
|
};
|
|
6652
7419
|
}
|
|
@@ -6667,8 +7434,12 @@ function subjectFromAudioEvent(ev) {
|
|
|
6667
7434
|
* neither is a score, and lending one to `minConfidence` would let a detection
|
|
6668
7435
|
* condition silently re-judge an audio rule on a number that means something
|
|
6669
7436
|
* else.
|
|
7437
|
+
*
|
|
7438
|
+
* `stillOwnerId` — see {@link subjectFromAudioEvent}. A sound owns no frame, so
|
|
7439
|
+
* the only picture it can carry is a photograph of its camera taken at the
|
|
7440
|
+
* confirmation, held on the still shelf under this id.
|
|
6670
7441
|
*/
|
|
6671
|
-
function subjectFromAudioWindow(hit) {
|
|
7442
|
+
function subjectFromAudioWindow(hit, stillOwnerId) {
|
|
6672
7443
|
const spec = hit.spec;
|
|
6673
7444
|
return {
|
|
6674
7445
|
kind: "audio-window",
|
|
@@ -6677,6 +7448,7 @@ function subjectFromAudioWindow(hit) {
|
|
|
6677
7448
|
timestamp: hit.timestamp,
|
|
6678
7449
|
classNames: hit.labels.map((l) => `audio-${l}`),
|
|
6679
7450
|
zones: [],
|
|
7451
|
+
...stillOwnerId !== void 0 ? { trackId: stillOwnerId } : {},
|
|
6680
7452
|
source: "audio",
|
|
6681
7453
|
audioWindow: audioSubjectFor(hit, spec)
|
|
6682
7454
|
};
|
|
@@ -6815,6 +7587,7 @@ function presentConditionIds(c) {
|
|
|
6815
7587
|
if (c.packagePhase !== void 0 && c.packagePhase !== "both") ids.push("packagePhase");
|
|
6816
7588
|
if (c.customZones !== void 0 && c.customZones.length > 0) ids.push("customZones");
|
|
6817
7589
|
if (c.occupancy !== void 0) ids.push("occupancy");
|
|
7590
|
+
if (c.scene !== void 0) ids.push("scene");
|
|
6818
7591
|
return ids;
|
|
6819
7592
|
}
|
|
6820
7593
|
/**
|
|
@@ -6994,7 +7767,7 @@ function matchesSystemEvent(condition, subject) {
|
|
|
6994
7767
|
if (condition.packageNames !== void 0 && (subject.packageName === void 0 || !condition.packageNames.includes(subject.packageName))) return false;
|
|
6995
7768
|
return true;
|
|
6996
7769
|
}
|
|
6997
|
-
function evaluateRule(rule, subject, deviceState, zoneOwners) {
|
|
7770
|
+
function evaluateRule(rule, subject, deviceState, zoneOwners, sceneStates) {
|
|
6998
7771
|
if (subject.kind === "system-event") {
|
|
6999
7772
|
if (rule.delivery !== "system-event") return fail("delivery");
|
|
7000
7773
|
const condition = rule.conditions.systemEvent;
|
|
@@ -7035,6 +7808,11 @@ function evaluateRule(rule, subject, deviceState, zoneOwners) {
|
|
|
7035
7808
|
if (current === void 0) return fail("deviceState");
|
|
7036
7809
|
if (!toLowerSet(c.deviceState.states).has(current.trim().toLowerCase())) return fail("deviceState");
|
|
7037
7810
|
}
|
|
7811
|
+
if (c.scene !== void 0) {
|
|
7812
|
+
const observation = sceneStates?.(c.scene.sceneId);
|
|
7813
|
+
if (observation === void 0) return fail("scene");
|
|
7814
|
+
if (!sceneConditionSatisfied(c.scene, observation)) return fail("scene");
|
|
7815
|
+
}
|
|
7038
7816
|
if (c.devices !== void 0 && c.devices.length > 0 && !matchesDeviceScope(c.devices, subject)) return fail("devices");
|
|
7039
7817
|
if (c.source !== void 0 && c.source !== "any") {
|
|
7040
7818
|
if ((subject.source ?? "pipeline") !== c.source) return fail("source");
|
|
@@ -7893,298 +8671,6 @@ var NcTextCatalog = class {
|
|
|
7893
8671
|
}
|
|
7894
8672
|
};
|
|
7895
8673
|
//#endregion
|
|
7896
|
-
//#region src/shared/frame/box-drawer.ts
|
|
7897
|
-
var DEFAULT_COLOR = DEFAULT_EVENT_COLOR;
|
|
7898
|
-
var DEFAULT_QUALITY = 80;
|
|
7899
|
-
var STROKE_WIDTH = 3;
|
|
7900
|
-
function escapeXml$2(s) {
|
|
7901
|
-
return s.replace(/[<>&'"]/g, (ch) => {
|
|
7902
|
-
switch (ch) {
|
|
7903
|
-
case "<": return "<";
|
|
7904
|
-
case ">": return ">";
|
|
7905
|
-
case "&": return "&";
|
|
7906
|
-
case "'": return "'";
|
|
7907
|
-
default: return """;
|
|
7908
|
-
}
|
|
7909
|
-
});
|
|
7910
|
-
}
|
|
7911
|
-
/** Clamp a pixel box to the frame so the rect always stays inside [0,W]×[0,H]. */
|
|
7912
|
-
function clampBox(b, frameWidth, frameHeight) {
|
|
7913
|
-
const x = Math.max(0, Math.min(Math.round(b.x), frameWidth - 1));
|
|
7914
|
-
const y = Math.max(0, Math.min(Math.round(b.y), frameHeight - 1));
|
|
7915
|
-
return {
|
|
7916
|
-
x,
|
|
7917
|
-
y,
|
|
7918
|
-
w: Math.max(1, Math.min(Math.round(b.w), frameWidth - x)),
|
|
7919
|
-
h: Math.max(1, Math.min(Math.round(b.h), frameHeight - y))
|
|
7920
|
-
};
|
|
7921
|
-
}
|
|
7922
|
-
/**
|
|
7923
|
-
* Draw bounding boxes over a raw RGB frame and JPEG-encode it.
|
|
7924
|
-
*
|
|
7925
|
-
* The frame is composited with an SVG overlay (one `<rect>` per box, plus an
|
|
7926
|
-
* optional caption) at full resolution, then optionally downscaled — so the
|
|
7927
|
-
* box stays crisp relative to the scene. Boxes are clamped to frame bounds to
|
|
7928
|
-
* avoid sharp `extract`/region errors on slightly-out-of-range detections.
|
|
7929
|
-
*/
|
|
7930
|
-
async function drawBoxedFrame(frameData, frameWidth, frameHeight, boxes, opts = {}) {
|
|
7931
|
-
const quality = opts.quality ?? DEFAULT_QUALITY;
|
|
7932
|
-
let base = frameData;
|
|
7933
|
-
let baseIsRaw = true;
|
|
7934
|
-
if (boxes.length > 0) {
|
|
7935
|
-
const fontSize = Math.max(12, Math.round(frameHeight / 30));
|
|
7936
|
-
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}">${boxes.map((b) => {
|
|
7937
|
-
const c = clampBox(b, frameWidth, frameHeight);
|
|
7938
|
-
const color = b.color ?? DEFAULT_COLOR;
|
|
7939
|
-
const rect = `<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${c.h}" fill="none" stroke="${color}" stroke-width="${STROKE_WIDTH}"/>`;
|
|
7940
|
-
if (!b.label) return rect;
|
|
7941
|
-
const ty = Math.max(fontSize, c.y - 4);
|
|
7942
|
-
return rect + `<text x="${c.x}" y="${ty}" font-family="sans-serif" font-size="${fontSize}" fill="${color}" stroke="black" stroke-width="0.5">${escapeXml$2(b.label)}</text>`;
|
|
7943
|
-
}).join("")}</svg>`;
|
|
7944
|
-
base = await sharp(frameData, { raw: {
|
|
7945
|
-
width: frameWidth,
|
|
7946
|
-
height: frameHeight,
|
|
7947
|
-
channels: 3
|
|
7948
|
-
} }).composite([{
|
|
7949
|
-
input: Buffer.from(svg),
|
|
7950
|
-
top: 0,
|
|
7951
|
-
left: 0
|
|
7952
|
-
}]).png().toBuffer();
|
|
7953
|
-
baseIsRaw = false;
|
|
7954
|
-
}
|
|
7955
|
-
let pipeline = baseIsRaw ? sharp(base, { raw: {
|
|
7956
|
-
width: frameWidth,
|
|
7957
|
-
height: frameHeight,
|
|
7958
|
-
channels: 3
|
|
7959
|
-
} }) : sharp(base);
|
|
7960
|
-
if (opts.maxWidth !== void 0) pipeline = pipeline.resize({
|
|
7961
|
-
width: opts.maxWidth,
|
|
7962
|
-
withoutEnlargement: true
|
|
7963
|
-
});
|
|
7964
|
-
return pipeline.jpeg({ quality }).toBuffer();
|
|
7965
|
-
}
|
|
7966
|
-
/** JPEG quality of the composed artefact — the mosaic's number, same reasons. */
|
|
7967
|
-
var GROUP_FRAME_QUALITY = 82;
|
|
7968
|
-
/**
|
|
7969
|
-
* Draw the members onto the frame. Returns `null` when there is nothing to
|
|
7970
|
-
* draw, or when anything at all goes wrong — the caller then ships the
|
|
7971
|
-
* unannotated frame unchanged.
|
|
7972
|
-
*/
|
|
7973
|
-
async function composeGroupFrame(input) {
|
|
7974
|
-
const freshnessMs = input.freshnessMs ?? 3e3;
|
|
7975
|
-
const fresh = input.boxes.filter((b) => Math.abs(input.at - b.observedAt) <= freshnessMs);
|
|
7976
|
-
const withheld = input.boxes.length - fresh.length;
|
|
7977
|
-
if (fresh.length < 2) {
|
|
7978
|
-
if (withheld > 0) input.logger.info("group frame not drawn — too few members with fresh geometry", {
|
|
7979
|
-
tags: { deviceId: input.deviceId },
|
|
7980
|
-
meta: {
|
|
7981
|
-
groupKey: input.groupKey,
|
|
7982
|
-
members: input.boxes.length,
|
|
7983
|
-
fresh: fresh.length,
|
|
7984
|
-
withheld
|
|
7985
|
-
}
|
|
7986
|
-
});
|
|
7987
|
-
return null;
|
|
7988
|
-
}
|
|
7989
|
-
try {
|
|
7990
|
-
const decoded = await sharp(input.jpeg).raw().toBuffer({ resolveWithObject: true });
|
|
7991
|
-
const { width, height, channels } = decoded.info;
|
|
7992
|
-
if (!(width > 0 && height > 0) || channels !== 3) {
|
|
7993
|
-
input.logger.warn("group frame not drawn — unexpected raster shape", {
|
|
7994
|
-
tags: { deviceId: input.deviceId },
|
|
7995
|
-
meta: {
|
|
7996
|
-
groupKey: input.groupKey,
|
|
7997
|
-
width,
|
|
7998
|
-
height,
|
|
7999
|
-
channels
|
|
8000
|
-
}
|
|
8001
|
-
});
|
|
8002
|
-
return null;
|
|
8003
|
-
}
|
|
8004
|
-
const boxes = fresh.map((b) => ({
|
|
8005
|
-
x: b.bbox.x * width,
|
|
8006
|
-
y: b.bbox.y * height,
|
|
8007
|
-
w: b.bbox.w * width,
|
|
8008
|
-
h: b.bbox.h * height,
|
|
8009
|
-
...b.label !== void 0 && b.label.length > 0 ? { label: b.label } : {}
|
|
8010
|
-
}));
|
|
8011
|
-
const bytes = await drawBoxedFrame(decoded.data, width, height, boxes, { quality: GROUP_FRAME_QUALITY });
|
|
8012
|
-
input.logger.debug("group frame composed", {
|
|
8013
|
-
tags: { deviceId: input.deviceId },
|
|
8014
|
-
meta: {
|
|
8015
|
-
groupKey: input.groupKey,
|
|
8016
|
-
drawn: boxes.length,
|
|
8017
|
-
withheld,
|
|
8018
|
-
width,
|
|
8019
|
-
height
|
|
8020
|
-
}
|
|
8021
|
-
});
|
|
8022
|
-
return {
|
|
8023
|
-
bytes,
|
|
8024
|
-
drawn: boxes.length,
|
|
8025
|
-
withheld
|
|
8026
|
-
};
|
|
8027
|
-
} catch (err) {
|
|
8028
|
-
input.logger.warn("group frame compose failed — shipping the unannotated frame", {
|
|
8029
|
-
tags: { deviceId: input.deviceId },
|
|
8030
|
-
meta: {
|
|
8031
|
-
groupKey: input.groupKey,
|
|
8032
|
-
error: err instanceof Error ? err.message : String(err)
|
|
8033
|
-
}
|
|
8034
|
-
});
|
|
8035
|
-
return null;
|
|
8036
|
-
}
|
|
8037
|
-
}
|
|
8038
|
-
//#endregion
|
|
8039
|
-
//#region src/notification-center/text-compose.ts
|
|
8040
|
-
/** One normalisation, used on both sides of every class lookup — a pipeline
|
|
8041
|
-
* that emitted `Person` must find the key written `class.person`. */
|
|
8042
|
-
function normaliseClass(className) {
|
|
8043
|
-
return className.trim().toLowerCase();
|
|
8044
|
-
}
|
|
8045
|
-
/**
|
|
8046
|
-
* The noun for a class, in the count's plural category.
|
|
8047
|
-
*
|
|
8048
|
-
* A class no locale names falls through to `class.other`, which prints the RAW
|
|
8049
|
-
* name: a custom model's class must appear in the sentence rather than vanish
|
|
8050
|
-
* out of it, and a missing translation is a smaller defect than a missing
|
|
8051
|
-
* detection.
|
|
8052
|
-
*/
|
|
8053
|
-
function ncClassNoun(texts, className, count) {
|
|
8054
|
-
const key = `class.${normaliseClass(className)}`;
|
|
8055
|
-
if (texts.has(key)) return texts.text({
|
|
8056
|
-
key,
|
|
8057
|
-
count,
|
|
8058
|
-
vars: { name: className }
|
|
8059
|
-
});
|
|
8060
|
-
return texts.text({
|
|
8061
|
-
key: "class.other",
|
|
8062
|
-
count,
|
|
8063
|
-
vars: { name: className }
|
|
8064
|
-
});
|
|
8065
|
-
}
|
|
8066
|
-
/** `person (John)` / `persona (John)` — the thing the notification is about. */
|
|
8067
|
-
function ncSubjectText(texts, input) {
|
|
8068
|
-
const classNoun = ncClassNoun(texts, input.className, 1);
|
|
8069
|
-
if (input.label === void 0) return texts.text({
|
|
8070
|
-
key: "detection.subject",
|
|
8071
|
-
vars: { classNoun }
|
|
8072
|
-
});
|
|
8073
|
-
return texts.text({
|
|
8074
|
-
key: subjectLabelKey(input.labelKind),
|
|
8075
|
-
vars: {
|
|
8076
|
-
classNoun,
|
|
8077
|
-
label: input.label
|
|
8078
|
-
}
|
|
8079
|
-
});
|
|
8080
|
-
}
|
|
8081
|
-
/**
|
|
8082
|
-
* Which labelled-subject key a recognition gets. A kind the catalog does not
|
|
8083
|
-
* name falls back to the generic key rather than printing nothing — the same
|
|
8084
|
-
* direction `ncClassNoun` degrades in, and for the same reason.
|
|
8085
|
-
*/
|
|
8086
|
-
function subjectLabelKey(kind) {
|
|
8087
|
-
if (kind === "identity") return "detection.subject.identity";
|
|
8088
|
-
if (kind === "plate") return "detection.subject.plate";
|
|
8089
|
-
return "detection.subject.labelled";
|
|
8090
|
-
}
|
|
8091
|
-
/**
|
|
8092
|
-
* ` in gate, drive` — INCLUDING its leading space, and empty when there are no
|
|
8093
|
-
* zones.
|
|
8094
|
-
*
|
|
8095
|
-
* The space belongs to the fragment rather than to the body key because the
|
|
8096
|
-
* alternative is a body that ends in a trailing space on every zone-less
|
|
8097
|
-
* notification. A translator moving the clause moves the space with it.
|
|
8098
|
-
*/
|
|
8099
|
-
function ncInZonesText(texts, zoneLabels) {
|
|
8100
|
-
if (zoneLabels.length === 0) return "";
|
|
8101
|
-
return texts.text({
|
|
8102
|
-
key: "detection.inZones",
|
|
8103
|
-
vars: { zones: zoneLabels.join(", ") }
|
|
8104
|
-
});
|
|
8105
|
-
}
|
|
8106
|
-
/** The human-readable edge polarity behind `{{op}}` and the occupancy body. */
|
|
8107
|
-
function ncOccupancyOp(texts, occupied) {
|
|
8108
|
-
return texts.text({ key: occupied ? "occupancy.op.occupied" : "occupancy.op.free" });
|
|
8109
|
-
}
|
|
8110
|
-
/**
|
|
8111
|
-
* `1 persona, 2 veicoli e 3 animali` — the language's own conjunction, its own
|
|
8112
|
-
* plural rules, and its own nouns.
|
|
8113
|
-
*
|
|
8114
|
-
* Zero-count classes are dropped: a selected class the window never contained
|
|
8115
|
-
* is a legitimate `0` in `{{count_vehicle}}`, but "0 veicoli" inside a summary
|
|
8116
|
-
* sentence is a line nobody reads. Order is the caller's (busiest first) and is
|
|
8117
|
-
* preserved, so the same night always produces the same sentence.
|
|
8118
|
-
*/
|
|
8119
|
-
function ncDetectionSummary(texts, counts) {
|
|
8120
|
-
const parts = counts.filter((entry) => entry.count > 0).map((entry) => `${entry.count} ${ncClassNoun(texts, entry.className, entry.count)}`);
|
|
8121
|
-
return texts.join(parts);
|
|
8122
|
-
}
|
|
8123
|
-
//#endregion
|
|
8124
|
-
//#region src/notification-center/group/nc-group-text.ts
|
|
8125
|
-
var DEFAULT_MAX_NAMES = 2;
|
|
8126
|
-
/**
|
|
8127
|
-
* `N altre persone` — the remainder phrase for one class, in its own gender.
|
|
8128
|
-
*
|
|
8129
|
-
* Mirrors {@link ncClassNoun} byte for byte, including the fallback: a class no
|
|
8130
|
-
* locale names falls through to `group.others.other`, which prints the raw class
|
|
8131
|
-
* name rather than vanishing. A custom model's class must appear in the sentence.
|
|
8132
|
-
*/
|
|
8133
|
-
function ncGroupOthers(texts, className, count) {
|
|
8134
|
-
const classNoun = ncClassNoun(texts, className, count);
|
|
8135
|
-
const key = `group.others.${className.trim().toLowerCase()}`;
|
|
8136
|
-
if (texts.has(key)) return texts.text({
|
|
8137
|
-
key,
|
|
8138
|
-
count,
|
|
8139
|
-
vars: {
|
|
8140
|
-
count: `${count}`,
|
|
8141
|
-
classNoun
|
|
8142
|
-
}
|
|
8143
|
-
});
|
|
8144
|
-
return texts.text({
|
|
8145
|
-
key: "group.others.other",
|
|
8146
|
-
count,
|
|
8147
|
-
vars: {
|
|
8148
|
-
count: `${count}`,
|
|
8149
|
-
classNoun
|
|
8150
|
-
}
|
|
8151
|
-
});
|
|
8152
|
-
}
|
|
8153
|
-
/** Counts per class, in first-seen order so the same burst always reads the same. */
|
|
8154
|
-
function countByClass(members) {
|
|
8155
|
-
const counts = /* @__PURE__ */ new Map();
|
|
8156
|
-
for (const m of members) counts.set(m.className, (counts.get(m.className) ?? 0) + 1);
|
|
8157
|
-
return [...counts].map(([className, count]) => ({
|
|
8158
|
-
className,
|
|
8159
|
-
count
|
|
8160
|
-
}));
|
|
8161
|
-
}
|
|
8162
|
-
/**
|
|
8163
|
-
* The finished `{{subject}}` phrase for a group.
|
|
8164
|
-
*
|
|
8165
|
-
* Pure: a catalog in, a string out. No clock, no store, no I/O — like every
|
|
8166
|
-
* other composer in `text-compose.ts`, and for the same reason.
|
|
8167
|
-
*/
|
|
8168
|
-
function ncGroupSubjectText(texts, input) {
|
|
8169
|
-
const members = input.members;
|
|
8170
|
-
const only = members.length === 1 ? members[0] : void 0;
|
|
8171
|
-
if (only !== void 0) return ncSubjectText(texts, {
|
|
8172
|
-
className: only.className,
|
|
8173
|
-
...only.label !== void 0 ? { label: only.label } : {},
|
|
8174
|
-
...only.labelKind !== void 0 ? { labelKind: only.labelKind } : {}
|
|
8175
|
-
});
|
|
8176
|
-
if (members.length === 0) return "";
|
|
8177
|
-
const maxNames = input.maxNames !== void 0 && input.maxNames >= 0 ? input.maxNames : DEFAULT_MAX_NAMES;
|
|
8178
|
-
const named = members.filter((m) => m.label !== void 0 && m.label.length > 0);
|
|
8179
|
-
const names = named.slice(0, maxNames).map((m) => m.label ?? "");
|
|
8180
|
-
if (names.length === 0) return ncDetectionSummary(texts, countByClass(members));
|
|
8181
|
-
const spoken = new Set(named.slice(0, maxNames));
|
|
8182
|
-
const remainder = members.filter((m) => !spoken.has(m));
|
|
8183
|
-
if (remainder.length === 0) return texts.join(names);
|
|
8184
|
-
const others = countByClass(remainder).map((c) => ncGroupOthers(texts, c.className, c.count));
|
|
8185
|
-
return texts.join([...names, ...others]);
|
|
8186
|
-
}
|
|
8187
|
-
//#endregion
|
|
8188
8674
|
//#region src/notification-center/dispatcher.ts
|
|
8189
8675
|
var DEFAULT_TARGET_CACHE_TTL_MS = 6e4;
|
|
8190
8676
|
/**
|
|
@@ -8747,7 +9233,7 @@ var NcDispatcher = class {
|
|
|
8747
9233
|
const stillOverride = zoneIdsWanted !== void 0 && zoneIdsWanted.length > 0 ? "keyFrame" : void 0;
|
|
8748
9234
|
const firstLook = await this.resolveAttachment(entry, stillOverride);
|
|
8749
9235
|
const footage = await this.renderFootage(entry);
|
|
8750
|
-
const still = firstLook !== null || entry.payload.media === "none" ? firstLook : footage.attempted ? await this.resolveAttachment(entry, stillOverride) : await this.waitForStill(entry, stillOverride);
|
|
9236
|
+
const still = firstLook !== null || entry.payload.media === "none" ? firstLook : footage.attempted ? await this.resolveAttachment(entry, stillOverride) ?? await this.waitForStill(entry, stillOverride) : await this.waitForStill(entry, stillOverride);
|
|
8751
9237
|
if (still !== null) {
|
|
8752
9238
|
const zoneIds = zoneIdsWanted;
|
|
8753
9239
|
if (zoneIds !== void 0 && zoneIds.length > 0) {
|
|
@@ -9235,17 +9721,24 @@ function incomingFromSensorEvent(event, markerTrackId) {
|
|
|
9235
9721
|
})
|
|
9236
9722
|
};
|
|
9237
9723
|
}
|
|
9238
|
-
/**
|
|
9239
|
-
|
|
9724
|
+
/**
|
|
9725
|
+
* Audio-event persist (classified episode).
|
|
9726
|
+
*
|
|
9727
|
+
* `stillOwnerId` is the audio still shelf's owner id for the photograph taken
|
|
9728
|
+
* at this episode (`audio-still.ts`). Absent when no rule that could fire asked
|
|
9729
|
+
* for a picture — the same shape `incomingFromSensorEvent` carries its marker.
|
|
9730
|
+
*/
|
|
9731
|
+
function incomingFromAudioEvent(event, stillOwnerId) {
|
|
9240
9732
|
return {
|
|
9241
|
-
subject: subjectFromAudioEvent(event),
|
|
9733
|
+
subject: subjectFromAudioEvent(event, stillOwnerId),
|
|
9242
9734
|
kind: "audio-event",
|
|
9243
9735
|
origin: "pipeline",
|
|
9244
9736
|
log: () => ({
|
|
9245
9737
|
tags: { deviceId: event.deviceId },
|
|
9246
9738
|
meta: {
|
|
9247
9739
|
audioEventId: event.id,
|
|
9248
|
-
class: event.classification?.className
|
|
9740
|
+
class: event.classification?.className,
|
|
9741
|
+
...stillOwnerId !== void 0 ? { stillOwner: stillOwnerId } : {}
|
|
9249
9742
|
}
|
|
9250
9743
|
})
|
|
9251
9744
|
};
|
|
@@ -9275,15 +9768,20 @@ function incomingFromPackageEvent(event, phase) {
|
|
|
9275
9768
|
* and a crash in the confirm→outbox window drops the notification rather than
|
|
9276
9769
|
* replaying it. Deliberate: a stale match is a claim about a noise that has
|
|
9277
9770
|
* already stopped.
|
|
9771
|
+
*
|
|
9772
|
+
* `stillOwnerId` — see {@link incomingFromAudioEvent}.
|
|
9278
9773
|
*/
|
|
9279
|
-
function incomingFromAudioWindow(hit) {
|
|
9774
|
+
function incomingFromAudioWindow(hit, stillOwnerId) {
|
|
9280
9775
|
return {
|
|
9281
|
-
subject: subjectFromAudioWindow(hit),
|
|
9776
|
+
subject: subjectFromAudioWindow(hit, stillOwnerId),
|
|
9282
9777
|
kind: "audio-window",
|
|
9283
9778
|
origin: "pipeline",
|
|
9284
9779
|
log: () => ({
|
|
9285
9780
|
tags: { deviceId: hit.deviceId },
|
|
9286
|
-
meta:
|
|
9781
|
+
meta: {
|
|
9782
|
+
...audioHitMeta(hit),
|
|
9783
|
+
...stillOwnerId !== void 0 ? { stillOwner: stillOwnerId } : {}
|
|
9784
|
+
}
|
|
9287
9785
|
})
|
|
9288
9786
|
};
|
|
9289
9787
|
}
|
|
@@ -13224,6 +13722,29 @@ function escapeXml$1(s) {
|
|
|
13224
13722
|
}
|
|
13225
13723
|
});
|
|
13226
13724
|
}
|
|
13725
|
+
/**
|
|
13726
|
+
* The single decision about drawn text — and the home of the DEFAULT.
|
|
13727
|
+
*
|
|
13728
|
+
* `undefined` means `none`, not `all`: a rule stored before this option existed
|
|
13729
|
+
* has no `captions` key, and the operator's request is that such a rule deliver
|
|
13730
|
+
* pictures. The schema materialises the same default on read
|
|
13731
|
+
* (`NcSummaryRuleInputSchema`), so this arm only fires for a caller that never
|
|
13732
|
+
* had a rule — the test bridge and the spec. Both agreeing is the point.
|
|
13733
|
+
*/
|
|
13734
|
+
function mosaicTextPlan(captions) {
|
|
13735
|
+
if (captions === "all") return {
|
|
13736
|
+
tileLabels: true,
|
|
13737
|
+
titleBand: true
|
|
13738
|
+
};
|
|
13739
|
+
if (captions === "tiles") return {
|
|
13740
|
+
tileLabels: true,
|
|
13741
|
+
titleBand: false
|
|
13742
|
+
};
|
|
13743
|
+
return {
|
|
13744
|
+
tileLabels: false,
|
|
13745
|
+
titleBand: false
|
|
13746
|
+
};
|
|
13747
|
+
}
|
|
13227
13748
|
/** Grid shape for `n` tiles: as square as possible, rows never exceeding cols
|
|
13228
13749
|
* by more than one — a 3×3 for nine, a 3×2 for five, never a 1×9 strip. */
|
|
13229
13750
|
function mosaicGrid(n) {
|
|
@@ -13330,22 +13851,25 @@ function titleSvg(width, height, caption) {
|
|
|
13330
13851
|
* treats that as "no mosaic" and still delivers the counts.
|
|
13331
13852
|
*/
|
|
13332
13853
|
async function renderMosaic(request, logger) {
|
|
13854
|
+
const plan = mosaicTextPlan(request.captions);
|
|
13333
13855
|
const decoded = [];
|
|
13334
13856
|
const probeLayout = mosaicLayout({
|
|
13335
13857
|
tileCount: Math.max(1, request.tiles.length),
|
|
13336
13858
|
...request.maxWidth !== void 0 ? { maxWidth: request.maxWidth } : {}
|
|
13337
13859
|
});
|
|
13338
13860
|
for (const tile of request.tiles) try {
|
|
13339
|
-
const
|
|
13861
|
+
const label = plan.tileLabels ? tile.label?.trim() ?? "" : "";
|
|
13862
|
+
const fitted = sharp(tile.jpeg).resize({
|
|
13340
13863
|
width: probeLayout.tileWidth,
|
|
13341
13864
|
height: probeLayout.tileHeight,
|
|
13342
13865
|
fit: "contain",
|
|
13343
13866
|
background: MOSAIC_BACKGROUND
|
|
13344
|
-
})
|
|
13345
|
-
|
|
13867
|
+
});
|
|
13868
|
+
const resized = await (label.length === 0 ? fitted : fitted.composite([{
|
|
13869
|
+
input: tileLabelSvg(probeLayout.tileWidth, probeLayout.tileHeight, label),
|
|
13346
13870
|
top: 0,
|
|
13347
13871
|
left: 0
|
|
13348
|
-
}]).jpeg({ quality: 82 }).toBuffer();
|
|
13872
|
+
}])).jpeg({ quality: 82 }).toBuffer();
|
|
13349
13873
|
decoded.push({
|
|
13350
13874
|
tile,
|
|
13351
13875
|
resized
|
|
@@ -13360,7 +13884,7 @@ async function renderMosaic(request, logger) {
|
|
|
13360
13884
|
});
|
|
13361
13885
|
}
|
|
13362
13886
|
const droppedTiles = request.tiles.length - decoded.length;
|
|
13363
|
-
const caption = request.caption?.trim() ?? "";
|
|
13887
|
+
const caption = plan.titleBand ? request.caption?.trim() ?? "" : "";
|
|
13364
13888
|
const layout = mosaicLayout({
|
|
13365
13889
|
tileCount: Math.max(1, decoded.length),
|
|
13366
13890
|
...request.maxWidth !== void 0 ? { maxWidth: request.maxWidth } : {},
|
|
@@ -14229,18 +14753,20 @@ var NcSummaryProducer = class {
|
|
|
14229
14753
|
};
|
|
14230
14754
|
let rendered;
|
|
14231
14755
|
try {
|
|
14756
|
+
const plan = mosaicTextPlan(rule.captions);
|
|
14232
14757
|
rendered = await renderMosaic({
|
|
14233
14758
|
tiles: tiles.map(({ candidate, jpeg }) => ({
|
|
14234
14759
|
jpeg,
|
|
14235
|
-
label: `${names.get(candidate.deviceId) ?? candidate.deviceId} · ${clockOf$2(candidate.firstSeen)} · ${candidate.className}
|
|
14760
|
+
...plan.tileLabels ? { label: `${names.get(candidate.deviceId) ?? candidate.deviceId} · ${clockOf$2(candidate.firstSeen)} · ${candidate.className}` } : {},
|
|
14236
14761
|
deviceId: candidate.deviceId,
|
|
14237
14762
|
trackId: candidate.trackId
|
|
14238
14763
|
})),
|
|
14239
|
-
|
|
14764
|
+
captions: rule.captions,
|
|
14765
|
+
...plan.titleBand ? { caption: summaryCaptionText({
|
|
14240
14766
|
...rule.captionText !== void 0 ? { captionText: rule.captionText } : {},
|
|
14241
14767
|
ruleName: rule.name,
|
|
14242
14768
|
window
|
|
14243
|
-
})
|
|
14769
|
+
}) } : {}
|
|
14244
14770
|
}, this.deps.logger);
|
|
14245
14771
|
} catch (err) {
|
|
14246
14772
|
this.deps.logger.warn("summary mosaic could not be composed — delivering the counts alone", { meta: {
|
|
@@ -14504,6 +15030,34 @@ var NcSummaryRankSchema = _enum([
|
|
|
14504
15030
|
"dwell"
|
|
14505
15031
|
]);
|
|
14506
15032
|
/**
|
|
15033
|
+
* How much TEXT is burnt into the mosaic — and the answer is NONE by default.
|
|
15034
|
+
*
|
|
15035
|
+
* `none` is the shipped default and, crucially, the reading of an ABSENT field:
|
|
15036
|
+
* `.default()` on the Input schema materialises it, and the persisted schema
|
|
15037
|
+
* extends Input, so a rule authored before this option existed parses as `none`
|
|
15038
|
+
* on the next read. No migration, no backfill, no rewrite of a stored rule.
|
|
15039
|
+
*
|
|
15040
|
+
* The operator asked for "solo immagini". Three reasons it is the right
|
|
15041
|
+
* default rather than a preference:
|
|
15042
|
+
*
|
|
15043
|
+
* - a caption is PERMANENT in the artefact, exactly like a detection box, and
|
|
15044
|
+
* a mosaic is re-read days later;
|
|
15045
|
+
* - the per-tile strip covers 14% of the tile from the bottom edge up, which
|
|
15046
|
+
* is where feet, wheels and parcels are;
|
|
15047
|
+
* - nothing downstream needs it. The AI pass is given the RAW per-track JPEGs
|
|
15048
|
+
* and gets camera, class and time as PROMPT TEXT — it never reads the
|
|
15049
|
+
* mosaic, so the pixels carry no fact the judge would lose.
|
|
15050
|
+
*
|
|
15051
|
+
* `tiles` and `all` keep the old behaviour reachable for an operator who wants
|
|
15052
|
+
* it; `all` is byte-for-byte what every rule produced before this field.
|
|
15053
|
+
*/
|
|
15054
|
+
var NcSummaryCaptionsSchema = _enum([
|
|
15055
|
+
"none",
|
|
15056
|
+
"tiles",
|
|
15057
|
+
"all"
|
|
15058
|
+
]);
|
|
15059
|
+
var NC_SUMMARY_DEFAULT_CAPTIONS = "none";
|
|
15060
|
+
/**
|
|
14507
15061
|
* The AI section — DECLARED IN P1, WIRED IN P2.
|
|
14508
15062
|
*
|
|
14509
15063
|
* Nothing in this addon reads it today. It is here so the rule shape does not
|
|
@@ -14537,6 +15091,8 @@ var NcSummaryRuleInputSchema = object({
|
|
|
14537
15091
|
barrierSeconds: BarrierSecondsField.default(120),
|
|
14538
15092
|
targets: TargetsField,
|
|
14539
15093
|
template: NcSummaryTemplateSchema.optional(),
|
|
15094
|
+
/** Burned onto the mosaic's title band — and ONLY when `captions` is `all`. */
|
|
15095
|
+
captions: NcSummaryCaptionsSchema.default(NC_SUMMARY_DEFAULT_CAPTIONS),
|
|
14540
15096
|
/** Burned onto the mosaic's title band. `''` is reachable and means "no
|
|
14541
15097
|
* caption" — which is why this is not `.min(1)`. */
|
|
14542
15098
|
captionText: string().max(200).optional(),
|
|
@@ -14566,6 +15122,7 @@ var NcSummaryRulePatchSchema = object({
|
|
|
14566
15122
|
barrierSeconds: BarrierSecondsField.optional(),
|
|
14567
15123
|
targets: TargetsField.optional(),
|
|
14568
15124
|
template: NcSummaryTemplateSchema.nullable().optional(),
|
|
15125
|
+
captions: NcSummaryCaptionsSchema.optional(),
|
|
14569
15126
|
captionText: string().max(200).optional(),
|
|
14570
15127
|
deliverEmpty: boolean().optional(),
|
|
14571
15128
|
priority: PriorityField.optional(),
|
|
@@ -15085,14 +15642,19 @@ function isSyntheticId(id) {
|
|
|
15085
15642
|
* snapshot that produced it lists the members being counted and the edge
|
|
15086
15643
|
* names one (`chooseOccupancyMediaOwner`), so the tester stands in for that
|
|
15087
15644
|
* member exactly as it stands in for the marker.
|
|
15645
|
+
* - and, since 2026-08-15, a `trackId` for both AUDIO kinds: a sound owns no
|
|
15646
|
+
* frame, so production photographs the camera at the match and holds the
|
|
15647
|
+
* bytes on the still shelf under an owner id the subject names
|
|
15648
|
+
* (`audio-still.ts`). The tester's RAM-held frame stands in for exactly that
|
|
15649
|
+
* photograph, and — unlike every other kind here — it is taken the same way
|
|
15650
|
+
* production takes it, at the same moment, from the same cap.
|
|
15088
15651
|
*
|
|
15089
|
-
*
|
|
15090
|
-
*
|
|
15091
|
-
*
|
|
15092
|
-
* feature that does not exist.
|
|
15652
|
+
* Only `system-event` remains: an infrastructure event is about no camera at
|
|
15653
|
+
* all, so there is nothing to photograph and a tester that invented a picture
|
|
15654
|
+
* for it would be advertising a feature that does not exist.
|
|
15093
15655
|
*/
|
|
15094
15656
|
function triggerCanCarryStill(kind) {
|
|
15095
|
-
return kind
|
|
15657
|
+
return kind !== "system-event";
|
|
15096
15658
|
}
|
|
15097
15659
|
/** The detection provenance production stamps on a subject of this kind. */
|
|
15098
15660
|
function syntheticSource(kind) {
|
|
@@ -15130,7 +15692,7 @@ function audioSubjectOf(input) {
|
|
|
15130
15692
|
function buildSyntheticEvent(input, recordId, now) {
|
|
15131
15693
|
const kind = input.trigger;
|
|
15132
15694
|
const timestamp = input.timestamp ?? now;
|
|
15133
|
-
const carriesTrackId = kind === "object-event" || kind === "track-end" || kind === "device-event" || kind === "occupancy-event";
|
|
15695
|
+
const carriesTrackId = kind === "object-event" || kind === "track-end" || kind === "device-event" || kind === "occupancy-event" || kind === "audio-event" || kind === "audio-window";
|
|
15134
15696
|
return {
|
|
15135
15697
|
subject: {
|
|
15136
15698
|
kind,
|
|
@@ -17623,6 +18185,22 @@ var NotificationCenter = class NotificationCenter {
|
|
|
17623
18185
|
/** States the `deviceState` rule gate reads. Refreshed on the reload tick;
|
|
17624
18186
|
* read with no I/O on the persist hook. */
|
|
17625
18187
|
deviceStates;
|
|
18188
|
+
/** Scene verdicts + latches the `scene` rule gate reads. Same contract as
|
|
18189
|
+
* {@link deviceStates}: refreshed on the reload tick and on the provider's
|
|
18190
|
+
* in-process edge tap, read with no I/O on the persist hook (D49). */
|
|
18191
|
+
sceneStates;
|
|
18192
|
+
/**
|
|
18193
|
+
* The last scene-gate answer REPORTED per rule, so the suppression line is
|
|
18194
|
+
* written on CHANGE rather than on every event.
|
|
18195
|
+
*
|
|
18196
|
+
* A branch that drops work silently must log it, and a gate that logged on
|
|
18197
|
+
* every detection on a busy camera would be a line nobody reads — which is
|
|
18198
|
+
* the same silence by a different route.
|
|
18199
|
+
*/
|
|
18200
|
+
sceneGateReported = /* @__PURE__ */ new Map();
|
|
18201
|
+
/** The watched scene id set, as last REPORTED — so the line is written when it
|
|
18202
|
+
* changes and not on every reload tick. `null` = never reported. */
|
|
18203
|
+
sceneWatchSignature = null;
|
|
17626
18204
|
/** WHO each device id is (name + type). The system-event intake reads it with
|
|
17627
18205
|
* no I/O; the reload tick refreshes it — see `device-directory.ts`. */
|
|
17628
18206
|
deviceDirectory;
|
|
@@ -17694,6 +18272,12 @@ var NotificationCenter = class NotificationCenter {
|
|
|
17694
18272
|
* is the one thing the synthetic producer promises not to do.
|
|
17695
18273
|
*/
|
|
17696
18274
|
syntheticMedia = /* @__PURE__ */ new Map();
|
|
18275
|
+
/**
|
|
18276
|
+
* The photograph a SOUND rule's notification carries — see `audio-still.ts`.
|
|
18277
|
+
* Null when this wiring has no snapshot cap, which leaves every audio
|
|
18278
|
+
* notification text-only rather than broken.
|
|
18279
|
+
*/
|
|
18280
|
+
audioStills;
|
|
17697
18281
|
/** Per-device rate limit for the "matched NO rule" report — see `reportNoMatch`. */
|
|
17698
18282
|
lastNoMatchReportAt = /* @__PURE__ */ new Map();
|
|
17699
18283
|
noMatchSuppressed = /* @__PURE__ */ new Map();
|
|
@@ -17985,6 +18569,12 @@ var NotificationCenter = class NotificationCenter {
|
|
|
17985
18569
|
this.deps = deps;
|
|
17986
18570
|
this.logger = deps.logger;
|
|
17987
18571
|
this.now = deps.now ?? (() => Date.now());
|
|
18572
|
+
const getSnapshot = deps.getSnapshot;
|
|
18573
|
+
this.audioStills = getSnapshot !== void 0 ? new NcAudioStillShelf({
|
|
18574
|
+
getSnapshot,
|
|
18575
|
+
logger: this.logger.child("audio-still"),
|
|
18576
|
+
now: this.now
|
|
18577
|
+
}) : null;
|
|
17988
18578
|
this.rules = new NcRuleStore({
|
|
17989
18579
|
store: deps.store,
|
|
17990
18580
|
logger: this.logger.child("rules"),
|
|
@@ -18022,6 +18612,11 @@ var NotificationCenter = class NotificationCenter {
|
|
|
18022
18612
|
readStates: deps.readDeviceStates ?? (async () => /* @__PURE__ */ new Map()),
|
|
18023
18613
|
...deps.now !== void 0 ? { now: deps.now } : {}
|
|
18024
18614
|
});
|
|
18615
|
+
this.sceneStates = new NcSceneStateCache({
|
|
18616
|
+
logger: this.logger.child("scene-state"),
|
|
18617
|
+
readScenes: deps.readSceneStates ?? (() => []),
|
|
18618
|
+
...deps.now !== void 0 ? { now: deps.now } : {}
|
|
18619
|
+
});
|
|
18025
18620
|
this.deviceDirectory = new NcDeviceDirectory({
|
|
18026
18621
|
logger: this.logger.child("device-directory"),
|
|
18027
18622
|
listDevices: deps.listDeviceIdentities ?? (async () => {
|
|
@@ -18057,6 +18652,8 @@ var NotificationCenter = class NotificationCenter {
|
|
|
18057
18652
|
getMediaForOwner: async (ownerKind, ownerId) => {
|
|
18058
18653
|
const held = isSyntheticId(ownerId) ? this.syntheticMedia.get(ownerId) : void 0;
|
|
18059
18654
|
if (held !== void 0) return held.files;
|
|
18655
|
+
const audio = isAudioStillId(ownerId) ? this.audioStills?.get(ownerId) : void 0;
|
|
18656
|
+
if (audio !== void 0) return audio;
|
|
18060
18657
|
return deps.dispatcher.getMediaForOwner(ownerKind, ownerId);
|
|
18061
18658
|
},
|
|
18062
18659
|
...deps.now !== void 0 ? { now: deps.now } : {},
|
|
@@ -18221,6 +18818,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
18221
18818
|
await this.deviceDirectory.refresh();
|
|
18222
18819
|
await this.hydrateLiveness();
|
|
18223
18820
|
await this.zoneOwners.refresh();
|
|
18821
|
+
this.sceneStates.refresh();
|
|
18224
18822
|
this.evaluationActive = true;
|
|
18225
18823
|
await this.outbox.load();
|
|
18226
18824
|
await this.seedCooldowns();
|
|
@@ -18260,6 +18858,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
18260
18858
|
}
|
|
18261
18859
|
this.timelapseScheduler?.stop();
|
|
18262
18860
|
this.summaryProducer?.stop();
|
|
18861
|
+
this.audioStills?.clear();
|
|
18263
18862
|
this.evaluationActive = false;
|
|
18264
18863
|
}
|
|
18265
18864
|
/**
|
|
@@ -18589,7 +19188,35 @@ var NotificationCenter = class NotificationCenter {
|
|
|
18589
19188
|
* durable guarantee begins at this hook, matching the device-event boundary.
|
|
18590
19189
|
*/
|
|
18591
19190
|
onAudioEventPersisted(event) {
|
|
18592
|
-
this.consumeEvent(incomingFromAudioEvent(event));
|
|
19191
|
+
this.consumeEvent(incomingFromAudioEvent(event, this.audioStillOwner(event.deviceId, event.timestamp, "legacy")));
|
|
19192
|
+
}
|
|
19193
|
+
/**
|
|
19194
|
+
* Start photographing `deviceId` NOW and return the owner id the audio
|
|
19195
|
+
* subject should name — or `undefined` when nothing would use it.
|
|
19196
|
+
*
|
|
19197
|
+
* **The capture is not awaited.** A sound is transient and a notification
|
|
19198
|
+
* about a scream must not wait on a camera; the bytes land while the outbox
|
|
19199
|
+
* row queues, and the dispatcher's ordinary bounded still-wait picks them up.
|
|
19200
|
+
* That is also why this runs BEFORE the cooldown and the mute are consulted:
|
|
19201
|
+
* by the time a rule's window has been judged the moment is gone.
|
|
19202
|
+
*
|
|
19203
|
+
* **The gate is the RULE's media policy, not the trigger.** Photographing a
|
|
19204
|
+
* camera on every confirmed window for rules that ship text anyway would be a
|
|
19205
|
+
* snapshot at whatever rate the sound happens to occur, for nothing. The rule
|
|
19206
|
+
* cache is an in-memory mirror, so this is not a fallible read (D49) — a
|
|
19207
|
+
* failed one would look exactly like "no rule wants a picture", which is the
|
|
19208
|
+
* direction that DESTROYS the media.
|
|
19209
|
+
*/
|
|
19210
|
+
audioStillOwner(deviceId, atMs, mode) {
|
|
19211
|
+
const shelf = this.audioStills;
|
|
19212
|
+
if (shelf === null || !this.evaluationActive) return void 0;
|
|
19213
|
+
for (const rule of this.rules.listEnabled("immediate")) {
|
|
19214
|
+
if (rule.media.attach === "none") continue;
|
|
19215
|
+
if (!(mode === "condition" ? rule.conditions.audio !== void 0 : rule.conditions.audio === void 0 && referencesAudioClass(rule.conditions.classes))) continue;
|
|
19216
|
+
const devices = rule.conditions.devices;
|
|
19217
|
+
if (devices !== void 0 && devices.length > 0 && !devices.includes(deviceId)) continue;
|
|
19218
|
+
return shelf.capture(deviceId, atMs);
|
|
19219
|
+
}
|
|
18593
19220
|
}
|
|
18594
19221
|
/**
|
|
18595
19222
|
* Called at the package object-event persist site (`PackageDropDetector` —
|
|
@@ -18687,7 +19314,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
18687
19314
|
tags: { deviceId },
|
|
18688
19315
|
meta: audioHitMeta(hit)
|
|
18689
19316
|
});
|
|
18690
|
-
this.consumeEvent(incomingFromAudioWindow(hit));
|
|
19317
|
+
this.consumeEvent(incomingFromAudioWindow(hit, this.audioStillOwner(deviceId, hit.timestamp, "condition")));
|
|
18691
19318
|
}
|
|
18692
19319
|
}
|
|
18693
19320
|
/**
|
|
@@ -18985,6 +19612,73 @@ var NotificationCenter = class NotificationCenter {
|
|
|
18985
19612
|
if (identity.isCamera && identity.detectionActive === false) return "detection-off";
|
|
18986
19613
|
}
|
|
18987
19614
|
/**
|
|
19615
|
+
* SAY that the scene gate suppressed this rule.
|
|
19616
|
+
*
|
|
19617
|
+
* A branch that drops work silently logs it — the three-hour media blackout
|
|
19618
|
+
* produced not one line, and a gate is the same shape of failure: from the
|
|
19619
|
+
* operator's side "the rule stopped notifying" and "the pipeline stopped
|
|
19620
|
+
* working" look identical. `rule did not match` covers it at DEBUG, which is
|
|
19621
|
+
* not a level this installation runs at.
|
|
19622
|
+
*
|
|
19623
|
+
* On CHANGE only, per rule: the alternative is one line per detection on a
|
|
19624
|
+
* busy camera, which is silence by a different route. The `deviceId` tag is
|
|
19625
|
+
* the SUBJECT's — that is the camera whose notification went missing, and the
|
|
19626
|
+
* question is always asked per-camera. The SCENE's own device is named in the
|
|
19627
|
+
* meta beside its id, because the two are frequently different cameras.
|
|
19628
|
+
*/
|
|
19629
|
+
reportSceneSuppression(rule, subject) {
|
|
19630
|
+
const condition = rule.conditions.scene;
|
|
19631
|
+
if (condition === void 0) return;
|
|
19632
|
+
const observation = this.sceneStates.get(condition.sceneId);
|
|
19633
|
+
const reason = sceneGateReason(condition, observation);
|
|
19634
|
+
if (this.sceneGateReported.get(rule.id) === reason) return;
|
|
19635
|
+
this.sceneGateReported.set(rule.id, reason);
|
|
19636
|
+
this.logger.info("rule suppressed by its scene condition", {
|
|
19637
|
+
tags: { deviceId: subject.deviceId },
|
|
19638
|
+
meta: {
|
|
19639
|
+
ruleId: rule.id,
|
|
19640
|
+
rule: rule.name,
|
|
19641
|
+
sceneId: condition.sceneId,
|
|
19642
|
+
sceneLabel: observation?.label,
|
|
19643
|
+
sceneDeviceId: observation?.deviceId ?? condition.deviceId,
|
|
19644
|
+
requiredState: condition.requiredState,
|
|
19645
|
+
reason
|
|
19646
|
+
}
|
|
19647
|
+
});
|
|
19648
|
+
}
|
|
19649
|
+
/**
|
|
19650
|
+
* A confirmed scene transition, straight from the provider in this process.
|
|
19651
|
+
*
|
|
19652
|
+
* Synchronous on purpose and for the same reason {@link onAlarmPublish} is:
|
|
19653
|
+
* the reload tick would otherwise be the only writer, and a rule gated on
|
|
19654
|
+
* "while the bin is out" would keep its old answer for up to thirty seconds
|
|
19655
|
+
* after the bin came back. The tick stays as reconciliation.
|
|
19656
|
+
*
|
|
19657
|
+
* A DIRECT CALL, not a bus subscription. `EnrichmentSceneStateChanged` is
|
|
19658
|
+
* still emitted for the badge and for anyone else, but D8 events are
|
|
19659
|
+
* droppable telemetry and a gate fed by droppable telemetry is a gate that is
|
|
19660
|
+
* sometimes wrong and never says so.
|
|
19661
|
+
*
|
|
19662
|
+
* This never OPENS a gate that the mirror had shut for lack of an entry: it
|
|
19663
|
+
* writes an observation the caller actually made. It is `refresh` that
|
|
19664
|
+
* removes entries, and it never empties on a failed read (D49).
|
|
19665
|
+
*/
|
|
19666
|
+
onSceneEdge(observation) {
|
|
19667
|
+
this.sceneStates.note(observation);
|
|
19668
|
+
}
|
|
19669
|
+
/**
|
|
19670
|
+
* Reconcile the scene mirror now.
|
|
19671
|
+
*
|
|
19672
|
+
* The scene provider is constructed AFTER this centre starts (it needs the
|
|
19673
|
+
* media store and the embedding encoder), so the boot refresh reads an empty
|
|
19674
|
+
* provider. The addon calls this once the provider has hydrated, rather than
|
|
19675
|
+
* leaving every scene-gated rule shut for a whole reload tick after every
|
|
19676
|
+
* respawn.
|
|
19677
|
+
*/
|
|
19678
|
+
refreshSceneStates() {
|
|
19679
|
+
this.sceneStates.refresh();
|
|
19680
|
+
}
|
|
19681
|
+
/**
|
|
18988
19682
|
* Everything the panel reports, on every publish.
|
|
18989
19683
|
*
|
|
18990
19684
|
* Synchronous on purpose. The state half refreshes the `deviceState` gate's
|
|
@@ -19283,8 +19977,9 @@ var NotificationCenter = class NotificationCenter {
|
|
|
19283
19977
|
let noOwnerReported = false;
|
|
19284
19978
|
const rejections = [];
|
|
19285
19979
|
for (const rule of candidates) {
|
|
19286
|
-
const evaluation = evaluateRule(rule, subject, (id) => this.deviceStates.get(id), (id) => this.zoneOwners.get(id));
|
|
19980
|
+
const evaluation = evaluateRule(rule, subject, (id) => this.deviceStates.get(id), (id) => this.zoneOwners.get(id), (sceneId) => this.sceneStates.get(sceneId));
|
|
19287
19981
|
if (!evaluation.matched) {
|
|
19982
|
+
if (evaluation.failedCondition === "scene") this.reportSceneSuppression(rule, subject);
|
|
19288
19983
|
rejections.push(`${rule.name}:${evaluation.failedCondition ?? "unknown"}`);
|
|
19289
19984
|
outcomes.push({
|
|
19290
19985
|
ruleId: rule.id,
|
|
@@ -19309,6 +20004,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
19309
20004
|
});
|
|
19310
20005
|
continue;
|
|
19311
20006
|
}
|
|
20007
|
+
this.sceneGateReported.delete(rule.id);
|
|
19312
20008
|
this.runRuleActions(rule, subject, kind, controls);
|
|
19313
20009
|
const admission = this.admitToGroup(rule, subject, now);
|
|
19314
20010
|
if (admission.outcome === "unchanged") {
|
|
@@ -19435,6 +20131,19 @@ var NotificationCenter = class NotificationCenter {
|
|
|
19435
20131
|
}
|
|
19436
20132
|
});
|
|
19437
20133
|
}
|
|
20134
|
+
if (!noOwnerReported && (kind === "audio-window" || kind === "audio-event") && subject.trackId === void 0 && rule.media.attach !== "none") {
|
|
20135
|
+
noOwnerReported = true;
|
|
20136
|
+
this.logger.warn("audio match has no still owner — this notification ships text-only", {
|
|
20137
|
+
tags: { deviceId: subject.deviceId },
|
|
20138
|
+
meta: {
|
|
20139
|
+
recordId: subject.recordId,
|
|
20140
|
+
kind,
|
|
20141
|
+
ruleId: rule.id,
|
|
20142
|
+
rule: rule.name,
|
|
20143
|
+
snapshotWired: this.audioStills !== null
|
|
20144
|
+
}
|
|
20145
|
+
});
|
|
20146
|
+
}
|
|
19438
20147
|
const userTargets = await this.resolveUserTargets(rule, subject.deviceId);
|
|
19439
20148
|
const entries = this.buildEntries(rule, subject, kind, evaluation.matchedOn, userTargets, origin, {
|
|
19440
20149
|
key,
|
|
@@ -19591,6 +20300,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
19591
20300
|
admitToGroup(rule, subject, now) {
|
|
19592
20301
|
const idleSec = rule.groupIdleSec;
|
|
19593
20302
|
if (idleSec === void 0 || idleSec <= 0) return { outcome: "disabled" };
|
|
20303
|
+
if (subject.kind === "audio-window" || subject.kind === "audio-event") return { outcome: "disabled" };
|
|
19594
20304
|
const trackId = subject.trackId;
|
|
19595
20305
|
if (trackId === void 0) return { outcome: "disabled" };
|
|
19596
20306
|
const member = {
|
|
@@ -19940,10 +20650,13 @@ var NotificationCenter = class NotificationCenter {
|
|
|
19940
20650
|
this.reportAudioWatch(audioWatch);
|
|
19941
20651
|
const gated = [];
|
|
19942
20652
|
const zoneScoped = [];
|
|
20653
|
+
const sceneIds = [];
|
|
19943
20654
|
for (const rule of this.rules.list()) {
|
|
19944
20655
|
if (!rule.enabled) continue;
|
|
19945
20656
|
const ds = rule.conditions.deviceState;
|
|
19946
20657
|
if (ds !== void 0) gated.push(ds.deviceId);
|
|
20658
|
+
const scene = rule.conditions.scene;
|
|
20659
|
+
if (scene !== void 0) sceneIds.push(scene.sceneId);
|
|
19947
20660
|
const zones = rule.conditions.zones;
|
|
19948
20661
|
const devices = rule.conditions.devices;
|
|
19949
20662
|
if (zones !== void 0 && zones.ids.length > 0 && devices !== void 0) zoneScoped.push(...devices);
|
|
@@ -19952,6 +20665,15 @@ var NotificationCenter = class NotificationCenter {
|
|
|
19952
20665
|
if (panel !== null) gated.push(...alarmContactWatchList(this.rules.list(), panel.deviceId, panel.availableModes()));
|
|
19953
20666
|
this.deviceStates.setWatched(gated);
|
|
19954
20667
|
this.zoneOwners.setWatched(zoneScoped);
|
|
20668
|
+
this.sceneStates.setWatched(sceneIds);
|
|
20669
|
+
const signature = [...new Set(sceneIds)].sort().join(",");
|
|
20670
|
+
if (signature !== this.sceneWatchSignature) {
|
|
20671
|
+
this.sceneWatchSignature = signature;
|
|
20672
|
+
this.logger.info("scene gate watched set", { meta: {
|
|
20673
|
+
scenes: this.sceneStates.watched(),
|
|
20674
|
+
gatedRules: sceneIds.length
|
|
20675
|
+
} });
|
|
20676
|
+
}
|
|
19955
20677
|
}
|
|
19956
20678
|
/**
|
|
19957
20679
|
* Feed every excluded sensor's mirrored state back to the panel.
|
|
@@ -20088,6 +20810,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
20088
20810
|
await this.deviceStates.refresh();
|
|
20089
20811
|
await this.deviceDirectory.refresh();
|
|
20090
20812
|
await this.zoneOwners.refresh();
|
|
20813
|
+
this.sceneStates.refresh();
|
|
20091
20814
|
this.observeAlarmContacts();
|
|
20092
20815
|
await this.occupancyStore.pruneExcept(this.activeOccupancyKeys());
|
|
20093
20816
|
}
|
|
@@ -20385,12 +21108,17 @@ var NotificationCenter = class NotificationCenter {
|
|
|
20385
21108
|
const fresh = await this.deps.readDeviceZoneIds(zoneScope);
|
|
20386
21109
|
zoneLookup = (id) => fresh.get(id) ?? this.zoneOwners.get(id);
|
|
20387
21110
|
} catch {}
|
|
21111
|
+
let sceneLookup = (id) => this.sceneStates.get(id);
|
|
21112
|
+
if (rule.conditions.scene !== void 0 && this.deps.readSceneStates !== void 0) try {
|
|
21113
|
+
const fresh = new Map(this.deps.readSceneStates().map((o) => [o.sceneId, o]));
|
|
21114
|
+
sceneLookup = (id) => fresh.get(id) ?? this.sceneStates.get(id);
|
|
21115
|
+
} catch {}
|
|
20388
21116
|
const occupancy = rule.conditions.occupancy;
|
|
20389
|
-
if (rule.delivery === "device-event" && occupancy !== void 0) return this.occupancyDryRun(rule, occupancy, lookup, zoneLookup);
|
|
21117
|
+
if (rule.delivery === "device-event" && occupancy !== void 0) return this.occupancyDryRun(rule, occupancy, lookup, zoneLookup, sceneLookup);
|
|
20390
21118
|
const results = [];
|
|
20391
21119
|
for (const subject of subjects.sort((a, b) => b.timestamp - a.timestamp)) {
|
|
20392
21120
|
if (results.length >= TEST_RULE_MAX_RESULTS) break;
|
|
20393
|
-
const evaluation = evaluateRule(rule, subject, lookup, zoneLookup);
|
|
21121
|
+
const evaluation = evaluateRule(rule, subject, lookup, zoneLookup, sceneLookup);
|
|
20394
21122
|
results.push({
|
|
20395
21123
|
recordId: subject.recordId,
|
|
20396
21124
|
recordKind: subject.kind === "track-end" ? "track" : subject.kind === "audio-event" || subject.kind === "audio-window" ? "object-event" : subject.kind === "occupancy-event" ? "device-event" : subject.kind,
|
|
@@ -20414,7 +21142,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
20414
21142
|
* from the ordinary `evaluateRule` on the ordinary subject — this method
|
|
20415
21143
|
* chooses the cameras and writes the sentence, nothing else.
|
|
20416
21144
|
*/
|
|
20417
|
-
async occupancyDryRun(rule, occupancy, lookup, zoneLookup) {
|
|
21145
|
+
async occupancyDryRun(rule, occupancy, lookup, zoneLookup, sceneLookup) {
|
|
20418
21146
|
const read = this.deps.readOccupancySnapshots;
|
|
20419
21147
|
if (read === void 0) return [];
|
|
20420
21148
|
const now = this.now();
|
|
@@ -20457,7 +21185,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
20457
21185
|
});
|
|
20458
21186
|
continue;
|
|
20459
21187
|
}
|
|
20460
|
-
const evaluation = evaluateRule(rule, probe.subject, lookup, zoneLookup);
|
|
21188
|
+
const evaluation = evaluateRule(rule, probe.subject, lookup, zoneLookup, sceneLookup);
|
|
20461
21189
|
results.push({
|
|
20462
21190
|
...common,
|
|
20463
21191
|
recordId: probe.subject.recordId,
|
|
@@ -20712,6 +21440,10 @@ function planRuleTest(input) {
|
|
|
20712
21440
|
conditionId: "deviceState",
|
|
20713
21441
|
note: `This rule only fires while device #${c.deviceState.deviceId} is in one of its chosen states. That gate reads the REAL device and cannot be simulated — put the device in that state first, or the test fails closed exactly as production would.`
|
|
20714
21442
|
});
|
|
21443
|
+
if (c.scene !== void 0) notes.push({
|
|
21444
|
+
conditionId: "scene",
|
|
21445
|
+
note: `This rule only fires while scene ${c.scene.sceneId} is ${c.scene.requiredState}. That gate reads the scene's REAL state and cannot be simulated — the test answers with whatever the scene is right now, and fails closed while it cannot judge or has not been read yet, exactly as production would.`
|
|
21446
|
+
});
|
|
20715
21447
|
if (c.customZones !== void 0 && c.customZones.length > 0) notes.push({
|
|
20716
21448
|
conditionId: "customZones",
|
|
20717
21449
|
note: "The subject is placed over the first drawn polygon so the condition passes. A rule that needs a specific polygon should be checked in the simulator, where the box can be moved."
|
|
@@ -25957,6 +26689,72 @@ function decideZeroMediaPolicy(input, config = DEFAULT_ZERO_MEDIA_POLICY_CONFIG)
|
|
|
25957
26689
|
}
|
|
25958
26690
|
//#endregion
|
|
25959
26691
|
//#region src/pipeline-analytics/pipeline/delete-track-cascade.ts
|
|
26692
|
+
var EMPTY_CASCADE_TALLY = {
|
|
26693
|
+
tracks: 0,
|
|
26694
|
+
events: 0,
|
|
26695
|
+
media: 0,
|
|
26696
|
+
faces: 0,
|
|
26697
|
+
plates: 0,
|
|
26698
|
+
embeddings: 0,
|
|
26699
|
+
bytesReclaimed: 0
|
|
26700
|
+
};
|
|
26701
|
+
/** Fold a leaf store's receipts into a running tally. */
|
|
26702
|
+
function addLeafCounts(tally, counts) {
|
|
26703
|
+
let next = tally;
|
|
26704
|
+
for (const c of counts) {
|
|
26705
|
+
const bytesReclaimed = next.bytesReclaimed + c.bytes;
|
|
26706
|
+
switch (c.family) {
|
|
26707
|
+
case "events":
|
|
26708
|
+
next = {
|
|
26709
|
+
...next,
|
|
26710
|
+
events: next.events + c.rows,
|
|
26711
|
+
bytesReclaimed
|
|
26712
|
+
};
|
|
26713
|
+
break;
|
|
26714
|
+
case "media":
|
|
26715
|
+
next = {
|
|
26716
|
+
...next,
|
|
26717
|
+
media: next.media + c.rows,
|
|
26718
|
+
bytesReclaimed
|
|
26719
|
+
};
|
|
26720
|
+
break;
|
|
26721
|
+
case "faces":
|
|
26722
|
+
next = {
|
|
26723
|
+
...next,
|
|
26724
|
+
faces: next.faces + c.rows,
|
|
26725
|
+
bytesReclaimed
|
|
26726
|
+
};
|
|
26727
|
+
break;
|
|
26728
|
+
case "plates":
|
|
26729
|
+
next = {
|
|
26730
|
+
...next,
|
|
26731
|
+
plates: next.plates + c.rows,
|
|
26732
|
+
bytesReclaimed
|
|
26733
|
+
};
|
|
26734
|
+
break;
|
|
26735
|
+
case "embeddings":
|
|
26736
|
+
next = {
|
|
26737
|
+
...next,
|
|
26738
|
+
embeddings: next.embeddings + c.rows,
|
|
26739
|
+
bytesReclaimed
|
|
26740
|
+
};
|
|
26741
|
+
break;
|
|
26742
|
+
}
|
|
26743
|
+
}
|
|
26744
|
+
return next;
|
|
26745
|
+
}
|
|
26746
|
+
/** Sum two tallies (per-track results into a per-device one). */
|
|
26747
|
+
function mergeCascadeTallies(a, b) {
|
|
26748
|
+
return {
|
|
26749
|
+
tracks: a.tracks + b.tracks,
|
|
26750
|
+
events: a.events + b.events,
|
|
26751
|
+
media: a.media + b.media,
|
|
26752
|
+
faces: a.faces + b.faces,
|
|
26753
|
+
plates: a.plates + b.plates,
|
|
26754
|
+
embeddings: a.embeddings + b.embeddings,
|
|
26755
|
+
bytesReclaimed: a.bytesReclaimed + b.bytesReclaimed
|
|
26756
|
+
};
|
|
26757
|
+
}
|
|
25960
26758
|
/**
|
|
25961
26759
|
* Extensible whole-track deletion cascade. Runs every registered
|
|
25962
26760
|
* `TrackScopedStore` (each removes its ephemeral rows + owned media for the
|
|
@@ -25973,8 +26771,16 @@ function decideZeroMediaPolicy(input, config = DEFAULT_ZERO_MEDIA_POLICY_CONFIG)
|
|
|
25973
26771
|
* ]
|
|
25974
26772
|
*/
|
|
25975
26773
|
async function cascadeDeleteTracks(registry, trackStore, trackIds) {
|
|
25976
|
-
|
|
25977
|
-
for (const
|
|
26774
|
+
let tally = EMPTY_CASCADE_TALLY;
|
|
26775
|
+
for (const store of registry) tally = addLeafCounts(tally, await store.deleteByTracks(trackIds));
|
|
26776
|
+
for (const trackId of trackIds) {
|
|
26777
|
+
await trackStore.deletePersisted(trackId);
|
|
26778
|
+
tally = {
|
|
26779
|
+
...tally,
|
|
26780
|
+
tracks: tally.tracks + 1
|
|
26781
|
+
};
|
|
26782
|
+
}
|
|
26783
|
+
return tally;
|
|
25978
26784
|
}
|
|
25979
26785
|
/**
|
|
25980
26786
|
* The one registry, in the one order — leaves first, root deleted internally
|
|
@@ -26005,14 +26811,28 @@ function buildAnalyticsCascadeRegistry(stores) {
|
|
|
26005
26811
|
* embeddings) is opted in by the CALLER's registry, not by this legacy shim.
|
|
26006
26812
|
*/
|
|
26007
26813
|
async function cascadeDeleteTrack(stores, trackId) {
|
|
26008
|
-
|
|
26814
|
+
return cascadeDeleteTracks([{ deleteByTracks: async (ids) => {
|
|
26815
|
+
let events = 0;
|
|
26816
|
+
let media = 0;
|
|
26009
26817
|
for (const id of ids) {
|
|
26010
26818
|
const eventIds = await stores.eventStore.deleteByTrack(id);
|
|
26011
|
-
|
|
26819
|
+
events += eventIds.length;
|
|
26820
|
+
if (eventIds.length > 0) media += await stores.mediaStore.deleteForEvents([...eventIds]);
|
|
26012
26821
|
}
|
|
26013
|
-
|
|
26014
|
-
|
|
26015
|
-
|
|
26822
|
+
return [{
|
|
26823
|
+
family: "events",
|
|
26824
|
+
rows: events,
|
|
26825
|
+
bytes: 0
|
|
26826
|
+
}, {
|
|
26827
|
+
family: "media",
|
|
26828
|
+
rows: media,
|
|
26829
|
+
bytes: 0
|
|
26830
|
+
}];
|
|
26831
|
+
} }, { deleteByTracks: async (ids) => [{
|
|
26832
|
+
family: "media",
|
|
26833
|
+
rows: await stores.mediaStore.deleteForTracks([...ids]),
|
|
26834
|
+
bytes: 0
|
|
26835
|
+
}] }], stores.trackStore, [trackId]);
|
|
26016
26836
|
}
|
|
26017
26837
|
/**
|
|
26018
26838
|
* Widened batch whole-track deletion — the ONE engine behind the three §5 entry
|
|
@@ -26023,9 +26843,10 @@ async function cascadeDeleteTrack(stores, trackId) {
|
|
|
26023
26843
|
*/
|
|
26024
26844
|
async function runTrackCascadeBatch(deps, trackIds) {
|
|
26025
26845
|
let deleted = 0;
|
|
26846
|
+
let tally = EMPTY_CASCADE_TALLY;
|
|
26026
26847
|
const failed = [];
|
|
26027
26848
|
for (const trackId of trackIds) try {
|
|
26028
|
-
await cascadeDeleteTracks(deps.registry, deps.trackStore, [trackId]);
|
|
26849
|
+
tally = mergeCascadeTallies(tally, await cascadeDeleteTracks(deps.registry, deps.trackStore, [trackId]));
|
|
26029
26850
|
deps.onTrackCleanup(deps.deviceId, trackId);
|
|
26030
26851
|
deleted += 1;
|
|
26031
26852
|
} catch (err) {
|
|
@@ -26034,7 +26855,8 @@ async function runTrackCascadeBatch(deps, trackIds) {
|
|
|
26034
26855
|
}
|
|
26035
26856
|
return {
|
|
26036
26857
|
deleted,
|
|
26037
|
-
failed
|
|
26858
|
+
failed,
|
|
26859
|
+
tally
|
|
26038
26860
|
};
|
|
26039
26861
|
}
|
|
26040
26862
|
//#endregion
|
|
@@ -27446,6 +28268,51 @@ var PlateGalleryProvider = class {
|
|
|
27446
28268
|
};
|
|
27447
28269
|
}
|
|
27448
28270
|
};
|
|
28271
|
+
var OrphanAuditActionInputSchema = object({
|
|
28272
|
+
mode: _enum(["report", "reclaim"]).default("report"),
|
|
28273
|
+
/**
|
|
28274
|
+
* Start every scope from the top instead of resuming its persisted cursor.
|
|
28275
|
+
*
|
|
28276
|
+
* What draining a backlog wants, and never the default — the cursor exists
|
|
28277
|
+
* so a 6-hourly pass makes progress instead of re-walking the same head.
|
|
28278
|
+
*/
|
|
28279
|
+
restart: boolean().optional(),
|
|
28280
|
+
/** Rows read per statement. Smaller = more yields = a gentler pass. */
|
|
28281
|
+
pageSize: number().int().min(50).max(5e3).optional(),
|
|
28282
|
+
/** Rows EXAMINED per collection in this pass — the pacing bound. */
|
|
28283
|
+
maxRowsPerScope: number().int().min(1).max(2e5).optional(),
|
|
28284
|
+
/** Rows DELETED per collection in this pass — the blast-radius bound. */
|
|
28285
|
+
maxReclaimPerScope: number().int().min(1).max(1e5).optional(),
|
|
28286
|
+
/**
|
|
28287
|
+
* Minutes of immunity for a recent row. Protective ONLY — raising it can
|
|
28288
|
+
* exclusively save rows, never select them, which is what keeps this off
|
|
28289
|
+
* the one-age-keyed-sweep guard's books.
|
|
28290
|
+
*/
|
|
28291
|
+
graceMinutes: number().int().min(1).max(10080).optional()
|
|
28292
|
+
});
|
|
28293
|
+
var OrphanAuditScopeReportSchema = object({
|
|
28294
|
+
collection: string(),
|
|
28295
|
+
examined: number(),
|
|
28296
|
+
orphans: number(),
|
|
28297
|
+
reclaimed: number(),
|
|
28298
|
+
bytesReclaimed: number(),
|
|
28299
|
+
exempt: number(),
|
|
28300
|
+
withinGrace: number(),
|
|
28301
|
+
unresolvable: number(),
|
|
28302
|
+
stoppedBy: string(),
|
|
28303
|
+
/** Where the NEXT pass resumes. `0` = the scope was walked to the end. */
|
|
28304
|
+
nextCursor: number()
|
|
28305
|
+
});
|
|
28306
|
+
var orphanAuditActions = defineCustomActions({ "retention.orphanAudit": customAction(OrphanAuditActionInputSchema, object({
|
|
28307
|
+
/** `false` when a pass was already in flight — nothing was done. */
|
|
28308
|
+
ran: boolean(),
|
|
28309
|
+
mode: _enum(["report", "reclaim"]),
|
|
28310
|
+
scopes: array(OrphanAuditScopeReportSchema),
|
|
28311
|
+
totalOrphans: number(),
|
|
28312
|
+
totalReclaimed: number(),
|
|
28313
|
+
totalBytesReclaimed: number(),
|
|
28314
|
+
durationMs: number()
|
|
28315
|
+
}), { auth: "admin" }) });
|
|
27449
28316
|
//#endregion
|
|
27450
28317
|
//#region src/pipeline-analytics/videoclips-provider.ts
|
|
27451
28318
|
var SOURCE = "analytics";
|
|
@@ -33190,13 +34057,25 @@ var StoragePressureTracker = class {
|
|
|
33190
34057
|
};
|
|
33191
34058
|
//#endregion
|
|
33192
34059
|
//#region src/pipeline-analytics/store/media-store.ts
|
|
34060
|
+
/**
|
|
34061
|
+
* `scene` is its own owner kind rather than borrowing `event`.
|
|
34062
|
+
*
|
|
34063
|
+
* Scene reference thumbnails were written as `ownerKind: 'event'` with an
|
|
34064
|
+
* `ownerId` of `scene-<monitorId>-…`, which resolves through
|
|
34065
|
+
* `resolveMediaOwner` to `{ kind: 'event', eventId: 'scene-…' }` — an event row
|
|
34066
|
+
* that has never existed. The orphan audit therefore classified every
|
|
34067
|
+
* operator-captured reference picture as collectable. It is the same class of
|
|
34068
|
+
* mistake `identity` and `vehicle` are exempt for: a blob the OPERATOR curated,
|
|
34069
|
+
* which nothing else can regenerate and no cascade owns.
|
|
34070
|
+
*/
|
|
33193
34071
|
var OWNER_KINDS = [
|
|
33194
34072
|
"event",
|
|
33195
34073
|
"track",
|
|
33196
34074
|
"face",
|
|
33197
34075
|
"identity",
|
|
33198
34076
|
"plate",
|
|
33199
|
-
"vehicle"
|
|
34077
|
+
"vehicle",
|
|
34078
|
+
"scene"
|
|
33200
34079
|
];
|
|
33201
34080
|
/**
|
|
33202
34081
|
* Narrow an arbitrary string to an `OwnerKind`.
|
|
@@ -33657,21 +34536,33 @@ var MediaStore = class {
|
|
|
33657
34536
|
* Best-effort per row; returns the number of index rows removed.
|
|
33658
34537
|
*/
|
|
33659
34538
|
async deleteForOwner(ownerKind, ownerIds) {
|
|
34539
|
+
return (await this.gated(() => this.deleteForOwnerUnchecked(ownerKind, ownerIds))).rows;
|
|
34540
|
+
}
|
|
34541
|
+
/**
|
|
34542
|
+
* The same delete, reporting the BYTES it released as well as the row count.
|
|
34543
|
+
*
|
|
34544
|
+
* `sizeBytes` is already on the index row, so the number costs nothing — it
|
|
34545
|
+
* was simply never returned, which is why every retention ops-log row this
|
|
34546
|
+
* addon has ever written says `bytesReclaimed: 0` (D161).
|
|
34547
|
+
*/
|
|
34548
|
+
async deleteForOwnerCounted(ownerKind, ownerIds) {
|
|
33660
34549
|
return this.gated(() => this.deleteForOwnerUnchecked(ownerKind, ownerIds));
|
|
33661
34550
|
}
|
|
33662
34551
|
async deleteForOwnerUnchecked(ownerKind, ownerIds) {
|
|
33663
|
-
let
|
|
34552
|
+
let rows = 0;
|
|
34553
|
+
let bytes = 0;
|
|
33664
34554
|
for (const ownerId of ownerIds) {
|
|
33665
|
-
const
|
|
34555
|
+
const found = await this.store.query.query({
|
|
33666
34556
|
collection: MEDIA_COLLECTION,
|
|
33667
34557
|
filter: { where: {
|
|
33668
34558
|
ownerKind,
|
|
33669
34559
|
ownerId
|
|
33670
34560
|
} }
|
|
33671
34561
|
});
|
|
33672
|
-
for (const row of
|
|
34562
|
+
for (const row of found) {
|
|
33673
34563
|
const rowData = row.data;
|
|
33674
34564
|
const path = String(rowData["path"] ?? "");
|
|
34565
|
+
const sizeBytes = Number(rowData["sizeBytes"]);
|
|
33675
34566
|
try {
|
|
33676
34567
|
if (path) await this.storage.delete({
|
|
33677
34568
|
location: mediaRowLocation(rowData),
|
|
@@ -33683,7 +34574,8 @@ var MediaStore = class {
|
|
|
33683
34574
|
collection: MEDIA_COLLECTION,
|
|
33684
34575
|
key: row.id
|
|
33685
34576
|
});
|
|
33686
|
-
|
|
34577
|
+
rows++;
|
|
34578
|
+
if (Number.isFinite(sizeBytes)) bytes += sizeBytes;
|
|
33687
34579
|
} catch (err) {
|
|
33688
34580
|
this.logger.debug("media delete-for-owner failed", { meta: {
|
|
33689
34581
|
key: row.id,
|
|
@@ -33692,7 +34584,10 @@ var MediaStore = class {
|
|
|
33692
34584
|
}
|
|
33693
34585
|
}
|
|
33694
34586
|
}
|
|
33695
|
-
return
|
|
34587
|
+
return {
|
|
34588
|
+
rows,
|
|
34589
|
+
bytes
|
|
34590
|
+
};
|
|
33696
34591
|
}
|
|
33697
34592
|
/**
|
|
33698
34593
|
* On-demand footprint aggregation for the events-management UI: sum
|
|
@@ -33831,11 +34726,20 @@ var MediaStore = class {
|
|
|
33831
34726
|
* batch.
|
|
33832
34727
|
*/
|
|
33833
34728
|
async deleteByTracks(trackIds) {
|
|
33834
|
-
await this.gated(async () => {
|
|
33835
|
-
await this.deleteForOwnerUnchecked("track", trackIds);
|
|
33836
|
-
await this.deleteForOwnerUnchecked("face", trackIds.map((id) => FACE_MEDIA_OWNER_PREFIX + id));
|
|
34729
|
+
const own = await this.gated(async () => {
|
|
34730
|
+
const tracks = await this.deleteForOwnerUnchecked("track", trackIds);
|
|
34731
|
+
const faces = await this.deleteForOwnerUnchecked("face", trackIds.map((id) => FACE_MEDIA_OWNER_PREFIX + id));
|
|
34732
|
+
return {
|
|
34733
|
+
rows: tracks.rows + faces.rows,
|
|
34734
|
+
bytes: tracks.bytes + faces.bytes
|
|
34735
|
+
};
|
|
33837
34736
|
});
|
|
33838
|
-
await this.
|
|
34737
|
+
const plates = await this.deleteForOwnerCounted("plate", trackIds.map((id) => PLATE_MEDIA_OWNER_PREFIX + id));
|
|
34738
|
+
return [{
|
|
34739
|
+
family: "media",
|
|
34740
|
+
rows: own.rows + plates.rows,
|
|
34741
|
+
bytes: own.bytes + plates.bytes
|
|
34742
|
+
}];
|
|
33839
34743
|
}
|
|
33840
34744
|
/**
|
|
33841
34745
|
* A stable page of media INDEX rows for the orphan audit — no blobs, no
|
|
@@ -38273,11 +39177,21 @@ var EventStore = class {
|
|
|
38273
39177
|
error: String(err)
|
|
38274
39178
|
} });
|
|
38275
39179
|
}
|
|
39180
|
+
let mediaRows = 0;
|
|
38276
39181
|
if (eventIds.length > 0 && this.media !== void 0) try {
|
|
38277
|
-
await this.media.deleteForEvents(eventIds);
|
|
39182
|
+
mediaRows = await this.media.deleteForEvents(eventIds);
|
|
38278
39183
|
} catch (err) {
|
|
38279
39184
|
this.logger.warn("EventStore.deleteByTracks: event media delete failed", { meta: { error: String(err) } });
|
|
38280
39185
|
}
|
|
39186
|
+
return [{
|
|
39187
|
+
family: "events",
|
|
39188
|
+
rows: eventIds.length,
|
|
39189
|
+
bytes: 0
|
|
39190
|
+
}, {
|
|
39191
|
+
family: "media",
|
|
39192
|
+
rows: mediaRows,
|
|
39193
|
+
bytes: 0
|
|
39194
|
+
}];
|
|
38281
39195
|
}
|
|
38282
39196
|
};
|
|
38283
39197
|
function slimMotion(id, data) {
|
|
@@ -38668,6 +39582,7 @@ var FaceStore = class {
|
|
|
38668
39582
|
* aborts the batch. (Crop media of these rows is removed by `MediaStore`.)
|
|
38669
39583
|
*/
|
|
38670
39584
|
async deleteByTracks(trackIds) {
|
|
39585
|
+
let removed = 0;
|
|
38671
39586
|
for (const trackId of trackIds) try {
|
|
38672
39587
|
const eligible = (await this.store.query.query({
|
|
38673
39588
|
collection: FACES_COLLECTION,
|
|
@@ -38678,6 +39593,7 @@ var FaceStore = class {
|
|
|
38678
39593
|
collection: FACES_COLLECTION,
|
|
38679
39594
|
key: row.id
|
|
38680
39595
|
});
|
|
39596
|
+
removed++;
|
|
38681
39597
|
} catch (err) {
|
|
38682
39598
|
this.logger.warn("FaceStore.deleteByTracks delete failed", { meta: {
|
|
38683
39599
|
faceId: row.id,
|
|
@@ -38690,6 +39606,11 @@ var FaceStore = class {
|
|
|
38690
39606
|
error: String(err)
|
|
38691
39607
|
} });
|
|
38692
39608
|
}
|
|
39609
|
+
return [{
|
|
39610
|
+
family: "faces",
|
|
39611
|
+
rows: removed,
|
|
39612
|
+
bytes: 0
|
|
39613
|
+
}];
|
|
38693
39614
|
}
|
|
38694
39615
|
/** Delete a single buffered face row by id (its crop media is removed by the
|
|
38695
39616
|
* caller — the FaceStore owns rows, not blobs). Best-effort. */
|
|
@@ -39625,6 +40546,7 @@ var PlateStore = class {
|
|
|
39625
40546
|
* the batch. (Crop media of these rows is removed by `MediaStore`.)
|
|
39626
40547
|
*/
|
|
39627
40548
|
async deleteByTracks(trackIds) {
|
|
40549
|
+
let removed = 0;
|
|
39628
40550
|
for (const trackId of trackIds) try {
|
|
39629
40551
|
const eligible = (await this.store.query.query({
|
|
39630
40552
|
collection: PLATES_COLLECTION,
|
|
@@ -39635,6 +40557,7 @@ var PlateStore = class {
|
|
|
39635
40557
|
collection: PLATES_COLLECTION,
|
|
39636
40558
|
key: row.id
|
|
39637
40559
|
});
|
|
40560
|
+
removed++;
|
|
39638
40561
|
} catch (err) {
|
|
39639
40562
|
this.logger.warn("PlateStore.deleteByTracks delete failed", { meta: {
|
|
39640
40563
|
plateId: row.id,
|
|
@@ -39647,6 +40570,11 @@ var PlateStore = class {
|
|
|
39647
40570
|
error: String(err)
|
|
39648
40571
|
} });
|
|
39649
40572
|
}
|
|
40573
|
+
return [{
|
|
40574
|
+
family: "plates",
|
|
40575
|
+
rows: removed,
|
|
40576
|
+
bytes: 0
|
|
40577
|
+
}];
|
|
39650
40578
|
}
|
|
39651
40579
|
/**
|
|
39652
40580
|
* Hold the unassigned plate buffer to `maxPerDevice` newest reads per camera.
|
|
@@ -40768,6 +41696,36 @@ var TrackStore = class {
|
|
|
40768
41696
|
dropActive(trackId) {
|
|
40769
41697
|
this.active.delete(trackId);
|
|
40770
41698
|
}
|
|
41699
|
+
/**
|
|
41700
|
+
* Close a still-ACTIVE track NOW: persist its row, then drop the in-RAM
|
|
41701
|
+
* entry. The early-close counterpart of {@link expireStale}, for a caller
|
|
41702
|
+
* that knows the track is over before its TTL says so.
|
|
41703
|
+
*
|
|
41704
|
+
* **This exists because {@link dropActive} orphans everything the track
|
|
41705
|
+
* owns.** A track's media, object events and CLIP vector are keyed by trackId
|
|
41706
|
+
* and written throughout its life; the ROW is what retention cascades from,
|
|
41707
|
+
* and it is written only here or at expiry. Forgetting a lived-in track
|
|
41708
|
+
* therefore leaves a full media set — key frame, thumbnail, first/last frame,
|
|
41709
|
+
* the snapshot filmstrip — with no owner any sweep can ever reach. Measured on
|
|
41710
|
+
* the live hub 2026-08-14: 74,700 such media rows, 18.9 GB, growing
|
|
41711
|
+
* ~1.15 GB/day, plus 23,296 object events and 4,543 CLIP vectors (D161).
|
|
41712
|
+
*
|
|
41713
|
+
* `dropActive` is still correct for a birth RETRACTED before it lived —
|
|
41714
|
+
* nothing has been written under that id yet. It is never correct for a track
|
|
41715
|
+
* that ran.
|
|
41716
|
+
*
|
|
41717
|
+
* Returns whether an active entry was found. THROWS if the persist fails: the
|
|
41718
|
+
* caller must log it, because a swallowed failure here IS the leak.
|
|
41719
|
+
*/
|
|
41720
|
+
async closeActive(trackId) {
|
|
41721
|
+
const t = this.active.get(trackId);
|
|
41722
|
+
if (t === void 0) return false;
|
|
41723
|
+
t.active = false;
|
|
41724
|
+
const record = cloneTrack(t);
|
|
41725
|
+
await this.persistCompleted(record);
|
|
41726
|
+
this.active.delete(trackId);
|
|
41727
|
+
return true;
|
|
41728
|
+
}
|
|
40771
41729
|
/** Delete the persisted track row (keyed by trackId) and drop the in-RAM
|
|
40772
41730
|
* active entry if present. Used by the whole-track deletion cascade. */
|
|
40773
41731
|
async deletePersisted(trackId) {
|
|
@@ -41899,7 +42857,11 @@ function auditableCollections() {
|
|
|
41899
42857
|
* exemption lives here now, and the orphan collector — the only sweep that
|
|
41900
42858
|
* walks media it was not handed by name — reads it.
|
|
41901
42859
|
*/
|
|
41902
|
-
var RETENTION_EXEMPT_OWNER_KINDS = new Set([
|
|
42860
|
+
var RETENTION_EXEMPT_OWNER_KINDS = new Set([
|
|
42861
|
+
"identity",
|
|
42862
|
+
"vehicle",
|
|
42863
|
+
"scene"
|
|
42864
|
+
]);
|
|
41903
42865
|
function isRetentionExemptOwnerKind(kind) {
|
|
41904
42866
|
return RETENTION_EXEMPT_OWNER_KINDS.has(kind);
|
|
41905
42867
|
}
|
|
@@ -42118,6 +43080,7 @@ async function auditScope(scope, deps, limits, cursor) {
|
|
|
42118
43080
|
} });
|
|
42119
43081
|
}
|
|
42120
43082
|
}
|
|
43083
|
+
if (deps.breathe) await deps.breathe();
|
|
42121
43084
|
if (ceilingHit) {
|
|
42122
43085
|
stoppedBy = "reclaim-ceiling";
|
|
42123
43086
|
offset += rows.length - reclaimedInPage;
|
|
@@ -44837,22 +45800,6 @@ async function projectSensorMarkers(deps, data, timestamp) {
|
|
|
44837
45800
|
}
|
|
44838
45801
|
return landed;
|
|
44839
45802
|
}
|
|
44840
|
-
/** JPEG quality for the downscaled full frame — matches the crop path. */
|
|
44841
|
-
var FULL_FRAME_QUALITY = 80;
|
|
44842
|
-
/**
|
|
44843
|
-
* Downscale an already-encoded JPEG full frame to FIT WITHIN
|
|
44844
|
-
* {@link FULL_FRAME_MAX_WIDTH}×{@link FULL_FRAME_MAX_HEIGHT}, preserving aspect
|
|
44845
|
-
* ratio (`fit: 'inside'`) and never enlarging a source already smaller than the
|
|
44846
|
-
* box. Re-encodes as JPEG. Used before persisting a synthetic sensor/control
|
|
44847
|
-
* track's whole-scene snapshot so a raw native-resolution frame (a 4K bedroom
|
|
44848
|
-
* at night) is never stored or served — the privacy fix moved to CAPTURE time.
|
|
44849
|
-
*/
|
|
44850
|
-
async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
|
|
44851
|
-
return sharp(Buffer.from(jpeg)).resize(maxWidth, maxHeight, {
|
|
44852
|
-
fit: "inside",
|
|
44853
|
-
withoutEnlargement: true
|
|
44854
|
-
}).jpeg({ quality: FULL_FRAME_QUALITY }).toBuffer();
|
|
44855
|
-
}
|
|
44856
45803
|
//#endregion
|
|
44857
45804
|
//#region src/pipeline-analytics/services/synthetic-track.ts
|
|
44858
45805
|
/**
|
|
@@ -46893,7 +47840,11 @@ var ObjectEmbeddingStore = class {
|
|
|
46893
47840
|
* breaks recognition (design §2/§4).
|
|
46894
47841
|
*/
|
|
46895
47842
|
async deleteByTracks(trackIds) {
|
|
46896
|
-
if (trackIds.length === 0) return
|
|
47843
|
+
if (trackIds.length === 0) return [{
|
|
47844
|
+
family: "embeddings",
|
|
47845
|
+
rows: 0,
|
|
47846
|
+
bytes: 0
|
|
47847
|
+
}];
|
|
46897
47848
|
try {
|
|
46898
47849
|
await this.vectorStore.deleteByIds.mutate({
|
|
46899
47850
|
index: OBJECT_CLIP_INDEX,
|
|
@@ -46904,7 +47855,17 @@ var ObjectEmbeddingStore = class {
|
|
|
46904
47855
|
count: trackIds.length,
|
|
46905
47856
|
error: String(err)
|
|
46906
47857
|
} });
|
|
47858
|
+
return [{
|
|
47859
|
+
family: "embeddings",
|
|
47860
|
+
rows: 0,
|
|
47861
|
+
bytes: 0
|
|
47862
|
+
}];
|
|
46907
47863
|
}
|
|
47864
|
+
return [{
|
|
47865
|
+
family: "embeddings",
|
|
47866
|
+
rows: trackIds.length,
|
|
47867
|
+
bytes: 0
|
|
47868
|
+
}];
|
|
46908
47869
|
}
|
|
46909
47870
|
};
|
|
46910
47871
|
/** Build the vector-store metadata for one embedding. */
|
|
@@ -47778,7 +48739,8 @@ var NO_PUBLICATION = {
|
|
|
47778
48739
|
var customActions = {
|
|
47779
48740
|
...ncActions,
|
|
47780
48741
|
...embeddingActions,
|
|
47781
|
-
...debugActions
|
|
48742
|
+
...debugActions,
|
|
48743
|
+
...orphanAuditActions
|
|
47782
48744
|
};
|
|
47783
48745
|
/**
|
|
47784
48746
|
* Assist threshold when the caller does not pin one. Packages have their own
|
|
@@ -48756,7 +49718,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
48756
49718
|
customActions: {
|
|
48757
49719
|
...ncActions,
|
|
48758
49720
|
...embeddingActions,
|
|
48759
|
-
...debugActions
|
|
49721
|
+
...debugActions,
|
|
49722
|
+
...orphanAuditActions
|
|
48760
49723
|
},
|
|
48761
49724
|
actionHandlers: {
|
|
48762
49725
|
...ncHandlers,
|
|
@@ -49381,6 +50344,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
49381
50344
|
addonId: device.addonId
|
|
49382
50345
|
};
|
|
49383
50346
|
},
|
|
50347
|
+
getSnapshot: (deviceId) => api.snapshot.getSnapshot.query({ deviceId }),
|
|
49384
50348
|
identityIdsByName: async () => {
|
|
49385
50349
|
const byName = /* @__PURE__ */ new Map();
|
|
49386
50350
|
const identities = await stores.identityStore.listIdentities();
|
|
@@ -49474,6 +50438,14 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
49474
50438
|
}
|
|
49475
50439
|
return out;
|
|
49476
50440
|
},
|
|
50441
|
+
readSceneStates: () => (this.sceneProvider?.listAllEnabled() ?? []).map(({ deviceId, monitor }) => ({
|
|
50442
|
+
sceneId: monitor.id,
|
|
50443
|
+
deviceId,
|
|
50444
|
+
label: monitor.label,
|
|
50445
|
+
verdict: monitor.verdict,
|
|
50446
|
+
latched: monitor.latched,
|
|
50447
|
+
emit: monitor.emit
|
|
50448
|
+
})),
|
|
49477
50449
|
readOccupancySnapshots: async (deviceIds) => {
|
|
49478
50450
|
const za = this.zoneAnalytics;
|
|
49479
50451
|
if (za === null) return /* @__PURE__ */ new Map();
|
|
@@ -50249,13 +51221,16 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
50249
51221
|
if (media === null) throw new Error("media store not ready");
|
|
50250
51222
|
return media.putReplacing({
|
|
50251
51223
|
deviceId,
|
|
50252
|
-
ownerKind: "
|
|
51224
|
+
ownerKind: "scene",
|
|
50253
51225
|
ownerId,
|
|
50254
51226
|
kind: "thumbnailSmall",
|
|
50255
51227
|
timestamp,
|
|
50256
51228
|
data
|
|
50257
51229
|
});
|
|
50258
51230
|
},
|
|
51231
|
+
dropMedia: async (mediaId) => {
|
|
51232
|
+
await this.mediaStore?.deleteByKey(mediaId);
|
|
51233
|
+
},
|
|
50259
51234
|
mirrorSlice: (deviceId, status) => {
|
|
50260
51235
|
this.mirrorSceneSlice(deviceId, status);
|
|
50261
51236
|
},
|
|
@@ -50272,6 +51247,16 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
50272
51247
|
data: payload
|
|
50273
51248
|
});
|
|
50274
51249
|
},
|
|
51250
|
+
onSceneEdge: (edge) => {
|
|
51251
|
+
this.notificationCenter?.onSceneEdge({
|
|
51252
|
+
sceneId: edge.monitorId,
|
|
51253
|
+
deviceId: edge.deviceId,
|
|
51254
|
+
label: edge.monitorLabel,
|
|
51255
|
+
verdict: edge.verdict,
|
|
51256
|
+
latched: edge.latched,
|
|
51257
|
+
emit: edge.emit
|
|
51258
|
+
});
|
|
51259
|
+
},
|
|
50275
51260
|
currentCondition: async (deviceId) => resolveCondition({
|
|
50276
51261
|
...await this.sceneConditionContext(deviceId),
|
|
50277
51262
|
now: /* @__PURE__ */ new Date()
|
|
@@ -50280,6 +51265,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
50280
51265
|
});
|
|
50281
51266
|
provider.hydrate();
|
|
50282
51267
|
this.sceneProvider = provider;
|
|
51268
|
+
this.notificationCenter?.refreshSceneStates();
|
|
50283
51269
|
if (!this.isPostProcessingNode) {
|
|
50284
51270
|
log.info("scene evaluation INERT on this node — CRUD only");
|
|
50285
51271
|
return;
|
|
@@ -52741,7 +53727,17 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
52741
53727
|
};
|
|
52742
53728
|
if (plausible) this.stationaryRegistry?.promote(entry);
|
|
52743
53729
|
processor.dropTrack(track.trackId);
|
|
52744
|
-
this.trackStore?.
|
|
53730
|
+
this.trackStore?.closeActive(track.trackId).catch((err) => {
|
|
53731
|
+
this.ctx.logger.warn("stationary promotion: closing the source track failed — its media, events and vector are now unowned", {
|
|
53732
|
+
tags: { deviceId },
|
|
53733
|
+
meta: {
|
|
53734
|
+
deviceId,
|
|
53735
|
+
trackId: track.trackId,
|
|
53736
|
+
entryId: entry.id,
|
|
53737
|
+
error: String(err)
|
|
53738
|
+
}
|
|
53739
|
+
});
|
|
53740
|
+
});
|
|
52745
53741
|
const faceEnd = this.faceRecognizer?.onTrackEnd(deviceId, track.trackId);
|
|
52746
53742
|
const dropKeyFrame = () => {
|
|
52747
53743
|
this.residents.dropKeyFrameKey(track.trackId);
|
|
@@ -53331,19 +54327,19 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
53331
54327
|
* stops at a per-scope row ceiling, RESUMES from a persisted cursor next run,
|
|
53332
54328
|
* and refuses to start if the previous run has not finished.
|
|
53333
54329
|
*/
|
|
53334
|
-
async runOrphanAuditPass() {
|
|
53335
|
-
if (this.shuttingDown) return;
|
|
54330
|
+
async runOrphanAuditPass(overrides) {
|
|
54331
|
+
if (this.shuttingDown) return null;
|
|
53336
54332
|
if (this.orphanAuditRunning) {
|
|
53337
54333
|
this.ctx.logger.info("analytics orphan audit skipped — previous run still in flight");
|
|
53338
|
-
return;
|
|
54334
|
+
return null;
|
|
53339
54335
|
}
|
|
53340
54336
|
const trackStore = this.trackStore;
|
|
53341
54337
|
const eventStore = this.eventStore;
|
|
53342
54338
|
const mediaStore = this.mediaStore;
|
|
53343
|
-
if (!trackStore || !eventStore || !mediaStore) return;
|
|
54339
|
+
if (!trackStore || !eventStore || !mediaStore) return null;
|
|
53344
54340
|
this.orphanAuditRunning = true;
|
|
53345
54341
|
try {
|
|
53346
|
-
const limits = await this.orphanAuditLimits();
|
|
54342
|
+
const limits = await this.orphanAuditLimits(overrides);
|
|
53347
54343
|
const deps = {
|
|
53348
54344
|
page: async (collection, offset, limit) => {
|
|
53349
54345
|
if (collection === "pipeline-analytics:media") return mediaStore.pageForAudit(offset, limit);
|
|
@@ -53375,13 +54371,14 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
53375
54371
|
});
|
|
53376
54372
|
},
|
|
53377
54373
|
now: () => Date.now(),
|
|
53378
|
-
logger: this.ctx.logger
|
|
54374
|
+
logger: this.ctx.logger,
|
|
54375
|
+
breathe: () => new Promise((resolve) => setImmediate(resolve))
|
|
53379
54376
|
};
|
|
53380
54377
|
const { report, cursors } = await runOrphanAudit({
|
|
53381
54378
|
scopes: buildOrphanScopes(),
|
|
53382
54379
|
deps,
|
|
53383
54380
|
limits,
|
|
53384
|
-
cursors: this.orphanAuditCursors
|
|
54381
|
+
cursors: overrides?.restart === true ? /* @__PURE__ */ new Map() : this.orphanAuditCursors
|
|
53385
54382
|
});
|
|
53386
54383
|
this.orphanAuditCursors = cursors;
|
|
53387
54384
|
for (const scope of report.scopes) {
|
|
@@ -53416,8 +54413,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
53416
54413
|
bytesReclaimed: report.totalBytesReclaimed,
|
|
53417
54414
|
detail: `orphan audit (${report.mode}): ${report.totalOrphans} orphaned rows across ${report.scopes.length} collections, ${report.totalReclaimed} reclaimed in ${report.durationMs}ms`
|
|
53418
54415
|
});
|
|
54416
|
+
return report;
|
|
53419
54417
|
} catch (err) {
|
|
53420
54418
|
this.ctx.logger.warn("analytics orphan audit failed", { meta: { error: String(err) } });
|
|
54419
|
+
return null;
|
|
53421
54420
|
} finally {
|
|
53422
54421
|
this.orphanAuditRunning = false;
|
|
53423
54422
|
}
|
|
@@ -53480,15 +54479,65 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
53480
54479
|
* raising them is raising the blast radius of a bug, and that should take a
|
|
53481
54480
|
* release.
|
|
53482
54481
|
*/
|
|
53483
|
-
async orphanAuditLimits() {
|
|
54482
|
+
async orphanAuditLimits(overrides) {
|
|
53484
54483
|
const mode = await this.orphanAuditModeState.get();
|
|
53485
54484
|
const pageSize = await this.orphanAuditPageSizeState.get();
|
|
54485
|
+
const graceMinutes = overrides?.graceMinutes;
|
|
54486
|
+
return {
|
|
54487
|
+
mode: overrides !== void 0 ? overrides.mode ?? "report" : mode === "reclaim" ? "reclaim" : "report",
|
|
54488
|
+
graceMs: graceMinutes === void 0 ? DEFAULT_ORPHAN_LIMITS.graceMs : graceMinutes * 6e4,
|
|
54489
|
+
pageSize: overrides?.pageSize ?? pageSize,
|
|
54490
|
+
maxRowsPerScope: overrides?.maxRowsPerScope ?? DEFAULT_ORPHAN_LIMITS.maxRowsPerScope,
|
|
54491
|
+
maxReclaimPerScope: overrides?.maxReclaimPerScope ?? DEFAULT_ORPHAN_LIMITS.maxReclaimPerScope
|
|
54492
|
+
};
|
|
54493
|
+
}
|
|
54494
|
+
/**
|
|
54495
|
+
* One operator-driven orphan-audit pass — the entry point behind
|
|
54496
|
+
* `retention.orphanAudit`.
|
|
54497
|
+
*
|
|
54498
|
+
* DRY RUN BY DEFAULT: an input with no `mode` reports and deletes nothing,
|
|
54499
|
+
* whatever `orphanAuditMode` is set to. Reclaiming is a word the caller has to
|
|
54500
|
+
* type. Draining the standing 18.9 GB means running this with
|
|
54501
|
+
* `mode: 'reclaim'` repeatedly until `totalOrphans` reaches 0 (D161).
|
|
54502
|
+
*/
|
|
54503
|
+
async runOrphanAuditNow(overrides) {
|
|
54504
|
+
const report = await this.runOrphanAuditPass(overrides);
|
|
54505
|
+
if (report === null) return {
|
|
54506
|
+
ran: false,
|
|
54507
|
+
mode: overrides.mode ?? "report",
|
|
54508
|
+
scopes: [],
|
|
54509
|
+
totalOrphans: 0,
|
|
54510
|
+
totalReclaimed: 0,
|
|
54511
|
+
totalBytesReclaimed: 0,
|
|
54512
|
+
durationMs: 0
|
|
54513
|
+
};
|
|
54514
|
+
this.ctx.logger.info("analytics orphan audit (operator)", { meta: {
|
|
54515
|
+
mode: report.mode,
|
|
54516
|
+
orphans: report.totalOrphans,
|
|
54517
|
+
reclaimed: report.totalReclaimed,
|
|
54518
|
+
bytesReclaimed: report.totalBytesReclaimed,
|
|
54519
|
+
durationMs: report.durationMs,
|
|
54520
|
+
restart: overrides.restart === true
|
|
54521
|
+
} });
|
|
53486
54522
|
return {
|
|
53487
|
-
|
|
53488
|
-
|
|
53489
|
-
|
|
53490
|
-
|
|
53491
|
-
|
|
54523
|
+
ran: true,
|
|
54524
|
+
mode: report.mode,
|
|
54525
|
+
scopes: report.scopes.map((scope) => ({
|
|
54526
|
+
collection: scope.collection,
|
|
54527
|
+
examined: scope.examined,
|
|
54528
|
+
orphans: scope.orphans,
|
|
54529
|
+
reclaimed: scope.reclaimed,
|
|
54530
|
+
bytesReclaimed: scope.bytesReclaimed,
|
|
54531
|
+
exempt: scope.exempt,
|
|
54532
|
+
withinGrace: scope.withinGrace,
|
|
54533
|
+
unresolvable: scope.unresolvable,
|
|
54534
|
+
stoppedBy: scope.stoppedBy,
|
|
54535
|
+
nextCursor: scope.nextCursor
|
|
54536
|
+
})),
|
|
54537
|
+
totalOrphans: report.totalOrphans,
|
|
54538
|
+
totalReclaimed: report.totalReclaimed,
|
|
54539
|
+
totalBytesReclaimed: report.totalBytesReclaimed,
|
|
54540
|
+
durationMs: report.durationMs
|
|
53492
54541
|
};
|
|
53493
54542
|
}
|
|
53494
54543
|
/**
|
|
@@ -53688,7 +54737,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
53688
54737
|
indexed
|
|
53689
54738
|
};
|
|
53690
54739
|
},
|
|
53691
|
-
"embedding.wipe": async () => this.wipeObjectEmbeddings()
|
|
54740
|
+
"embedding.wipe": async () => this.wipeObjectEmbeddings(),
|
|
54741
|
+
"retention.orphanAudit": async (input) => this.runOrphanAuditNow(input)
|
|
53692
54742
|
};
|
|
53693
54743
|
}
|
|
53694
54744
|
/** Progress of the detached rebuild — the only way to observe a long pass. */
|
|
@@ -53947,8 +54997,14 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
53947
54997
|
* Track-centric time-based retention (design §5.1). Drains every persisted
|
|
53948
54998
|
* track for the device whose `lastSeen < cutoffMs`, page by page, through the
|
|
53949
54999
|
* widened cascade — enrolled faces/plates + identity media are exempt (design
|
|
53950
|
-
* §4).
|
|
53951
|
-
*
|
|
55000
|
+
* §4).
|
|
55001
|
+
*
|
|
55002
|
+
* **Every family count here is REAL.** It used to return `tracks` and a
|
|
55003
|
+
* literal 0 for every leaf, because `TrackScopedStore.deleteByTracks` returned
|
|
55004
|
+
* `void` — so `bytesReclaimed: 0` went onto all 3,732 ops-log rows this addon
|
|
55005
|
+
* had ever written and 18.9 GB of leaked media read as "nothing to reclaim"
|
|
55006
|
+
* (D161). The bytes ride {@link lastPruneBytes} rather than
|
|
55007
|
+
* `TrackCascadeCounts`, which is a cap output shape.
|
|
53952
55008
|
*/
|
|
53953
55009
|
async pruneTracksBefore(input) {
|
|
53954
55010
|
const empty = {
|
|
@@ -53959,26 +55015,32 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
53959
55015
|
plates: 0,
|
|
53960
55016
|
embeddings: 0
|
|
53961
55017
|
};
|
|
55018
|
+
this.lastPruneBytes = 0;
|
|
53962
55019
|
const cascade = this.buildTrackCascadeRegistry();
|
|
53963
55020
|
if (!cascade) return empty;
|
|
55021
|
+
let tally = EMPTY_CASCADE_TALLY;
|
|
53964
55022
|
const { deleted: totalDeleted, skipped } = await drainTracksBefore({
|
|
53965
55023
|
stopped: () => this.shuttingDown,
|
|
53966
55024
|
listIdsBefore: (offset, limit) => cascade.trackStore.listIdsBefore(input.deviceId, input.cutoffMs, limit, offset),
|
|
53967
|
-
cascade: (ids) =>
|
|
53968
|
-
|
|
53969
|
-
|
|
53970
|
-
|
|
53971
|
-
|
|
53972
|
-
|
|
53973
|
-
|
|
53974
|
-
|
|
53975
|
-
|
|
53976
|
-
|
|
53977
|
-
|
|
53978
|
-
|
|
53979
|
-
|
|
53980
|
-
|
|
53981
|
-
|
|
55025
|
+
cascade: async (ids) => {
|
|
55026
|
+
const result = await runTrackCascadeBatch({
|
|
55027
|
+
registry: cascade.registry,
|
|
55028
|
+
trackStore: cascade.trackStore,
|
|
55029
|
+
deviceId: input.deviceId,
|
|
55030
|
+
onTrackCleanup: (deviceId, trackId) => this.clearLiveTrackState(deviceId, trackId),
|
|
55031
|
+
onFailure: (trackId, err) => {
|
|
55032
|
+
this.ctx.logger.debug("pruneTracksBefore track failed", {
|
|
55033
|
+
tags: { deviceId: input.deviceId },
|
|
55034
|
+
meta: {
|
|
55035
|
+
trackId,
|
|
55036
|
+
error: String(err)
|
|
55037
|
+
}
|
|
55038
|
+
});
|
|
55039
|
+
}
|
|
55040
|
+
}, ids);
|
|
55041
|
+
tally = mergeCascadeTallies(tally, result.tally);
|
|
55042
|
+
return { deleted: result.deleted };
|
|
55043
|
+
}
|
|
53982
55044
|
});
|
|
53983
55045
|
if (skipped > 0) this.ctx.logger.warn("analytics track retention skipped undeletable rows", {
|
|
53984
55046
|
tags: { deviceId: input.deviceId },
|
|
@@ -53989,20 +55051,41 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
53989
55051
|
pruned: totalDeleted
|
|
53990
55052
|
}
|
|
53991
55053
|
});
|
|
55054
|
+
this.lastPruneBytes = tally.bytesReclaimed;
|
|
53992
55055
|
if (totalDeleted > 0) this.ctx.logger.info("analytics track retention prune", {
|
|
53993
55056
|
tags: { deviceId: input.deviceId },
|
|
53994
55057
|
meta: {
|
|
53995
55058
|
deviceId: input.deviceId,
|
|
53996
55059
|
cutoffMs: input.cutoffMs,
|
|
53997
|
-
tracks: totalDeleted
|
|
55060
|
+
tracks: totalDeleted,
|
|
55061
|
+
events: tally.events,
|
|
55062
|
+
media: tally.media,
|
|
55063
|
+
faces: tally.faces,
|
|
55064
|
+
plates: tally.plates,
|
|
55065
|
+
embeddings: tally.embeddings,
|
|
55066
|
+
bytesReclaimed: tally.bytesReclaimed
|
|
53998
55067
|
}
|
|
53999
55068
|
});
|
|
54000
55069
|
return {
|
|
54001
55070
|
...empty,
|
|
54002
|
-
tracks: totalDeleted
|
|
55071
|
+
tracks: totalDeleted,
|
|
55072
|
+
events: tally.events,
|
|
55073
|
+
media: tally.media,
|
|
55074
|
+
faces: tally.faces,
|
|
55075
|
+
plates: tally.plates,
|
|
55076
|
+
embeddings: tally.embeddings
|
|
54003
55077
|
};
|
|
54004
55078
|
}
|
|
54005
55079
|
/**
|
|
55080
|
+
* Bytes released by the most recent {@link pruneTracksBefore}.
|
|
55081
|
+
*
|
|
55082
|
+
* A field rather than a return value because `TrackCascadeCounts` is a cap
|
|
55083
|
+
* OUTPUT shape and widening it is a codegen change; the ops-log row already
|
|
55084
|
+
* has a `bytesReclaimed` column and this is what fills it. Reset to 0 at the
|
|
55085
|
+
* top of every prune, so a stale value can never be reported as a fresh one.
|
|
55086
|
+
*/
|
|
55087
|
+
lastPruneBytes = 0;
|
|
55088
|
+
/**
|
|
54006
55089
|
* Operator "clean slate" for a device (design §5.3): prune EVERY persisted
|
|
54007
55090
|
* track via the same cascade as `pruneTracksBefore` with `cutoffMs = now`.
|
|
54008
55091
|
* Enrolled gallery + identity media are exempt.
|
|
@@ -54056,8 +55139,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
54056
55139
|
reason: "retention",
|
|
54057
55140
|
deviceId,
|
|
54058
55141
|
itemsAffected: counts.tracks,
|
|
54059
|
-
bytesReclaimed:
|
|
54060
|
-
detail: `track retention cascade: ${counts.tracks} tracks, ${counts.events} events, ${counts.media} media`
|
|
55142
|
+
bytesReclaimed: this.lastPruneBytes,
|
|
55143
|
+
detail: `track retention cascade: ${counts.tracks} tracks, ${counts.events} events, ${counts.media} media, ${counts.faces} faces, ${counts.plates} plates, ${counts.embeddings} vectors`
|
|
54061
55144
|
});
|
|
54062
55145
|
return counts;
|
|
54063
55146
|
},
|