@camstack/addon-post-analysis 1.2.77 → 1.2.79
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-Bs6oboqZ.js → dist-CWoaZE47.js} +274 -6
- package/dist/{dist-CxeFHSSV.mjs → dist-D6_m_zg1.mjs} +269 -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-BP6Na43Q.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-DhzYNXUk.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-BeCNd7Q3.mjs +26 -0
- package/dist/pipeline-analytics/{hostInit-DTI0jC8p.mjs → hostInit-CcziTvBY.mjs} +2 -2
- package/dist/pipeline-analytics/index.js +1180 -473
- package/dist/pipeline-analytics/index.mjs +1143 -436
- 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-D6_m_zg1.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
|
};
|
|
@@ -4883,8 +4974,29 @@ var SceneMonitorProvider = class {
|
|
|
4883
4974
|
updatedAt: now
|
|
4884
4975
|
};
|
|
4885
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
|
+
}
|
|
4886
4997
|
/** Snapshot → ROI crop + whole-frame anchor → two embeddings + a thumbnail. */
|
|
4887
4998
|
async encodeReference(deviceId, monitor, condition) {
|
|
4999
|
+
const capturedAt = this.now();
|
|
4888
5000
|
const snap = await this.deps.getSnapshot(deviceId, true);
|
|
4889
5001
|
if (snap === null) throw new Error("no snapshot available for this camera");
|
|
4890
5002
|
const encoded = Buffer.from(snap.base64, "base64");
|
|
@@ -4902,15 +5014,15 @@ var SceneMonitorProvider = class {
|
|
|
4902
5014
|
});
|
|
4903
5015
|
const thumbnailMediaId = await this.deps.putMedia?.({
|
|
4904
5016
|
deviceId,
|
|
4905
|
-
ownerId: `scene-${monitor.id}-${condition}`,
|
|
5017
|
+
ownerId: `scene-${monitor.id}-${condition}-${capturedAt}`,
|
|
4906
5018
|
data: roi.crop,
|
|
4907
|
-
timestamp:
|
|
5019
|
+
timestamp: capturedAt
|
|
4908
5020
|
}).catch(() => void 0);
|
|
4909
5021
|
return {
|
|
4910
5022
|
embedding,
|
|
4911
5023
|
modelId: info.modelId,
|
|
4912
5024
|
condition,
|
|
4913
|
-
capturedAt
|
|
5025
|
+
capturedAt,
|
|
4914
5026
|
...thumbnailMediaId !== void 0 ? { thumbnailMediaId } : {},
|
|
4915
5027
|
...anchor !== null ? { anchorEmbedding: anchor } : {}
|
|
4916
5028
|
};
|
|
@@ -5406,6 +5518,256 @@ function observeLabel(deviceId, spec, sample, now) {
|
|
|
5406
5518
|
spec
|
|
5407
5519
|
};
|
|
5408
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
|
+
}
|
|
5409
5771
|
//#endregion
|
|
5410
5772
|
//#region src/shared/llm-vision/prompt-hygiene.ts
|
|
5411
5773
|
/**
|
|
@@ -6016,6 +6378,298 @@ var NcDeviceStateCache = class {
|
|
|
6016
6378
|
}
|
|
6017
6379
|
};
|
|
6018
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
|
|
6019
6673
|
//#region src/notification-center/render-template.ts
|
|
6020
6674
|
/**
|
|
6021
6675
|
* The ONE `{{var}}` renderer of the notification centre.
|
|
@@ -6739,10 +7393,17 @@ function subjectFromSensorEvent(ev, markerTrackId) {
|
|
|
6739
7393
|
* `minConfidence` condition composes naturally). A level-path audio event (no
|
|
6740
7394
|
* `classification`) yields NO class ⇒ it can never satisfy the audio opt-in
|
|
6741
7395
|
* gate, so only classified audio ever notifies (documented boundary). Audio has
|
|
6742
|
-
* no zones / bbox / label
|
|
6743
|
-
*
|
|
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.
|
|
6744
7405
|
*/
|
|
6745
|
-
function subjectFromAudioEvent(ev) {
|
|
7406
|
+
function subjectFromAudioEvent(ev, stillOwnerId) {
|
|
6746
7407
|
const macro = ev.classification?.className;
|
|
6747
7408
|
return {
|
|
6748
7409
|
kind: "audio-event",
|
|
@@ -6752,6 +7413,7 @@ function subjectFromAudioEvent(ev) {
|
|
|
6752
7413
|
classNames: macro !== void 0 ? [`audio-${macro}`] : [],
|
|
6753
7414
|
...ev.classification?.score !== void 0 ? { confidence: ev.classification.score } : {},
|
|
6754
7415
|
zones: [],
|
|
7416
|
+
...stillOwnerId !== void 0 ? { trackId: stillOwnerId } : {},
|
|
6755
7417
|
source: "pipeline"
|
|
6756
7418
|
};
|
|
6757
7419
|
}
|
|
@@ -6772,8 +7434,12 @@ function subjectFromAudioEvent(ev) {
|
|
|
6772
7434
|
* neither is a score, and lending one to `minConfidence` would let a detection
|
|
6773
7435
|
* condition silently re-judge an audio rule on a number that means something
|
|
6774
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.
|
|
6775
7441
|
*/
|
|
6776
|
-
function subjectFromAudioWindow(hit) {
|
|
7442
|
+
function subjectFromAudioWindow(hit, stillOwnerId) {
|
|
6777
7443
|
const spec = hit.spec;
|
|
6778
7444
|
return {
|
|
6779
7445
|
kind: "audio-window",
|
|
@@ -6782,6 +7448,7 @@ function subjectFromAudioWindow(hit) {
|
|
|
6782
7448
|
timestamp: hit.timestamp,
|
|
6783
7449
|
classNames: hit.labels.map((l) => `audio-${l}`),
|
|
6784
7450
|
zones: [],
|
|
7451
|
+
...stillOwnerId !== void 0 ? { trackId: stillOwnerId } : {},
|
|
6785
7452
|
source: "audio",
|
|
6786
7453
|
audioWindow: audioSubjectFor(hit, spec)
|
|
6787
7454
|
};
|
|
@@ -8004,298 +8671,6 @@ var NcTextCatalog = class {
|
|
|
8004
8671
|
}
|
|
8005
8672
|
};
|
|
8006
8673
|
//#endregion
|
|
8007
|
-
//#region src/shared/frame/box-drawer.ts
|
|
8008
|
-
var DEFAULT_COLOR = DEFAULT_EVENT_COLOR;
|
|
8009
|
-
var DEFAULT_QUALITY = 80;
|
|
8010
|
-
var STROKE_WIDTH = 3;
|
|
8011
|
-
function escapeXml$2(s) {
|
|
8012
|
-
return s.replace(/[<>&'"]/g, (ch) => {
|
|
8013
|
-
switch (ch) {
|
|
8014
|
-
case "<": return "<";
|
|
8015
|
-
case ">": return ">";
|
|
8016
|
-
case "&": return "&";
|
|
8017
|
-
case "'": return "'";
|
|
8018
|
-
default: return """;
|
|
8019
|
-
}
|
|
8020
|
-
});
|
|
8021
|
-
}
|
|
8022
|
-
/** Clamp a pixel box to the frame so the rect always stays inside [0,W]×[0,H]. */
|
|
8023
|
-
function clampBox(b, frameWidth, frameHeight) {
|
|
8024
|
-
const x = Math.max(0, Math.min(Math.round(b.x), frameWidth - 1));
|
|
8025
|
-
const y = Math.max(0, Math.min(Math.round(b.y), frameHeight - 1));
|
|
8026
|
-
return {
|
|
8027
|
-
x,
|
|
8028
|
-
y,
|
|
8029
|
-
w: Math.max(1, Math.min(Math.round(b.w), frameWidth - x)),
|
|
8030
|
-
h: Math.max(1, Math.min(Math.round(b.h), frameHeight - y))
|
|
8031
|
-
};
|
|
8032
|
-
}
|
|
8033
|
-
/**
|
|
8034
|
-
* Draw bounding boxes over a raw RGB frame and JPEG-encode it.
|
|
8035
|
-
*
|
|
8036
|
-
* The frame is composited with an SVG overlay (one `<rect>` per box, plus an
|
|
8037
|
-
* optional caption) at full resolution, then optionally downscaled — so the
|
|
8038
|
-
* box stays crisp relative to the scene. Boxes are clamped to frame bounds to
|
|
8039
|
-
* avoid sharp `extract`/region errors on slightly-out-of-range detections.
|
|
8040
|
-
*/
|
|
8041
|
-
async function drawBoxedFrame(frameData, frameWidth, frameHeight, boxes, opts = {}) {
|
|
8042
|
-
const quality = opts.quality ?? DEFAULT_QUALITY;
|
|
8043
|
-
let base = frameData;
|
|
8044
|
-
let baseIsRaw = true;
|
|
8045
|
-
if (boxes.length > 0) {
|
|
8046
|
-
const fontSize = Math.max(12, Math.round(frameHeight / 30));
|
|
8047
|
-
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}">${boxes.map((b) => {
|
|
8048
|
-
const c = clampBox(b, frameWidth, frameHeight);
|
|
8049
|
-
const color = b.color ?? DEFAULT_COLOR;
|
|
8050
|
-
const rect = `<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${c.h}" fill="none" stroke="${color}" stroke-width="${STROKE_WIDTH}"/>`;
|
|
8051
|
-
if (!b.label) return rect;
|
|
8052
|
-
const ty = Math.max(fontSize, c.y - 4);
|
|
8053
|
-
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>`;
|
|
8054
|
-
}).join("")}</svg>`;
|
|
8055
|
-
base = await sharp(frameData, { raw: {
|
|
8056
|
-
width: frameWidth,
|
|
8057
|
-
height: frameHeight,
|
|
8058
|
-
channels: 3
|
|
8059
|
-
} }).composite([{
|
|
8060
|
-
input: Buffer.from(svg),
|
|
8061
|
-
top: 0,
|
|
8062
|
-
left: 0
|
|
8063
|
-
}]).png().toBuffer();
|
|
8064
|
-
baseIsRaw = false;
|
|
8065
|
-
}
|
|
8066
|
-
let pipeline = baseIsRaw ? sharp(base, { raw: {
|
|
8067
|
-
width: frameWidth,
|
|
8068
|
-
height: frameHeight,
|
|
8069
|
-
channels: 3
|
|
8070
|
-
} }) : sharp(base);
|
|
8071
|
-
if (opts.maxWidth !== void 0) pipeline = pipeline.resize({
|
|
8072
|
-
width: opts.maxWidth,
|
|
8073
|
-
withoutEnlargement: true
|
|
8074
|
-
});
|
|
8075
|
-
return pipeline.jpeg({ quality }).toBuffer();
|
|
8076
|
-
}
|
|
8077
|
-
/** JPEG quality of the composed artefact — the mosaic's number, same reasons. */
|
|
8078
|
-
var GROUP_FRAME_QUALITY = 82;
|
|
8079
|
-
/**
|
|
8080
|
-
* Draw the members onto the frame. Returns `null` when there is nothing to
|
|
8081
|
-
* draw, or when anything at all goes wrong — the caller then ships the
|
|
8082
|
-
* unannotated frame unchanged.
|
|
8083
|
-
*/
|
|
8084
|
-
async function composeGroupFrame(input) {
|
|
8085
|
-
const freshnessMs = input.freshnessMs ?? 3e3;
|
|
8086
|
-
const fresh = input.boxes.filter((b) => Math.abs(input.at - b.observedAt) <= freshnessMs);
|
|
8087
|
-
const withheld = input.boxes.length - fresh.length;
|
|
8088
|
-
if (fresh.length < 2) {
|
|
8089
|
-
if (withheld > 0) input.logger.info("group frame not drawn — too few members with fresh geometry", {
|
|
8090
|
-
tags: { deviceId: input.deviceId },
|
|
8091
|
-
meta: {
|
|
8092
|
-
groupKey: input.groupKey,
|
|
8093
|
-
members: input.boxes.length,
|
|
8094
|
-
fresh: fresh.length,
|
|
8095
|
-
withheld
|
|
8096
|
-
}
|
|
8097
|
-
});
|
|
8098
|
-
return null;
|
|
8099
|
-
}
|
|
8100
|
-
try {
|
|
8101
|
-
const decoded = await sharp(input.jpeg).raw().toBuffer({ resolveWithObject: true });
|
|
8102
|
-
const { width, height, channels } = decoded.info;
|
|
8103
|
-
if (!(width > 0 && height > 0) || channels !== 3) {
|
|
8104
|
-
input.logger.warn("group frame not drawn — unexpected raster shape", {
|
|
8105
|
-
tags: { deviceId: input.deviceId },
|
|
8106
|
-
meta: {
|
|
8107
|
-
groupKey: input.groupKey,
|
|
8108
|
-
width,
|
|
8109
|
-
height,
|
|
8110
|
-
channels
|
|
8111
|
-
}
|
|
8112
|
-
});
|
|
8113
|
-
return null;
|
|
8114
|
-
}
|
|
8115
|
-
const boxes = fresh.map((b) => ({
|
|
8116
|
-
x: b.bbox.x * width,
|
|
8117
|
-
y: b.bbox.y * height,
|
|
8118
|
-
w: b.bbox.w * width,
|
|
8119
|
-
h: b.bbox.h * height,
|
|
8120
|
-
...b.label !== void 0 && b.label.length > 0 ? { label: b.label } : {}
|
|
8121
|
-
}));
|
|
8122
|
-
const bytes = await drawBoxedFrame(decoded.data, width, height, boxes, { quality: GROUP_FRAME_QUALITY });
|
|
8123
|
-
input.logger.debug("group frame composed", {
|
|
8124
|
-
tags: { deviceId: input.deviceId },
|
|
8125
|
-
meta: {
|
|
8126
|
-
groupKey: input.groupKey,
|
|
8127
|
-
drawn: boxes.length,
|
|
8128
|
-
withheld,
|
|
8129
|
-
width,
|
|
8130
|
-
height
|
|
8131
|
-
}
|
|
8132
|
-
});
|
|
8133
|
-
return {
|
|
8134
|
-
bytes,
|
|
8135
|
-
drawn: boxes.length,
|
|
8136
|
-
withheld
|
|
8137
|
-
};
|
|
8138
|
-
} catch (err) {
|
|
8139
|
-
input.logger.warn("group frame compose failed — shipping the unannotated frame", {
|
|
8140
|
-
tags: { deviceId: input.deviceId },
|
|
8141
|
-
meta: {
|
|
8142
|
-
groupKey: input.groupKey,
|
|
8143
|
-
error: err instanceof Error ? err.message : String(err)
|
|
8144
|
-
}
|
|
8145
|
-
});
|
|
8146
|
-
return null;
|
|
8147
|
-
}
|
|
8148
|
-
}
|
|
8149
|
-
//#endregion
|
|
8150
|
-
//#region src/notification-center/text-compose.ts
|
|
8151
|
-
/** One normalisation, used on both sides of every class lookup — a pipeline
|
|
8152
|
-
* that emitted `Person` must find the key written `class.person`. */
|
|
8153
|
-
function normaliseClass(className) {
|
|
8154
|
-
return className.trim().toLowerCase();
|
|
8155
|
-
}
|
|
8156
|
-
/**
|
|
8157
|
-
* The noun for a class, in the count's plural category.
|
|
8158
|
-
*
|
|
8159
|
-
* A class no locale names falls through to `class.other`, which prints the RAW
|
|
8160
|
-
* name: a custom model's class must appear in the sentence rather than vanish
|
|
8161
|
-
* out of it, and a missing translation is a smaller defect than a missing
|
|
8162
|
-
* detection.
|
|
8163
|
-
*/
|
|
8164
|
-
function ncClassNoun(texts, className, count) {
|
|
8165
|
-
const key = `class.${normaliseClass(className)}`;
|
|
8166
|
-
if (texts.has(key)) return texts.text({
|
|
8167
|
-
key,
|
|
8168
|
-
count,
|
|
8169
|
-
vars: { name: className }
|
|
8170
|
-
});
|
|
8171
|
-
return texts.text({
|
|
8172
|
-
key: "class.other",
|
|
8173
|
-
count,
|
|
8174
|
-
vars: { name: className }
|
|
8175
|
-
});
|
|
8176
|
-
}
|
|
8177
|
-
/** `person (John)` / `persona (John)` — the thing the notification is about. */
|
|
8178
|
-
function ncSubjectText(texts, input) {
|
|
8179
|
-
const classNoun = ncClassNoun(texts, input.className, 1);
|
|
8180
|
-
if (input.label === void 0) return texts.text({
|
|
8181
|
-
key: "detection.subject",
|
|
8182
|
-
vars: { classNoun }
|
|
8183
|
-
});
|
|
8184
|
-
return texts.text({
|
|
8185
|
-
key: subjectLabelKey(input.labelKind),
|
|
8186
|
-
vars: {
|
|
8187
|
-
classNoun,
|
|
8188
|
-
label: input.label
|
|
8189
|
-
}
|
|
8190
|
-
});
|
|
8191
|
-
}
|
|
8192
|
-
/**
|
|
8193
|
-
* Which labelled-subject key a recognition gets. A kind the catalog does not
|
|
8194
|
-
* name falls back to the generic key rather than printing nothing — the same
|
|
8195
|
-
* direction `ncClassNoun` degrades in, and for the same reason.
|
|
8196
|
-
*/
|
|
8197
|
-
function subjectLabelKey(kind) {
|
|
8198
|
-
if (kind === "identity") return "detection.subject.identity";
|
|
8199
|
-
if (kind === "plate") return "detection.subject.plate";
|
|
8200
|
-
return "detection.subject.labelled";
|
|
8201
|
-
}
|
|
8202
|
-
/**
|
|
8203
|
-
* ` in gate, drive` — INCLUDING its leading space, and empty when there are no
|
|
8204
|
-
* zones.
|
|
8205
|
-
*
|
|
8206
|
-
* The space belongs to the fragment rather than to the body key because the
|
|
8207
|
-
* alternative is a body that ends in a trailing space on every zone-less
|
|
8208
|
-
* notification. A translator moving the clause moves the space with it.
|
|
8209
|
-
*/
|
|
8210
|
-
function ncInZonesText(texts, zoneLabels) {
|
|
8211
|
-
if (zoneLabels.length === 0) return "";
|
|
8212
|
-
return texts.text({
|
|
8213
|
-
key: "detection.inZones",
|
|
8214
|
-
vars: { zones: zoneLabels.join(", ") }
|
|
8215
|
-
});
|
|
8216
|
-
}
|
|
8217
|
-
/** The human-readable edge polarity behind `{{op}}` and the occupancy body. */
|
|
8218
|
-
function ncOccupancyOp(texts, occupied) {
|
|
8219
|
-
return texts.text({ key: occupied ? "occupancy.op.occupied" : "occupancy.op.free" });
|
|
8220
|
-
}
|
|
8221
|
-
/**
|
|
8222
|
-
* `1 persona, 2 veicoli e 3 animali` — the language's own conjunction, its own
|
|
8223
|
-
* plural rules, and its own nouns.
|
|
8224
|
-
*
|
|
8225
|
-
* Zero-count classes are dropped: a selected class the window never contained
|
|
8226
|
-
* is a legitimate `0` in `{{count_vehicle}}`, but "0 veicoli" inside a summary
|
|
8227
|
-
* sentence is a line nobody reads. Order is the caller's (busiest first) and is
|
|
8228
|
-
* preserved, so the same night always produces the same sentence.
|
|
8229
|
-
*/
|
|
8230
|
-
function ncDetectionSummary(texts, counts) {
|
|
8231
|
-
const parts = counts.filter((entry) => entry.count > 0).map((entry) => `${entry.count} ${ncClassNoun(texts, entry.className, entry.count)}`);
|
|
8232
|
-
return texts.join(parts);
|
|
8233
|
-
}
|
|
8234
|
-
//#endregion
|
|
8235
|
-
//#region src/notification-center/group/nc-group-text.ts
|
|
8236
|
-
var DEFAULT_MAX_NAMES = 2;
|
|
8237
|
-
/**
|
|
8238
|
-
* `N altre persone` — the remainder phrase for one class, in its own gender.
|
|
8239
|
-
*
|
|
8240
|
-
* Mirrors {@link ncClassNoun} byte for byte, including the fallback: a class no
|
|
8241
|
-
* locale names falls through to `group.others.other`, which prints the raw class
|
|
8242
|
-
* name rather than vanishing. A custom model's class must appear in the sentence.
|
|
8243
|
-
*/
|
|
8244
|
-
function ncGroupOthers(texts, className, count) {
|
|
8245
|
-
const classNoun = ncClassNoun(texts, className, count);
|
|
8246
|
-
const key = `group.others.${className.trim().toLowerCase()}`;
|
|
8247
|
-
if (texts.has(key)) return texts.text({
|
|
8248
|
-
key,
|
|
8249
|
-
count,
|
|
8250
|
-
vars: {
|
|
8251
|
-
count: `${count}`,
|
|
8252
|
-
classNoun
|
|
8253
|
-
}
|
|
8254
|
-
});
|
|
8255
|
-
return texts.text({
|
|
8256
|
-
key: "group.others.other",
|
|
8257
|
-
count,
|
|
8258
|
-
vars: {
|
|
8259
|
-
count: `${count}`,
|
|
8260
|
-
classNoun
|
|
8261
|
-
}
|
|
8262
|
-
});
|
|
8263
|
-
}
|
|
8264
|
-
/** Counts per class, in first-seen order so the same burst always reads the same. */
|
|
8265
|
-
function countByClass(members) {
|
|
8266
|
-
const counts = /* @__PURE__ */ new Map();
|
|
8267
|
-
for (const m of members) counts.set(m.className, (counts.get(m.className) ?? 0) + 1);
|
|
8268
|
-
return [...counts].map(([className, count]) => ({
|
|
8269
|
-
className,
|
|
8270
|
-
count
|
|
8271
|
-
}));
|
|
8272
|
-
}
|
|
8273
|
-
/**
|
|
8274
|
-
* The finished `{{subject}}` phrase for a group.
|
|
8275
|
-
*
|
|
8276
|
-
* Pure: a catalog in, a string out. No clock, no store, no I/O — like every
|
|
8277
|
-
* other composer in `text-compose.ts`, and for the same reason.
|
|
8278
|
-
*/
|
|
8279
|
-
function ncGroupSubjectText(texts, input) {
|
|
8280
|
-
const members = input.members;
|
|
8281
|
-
const only = members.length === 1 ? members[0] : void 0;
|
|
8282
|
-
if (only !== void 0) return ncSubjectText(texts, {
|
|
8283
|
-
className: only.className,
|
|
8284
|
-
...only.label !== void 0 ? { label: only.label } : {},
|
|
8285
|
-
...only.labelKind !== void 0 ? { labelKind: only.labelKind } : {}
|
|
8286
|
-
});
|
|
8287
|
-
if (members.length === 0) return "";
|
|
8288
|
-
const maxNames = input.maxNames !== void 0 && input.maxNames >= 0 ? input.maxNames : DEFAULT_MAX_NAMES;
|
|
8289
|
-
const named = members.filter((m) => m.label !== void 0 && m.label.length > 0);
|
|
8290
|
-
const names = named.slice(0, maxNames).map((m) => m.label ?? "");
|
|
8291
|
-
if (names.length === 0) return ncDetectionSummary(texts, countByClass(members));
|
|
8292
|
-
const spoken = new Set(named.slice(0, maxNames));
|
|
8293
|
-
const remainder = members.filter((m) => !spoken.has(m));
|
|
8294
|
-
if (remainder.length === 0) return texts.join(names);
|
|
8295
|
-
const others = countByClass(remainder).map((c) => ncGroupOthers(texts, c.className, c.count));
|
|
8296
|
-
return texts.join([...names, ...others]);
|
|
8297
|
-
}
|
|
8298
|
-
//#endregion
|
|
8299
8674
|
//#region src/notification-center/dispatcher.ts
|
|
8300
8675
|
var DEFAULT_TARGET_CACHE_TTL_MS = 6e4;
|
|
8301
8676
|
/**
|
|
@@ -8858,7 +9233,7 @@ var NcDispatcher = class {
|
|
|
8858
9233
|
const stillOverride = zoneIdsWanted !== void 0 && zoneIdsWanted.length > 0 ? "keyFrame" : void 0;
|
|
8859
9234
|
const firstLook = await this.resolveAttachment(entry, stillOverride);
|
|
8860
9235
|
const footage = await this.renderFootage(entry);
|
|
8861
|
-
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);
|
|
8862
9237
|
if (still !== null) {
|
|
8863
9238
|
const zoneIds = zoneIdsWanted;
|
|
8864
9239
|
if (zoneIds !== void 0 && zoneIds.length > 0) {
|
|
@@ -9346,17 +9721,24 @@ function incomingFromSensorEvent(event, markerTrackId) {
|
|
|
9346
9721
|
})
|
|
9347
9722
|
};
|
|
9348
9723
|
}
|
|
9349
|
-
/**
|
|
9350
|
-
|
|
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) {
|
|
9351
9732
|
return {
|
|
9352
|
-
subject: subjectFromAudioEvent(event),
|
|
9733
|
+
subject: subjectFromAudioEvent(event, stillOwnerId),
|
|
9353
9734
|
kind: "audio-event",
|
|
9354
9735
|
origin: "pipeline",
|
|
9355
9736
|
log: () => ({
|
|
9356
9737
|
tags: { deviceId: event.deviceId },
|
|
9357
9738
|
meta: {
|
|
9358
9739
|
audioEventId: event.id,
|
|
9359
|
-
class: event.classification?.className
|
|
9740
|
+
class: event.classification?.className,
|
|
9741
|
+
...stillOwnerId !== void 0 ? { stillOwner: stillOwnerId } : {}
|
|
9360
9742
|
}
|
|
9361
9743
|
})
|
|
9362
9744
|
};
|
|
@@ -9386,15 +9768,20 @@ function incomingFromPackageEvent(event, phase) {
|
|
|
9386
9768
|
* and a crash in the confirm→outbox window drops the notification rather than
|
|
9387
9769
|
* replaying it. Deliberate: a stale match is a claim about a noise that has
|
|
9388
9770
|
* already stopped.
|
|
9771
|
+
*
|
|
9772
|
+
* `stillOwnerId` — see {@link incomingFromAudioEvent}.
|
|
9389
9773
|
*/
|
|
9390
|
-
function incomingFromAudioWindow(hit) {
|
|
9774
|
+
function incomingFromAudioWindow(hit, stillOwnerId) {
|
|
9391
9775
|
return {
|
|
9392
|
-
subject: subjectFromAudioWindow(hit),
|
|
9776
|
+
subject: subjectFromAudioWindow(hit, stillOwnerId),
|
|
9393
9777
|
kind: "audio-window",
|
|
9394
9778
|
origin: "pipeline",
|
|
9395
9779
|
log: () => ({
|
|
9396
9780
|
tags: { deviceId: hit.deviceId },
|
|
9397
|
-
meta:
|
|
9781
|
+
meta: {
|
|
9782
|
+
...audioHitMeta(hit),
|
|
9783
|
+
...stillOwnerId !== void 0 ? { stillOwner: stillOwnerId } : {}
|
|
9784
|
+
}
|
|
9398
9785
|
})
|
|
9399
9786
|
};
|
|
9400
9787
|
}
|
|
@@ -13335,6 +13722,29 @@ function escapeXml$1(s) {
|
|
|
13335
13722
|
}
|
|
13336
13723
|
});
|
|
13337
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
|
+
}
|
|
13338
13748
|
/** Grid shape for `n` tiles: as square as possible, rows never exceeding cols
|
|
13339
13749
|
* by more than one — a 3×3 for nine, a 3×2 for five, never a 1×9 strip. */
|
|
13340
13750
|
function mosaicGrid(n) {
|
|
@@ -13441,22 +13851,25 @@ function titleSvg(width, height, caption) {
|
|
|
13441
13851
|
* treats that as "no mosaic" and still delivers the counts.
|
|
13442
13852
|
*/
|
|
13443
13853
|
async function renderMosaic(request, logger) {
|
|
13854
|
+
const plan = mosaicTextPlan(request.captions);
|
|
13444
13855
|
const decoded = [];
|
|
13445
13856
|
const probeLayout = mosaicLayout({
|
|
13446
13857
|
tileCount: Math.max(1, request.tiles.length),
|
|
13447
13858
|
...request.maxWidth !== void 0 ? { maxWidth: request.maxWidth } : {}
|
|
13448
13859
|
});
|
|
13449
13860
|
for (const tile of request.tiles) try {
|
|
13450
|
-
const
|
|
13861
|
+
const label = plan.tileLabels ? tile.label?.trim() ?? "" : "";
|
|
13862
|
+
const fitted = sharp(tile.jpeg).resize({
|
|
13451
13863
|
width: probeLayout.tileWidth,
|
|
13452
13864
|
height: probeLayout.tileHeight,
|
|
13453
13865
|
fit: "contain",
|
|
13454
13866
|
background: MOSAIC_BACKGROUND
|
|
13455
|
-
})
|
|
13456
|
-
|
|
13867
|
+
});
|
|
13868
|
+
const resized = await (label.length === 0 ? fitted : fitted.composite([{
|
|
13869
|
+
input: tileLabelSvg(probeLayout.tileWidth, probeLayout.tileHeight, label),
|
|
13457
13870
|
top: 0,
|
|
13458
13871
|
left: 0
|
|
13459
|
-
}]).jpeg({ quality: 82 }).toBuffer();
|
|
13872
|
+
}])).jpeg({ quality: 82 }).toBuffer();
|
|
13460
13873
|
decoded.push({
|
|
13461
13874
|
tile,
|
|
13462
13875
|
resized
|
|
@@ -13471,7 +13884,7 @@ async function renderMosaic(request, logger) {
|
|
|
13471
13884
|
});
|
|
13472
13885
|
}
|
|
13473
13886
|
const droppedTiles = request.tiles.length - decoded.length;
|
|
13474
|
-
const caption = request.caption?.trim() ?? "";
|
|
13887
|
+
const caption = plan.titleBand ? request.caption?.trim() ?? "" : "";
|
|
13475
13888
|
const layout = mosaicLayout({
|
|
13476
13889
|
tileCount: Math.max(1, decoded.length),
|
|
13477
13890
|
...request.maxWidth !== void 0 ? { maxWidth: request.maxWidth } : {},
|
|
@@ -13725,12 +14138,15 @@ function dwellSeconds(candidate) {
|
|
|
13725
14138
|
* branch that discards work silently reads as "it never happened".
|
|
13726
14139
|
*/
|
|
13727
14140
|
function filterSummaryCandidates(candidates, filters, zoneOwner) {
|
|
14141
|
+
const moving = filters?.includeStationary === true ? candidates : candidates.filter((candidate) => candidate.stationary !== true);
|
|
14142
|
+
const droppedStationary = candidates.length - moving.length;
|
|
13728
14143
|
if (filters === void 0) return {
|
|
13729
|
-
kept: [...
|
|
14144
|
+
kept: [...moving],
|
|
13730
14145
|
droppedByClass: 0,
|
|
13731
14146
|
droppedByZone: 0,
|
|
13732
14147
|
droppedByImportance: 0,
|
|
13733
|
-
droppedByDwell: 0
|
|
14148
|
+
droppedByDwell: 0,
|
|
14149
|
+
droppedStationary
|
|
13734
14150
|
};
|
|
13735
14151
|
const include = filters.classes;
|
|
13736
14152
|
const exclude = new Set(filters.classesExclude ?? []);
|
|
@@ -13739,7 +14155,7 @@ function filterSummaryCandidates(candidates, filters, zoneOwner) {
|
|
|
13739
14155
|
let droppedByZone = 0;
|
|
13740
14156
|
let droppedByImportance = 0;
|
|
13741
14157
|
let droppedByDwell = 0;
|
|
13742
|
-
for (const candidate of
|
|
14158
|
+
for (const candidate of moving) {
|
|
13743
14159
|
const own = classesOf(candidate);
|
|
13744
14160
|
if (own.some((c) => exclude.has(c))) {
|
|
13745
14161
|
droppedByClass += 1;
|
|
@@ -13775,7 +14191,8 @@ function filterSummaryCandidates(candidates, filters, zoneOwner) {
|
|
|
13775
14191
|
droppedByClass,
|
|
13776
14192
|
droppedByZone,
|
|
13777
14193
|
droppedByImportance,
|
|
13778
|
-
droppedByDwell
|
|
14194
|
+
droppedByDwell,
|
|
14195
|
+
droppedStationary
|
|
13779
14196
|
};
|
|
13780
14197
|
}
|
|
13781
14198
|
/**
|
|
@@ -14150,7 +14567,8 @@ var NcSummaryProducer = class {
|
|
|
14150
14567
|
droppedByClass: filtered.droppedByClass,
|
|
14151
14568
|
droppedByZone: filtered.droppedByZone,
|
|
14152
14569
|
droppedByImportance: filtered.droppedByImportance,
|
|
14153
|
-
droppedByDwell: filtered.droppedByDwell
|
|
14570
|
+
droppedByDwell: filtered.droppedByDwell,
|
|
14571
|
+
droppedStationary: filtered.droppedStationary
|
|
14154
14572
|
} });
|
|
14155
14573
|
if (opts.stamp) await this.deps.store.markGenerated(rule.id, window.endMs);
|
|
14156
14574
|
return {
|
|
@@ -14158,7 +14576,8 @@ var NcSummaryProducer = class {
|
|
|
14158
14576
|
window,
|
|
14159
14577
|
tiles: 0,
|
|
14160
14578
|
cameras: 0,
|
|
14161
|
-
matched: 0
|
|
14579
|
+
matched: 0,
|
|
14580
|
+
droppedStationary: filtered.droppedStationary
|
|
14162
14581
|
};
|
|
14163
14582
|
}
|
|
14164
14583
|
const loaded = await this.loadTiles(rule, selection.tiles);
|
|
@@ -14181,6 +14600,7 @@ var NcSummaryProducer = class {
|
|
|
14181
14600
|
produced: 0,
|
|
14182
14601
|
window,
|
|
14183
14602
|
tiles: loaded.length,
|
|
14603
|
+
droppedStationary: filtered.droppedStationary,
|
|
14184
14604
|
skippedByAi: true,
|
|
14185
14605
|
...ai.aiFailure !== void 0 ? { aiFailure: ai.aiFailure } : {},
|
|
14186
14606
|
...ai.aiImages !== void 0 ? { aiImages: ai.aiImages } : {}
|
|
@@ -14211,6 +14631,8 @@ var NcSummaryProducer = class {
|
|
|
14211
14631
|
...meta,
|
|
14212
14632
|
scanned: candidates.length,
|
|
14213
14633
|
matched: filtered.kept.length,
|
|
14634
|
+
droppedStationary: filtered.droppedStationary,
|
|
14635
|
+
droppedByClass: filtered.droppedByClass,
|
|
14214
14636
|
tiles: loaded.length,
|
|
14215
14637
|
cameras: new Set(loaded.map((t) => t.candidate.deviceId)).size,
|
|
14216
14638
|
camerasDropped: selection.camerasDropped,
|
|
@@ -14229,6 +14651,7 @@ var NcSummaryProducer = class {
|
|
|
14229
14651
|
tiles: loaded.length,
|
|
14230
14652
|
cameras: new Set(loaded.map((t) => t.candidate.deviceId)).size,
|
|
14231
14653
|
matched: filtered.kept.length,
|
|
14654
|
+
droppedStationary: filtered.droppedStationary,
|
|
14232
14655
|
enqueued,
|
|
14233
14656
|
...mosaic.bytes > 0 ? { mosaicBytes: mosaic.bytes } : {},
|
|
14234
14657
|
...mosaic.ref !== null ? { mosaicUrl: mosaic.ref.url } : {},
|
|
@@ -14340,18 +14763,20 @@ var NcSummaryProducer = class {
|
|
|
14340
14763
|
};
|
|
14341
14764
|
let rendered;
|
|
14342
14765
|
try {
|
|
14766
|
+
const plan = mosaicTextPlan(rule.captions);
|
|
14343
14767
|
rendered = await renderMosaic({
|
|
14344
14768
|
tiles: tiles.map(({ candidate, jpeg }) => ({
|
|
14345
14769
|
jpeg,
|
|
14346
|
-
label: `${names.get(candidate.deviceId) ?? candidate.deviceId} · ${clockOf$2(candidate.firstSeen)} · ${candidate.className}
|
|
14770
|
+
...plan.tileLabels ? { label: `${names.get(candidate.deviceId) ?? candidate.deviceId} · ${clockOf$2(candidate.firstSeen)} · ${candidate.className}` } : {},
|
|
14347
14771
|
deviceId: candidate.deviceId,
|
|
14348
14772
|
trackId: candidate.trackId
|
|
14349
14773
|
})),
|
|
14350
|
-
|
|
14774
|
+
captions: rule.captions,
|
|
14775
|
+
...plan.titleBand ? { caption: summaryCaptionText({
|
|
14351
14776
|
...rule.captionText !== void 0 ? { captionText: rule.captionText } : {},
|
|
14352
14777
|
ruleName: rule.name,
|
|
14353
14778
|
window
|
|
14354
|
-
})
|
|
14779
|
+
}) } : {}
|
|
14355
14780
|
}, this.deps.logger);
|
|
14356
14781
|
} catch (err) {
|
|
14357
14782
|
this.deps.logger.warn("summary mosaic could not be composed — delivering the counts alone", { meta: {
|
|
@@ -14599,7 +15024,26 @@ var NcSummaryFiltersSchema = object({
|
|
|
14599
15024
|
classesExclude: array(string().min(1).max(40)).max(20).optional(),
|
|
14600
15025
|
zones: array(string()).max(40).optional(),
|
|
14601
15026
|
minImportance: number().min(0).max(1).optional(),
|
|
14602
|
-
minDwellSeconds: number().min(0).max(3600).optional()
|
|
15027
|
+
minDwellSeconds: number().min(0).max(3600).optional(),
|
|
15028
|
+
/**
|
|
15029
|
+
* Put PARKED objects in the digest too. Off unless explicitly turned on, and
|
|
15030
|
+
* an ABSENT field reads as off — the soft migration, no backfill.
|
|
15031
|
+
*
|
|
15032
|
+
* A digest is a record of PASSAGES: the operator's words are "the last
|
|
15033
|
+
* summaries still include an enormous number of stationary tracks". Since
|
|
15034
|
+
* D161 a track promoted to the stationary registry is persisted rather than
|
|
15035
|
+
* dropped (it owns media the retention sweep has to reach), so the car parked
|
|
15036
|
+
* in front of camera 617 now enters every window it overlaps and, ranked on
|
|
15037
|
+
* importance and dwell, wins tiles from the passages the digest is about. The
|
|
15038
|
+
* measured effect on the live hub was 1-2 tiles becoming 10.
|
|
15039
|
+
*
|
|
15040
|
+
* `.optional()` and NOT `.default(false)`: a `.default()` here would be
|
|
15041
|
+
* materialised by `NcSummaryRulePatchSchema` on any patch that named
|
|
15042
|
+
* `filters`, which is the `.partial()` trap this module's docblock describes.
|
|
15043
|
+
* `filterSummaryCandidates` reads `=== true`, so absent and false are the
|
|
15044
|
+
* same answer and neither needs a stored value.
|
|
15045
|
+
*/
|
|
15046
|
+
includeStationary: boolean().optional()
|
|
14603
15047
|
});
|
|
14604
15048
|
/**
|
|
14605
15049
|
* How tiles are chosen when the window holds more tracks than the budget.
|
|
@@ -14615,6 +15059,34 @@ var NcSummaryRankSchema = _enum([
|
|
|
14615
15059
|
"dwell"
|
|
14616
15060
|
]);
|
|
14617
15061
|
/**
|
|
15062
|
+
* How much TEXT is burnt into the mosaic — and the answer is NONE by default.
|
|
15063
|
+
*
|
|
15064
|
+
* `none` is the shipped default and, crucially, the reading of an ABSENT field:
|
|
15065
|
+
* `.default()` on the Input schema materialises it, and the persisted schema
|
|
15066
|
+
* extends Input, so a rule authored before this option existed parses as `none`
|
|
15067
|
+
* on the next read. No migration, no backfill, no rewrite of a stored rule.
|
|
15068
|
+
*
|
|
15069
|
+
* The operator asked for "solo immagini". Three reasons it is the right
|
|
15070
|
+
* default rather than a preference:
|
|
15071
|
+
*
|
|
15072
|
+
* - a caption is PERMANENT in the artefact, exactly like a detection box, and
|
|
15073
|
+
* a mosaic is re-read days later;
|
|
15074
|
+
* - the per-tile strip covers 14% of the tile from the bottom edge up, which
|
|
15075
|
+
* is where feet, wheels and parcels are;
|
|
15076
|
+
* - nothing downstream needs it. The AI pass is given the RAW per-track JPEGs
|
|
15077
|
+
* and gets camera, class and time as PROMPT TEXT — it never reads the
|
|
15078
|
+
* mosaic, so the pixels carry no fact the judge would lose.
|
|
15079
|
+
*
|
|
15080
|
+
* `tiles` and `all` keep the old behaviour reachable for an operator who wants
|
|
15081
|
+
* it; `all` is byte-for-byte what every rule produced before this field.
|
|
15082
|
+
*/
|
|
15083
|
+
var NcSummaryCaptionsSchema = _enum([
|
|
15084
|
+
"none",
|
|
15085
|
+
"tiles",
|
|
15086
|
+
"all"
|
|
15087
|
+
]);
|
|
15088
|
+
var NC_SUMMARY_DEFAULT_CAPTIONS = "none";
|
|
15089
|
+
/**
|
|
14618
15090
|
* The AI section — DECLARED IN P1, WIRED IN P2.
|
|
14619
15091
|
*
|
|
14620
15092
|
* Nothing in this addon reads it today. It is here so the rule shape does not
|
|
@@ -14648,6 +15120,8 @@ var NcSummaryRuleInputSchema = object({
|
|
|
14648
15120
|
barrierSeconds: BarrierSecondsField.default(120),
|
|
14649
15121
|
targets: TargetsField,
|
|
14650
15122
|
template: NcSummaryTemplateSchema.optional(),
|
|
15123
|
+
/** Burned onto the mosaic's title band — and ONLY when `captions` is `all`. */
|
|
15124
|
+
captions: NcSummaryCaptionsSchema.default(NC_SUMMARY_DEFAULT_CAPTIONS),
|
|
14651
15125
|
/** Burned onto the mosaic's title band. `''` is reachable and means "no
|
|
14652
15126
|
* caption" — which is why this is not `.min(1)`. */
|
|
14653
15127
|
captionText: string().max(200).optional(),
|
|
@@ -14677,6 +15151,7 @@ var NcSummaryRulePatchSchema = object({
|
|
|
14677
15151
|
barrierSeconds: BarrierSecondsField.optional(),
|
|
14678
15152
|
targets: TargetsField.optional(),
|
|
14679
15153
|
template: NcSummaryTemplateSchema.nullable().optional(),
|
|
15154
|
+
captions: NcSummaryCaptionsSchema.optional(),
|
|
14680
15155
|
captionText: string().max(200).optional(),
|
|
14681
15156
|
deliverEmpty: boolean().optional(),
|
|
14682
15157
|
priority: PriorityField.optional(),
|
|
@@ -15196,14 +15671,19 @@ function isSyntheticId(id) {
|
|
|
15196
15671
|
* snapshot that produced it lists the members being counted and the edge
|
|
15197
15672
|
* names one (`chooseOccupancyMediaOwner`), so the tester stands in for that
|
|
15198
15673
|
* member exactly as it stands in for the marker.
|
|
15674
|
+
* - and, since 2026-08-15, a `trackId` for both AUDIO kinds: a sound owns no
|
|
15675
|
+
* frame, so production photographs the camera at the match and holds the
|
|
15676
|
+
* bytes on the still shelf under an owner id the subject names
|
|
15677
|
+
* (`audio-still.ts`). The tester's RAM-held frame stands in for exactly that
|
|
15678
|
+
* photograph, and — unlike every other kind here — it is taken the same way
|
|
15679
|
+
* production takes it, at the same moment, from the same cap.
|
|
15199
15680
|
*
|
|
15200
|
-
*
|
|
15201
|
-
*
|
|
15202
|
-
*
|
|
15203
|
-
* feature that does not exist.
|
|
15681
|
+
* Only `system-event` remains: an infrastructure event is about no camera at
|
|
15682
|
+
* all, so there is nothing to photograph and a tester that invented a picture
|
|
15683
|
+
* for it would be advertising a feature that does not exist.
|
|
15204
15684
|
*/
|
|
15205
15685
|
function triggerCanCarryStill(kind) {
|
|
15206
|
-
return kind
|
|
15686
|
+
return kind !== "system-event";
|
|
15207
15687
|
}
|
|
15208
15688
|
/** The detection provenance production stamps on a subject of this kind. */
|
|
15209
15689
|
function syntheticSource(kind) {
|
|
@@ -15241,7 +15721,7 @@ function audioSubjectOf(input) {
|
|
|
15241
15721
|
function buildSyntheticEvent(input, recordId, now) {
|
|
15242
15722
|
const kind = input.trigger;
|
|
15243
15723
|
const timestamp = input.timestamp ?? now;
|
|
15244
|
-
const carriesTrackId = kind === "object-event" || kind === "track-end" || kind === "device-event" || kind === "occupancy-event";
|
|
15724
|
+
const carriesTrackId = kind === "object-event" || kind === "track-end" || kind === "device-event" || kind === "occupancy-event" || kind === "audio-event" || kind === "audio-window";
|
|
15245
15725
|
return {
|
|
15246
15726
|
subject: {
|
|
15247
15727
|
kind,
|
|
@@ -17821,6 +18301,12 @@ var NotificationCenter = class NotificationCenter {
|
|
|
17821
18301
|
* is the one thing the synthetic producer promises not to do.
|
|
17822
18302
|
*/
|
|
17823
18303
|
syntheticMedia = /* @__PURE__ */ new Map();
|
|
18304
|
+
/**
|
|
18305
|
+
* The photograph a SOUND rule's notification carries — see `audio-still.ts`.
|
|
18306
|
+
* Null when this wiring has no snapshot cap, which leaves every audio
|
|
18307
|
+
* notification text-only rather than broken.
|
|
18308
|
+
*/
|
|
18309
|
+
audioStills;
|
|
17824
18310
|
/** Per-device rate limit for the "matched NO rule" report — see `reportNoMatch`. */
|
|
17825
18311
|
lastNoMatchReportAt = /* @__PURE__ */ new Map();
|
|
17826
18312
|
noMatchSuppressed = /* @__PURE__ */ new Map();
|
|
@@ -18112,6 +18598,12 @@ var NotificationCenter = class NotificationCenter {
|
|
|
18112
18598
|
this.deps = deps;
|
|
18113
18599
|
this.logger = deps.logger;
|
|
18114
18600
|
this.now = deps.now ?? (() => Date.now());
|
|
18601
|
+
const getSnapshot = deps.getSnapshot;
|
|
18602
|
+
this.audioStills = getSnapshot !== void 0 ? new NcAudioStillShelf({
|
|
18603
|
+
getSnapshot,
|
|
18604
|
+
logger: this.logger.child("audio-still"),
|
|
18605
|
+
now: this.now
|
|
18606
|
+
}) : null;
|
|
18115
18607
|
this.rules = new NcRuleStore({
|
|
18116
18608
|
store: deps.store,
|
|
18117
18609
|
logger: this.logger.child("rules"),
|
|
@@ -18189,6 +18681,8 @@ var NotificationCenter = class NotificationCenter {
|
|
|
18189
18681
|
getMediaForOwner: async (ownerKind, ownerId) => {
|
|
18190
18682
|
const held = isSyntheticId(ownerId) ? this.syntheticMedia.get(ownerId) : void 0;
|
|
18191
18683
|
if (held !== void 0) return held.files;
|
|
18684
|
+
const audio = isAudioStillId(ownerId) ? this.audioStills?.get(ownerId) : void 0;
|
|
18685
|
+
if (audio !== void 0) return audio;
|
|
18192
18686
|
return deps.dispatcher.getMediaForOwner(ownerKind, ownerId);
|
|
18193
18687
|
},
|
|
18194
18688
|
...deps.now !== void 0 ? { now: deps.now } : {},
|
|
@@ -18393,6 +18887,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
18393
18887
|
}
|
|
18394
18888
|
this.timelapseScheduler?.stop();
|
|
18395
18889
|
this.summaryProducer?.stop();
|
|
18890
|
+
this.audioStills?.clear();
|
|
18396
18891
|
this.evaluationActive = false;
|
|
18397
18892
|
}
|
|
18398
18893
|
/**
|
|
@@ -18722,7 +19217,35 @@ var NotificationCenter = class NotificationCenter {
|
|
|
18722
19217
|
* durable guarantee begins at this hook, matching the device-event boundary.
|
|
18723
19218
|
*/
|
|
18724
19219
|
onAudioEventPersisted(event) {
|
|
18725
|
-
this.consumeEvent(incomingFromAudioEvent(event));
|
|
19220
|
+
this.consumeEvent(incomingFromAudioEvent(event, this.audioStillOwner(event.deviceId, event.timestamp, "legacy")));
|
|
19221
|
+
}
|
|
19222
|
+
/**
|
|
19223
|
+
* Start photographing `deviceId` NOW and return the owner id the audio
|
|
19224
|
+
* subject should name — or `undefined` when nothing would use it.
|
|
19225
|
+
*
|
|
19226
|
+
* **The capture is not awaited.** A sound is transient and a notification
|
|
19227
|
+
* about a scream must not wait on a camera; the bytes land while the outbox
|
|
19228
|
+
* row queues, and the dispatcher's ordinary bounded still-wait picks them up.
|
|
19229
|
+
* That is also why this runs BEFORE the cooldown and the mute are consulted:
|
|
19230
|
+
* by the time a rule's window has been judged the moment is gone.
|
|
19231
|
+
*
|
|
19232
|
+
* **The gate is the RULE's media policy, not the trigger.** Photographing a
|
|
19233
|
+
* camera on every confirmed window for rules that ship text anyway would be a
|
|
19234
|
+
* snapshot at whatever rate the sound happens to occur, for nothing. The rule
|
|
19235
|
+
* cache is an in-memory mirror, so this is not a fallible read (D49) — a
|
|
19236
|
+
* failed one would look exactly like "no rule wants a picture", which is the
|
|
19237
|
+
* direction that DESTROYS the media.
|
|
19238
|
+
*/
|
|
19239
|
+
audioStillOwner(deviceId, atMs, mode) {
|
|
19240
|
+
const shelf = this.audioStills;
|
|
19241
|
+
if (shelf === null || !this.evaluationActive) return void 0;
|
|
19242
|
+
for (const rule of this.rules.listEnabled("immediate")) {
|
|
19243
|
+
if (rule.media.attach === "none") continue;
|
|
19244
|
+
if (!(mode === "condition" ? rule.conditions.audio !== void 0 : rule.conditions.audio === void 0 && referencesAudioClass(rule.conditions.classes))) continue;
|
|
19245
|
+
const devices = rule.conditions.devices;
|
|
19246
|
+
if (devices !== void 0 && devices.length > 0 && !devices.includes(deviceId)) continue;
|
|
19247
|
+
return shelf.capture(deviceId, atMs);
|
|
19248
|
+
}
|
|
18726
19249
|
}
|
|
18727
19250
|
/**
|
|
18728
19251
|
* Called at the package object-event persist site (`PackageDropDetector` —
|
|
@@ -18820,7 +19343,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
18820
19343
|
tags: { deviceId },
|
|
18821
19344
|
meta: audioHitMeta(hit)
|
|
18822
19345
|
});
|
|
18823
|
-
this.consumeEvent(incomingFromAudioWindow(hit));
|
|
19346
|
+
this.consumeEvent(incomingFromAudioWindow(hit, this.audioStillOwner(deviceId, hit.timestamp, "condition")));
|
|
18824
19347
|
}
|
|
18825
19348
|
}
|
|
18826
19349
|
/**
|
|
@@ -19637,6 +20160,19 @@ var NotificationCenter = class NotificationCenter {
|
|
|
19637
20160
|
}
|
|
19638
20161
|
});
|
|
19639
20162
|
}
|
|
20163
|
+
if (!noOwnerReported && (kind === "audio-window" || kind === "audio-event") && subject.trackId === void 0 && rule.media.attach !== "none") {
|
|
20164
|
+
noOwnerReported = true;
|
|
20165
|
+
this.logger.warn("audio match has no still owner — this notification ships text-only", {
|
|
20166
|
+
tags: { deviceId: subject.deviceId },
|
|
20167
|
+
meta: {
|
|
20168
|
+
recordId: subject.recordId,
|
|
20169
|
+
kind,
|
|
20170
|
+
ruleId: rule.id,
|
|
20171
|
+
rule: rule.name,
|
|
20172
|
+
snapshotWired: this.audioStills !== null
|
|
20173
|
+
}
|
|
20174
|
+
});
|
|
20175
|
+
}
|
|
19640
20176
|
const userTargets = await this.resolveUserTargets(rule, subject.deviceId);
|
|
19641
20177
|
const entries = this.buildEntries(rule, subject, kind, evaluation.matchedOn, userTargets, origin, {
|
|
19642
20178
|
key,
|
|
@@ -19793,6 +20329,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
19793
20329
|
admitToGroup(rule, subject, now) {
|
|
19794
20330
|
const idleSec = rule.groupIdleSec;
|
|
19795
20331
|
if (idleSec === void 0 || idleSec <= 0) return { outcome: "disabled" };
|
|
20332
|
+
if (subject.kind === "audio-window" || subject.kind === "audio-event") return { outcome: "disabled" };
|
|
19796
20333
|
const trackId = subject.trackId;
|
|
19797
20334
|
if (trackId === void 0) return { outcome: "disabled" };
|
|
19798
20335
|
const member = {
|
|
@@ -21290,6 +21827,14 @@ var ncActions = defineCustomActions({
|
|
|
21290
21827
|
tiles: number().int().optional(),
|
|
21291
21828
|
cameras: number().int().optional(),
|
|
21292
21829
|
matched: number().int().optional(),
|
|
21830
|
+
/**
|
|
21831
|
+
* PARKED objects the window held and the rule dropped (`filters
|
|
21832
|
+
* .includeStationary`). Without it a Test that returns two tiles out of
|
|
21833
|
+
* forty tracks cannot tell the operator whether the night was quiet or
|
|
21834
|
+
* whether it was the car park — and that is the exact question this
|
|
21835
|
+
* option exists to answer.
|
|
21836
|
+
*/
|
|
21837
|
+
droppedStationary: number().int().optional(),
|
|
21293
21838
|
enqueued: number().int().optional(),
|
|
21294
21839
|
mosaicBytes: number().int().optional(),
|
|
21295
21840
|
mosaicUrl: string().optional(),
|
|
@@ -21756,6 +22301,7 @@ function makeNcActionHandlers(deps) {
|
|
|
21756
22301
|
...out.tiles !== void 0 ? { tiles: out.tiles } : {},
|
|
21757
22302
|
...out.cameras !== void 0 ? { cameras: out.cameras } : {},
|
|
21758
22303
|
...out.matched !== void 0 ? { matched: out.matched } : {},
|
|
22304
|
+
...out.droppedStationary !== void 0 ? { droppedStationary: out.droppedStationary } : {},
|
|
21759
22305
|
...out.enqueued !== void 0 ? { enqueued: out.enqueued } : {},
|
|
21760
22306
|
...out.mosaicBytes !== void 0 ? { mosaicBytes: out.mosaicBytes } : {},
|
|
21761
22307
|
...out.mosaicUrl !== void 0 ? { mosaicUrl: out.mosaicUrl } : {},
|
|
@@ -23444,51 +23990,6 @@ var embeddingActions = defineCustomActions({
|
|
|
23444
23990
|
"embedding.rebuildStatus": customAction(NoInputSchema, RebuildStatusSchema),
|
|
23445
23991
|
"embedding.wipe": customAction(NoInputSchema, WipeResultSchema, { auth: "admin" })
|
|
23446
23992
|
});
|
|
23447
|
-
var OrphanAuditActionInputSchema = object({
|
|
23448
|
-
mode: _enum(["report", "reclaim"]).default("report"),
|
|
23449
|
-
/**
|
|
23450
|
-
* Start every scope from the top instead of resuming its persisted cursor.
|
|
23451
|
-
*
|
|
23452
|
-
* What draining a backlog wants, and never the default — the cursor exists
|
|
23453
|
-
* so a 6-hourly pass makes progress instead of re-walking the same head.
|
|
23454
|
-
*/
|
|
23455
|
-
restart: boolean().optional(),
|
|
23456
|
-
/** Rows read per statement. Smaller = more yields = a gentler pass. */
|
|
23457
|
-
pageSize: number().int().min(50).max(5e3).optional(),
|
|
23458
|
-
/** Rows EXAMINED per collection in this pass — the pacing bound. */
|
|
23459
|
-
maxRowsPerScope: number().int().min(1).max(2e5).optional(),
|
|
23460
|
-
/** Rows DELETED per collection in this pass — the blast-radius bound. */
|
|
23461
|
-
maxReclaimPerScope: number().int().min(1).max(1e5).optional(),
|
|
23462
|
-
/**
|
|
23463
|
-
* Minutes of immunity for a recent row. Protective ONLY — raising it can
|
|
23464
|
-
* exclusively save rows, never select them, which is what keeps this off
|
|
23465
|
-
* the one-age-keyed-sweep guard's books.
|
|
23466
|
-
*/
|
|
23467
|
-
graceMinutes: number().int().min(1).max(10080).optional()
|
|
23468
|
-
});
|
|
23469
|
-
var OrphanAuditScopeReportSchema = object({
|
|
23470
|
-
collection: string(),
|
|
23471
|
-
examined: number(),
|
|
23472
|
-
orphans: number(),
|
|
23473
|
-
reclaimed: number(),
|
|
23474
|
-
bytesReclaimed: number(),
|
|
23475
|
-
exempt: number(),
|
|
23476
|
-
withinGrace: number(),
|
|
23477
|
-
unresolvable: number(),
|
|
23478
|
-
stoppedBy: string(),
|
|
23479
|
-
/** Where the NEXT pass resumes. `0` = the scope was walked to the end. */
|
|
23480
|
-
nextCursor: number()
|
|
23481
|
-
});
|
|
23482
|
-
var orphanAuditActions = defineCustomActions({ "retention.orphanAudit": customAction(OrphanAuditActionInputSchema, object({
|
|
23483
|
-
/** `false` when a pass was already in flight — nothing was done. */
|
|
23484
|
-
ran: boolean(),
|
|
23485
|
-
mode: _enum(["report", "reclaim"]),
|
|
23486
|
-
scopes: array(OrphanAuditScopeReportSchema),
|
|
23487
|
-
totalOrphans: number(),
|
|
23488
|
-
totalReclaimed: number(),
|
|
23489
|
-
totalBytesReclaimed: number(),
|
|
23490
|
-
durationMs: number()
|
|
23491
|
-
}), { auth: "admin" }) });
|
|
23492
23993
|
//#endregion
|
|
23493
23994
|
//#region src/pipeline-analytics/map-with-concurrency.ts
|
|
23494
23995
|
/**
|
|
@@ -25804,7 +26305,9 @@ var FrameProcessor = class {
|
|
|
25804
26305
|
}) : {
|
|
25805
26306
|
suppressedIndices: /* @__PURE__ */ new Set(),
|
|
25806
26307
|
confirmed: [],
|
|
25807
|
-
wokenEntryIds: []
|
|
26308
|
+
wokenEntryIds: [],
|
|
26309
|
+
armedEntryIds: [],
|
|
26310
|
+
disarmedEntryIds: []
|
|
25808
26311
|
};
|
|
25809
26312
|
const trackerInput = gate.suppressedIndices.size > 0 ? filteredDetections.filter((_, i) => !gate.suppressedIndices.has(i)) : filteredDetections;
|
|
25810
26313
|
const trackedDetections = this.tracker.update(trackerInput, timestamp, {
|
|
@@ -26014,6 +26517,8 @@ var FrameProcessor = class {
|
|
|
26014
26517
|
rawTrackedDetections: trackedDetections,
|
|
26015
26518
|
stationaryConfirmed: gate.confirmed,
|
|
26016
26519
|
stationaryWoken: gate.wokenEntryIds,
|
|
26520
|
+
stationaryArmed: gate.armedEntryIds,
|
|
26521
|
+
stationaryDisarmed: gate.disarmedEntryIds,
|
|
26017
26522
|
riderFolds
|
|
26018
26523
|
};
|
|
26019
26524
|
}
|
|
@@ -27805,6 +28310,51 @@ var PlateGalleryProvider = class {
|
|
|
27805
28310
|
};
|
|
27806
28311
|
}
|
|
27807
28312
|
};
|
|
28313
|
+
var OrphanAuditActionInputSchema = object({
|
|
28314
|
+
mode: _enum(["report", "reclaim"]).default("report"),
|
|
28315
|
+
/**
|
|
28316
|
+
* Start every scope from the top instead of resuming its persisted cursor.
|
|
28317
|
+
*
|
|
28318
|
+
* What draining a backlog wants, and never the default — the cursor exists
|
|
28319
|
+
* so a 6-hourly pass makes progress instead of re-walking the same head.
|
|
28320
|
+
*/
|
|
28321
|
+
restart: boolean().optional(),
|
|
28322
|
+
/** Rows read per statement. Smaller = more yields = a gentler pass. */
|
|
28323
|
+
pageSize: number().int().min(50).max(5e3).optional(),
|
|
28324
|
+
/** Rows EXAMINED per collection in this pass — the pacing bound. */
|
|
28325
|
+
maxRowsPerScope: number().int().min(1).max(2e5).optional(),
|
|
28326
|
+
/** Rows DELETED per collection in this pass — the blast-radius bound. */
|
|
28327
|
+
maxReclaimPerScope: number().int().min(1).max(1e5).optional(),
|
|
28328
|
+
/**
|
|
28329
|
+
* Minutes of immunity for a recent row. Protective ONLY — raising it can
|
|
28330
|
+
* exclusively save rows, never select them, which is what keeps this off
|
|
28331
|
+
* the one-age-keyed-sweep guard's books.
|
|
28332
|
+
*/
|
|
28333
|
+
graceMinutes: number().int().min(1).max(10080).optional()
|
|
28334
|
+
});
|
|
28335
|
+
var OrphanAuditScopeReportSchema = object({
|
|
28336
|
+
collection: string(),
|
|
28337
|
+
examined: number(),
|
|
28338
|
+
orphans: number(),
|
|
28339
|
+
reclaimed: number(),
|
|
28340
|
+
bytesReclaimed: number(),
|
|
28341
|
+
exempt: number(),
|
|
28342
|
+
withinGrace: number(),
|
|
28343
|
+
unresolvable: number(),
|
|
28344
|
+
stoppedBy: string(),
|
|
28345
|
+
/** Where the NEXT pass resumes. `0` = the scope was walked to the end. */
|
|
28346
|
+
nextCursor: number()
|
|
28347
|
+
});
|
|
28348
|
+
var orphanAuditActions = defineCustomActions({ "retention.orphanAudit": customAction(OrphanAuditActionInputSchema, object({
|
|
28349
|
+
/** `false` when a pass was already in flight — nothing was done. */
|
|
28350
|
+
ran: boolean(),
|
|
28351
|
+
mode: _enum(["report", "reclaim"]),
|
|
28352
|
+
scopes: array(OrphanAuditScopeReportSchema),
|
|
28353
|
+
totalOrphans: number(),
|
|
28354
|
+
totalReclaimed: number(),
|
|
28355
|
+
totalBytesReclaimed: number(),
|
|
28356
|
+
durationMs: number()
|
|
28357
|
+
}), { auth: "admin" }) });
|
|
27808
28358
|
//#endregion
|
|
27809
28359
|
//#region src/pipeline-analytics/videoclips-provider.ts
|
|
27810
28360
|
var SOURCE = "analytics";
|
|
@@ -33549,13 +34099,25 @@ var StoragePressureTracker = class {
|
|
|
33549
34099
|
};
|
|
33550
34100
|
//#endregion
|
|
33551
34101
|
//#region src/pipeline-analytics/store/media-store.ts
|
|
34102
|
+
/**
|
|
34103
|
+
* `scene` is its own owner kind rather than borrowing `event`.
|
|
34104
|
+
*
|
|
34105
|
+
* Scene reference thumbnails were written as `ownerKind: 'event'` with an
|
|
34106
|
+
* `ownerId` of `scene-<monitorId>-…`, which resolves through
|
|
34107
|
+
* `resolveMediaOwner` to `{ kind: 'event', eventId: 'scene-…' }` — an event row
|
|
34108
|
+
* that has never existed. The orphan audit therefore classified every
|
|
34109
|
+
* operator-captured reference picture as collectable. It is the same class of
|
|
34110
|
+
* mistake `identity` and `vehicle` are exempt for: a blob the OPERATOR curated,
|
|
34111
|
+
* which nothing else can regenerate and no cascade owns.
|
|
34112
|
+
*/
|
|
33552
34113
|
var OWNER_KINDS = [
|
|
33553
34114
|
"event",
|
|
33554
34115
|
"track",
|
|
33555
34116
|
"face",
|
|
33556
34117
|
"identity",
|
|
33557
34118
|
"plate",
|
|
33558
|
-
"vehicle"
|
|
34119
|
+
"vehicle",
|
|
34120
|
+
"scene"
|
|
33559
34121
|
];
|
|
33560
34122
|
/**
|
|
33561
34123
|
* Narrow an arbitrary string to an `OwnerKind`.
|
|
@@ -35484,20 +36046,38 @@ function centroidInside(b, p) {
|
|
|
35484
36046
|
* box → SUPPRESS (detector box jitter — the box shrank/grew around the same
|
|
35485
36047
|
* centre; still the parked object, must not spawn a duplicate track).
|
|
35486
36048
|
* - `wakeAssocIou` ≤ best IoU < `suppressIou`, centroid OUTSIDE the parked box
|
|
35487
|
-
* →
|
|
35488
|
-
* suppressed, so it spawns a fresh moving track.
|
|
36049
|
+
* → DEPARTING (the object's box slid off its spot).
|
|
35489
36050
|
* - best IoU < `wakeAssocIou` → MISS: no detection overlaps this entry (a
|
|
35490
|
-
* different object, or the parked object is momentarily undetected).
|
|
35491
|
-
*
|
|
36051
|
+
* different object, or the parked object is momentarily undetected). The TTL
|
|
36052
|
+
* sweep retires it only if the absence persists.
|
|
36053
|
+
*
|
|
36054
|
+
* ## A departure is never acted on from ONE read (D49)
|
|
36055
|
+
*
|
|
36056
|
+
* Retiring an entry DESTROYS work: the object's registry identity, and with it
|
|
36057
|
+
* the suppression that stops it re-spawning a track every 30 s. So a single
|
|
36058
|
+
* departing read only ARMS the entry — the detection is still suppressed and
|
|
36059
|
+
* the entry survives. The entry is retired only when a SECOND read agrees:
|
|
36060
|
+
* departing again, or missing entirely (the object really has left the spot).
|
|
36061
|
+
* A confirming read DISARMS it.
|
|
36062
|
+
*
|
|
36063
|
+
* The caller owns the armed set and passes it back in; this function stays
|
|
36064
|
+
* pure. Cost of the extra read: a genuinely departing object spawns its track
|
|
36065
|
+
* one frame later. Cost of NOT having it, measured on the live hub over 24 h:
|
|
36066
|
+
* 65% of all stationary entries were retired within 300 s — below the TTL, so
|
|
36067
|
+
* by a wake — and 38% of the tracks that promotion then re-created had never
|
|
36068
|
+
* moved in their entire life. One bad box per parked object was enough.
|
|
35492
36069
|
*
|
|
35493
36070
|
* A detection can suppress at most one spawn even if it overlaps two entries
|
|
35494
36071
|
* (`suppressedIndices` is a set).
|
|
35495
36072
|
*/
|
|
35496
36073
|
function partitionDetectionsAgainstRegistry(input) {
|
|
35497
36074
|
const { entries, detections, config } = input;
|
|
36075
|
+
const armed = input.armedEntryIds ?? EMPTY_ARMED;
|
|
35498
36076
|
const suppressed = /* @__PURE__ */ new Set();
|
|
35499
36077
|
const confirmed = [];
|
|
35500
36078
|
const woken = [];
|
|
36079
|
+
const toArm = [];
|
|
36080
|
+
const toDisarm = [];
|
|
35501
36081
|
for (const entry of entries) {
|
|
35502
36082
|
let bestIou = 0;
|
|
35503
36083
|
let bestIdx = -1;
|
|
@@ -35510,23 +36090,37 @@ function partitionDetectionsAgainstRegistry(input) {
|
|
|
35510
36090
|
bestIdx = di;
|
|
35511
36091
|
}
|
|
35512
36092
|
}
|
|
35513
|
-
if (bestIdx < 0 || bestIou < config.wakeAssocIou)
|
|
36093
|
+
if (bestIdx < 0 || bestIou < config.wakeAssocIou) {
|
|
36094
|
+
if (armed.has(entry.id)) woken.push(entry.id);
|
|
36095
|
+
continue;
|
|
36096
|
+
}
|
|
35514
36097
|
const detCentroid = centroid(detections[bestIdx].bbox);
|
|
35515
|
-
|
|
36098
|
+
const strong = bestIou >= config.suppressIou;
|
|
36099
|
+
if (strong || centroidInside(entry.bbox, detCentroid)) {
|
|
35516
36100
|
suppressed.add(bestIdx);
|
|
35517
36101
|
confirmed.push({
|
|
35518
36102
|
entryId: entry.id,
|
|
35519
36103
|
className: entry.className,
|
|
35520
|
-
bbox: { ...entry.bbox }
|
|
36104
|
+
bbox: { ...entry.bbox },
|
|
36105
|
+
observedBbox: { ...detections[bestIdx].bbox },
|
|
36106
|
+
strong
|
|
35521
36107
|
});
|
|
35522
|
-
|
|
36108
|
+
if (armed.has(entry.id)) toDisarm.push(entry.id);
|
|
36109
|
+
} else if (armed.has(entry.id)) woken.push(entry.id);
|
|
36110
|
+
else {
|
|
36111
|
+
toArm.push(entry.id);
|
|
36112
|
+
suppressed.add(bestIdx);
|
|
36113
|
+
}
|
|
35523
36114
|
}
|
|
35524
36115
|
return {
|
|
35525
36116
|
suppressedIndices: suppressed,
|
|
35526
36117
|
confirmed,
|
|
35527
|
-
wokenEntryIds: woken
|
|
36118
|
+
wokenEntryIds: woken,
|
|
36119
|
+
armedEntryIds: toArm,
|
|
36120
|
+
disarmedEntryIds: toDisarm
|
|
35528
36121
|
};
|
|
35529
36122
|
}
|
|
36123
|
+
var EMPTY_ARMED = /* @__PURE__ */ new Set();
|
|
35530
36124
|
/**
|
|
35531
36125
|
* A track is promoted when, over the recent `windowMs`, its centroid barely
|
|
35532
36126
|
* moved (both net displacement and path span below the static thresholds) AND
|
|
@@ -36099,6 +36693,21 @@ var StationaryObjectRegistry = class {
|
|
|
36099
36693
|
* between motion sessions; that silence is not evidence the object left,
|
|
36100
36694
|
* so quiet minutes must not age the entries (see {@link sweep}). */
|
|
36101
36695
|
lastFrameAtByDevice = /* @__PURE__ */ new Map();
|
|
36696
|
+
/**
|
|
36697
|
+
* Entries carrying ONE unconfirmed departure read, per device (D49, D174).
|
|
36698
|
+
*
|
|
36699
|
+
* Retiring an entry destroys work — the object's registry identity and the
|
|
36700
|
+
* suppression that keeps it from re-spawning a track every promotion window.
|
|
36701
|
+
* A single frame whose box slid off the parked spot is a fallible read (a
|
|
36702
|
+
* partial occlusion, a shadow, one bad box), so it only ARMS the entry here.
|
|
36703
|
+
* The retirement happens on the next frame that AGREES; a confirming frame
|
|
36704
|
+
* clears the strike.
|
|
36705
|
+
*/
|
|
36706
|
+
departureStrikes = /* @__PURE__ */ new Map();
|
|
36707
|
+
/** Detections absorbed by an existing entry since the last sweep, per device.
|
|
36708
|
+
* Rolled up into ONE log line per sweep — this fires on every frame of every
|
|
36709
|
+
* parked object, so it can never be a line of its own. */
|
|
36710
|
+
absorbedSinceSweep = /* @__PURE__ */ new Map();
|
|
36102
36711
|
constructor(deps) {
|
|
36103
36712
|
this.logger = deps.logger;
|
|
36104
36713
|
this.ledger = new DurableLedger({
|
|
@@ -36171,12 +36780,15 @@ var StationaryObjectRegistry = class {
|
|
|
36171
36780
|
if (entries.length === 0) return {
|
|
36172
36781
|
suppressedIndices: /* @__PURE__ */ new Set(),
|
|
36173
36782
|
confirmed: [],
|
|
36174
|
-
wokenEntryIds: []
|
|
36783
|
+
wokenEntryIds: [],
|
|
36784
|
+
armedEntryIds: [],
|
|
36785
|
+
disarmedEntryIds: []
|
|
36175
36786
|
};
|
|
36176
36787
|
return partitionDetectionsAgainstRegistry({
|
|
36177
36788
|
entries,
|
|
36178
36789
|
detections: input.detections,
|
|
36179
|
-
config: config ?? this.matchConfig
|
|
36790
|
+
config: config ?? this.matchConfig,
|
|
36791
|
+
...this.departureStrikes.has(input.deviceId) ? { armedEntryIds: this.departureStrikes.get(input.deviceId) } : {}
|
|
36180
36792
|
});
|
|
36181
36793
|
}
|
|
36182
36794
|
/** Fold a frame's gate result back into state: advance confirmed entries'
|
|
@@ -36193,15 +36805,41 @@ var StationaryObjectRegistry = class {
|
|
|
36193
36805
|
if (!e) continue;
|
|
36194
36806
|
this.ledger.stage({
|
|
36195
36807
|
...e,
|
|
36196
|
-
lastConfirmedAt: input.timestamp
|
|
36808
|
+
lastConfirmedAt: input.timestamp,
|
|
36809
|
+
...c.strong ? { bbox: { ...c.observedBbox } } : {}
|
|
36197
36810
|
});
|
|
36198
36811
|
this.dirty.add(c.entryId);
|
|
36199
36812
|
}
|
|
36813
|
+
this.absorbedSinceSweep.set(input.deviceId, (this.absorbedSinceSweep.get(input.deviceId) ?? 0) + input.confirmed.length);
|
|
36814
|
+
if (input.armedEntryIds && input.armedEntryIds.length > 0) {
|
|
36815
|
+
let armed = this.departureStrikes.get(input.deviceId);
|
|
36816
|
+
if (!armed) {
|
|
36817
|
+
armed = /* @__PURE__ */ new Set();
|
|
36818
|
+
this.departureStrikes.set(input.deviceId, armed);
|
|
36819
|
+
}
|
|
36820
|
+
for (const id of input.armedEntryIds) if (ids.has(id)) armed.add(id);
|
|
36821
|
+
}
|
|
36822
|
+
const armed = this.departureStrikes.get(input.deviceId);
|
|
36823
|
+
if (armed && input.disarmedEntryIds) {
|
|
36824
|
+
for (const id of input.disarmedEntryIds) armed.delete(id);
|
|
36825
|
+
if (armed.size === 0) this.departureStrikes.delete(input.deviceId);
|
|
36826
|
+
}
|
|
36200
36827
|
for (const id of input.wokenEntryIds) {
|
|
36201
36828
|
if (!ids.has(id)) continue;
|
|
36202
36829
|
const e = this.ledger.get(id);
|
|
36203
36830
|
if (!e) continue;
|
|
36204
36831
|
this.dropEntry(input.deviceId, id);
|
|
36832
|
+
this.logger.info("stationary entry retired", {
|
|
36833
|
+
tags: { deviceId: input.deviceId },
|
|
36834
|
+
meta: {
|
|
36835
|
+
deviceId: input.deviceId,
|
|
36836
|
+
entryId: id,
|
|
36837
|
+
className: e.className,
|
|
36838
|
+
reason: "departed",
|
|
36839
|
+
parkedForMs: input.timestamp - e.becameStationaryAt,
|
|
36840
|
+
...e.sourceTrackId !== void 0 ? { sourceTrackId: e.sourceTrackId } : {}
|
|
36841
|
+
}
|
|
36842
|
+
});
|
|
36205
36843
|
this.onChange?.({
|
|
36206
36844
|
phase: "departed",
|
|
36207
36845
|
entry: e,
|
|
@@ -36248,6 +36886,18 @@ var StationaryObjectRegistry = class {
|
|
|
36248
36886
|
if (observedAt - e.lastConfirmedAt > ttl) {
|
|
36249
36887
|
this.dropEntry(deviceId, id);
|
|
36250
36888
|
retired.push(e);
|
|
36889
|
+
this.logger.info("stationary entry retired", {
|
|
36890
|
+
tags: { deviceId },
|
|
36891
|
+
meta: {
|
|
36892
|
+
deviceId,
|
|
36893
|
+
entryId: id,
|
|
36894
|
+
className: e.className,
|
|
36895
|
+
reason: "ttl",
|
|
36896
|
+
ttlMs: ttl,
|
|
36897
|
+
unconfirmedForMs: observedAt - e.lastConfirmedAt,
|
|
36898
|
+
parkedForMs: observedAt - e.becameStationaryAt
|
|
36899
|
+
}
|
|
36900
|
+
});
|
|
36251
36901
|
this.onChange?.({
|
|
36252
36902
|
phase: "departed",
|
|
36253
36903
|
entry: e,
|
|
@@ -36257,6 +36907,18 @@ var StationaryObjectRegistry = class {
|
|
|
36257
36907
|
}
|
|
36258
36908
|
if (ids.size === 0) this.idsByDevice.delete(deviceId);
|
|
36259
36909
|
}
|
|
36910
|
+
for (const [deviceId, absorbed] of this.absorbedSinceSweep) {
|
|
36911
|
+
if (absorbed === 0) continue;
|
|
36912
|
+
this.logger.info("stationary registry absorbed detections", {
|
|
36913
|
+
tags: { deviceId },
|
|
36914
|
+
meta: {
|
|
36915
|
+
deviceId,
|
|
36916
|
+
absorbed,
|
|
36917
|
+
entries: this.count(deviceId)
|
|
36918
|
+
}
|
|
36919
|
+
});
|
|
36920
|
+
}
|
|
36921
|
+
this.absorbedSinceSweep.clear();
|
|
36260
36922
|
for (const id of [...this.dirty]) {
|
|
36261
36923
|
this.dirty.delete(id);
|
|
36262
36924
|
const entry = this.ledger.get(id);
|
|
@@ -36269,6 +36931,8 @@ var StationaryObjectRegistry = class {
|
|
|
36269
36931
|
* Used on device unbind; a rebind reloads from the store. */
|
|
36270
36932
|
forgetDevice(deviceId) {
|
|
36271
36933
|
this.lastFrameAtByDevice.delete(deviceId);
|
|
36934
|
+
this.departureStrikes.delete(deviceId);
|
|
36935
|
+
this.absorbedSinceSweep.delete(deviceId);
|
|
36272
36936
|
const ids = this.idsByDevice.get(deviceId);
|
|
36273
36937
|
if (!ids) return;
|
|
36274
36938
|
for (const id of ids) {
|
|
@@ -36280,6 +36944,8 @@ var StationaryObjectRegistry = class {
|
|
|
36280
36944
|
/** Delete every persisted + in-memory entry for a device (operator wipe). */
|
|
36281
36945
|
async clearDevice(deviceId) {
|
|
36282
36946
|
this.lastFrameAtByDevice.delete(deviceId);
|
|
36947
|
+
this.departureStrikes.delete(deviceId);
|
|
36948
|
+
this.absorbedSinceSweep.delete(deviceId);
|
|
36283
36949
|
const ids = this.idsByDevice.get(deviceId);
|
|
36284
36950
|
if (ids) {
|
|
36285
36951
|
for (const id of [...ids]) {
|
|
@@ -36297,10 +36963,17 @@ var StationaryObjectRegistry = class {
|
|
|
36297
36963
|
}
|
|
36298
36964
|
set.add(id);
|
|
36299
36965
|
}
|
|
36300
|
-
/** Retire one entry: index, staged advance and durable row
|
|
36966
|
+
/** Retire one entry: index, staged advance, pending strike and durable row
|
|
36967
|
+
* all go. A strike that outlived its entry would arm the NEXT entry to reuse
|
|
36968
|
+
* the id — the registry mints UUIDs, but the invariant is cheap to keep. */
|
|
36301
36969
|
dropEntry(deviceId, id) {
|
|
36302
36970
|
this.idsByDevice.get(deviceId)?.delete(id);
|
|
36303
36971
|
this.dirty.delete(id);
|
|
36972
|
+
const armed = this.departureStrikes.get(deviceId);
|
|
36973
|
+
if (armed) {
|
|
36974
|
+
armed.delete(id);
|
|
36975
|
+
if (armed.size === 0) this.departureStrikes.delete(deviceId);
|
|
36976
|
+
}
|
|
36304
36977
|
this.ledger.forget(id);
|
|
36305
36978
|
}
|
|
36306
36979
|
};
|
|
@@ -40344,6 +41017,26 @@ function isAfterCursor(row, cursor) {
|
|
|
40344
41017
|
}
|
|
40345
41018
|
//#endregion
|
|
40346
41019
|
//#region src/pipeline-analytics/store/track-store.ts
|
|
41020
|
+
/**
|
|
41021
|
+
* WHY a track's row was written EARLY — i.e. by {@link TrackStore.closeActive}
|
|
41022
|
+
* rather than by the ordinary TTL expiry.
|
|
41023
|
+
*
|
|
41024
|
+
* `'stationary-promotion'` is the only member and means: the tracker decided
|
|
41025
|
+
* this subject had PARKED, handed it to the stationary registry, and the row
|
|
41026
|
+
* was written so its media, events and vector keep an owner (D161). It is not a
|
|
41027
|
+
* quality signal and it never hides the track — a parked car is still a track,
|
|
41028
|
+
* still on the events page, still swept by the same retention. It exists so a
|
|
41029
|
+
* consumer that wants PASSAGES can say so exactly, instead of re-deriving
|
|
41030
|
+
* "did it move" from a trajectory downstream and getting it wrong on the
|
|
41031
|
+
* subject that stood still for one honest reason.
|
|
41032
|
+
*
|
|
41033
|
+
* An ABSENT value means "expired normally, or written before this marker
|
|
41034
|
+
* existed". Never read an absence as a positive — same rule as `hasFace`.
|
|
41035
|
+
*/
|
|
41036
|
+
var TRACK_CLOSE_REASONS = ["stationary-promotion"];
|
|
41037
|
+
function parseCloseReason(value) {
|
|
41038
|
+
return TRACK_CLOSE_REASONS.find((reason) => reason === value);
|
|
41039
|
+
}
|
|
40347
41040
|
var DEFAULT_CONFIG = {
|
|
40348
41041
|
ttlMs: 3e4,
|
|
40349
41042
|
maxPositionHistory: 300
|
|
@@ -40566,6 +41259,10 @@ var TRACKS_COLUMNS = [
|
|
|
40566
41259
|
{
|
|
40567
41260
|
name: "frameHeight",
|
|
40568
41261
|
type: "INTEGER"
|
|
41262
|
+
},
|
|
41263
|
+
{
|
|
41264
|
+
name: "closedReason",
|
|
41265
|
+
type: "TEXT"
|
|
40569
41266
|
}
|
|
40570
41267
|
];
|
|
40571
41268
|
var TRACKS_INDEXES = [{
|
|
@@ -40625,7 +41322,8 @@ function cloneTrack(t) {
|
|
|
40625
41322
|
} : {},
|
|
40626
41323
|
...t.debug !== void 0 ? { debug: t.debug } : {},
|
|
40627
41324
|
...t.hasFace !== void 0 ? { hasFace: t.hasFace } : {},
|
|
40628
|
-
...t.hasRider !== void 0 ? { hasRider: t.hasRider } : {}
|
|
41325
|
+
...t.hasRider !== void 0 ? { hasRider: t.hasRider } : {},
|
|
41326
|
+
...t.closedReason !== void 0 ? { closedReason: t.closedReason } : {}
|
|
40629
41327
|
};
|
|
40630
41328
|
}
|
|
40631
41329
|
var TrackStore = class {
|
|
@@ -41194,13 +41892,22 @@ var TrackStore = class {
|
|
|
41194
41892
|
* nothing has been written under that id yet. It is never correct for a track
|
|
41195
41893
|
* that ran.
|
|
41196
41894
|
*
|
|
41895
|
+
* **The row SAYS why it was closed early** (`reason` → the `closedReason`
|
|
41896
|
+
* column). Persisting a promoted track put its footprint back under the
|
|
41897
|
+
* retention sweep, and it also put the parked car back in front of every
|
|
41898
|
+
* consumer that reads the window's tracks: the operator's nightly digest went
|
|
41899
|
+
* from 1-2 tiles to 10, most of them the same stationary car. The marker is
|
|
41900
|
+
* written HERE, at the one place that knows, so a consumer can ask for
|
|
41901
|
+
* passages exactly instead of guessing at movement from a stored trajectory.
|
|
41902
|
+
*
|
|
41197
41903
|
* Returns whether an active entry was found. THROWS if the persist fails: the
|
|
41198
41904
|
* caller must log it, because a swallowed failure here IS the leak.
|
|
41199
41905
|
*/
|
|
41200
|
-
async closeActive(trackId) {
|
|
41906
|
+
async closeActive(trackId, reason) {
|
|
41201
41907
|
const t = this.active.get(trackId);
|
|
41202
41908
|
if (t === void 0) return false;
|
|
41203
41909
|
t.active = false;
|
|
41910
|
+
t.closedReason = reason;
|
|
41204
41911
|
const record = cloneTrack(t);
|
|
41205
41912
|
await this.persistCompleted(record);
|
|
41206
41913
|
this.active.delete(trackId);
|
|
@@ -41782,6 +42489,7 @@ var TrackStore = class {
|
|
|
41782
42489
|
...t.debug !== void 0 ? { debug: t.debug } : {},
|
|
41783
42490
|
...t.hasFace !== void 0 ? { hasFace: t.hasFace } : {},
|
|
41784
42491
|
...t.hasRider !== void 0 ? { hasRider: t.hasRider } : {},
|
|
42492
|
+
...t.closedReason !== void 0 ? { closedReason: t.closedReason } : {},
|
|
41785
42493
|
...envelope !== null && dims !== void 0 ? {
|
|
41786
42494
|
envMinX: envelope.minX,
|
|
41787
42495
|
envMinY: envelope.minY,
|
|
@@ -41819,6 +42527,7 @@ var TrackStore = class {
|
|
|
41819
42527
|
const hasFace = data["hasFace"];
|
|
41820
42528
|
/** Same read as `hasFace` — NULL / missing stays ABSENT, never `false`. */
|
|
41821
42529
|
const hasRider = data["hasRider"];
|
|
42530
|
+
const closedReason = parseCloseReason(data["closedReason"]);
|
|
41822
42531
|
const envMinX = data["envMinX"];
|
|
41823
42532
|
const envMinY = data["envMinY"];
|
|
41824
42533
|
const envMaxX = data["envMaxX"];
|
|
@@ -41856,6 +42565,7 @@ var TrackStore = class {
|
|
|
41856
42565
|
...typeof debug === "boolean" ? { debug } : {},
|
|
41857
42566
|
...typeof hasFace === "boolean" ? { hasFace } : {},
|
|
41858
42567
|
...typeof hasRider === "boolean" ? { hasRider } : {},
|
|
42568
|
+
...closedReason !== void 0 ? { closedReason } : {},
|
|
41859
42569
|
...envelope !== null ? { envelope } : {}
|
|
41860
42570
|
};
|
|
41861
42571
|
}
|
|
@@ -42337,7 +43047,11 @@ function auditableCollections() {
|
|
|
42337
43047
|
* exemption lives here now, and the orphan collector — the only sweep that
|
|
42338
43048
|
* walks media it was not handed by name — reads it.
|
|
42339
43049
|
*/
|
|
42340
|
-
var RETENTION_EXEMPT_OWNER_KINDS = new Set([
|
|
43050
|
+
var RETENTION_EXEMPT_OWNER_KINDS = new Set([
|
|
43051
|
+
"identity",
|
|
43052
|
+
"vehicle",
|
|
43053
|
+
"scene"
|
|
43054
|
+
]);
|
|
42341
43055
|
function isRetentionExemptOwnerKind(kind) {
|
|
42342
43056
|
return RETENTION_EXEMPT_OWNER_KINDS.has(kind);
|
|
42343
43057
|
}
|
|
@@ -45276,22 +45990,6 @@ async function projectSensorMarkers(deps, data, timestamp) {
|
|
|
45276
45990
|
}
|
|
45277
45991
|
return landed;
|
|
45278
45992
|
}
|
|
45279
|
-
/** JPEG quality for the downscaled full frame — matches the crop path. */
|
|
45280
|
-
var FULL_FRAME_QUALITY = 80;
|
|
45281
|
-
/**
|
|
45282
|
-
* Downscale an already-encoded JPEG full frame to FIT WITHIN
|
|
45283
|
-
* {@link FULL_FRAME_MAX_WIDTH}×{@link FULL_FRAME_MAX_HEIGHT}, preserving aspect
|
|
45284
|
-
* ratio (`fit: 'inside'`) and never enlarging a source already smaller than the
|
|
45285
|
-
* box. Re-encodes as JPEG. Used before persisting a synthetic sensor/control
|
|
45286
|
-
* track's whole-scene snapshot so a raw native-resolution frame (a 4K bedroom
|
|
45287
|
-
* at night) is never stored or served — the privacy fix moved to CAPTURE time.
|
|
45288
|
-
*/
|
|
45289
|
-
async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
|
|
45290
|
-
return sharp(Buffer.from(jpeg)).resize(maxWidth, maxHeight, {
|
|
45291
|
-
fit: "inside",
|
|
45292
|
-
withoutEnlargement: true
|
|
45293
|
-
}).jpeg({ quality: FULL_FRAME_QUALITY }).toBuffer();
|
|
45294
|
-
}
|
|
45295
45993
|
//#endregion
|
|
45296
45994
|
//#region src/pipeline-analytics/services/synthetic-track.ts
|
|
45297
45995
|
/**
|
|
@@ -49836,6 +50534,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
49836
50534
|
addonId: device.addonId
|
|
49837
50535
|
};
|
|
49838
50536
|
},
|
|
50537
|
+
getSnapshot: (deviceId) => api.snapshot.getSnapshot.query({ deviceId }),
|
|
49839
50538
|
identityIdsByName: async () => {
|
|
49840
50539
|
const byName = /* @__PURE__ */ new Map();
|
|
49841
50540
|
const identities = await stores.identityStore.listIdentities();
|
|
@@ -50054,7 +50753,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
50054
50753
|
lastSeen: t.lastSeen,
|
|
50055
50754
|
...t.importance !== void 0 ? { importance: t.importance } : {},
|
|
50056
50755
|
zones: [...t.zonesVisited],
|
|
50057
|
-
...t.classes !== void 0 ? { classes: [...t.classes] } : {}
|
|
50756
|
+
...t.classes !== void 0 ? { classes: [...t.classes] } : {},
|
|
50757
|
+
...t.closedReason === "stationary-promotion" ? { stationary: true } : {}
|
|
50058
50758
|
}));
|
|
50059
50759
|
},
|
|
50060
50760
|
listMediaInfo: async (trackId) => {
|
|
@@ -50712,13 +51412,16 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
50712
51412
|
if (media === null) throw new Error("media store not ready");
|
|
50713
51413
|
return media.putReplacing({
|
|
50714
51414
|
deviceId,
|
|
50715
|
-
ownerKind: "
|
|
51415
|
+
ownerKind: "scene",
|
|
50716
51416
|
ownerId,
|
|
50717
51417
|
kind: "thumbnailSmall",
|
|
50718
51418
|
timestamp,
|
|
50719
51419
|
data
|
|
50720
51420
|
});
|
|
50721
51421
|
},
|
|
51422
|
+
dropMedia: async (mediaId) => {
|
|
51423
|
+
await this.mediaStore?.deleteByKey(mediaId);
|
|
51424
|
+
},
|
|
50722
51425
|
mirrorSlice: (deviceId, status) => {
|
|
50723
51426
|
this.mirrorSceneSlice(deviceId, status);
|
|
50724
51427
|
},
|
|
@@ -51155,10 +51858,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
51155
51858
|
this.trackStore?.markRiderFolded(fold.trackId);
|
|
51156
51859
|
}
|
|
51157
51860
|
if (source === "pipeline") this.stationaryRegistry?.noteFrame(deviceId, result.timestamp);
|
|
51158
|
-
if (this.stationaryRegistry && (result.stationaryConfirmed.length > 0 || result.stationaryWoken.length > 0)) this.stationaryRegistry.applyFrameOutcome({
|
|
51861
|
+
if (this.stationaryRegistry && (result.stationaryConfirmed.length > 0 || result.stationaryWoken.length > 0 || result.stationaryArmed.length > 0 || result.stationaryDisarmed.length > 0)) this.stationaryRegistry.applyFrameOutcome({
|
|
51159
51862
|
deviceId,
|
|
51160
51863
|
confirmed: result.stationaryConfirmed,
|
|
51161
51864
|
wokenEntryIds: result.stationaryWoken,
|
|
51865
|
+
armedEntryIds: result.stationaryArmed,
|
|
51866
|
+
disarmedEntryIds: result.stationaryDisarmed,
|
|
51162
51867
|
timestamp: result.timestamp
|
|
51163
51868
|
});
|
|
51164
51869
|
const stationaryViews = this.stationaryRegistry?.listViews(deviceId) ?? [];
|
|
@@ -53215,7 +53920,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
53215
53920
|
};
|
|
53216
53921
|
if (plausible) this.stationaryRegistry?.promote(entry);
|
|
53217
53922
|
processor.dropTrack(track.trackId);
|
|
53218
|
-
this.trackStore?.closeActive(track.trackId).catch((err) => {
|
|
53923
|
+
this.trackStore?.closeActive(track.trackId, "stationary-promotion").catch((err) => {
|
|
53219
53924
|
this.ctx.logger.warn("stationary promotion: closing the source track failed — its media, events and vector are now unowned", {
|
|
53220
53925
|
tags: { deviceId },
|
|
53221
53926
|
meta: {
|
|
@@ -53318,7 +54023,9 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
53318
54023
|
if (!s.enabled) return {
|
|
53319
54024
|
suppressedIndices: /* @__PURE__ */ new Set(),
|
|
53320
54025
|
confirmed: [],
|
|
53321
|
-
wokenEntryIds: []
|
|
54026
|
+
wokenEntryIds: [],
|
|
54027
|
+
armedEntryIds: [],
|
|
54028
|
+
disarmedEntryIds: []
|
|
53322
54029
|
};
|
|
53323
54030
|
const matchConfig = {
|
|
53324
54031
|
suppressIou: s.suppressIou,
|