@camstack/addon-post-analysis 1.2.76 → 1.2.78
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{dist-CYfG8ulU.mjs → dist-CA2KdMY9.mjs} +141 -8
- package/dist/{dist-BsBjSNIo.js → dist-CFOT1Yoz.js} +146 -7
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/pipeline-analytics/_stub.js +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-DCcRtqVf.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-DteUZxxp.mjs} +2 -2
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-B4LMjxKe.mjs +26 -0
- package/dist/pipeline-analytics/{hostInit-sdxJJeWR.mjs → hostInit-BTue_6nN.mjs} +2 -2
- package/dist/pipeline-analytics/index.js +1548 -428
- package/dist/pipeline-analytics/index.mjs +1511 -428
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DFckpC3K.mjs +0 -26
|
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
|
|
|
2
2
|
__esModule: { value: true },
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
|
-
const require_dist = require("../dist-
|
|
5
|
+
const require_dist = require("../dist-CFOT1Yoz.js");
|
|
6
6
|
let node_fs = require("node:fs");
|
|
7
7
|
node_fs = require_dist.__toESM(node_fs, 1);
|
|
8
8
|
let node_path = require("node:path");
|
|
@@ -3390,19 +3390,26 @@ function cosineSimilarity(a, b) {
|
|
|
3390
3390
|
}
|
|
3391
3391
|
/**
|
|
3392
3392
|
* Select the references a live crop is scored against for a given condition —
|
|
3393
|
-
* **exact condition match,
|
|
3394
|
-
*
|
|
3395
|
-
* There used to be
|
|
3396
|
-
* condition had none. For a named-states monitor that was a defensible
|
|
3397
|
-
* than blind". For a latched alarm it is a false positive every single
|
|
3398
|
-
* the day reference is compared against an IR frame, the cosine
|
|
3399
|
-
* scene latches `diverged`, and the operator is told the bin was
|
|
3400
|
-
* 21:40. Cross-condition cosines are not comparable, so the honest
|
|
3401
|
-
* have never seen this scene in this light" is *I don't know* —
|
|
3402
|
-
* in the direction that raises an alarm.
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
3393
|
+
* **exact condition match, and by default NO fallback.**
|
|
3394
|
+
*
|
|
3395
|
+
* There used to be an unconditional silent fallback to every reference when the
|
|
3396
|
+
* current condition had none. For a named-states monitor that was a defensible
|
|
3397
|
+
* "better than blind". For a latched alarm it is a false positive every single
|
|
3398
|
+
* night: the day reference is compared against an IR frame, the cosine
|
|
3399
|
+
* collapses, the scene latches `diverged`, and the operator is told the bin was
|
|
3400
|
+
* taken at 21:40. Cross-condition cosines are not comparable, so the honest
|
|
3401
|
+
* answer to "I have never seen this scene in this light" is *I don't know* —
|
|
3402
|
+
* never a guess in the direction that raises an alarm.
|
|
3403
|
+
*
|
|
3404
|
+
* The fallback survives only as an explicit per-scene opt-in, and even then it
|
|
3405
|
+
* is a LAST resort: when the exact condition has references of its own they
|
|
3406
|
+
* win outright, so opting in never widens a comparison the operator already
|
|
3407
|
+
* covered properly.
|
|
3408
|
+
*/
|
|
3409
|
+
function selectConditionRefs(references, currentCondition, crossConditionFallback) {
|
|
3410
|
+
const exact = references.filter((r) => r.condition === currentCondition);
|
|
3411
|
+
if (exact.length > 0 || !crossConditionFallback) return exact;
|
|
3412
|
+
return references;
|
|
3406
3413
|
}
|
|
3407
3414
|
/** A reference is comparable only when its `modelId` equals the live encoder's
|
|
3408
3415
|
* model AND its dimension equals the encoder's embedding dim (face-matcher
|
|
@@ -3418,11 +3425,12 @@ function comparable(r, encoderModelId, encoderDim) {
|
|
|
3418
3425
|
* Returns `{ bestStateId: null, bestScore: null }` when NOTHING was comparable
|
|
3419
3426
|
* — see `ScoreResult.bestScore`.
|
|
3420
3427
|
*/
|
|
3421
|
-
function scoreStates(states, liveEmbedding, currentCondition, encoderModelId, encoderDim) {
|
|
3428
|
+
function scoreStates(states, liveEmbedding, currentCondition, encoderModelId, encoderDim, options) {
|
|
3422
3429
|
let bestStateId = null;
|
|
3423
3430
|
let bestScore = null;
|
|
3431
|
+
const crossConditionFallback = options?.crossConditionFallback === true;
|
|
3424
3432
|
for (const s of states) {
|
|
3425
|
-
const refs = selectConditionRefs(s.references, currentCondition);
|
|
3433
|
+
const refs = selectConditionRefs(s.references, currentCondition, crossConditionFallback);
|
|
3426
3434
|
let stateScore = null;
|
|
3427
3435
|
for (const r of refs) {
|
|
3428
3436
|
if (!comparable(r, encoderModelId, encoderDim)) continue;
|
|
@@ -3608,7 +3616,8 @@ var SceneEngine = class {
|
|
|
3608
3616
|
lastCountedAt: 0,
|
|
3609
3617
|
lastAvailability: "ok",
|
|
3610
3618
|
lastVerdict: currentStateId === null ? "unknown" : "matched",
|
|
3611
|
-
lastUnavailable: null
|
|
3619
|
+
lastUnavailable: null,
|
|
3620
|
+
suspendedCondition: null
|
|
3612
3621
|
});
|
|
3613
3622
|
}
|
|
3614
3623
|
/** Force an out-of-cycle check for a single monitor. */
|
|
@@ -3631,7 +3640,8 @@ var SceneEngine = class {
|
|
|
3631
3640
|
lastCountedAt: 0,
|
|
3632
3641
|
lastAvailability: monitor.availability,
|
|
3633
3642
|
lastVerdict: monitor.verdict,
|
|
3634
|
-
lastUnavailable: monitor.unavailable
|
|
3643
|
+
lastUnavailable: monitor.unavailable,
|
|
3644
|
+
suspendedCondition: monitor.suspendedCondition
|
|
3635
3645
|
};
|
|
3636
3646
|
rt.lastCheckAt = now;
|
|
3637
3647
|
this.runtime.set(k, rt);
|
|
@@ -3682,6 +3692,7 @@ var SceneEngine = class {
|
|
|
3682
3692
|
availability: "unavailable",
|
|
3683
3693
|
unavailableReason: llmResult.reason,
|
|
3684
3694
|
coveredConditions: [],
|
|
3695
|
+
suspendedCondition: null,
|
|
3685
3696
|
transitioned: false,
|
|
3686
3697
|
checkedAt: now
|
|
3687
3698
|
});
|
|
@@ -3735,12 +3746,20 @@ var SceneEngine = class {
|
|
|
3735
3746
|
if (monitor.check.mode !== "similarity") return;
|
|
3736
3747
|
const { embedding } = await this.deps.encoder.encode(crop, cropWidth, cropHeight);
|
|
3737
3748
|
const info = await this.encoderInfo();
|
|
3738
|
-
const { bestStateId, bestScore } = scoreStates(monitor.states, embedding, condition, info.modelId, info.embeddingDim);
|
|
3749
|
+
const { bestStateId, bestScore } = scoreStates(monitor.states, embedding, condition, info.modelId, info.embeddingDim, { crossConditionFallback: monitor.onUncoveredCondition === "judge-anyway" });
|
|
3739
3750
|
const covered = [...coveredConditions(monitor.states, info.modelId, info.embeddingDim)];
|
|
3740
3751
|
if (bestScore === null) {
|
|
3741
3752
|
const cause = unknownCause(monitor, condition);
|
|
3742
3753
|
rt.lastAvailability = "ok";
|
|
3743
3754
|
this.runtime.set(k, rt);
|
|
3755
|
+
if (cause === "no-reference-for-condition" && covered.length > 0 && monitor.onUncoveredCondition === "skip") {
|
|
3756
|
+
await this.reportSuspended(deviceId, monitor, rt, {
|
|
3757
|
+
condition,
|
|
3758
|
+
covered,
|
|
3759
|
+
now
|
|
3760
|
+
});
|
|
3761
|
+
return;
|
|
3762
|
+
}
|
|
3744
3763
|
await this.reportUnknown(deviceId, monitor, rt, {
|
|
3745
3764
|
condition,
|
|
3746
3765
|
cause,
|
|
@@ -3808,9 +3827,11 @@ var SceneEngine = class {
|
|
|
3808
3827
|
rt.lastAvailability = "ok";
|
|
3809
3828
|
this.runtime.set(k, rt);
|
|
3810
3829
|
const verdict = projectVerdict(step.currentStateId, monitor.baselineStateId);
|
|
3811
|
-
const
|
|
3830
|
+
const resumed = rt.suspendedCondition !== null;
|
|
3831
|
+
const changed = step.transitioned || verdict !== rt.lastVerdict || rt.lastAvailability !== monitor.availability || rt.lastUnavailable !== null || resumed;
|
|
3812
3832
|
rt.lastVerdict = verdict;
|
|
3813
3833
|
rt.lastUnavailable = null;
|
|
3834
|
+
rt.suspendedCondition = null;
|
|
3814
3835
|
this.runtime.set(k, rt);
|
|
3815
3836
|
if (changed) await this.deps.applyEngineResult(deviceId, monitor.id, {
|
|
3816
3837
|
currentStateId: step.currentStateId,
|
|
@@ -3822,20 +3843,61 @@ var SceneEngine = class {
|
|
|
3822
3843
|
availability: "ok",
|
|
3823
3844
|
unavailableReason: null,
|
|
3824
3845
|
coveredConditions: covered,
|
|
3846
|
+
suspendedCondition: null,
|
|
3825
3847
|
transitioned: step.transitioned,
|
|
3826
3848
|
checkedAt: now
|
|
3827
3849
|
});
|
|
3828
3850
|
}
|
|
3829
3851
|
/**
|
|
3852
|
+
* Announce that this scene is SITTING OUT the current light — once per
|
|
3853
|
+
* transition into it, never once per tick. At a 60 s cadence a per-tick line
|
|
3854
|
+
* would be ~480 lines a night per scene, which is how a log stops being read
|
|
3855
|
+
* at all.
|
|
3856
|
+
*
|
|
3857
|
+
* `info`, not `warn`: nothing is wrong. The operator chose not to capture
|
|
3858
|
+
* this light, or has not got round to it, and either way the scene is doing
|
|
3859
|
+
* exactly what it was configured to do. A branch that drops work still has to
|
|
3860
|
+
* SAY so — silence reads as "never happened" — but saying it at warning level
|
|
3861
|
+
* would train the operator to ignore the one channel that reports real
|
|
3862
|
+
* faults.
|
|
3863
|
+
*/
|
|
3864
|
+
async reportSuspended(deviceId, monitor, rt, input) {
|
|
3865
|
+
if (rt.suspendedCondition === input.condition) return;
|
|
3866
|
+
rt.suspendedCondition = input.condition;
|
|
3867
|
+
this.deps.logger.info("scene checks paused — nothing captured in this light", {
|
|
3868
|
+
tags: { deviceId },
|
|
3869
|
+
meta: {
|
|
3870
|
+
monitorId: monitor.id,
|
|
3871
|
+
condition: input.condition,
|
|
3872
|
+
covered: input.covered.join(",")
|
|
3873
|
+
}
|
|
3874
|
+
});
|
|
3875
|
+
await this.deps.applyEngineResult(deviceId, monitor.id, {
|
|
3876
|
+
currentStateId: rt.hysteresis.currentStateId,
|
|
3877
|
+
previousStateId: rt.hysteresis.currentStateId,
|
|
3878
|
+
lastConfidence: null,
|
|
3879
|
+
currentCondition: input.condition,
|
|
3880
|
+
verdict: rt.lastVerdict,
|
|
3881
|
+
unavailable: rt.lastUnavailable,
|
|
3882
|
+
availability: "ok",
|
|
3883
|
+
unavailableReason: null,
|
|
3884
|
+
coveredConditions: input.covered,
|
|
3885
|
+
suspendedCondition: input.condition,
|
|
3886
|
+
transitioned: false,
|
|
3887
|
+
checkedAt: input.now
|
|
3888
|
+
});
|
|
3889
|
+
}
|
|
3890
|
+
/**
|
|
3830
3891
|
* Push an `unknown` verdict — once per transition, not once per poll. The
|
|
3831
3892
|
* held state is deliberately NOT destroyed: a scene that cannot judge is a
|
|
3832
3893
|
* scene that has not observed anything, and an observation it could not make
|
|
3833
3894
|
* must never spend or clear hysteresis credit in either direction.
|
|
3834
3895
|
*/
|
|
3835
3896
|
async reportUnknown(deviceId, monitor, rt, input) {
|
|
3836
|
-
if (rt.lastVerdict === "unknown" && rt.lastUnavailable === input.cause) return;
|
|
3897
|
+
if (rt.lastVerdict === "unknown" && rt.lastUnavailable === input.cause && rt.suspendedCondition === null) return;
|
|
3837
3898
|
rt.lastVerdict = "unknown";
|
|
3838
3899
|
rt.lastUnavailable = input.cause;
|
|
3900
|
+
rt.suspendedCondition = null;
|
|
3839
3901
|
this.deps.logger.warn("scene cannot judge — reporting unknown", {
|
|
3840
3902
|
tags: { deviceId },
|
|
3841
3903
|
meta: {
|
|
@@ -3855,6 +3917,7 @@ var SceneEngine = class {
|
|
|
3855
3917
|
availability: "ok",
|
|
3856
3918
|
unavailableReason: `no comparable reference for "${input.condition}"`,
|
|
3857
3919
|
coveredConditions: input.covered,
|
|
3920
|
+
suspendedCondition: null,
|
|
3858
3921
|
transitioned: false,
|
|
3859
3922
|
checkedAt: input.now
|
|
3860
3923
|
});
|
|
@@ -4577,6 +4640,8 @@ var SceneMonitorProvider = class {
|
|
|
4577
4640
|
minObservationSpacingSec: 120,
|
|
4578
4641
|
anchorThreshold: require_dist.SCENE_DEFAULT_ANCHOR_THRESHOLD,
|
|
4579
4642
|
autoRestore: false,
|
|
4643
|
+
onUncoveredCondition: require_dist.SCENE_DEFAULT_UNCOVERED_POLICY,
|
|
4644
|
+
suspendedCondition: null,
|
|
4580
4645
|
unavailable: "no-reference-for-condition",
|
|
4581
4646
|
coveredConditions: []
|
|
4582
4647
|
};
|
|
@@ -4606,7 +4671,9 @@ var SceneMonitorProvider = class {
|
|
|
4606
4671
|
this.refresh(input.deviceId);
|
|
4607
4672
|
}
|
|
4608
4673
|
async deleteScene(input) {
|
|
4674
|
+
const doomed = this.deps.store.getScene(input.deviceId, input.monitorId);
|
|
4609
4675
|
await this.deps.store.deleteScene(input.deviceId, input.monitorId);
|
|
4676
|
+
await this.dropOrphanedMedia(doomed?.monitor.states.flatMap((s) => s.references) ?? []);
|
|
4610
4677
|
this.deps.logger.info("scene deleted", {
|
|
4611
4678
|
tags: { deviceId: input.deviceId },
|
|
4612
4679
|
meta: { monitorId: input.monitorId }
|
|
@@ -4629,7 +4696,9 @@ var SceneMonitorProvider = class {
|
|
|
4629
4696
|
const stateId = input.stateId ?? monitor.states[0]?.id ?? (0, node_crypto.randomUUID)();
|
|
4630
4697
|
const existing = monitor.states.find((s) => s.id === stateId);
|
|
4631
4698
|
const label = input.label ?? existing?.label ?? monitor.label;
|
|
4632
|
-
const
|
|
4699
|
+
const kept = [...existing?.references ?? [], reference];
|
|
4700
|
+
const references = boundReferences(kept, condition);
|
|
4701
|
+
await this.dropOrphanedMedia(kept.filter((r) => !references.includes(r)));
|
|
4633
4702
|
const state = {
|
|
4634
4703
|
id: stateId,
|
|
4635
4704
|
label,
|
|
@@ -4663,6 +4732,7 @@ var SceneMonitorProvider = class {
|
|
|
4663
4732
|
}
|
|
4664
4733
|
async deleteReference(input) {
|
|
4665
4734
|
const monitor = this.requireScene(input.deviceId, input.monitorId);
|
|
4735
|
+
const removed = monitor.states.find((s) => s.id === input.stateId)?.references[input.index];
|
|
4666
4736
|
const states = monitor.states.map((s) => {
|
|
4667
4737
|
if (s.id !== input.stateId) return s;
|
|
4668
4738
|
const references = s.references.filter((_, i) => i !== input.index);
|
|
@@ -4676,6 +4746,17 @@ var SceneMonitorProvider = class {
|
|
|
4676
4746
|
...monitor,
|
|
4677
4747
|
states
|
|
4678
4748
|
}, this.now());
|
|
4749
|
+
await this.dropOrphanedMedia(removed === void 0 ? [] : [removed]);
|
|
4750
|
+
this.deps.logger.info("scene reference deleted", {
|
|
4751
|
+
tags: { deviceId: input.deviceId },
|
|
4752
|
+
meta: {
|
|
4753
|
+
monitorId: input.monitorId,
|
|
4754
|
+
stateId: input.stateId,
|
|
4755
|
+
index: input.index,
|
|
4756
|
+
condition: removed?.condition ?? "(none)",
|
|
4757
|
+
remaining: states.reduce((n, s) => n + s.references.length, 0)
|
|
4758
|
+
}
|
|
4759
|
+
});
|
|
4679
4760
|
this.refresh(input.deviceId);
|
|
4680
4761
|
}
|
|
4681
4762
|
async recheckNow(input) {
|
|
@@ -4760,6 +4841,15 @@ var SceneMonitorProvider = class {
|
|
|
4760
4841
|
const row = this.deps.store.getScene(deviceId, monitorId);
|
|
4761
4842
|
if (row === void 0) return;
|
|
4762
4843
|
const monitor = row.monitor;
|
|
4844
|
+
if (result.suspendedCondition !== null) {
|
|
4845
|
+
await this.deps.store.putScene(deviceId, {
|
|
4846
|
+
...monitor,
|
|
4847
|
+
suspendedCondition: result.suspendedCondition,
|
|
4848
|
+
coveredConditions: [...result.coveredConditions]
|
|
4849
|
+
}, result.checkedAt);
|
|
4850
|
+
this.refresh(deviceId);
|
|
4851
|
+
return;
|
|
4852
|
+
}
|
|
4763
4853
|
const previous = this.deps.store.getLatch(deviceId, monitorId);
|
|
4764
4854
|
const wasLatched = previous?.latched ?? monitor.latched;
|
|
4765
4855
|
const wasVerdict = previous?.verdict ?? monitor.verdict;
|
|
@@ -4786,6 +4876,7 @@ var SceneMonitorProvider = class {
|
|
|
4786
4876
|
const merged = {
|
|
4787
4877
|
...monitor,
|
|
4788
4878
|
coveredConditions: [...result.coveredConditions],
|
|
4879
|
+
suspendedCondition: null,
|
|
4789
4880
|
availability: result.availability,
|
|
4790
4881
|
unavailableReason: result.unavailableReason
|
|
4791
4882
|
};
|
|
@@ -4821,6 +4912,7 @@ var SceneMonitorProvider = class {
|
|
|
4821
4912
|
direction,
|
|
4822
4913
|
verdict: result.verdict,
|
|
4823
4914
|
latched,
|
|
4915
|
+
emit: monitor.emit,
|
|
4824
4916
|
condition: result.currentCondition,
|
|
4825
4917
|
confidence: result.lastConfidence,
|
|
4826
4918
|
timestamp: result.checkedAt
|
|
@@ -4888,8 +4980,29 @@ var SceneMonitorProvider = class {
|
|
|
4888
4980
|
updatedAt: now
|
|
4889
4981
|
};
|
|
4890
4982
|
}
|
|
4983
|
+
/**
|
|
4984
|
+
* Drop the pictures of references that are no longer stored anywhere.
|
|
4985
|
+
*
|
|
4986
|
+
* Best-effort per blob and never throwing: the reference row is already gone,
|
|
4987
|
+
* and refusing the operator's delete because a JPEG would not unlink would be
|
|
4988
|
+
* failing the request over the follower rather than the fact.
|
|
4989
|
+
*/
|
|
4990
|
+
async dropOrphanedMedia(references) {
|
|
4991
|
+
const drop = this.deps.dropMedia;
|
|
4992
|
+
if (drop === void 0) return;
|
|
4993
|
+
for (const r of references) {
|
|
4994
|
+
if (r.thumbnailMediaId === void 0) continue;
|
|
4995
|
+
await drop(r.thumbnailMediaId).catch((err) => {
|
|
4996
|
+
this.deps.logger.debug("scene reference thumbnail delete failed", { meta: {
|
|
4997
|
+
mediaId: r.thumbnailMediaId,
|
|
4998
|
+
error: String(err)
|
|
4999
|
+
} });
|
|
5000
|
+
});
|
|
5001
|
+
}
|
|
5002
|
+
}
|
|
4891
5003
|
/** Snapshot → ROI crop + whole-frame anchor → two embeddings + a thumbnail. */
|
|
4892
5004
|
async encodeReference(deviceId, monitor, condition) {
|
|
5005
|
+
const capturedAt = this.now();
|
|
4893
5006
|
const snap = await this.deps.getSnapshot(deviceId, true);
|
|
4894
5007
|
if (snap === null) throw new Error("no snapshot available for this camera");
|
|
4895
5008
|
const encoded = Buffer.from(snap.base64, "base64");
|
|
@@ -4907,15 +5020,15 @@ var SceneMonitorProvider = class {
|
|
|
4907
5020
|
});
|
|
4908
5021
|
const thumbnailMediaId = await this.deps.putMedia?.({
|
|
4909
5022
|
deviceId,
|
|
4910
|
-
ownerId: `scene-${monitor.id}-${condition}`,
|
|
5023
|
+
ownerId: `scene-${monitor.id}-${condition}-${capturedAt}`,
|
|
4911
5024
|
data: roi.crop,
|
|
4912
|
-
timestamp:
|
|
5025
|
+
timestamp: capturedAt
|
|
4913
5026
|
}).catch(() => void 0);
|
|
4914
5027
|
return {
|
|
4915
5028
|
embedding,
|
|
4916
5029
|
modelId: info.modelId,
|
|
4917
5030
|
condition,
|
|
4918
|
-
capturedAt
|
|
5031
|
+
capturedAt,
|
|
4919
5032
|
...thumbnailMediaId !== void 0 ? { thumbnailMediaId } : {},
|
|
4920
5033
|
...anchor !== null ? { anchorEmbedding: anchor } : {}
|
|
4921
5034
|
};
|
|
@@ -5411,6 +5524,256 @@ function observeLabel(deviceId, spec, sample, now) {
|
|
|
5411
5524
|
spec
|
|
5412
5525
|
};
|
|
5413
5526
|
}
|
|
5527
|
+
/** JPEG quality for the downscaled full frame — matches the crop path. */
|
|
5528
|
+
var FULL_FRAME_QUALITY = 80;
|
|
5529
|
+
/**
|
|
5530
|
+
* Downscale an already-encoded JPEG full frame to FIT WITHIN
|
|
5531
|
+
* {@link FULL_FRAME_MAX_WIDTH}×{@link FULL_FRAME_MAX_HEIGHT}, preserving aspect
|
|
5532
|
+
* ratio (`fit: 'inside'`) and never enlarging a source already smaller than the
|
|
5533
|
+
* box. Re-encodes as JPEG. Used before persisting a synthetic sensor/control
|
|
5534
|
+
* track's whole-scene snapshot so a raw native-resolution frame (a 4K bedroom
|
|
5535
|
+
* at night) is never stored or served — the privacy fix moved to CAPTURE time.
|
|
5536
|
+
*/
|
|
5537
|
+
async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
|
|
5538
|
+
return (0, sharp.default)(Buffer.from(jpeg)).resize(maxWidth, maxHeight, {
|
|
5539
|
+
fit: "inside",
|
|
5540
|
+
withoutEnlargement: true
|
|
5541
|
+
}).jpeg({ quality: FULL_FRAME_QUALITY }).toBuffer();
|
|
5542
|
+
}
|
|
5543
|
+
//#endregion
|
|
5544
|
+
//#region src/notification-center/audio-still.ts
|
|
5545
|
+
/**
|
|
5546
|
+
* The audio still shelf — the PHOTOGRAPH a sound rule's notification carries.
|
|
5547
|
+
*
|
|
5548
|
+
* ## Why a sound needs one at all
|
|
5549
|
+
*
|
|
5550
|
+
* The attachment ladder resolves media by OWNER, and every other trigger has
|
|
5551
|
+
* one: an object or package event owns its crops, a closed track owns its best
|
|
5552
|
+
* shot, a doorbell press owns the marker track the same press projected
|
|
5553
|
+
* (`sensor-marker-projector.ts`), an occupancy edge names one of the objects it
|
|
5554
|
+
* counted (`chooseOccupancyMediaOwner`). An audio match owns nothing. Nothing
|
|
5555
|
+
* was boxed, nothing was tracked, and — deliberately — nothing is persisted at
|
|
5556
|
+
* all: a confirmed window is a claim about sound that has already stopped, and
|
|
5557
|
+
* `event-intake.ts` states why replaying it later would be wrong.
|
|
5558
|
+
*
|
|
5559
|
+
* So the only honest picture is a PHOTOGRAPH of the camera taken at the moment
|
|
5560
|
+
* of the match. Not of the sound — of what the camera could see while it was
|
|
5561
|
+
* heard.
|
|
5562
|
+
*
|
|
5563
|
+
* ## Three properties, and each one is a decision
|
|
5564
|
+
*
|
|
5565
|
+
* **It is not a record.** The bytes live here, in RAM, under an owner id and a
|
|
5566
|
+
* TTL that covers the outbox's whole retry horizon — and nowhere else. The
|
|
5567
|
+
* alternative was the doorbell's: materialise a synthetic marker track through
|
|
5568
|
+
* `SyntheticTrackMaterializer` and let the notification name it. That would put
|
|
5569
|
+
* a durable Track on the camera's timeline for every confirmed window, feeding
|
|
5570
|
+
* the digest's `listTracks`, retention, and the audio-marker feature's own
|
|
5571
|
+
* operator ceilings (`audio-marker-projector.ts` exists precisely to bound how
|
|
5572
|
+
* many audio markers a camera may emit). A notification must not manufacture
|
|
5573
|
+
* timeline history as a side effect of wanting a picture.
|
|
5574
|
+
*
|
|
5575
|
+
* **The capture STARTS immediately and is never awaited.** A sound is transient
|
|
5576
|
+
* — a scream is over before a snapshot round-trip completes — so the fetch is
|
|
5577
|
+
* kicked off at the confirmation, before the rule evaluation runs, and the
|
|
5578
|
+
* owner id is minted synchronously so the outbox row can name it. The bytes
|
|
5579
|
+
* land while the row waits in the queue, and the dispatcher's existing bounded
|
|
5580
|
+
* still-wait (or the pause its own footage render already costs) picks them up.
|
|
5581
|
+
* A camera that never answers costs the picture and never the notification.
|
|
5582
|
+
*
|
|
5583
|
+
* **Two confirmations seconds apart share ONE capture.** A barking dog confirms
|
|
5584
|
+
* repeatedly and a label-mode rule has no re-arm timer at all (D157) — the
|
|
5585
|
+
* rule's cooldown is its only brake, and the cooldown is applied AFTER this.
|
|
5586
|
+
* Without a reuse window this would photograph a camera at whatever rate the
|
|
5587
|
+
* sound happens to occur. Each confirmation still gets its OWN owner id, so two
|
|
5588
|
+
* outbox rows are never mistaken for one subject; they merely point at the same
|
|
5589
|
+
* frame, which is the truth — the scene did not change in ten seconds.
|
|
5590
|
+
*
|
|
5591
|
+
* Nothing here is silent: a capture that lands and a camera that refuses each
|
|
5592
|
+
* emit one line carrying `tags: { deviceId }`, because "why did 617 get a photo
|
|
5593
|
+
* and 615 not" is the only form that question is ever asked in.
|
|
5594
|
+
*/
|
|
5595
|
+
/**
|
|
5596
|
+
* The owner-id namespace. It is what routes a lookup here instead of to the
|
|
5597
|
+
* media store, and it is the reason the dispatcher needs no audio branch —
|
|
5598
|
+
* `getMediaForOwner` answers for both under one signature.
|
|
5599
|
+
*/
|
|
5600
|
+
var NC_AUDIO_STILL_PREFIX = "nc-audio-still:";
|
|
5601
|
+
/** True for an owner id this shelf minted. */
|
|
5602
|
+
function isAudioStillId(id) {
|
|
5603
|
+
return id.startsWith(NC_AUDIO_STILL_PREFIX);
|
|
5604
|
+
}
|
|
5605
|
+
/**
|
|
5606
|
+
* How long the bytes are held.
|
|
5607
|
+
*
|
|
5608
|
+
* The outbox retries 8 times with a 5 s → 300 s backoff, which tops out around
|
|
5609
|
+
* ten minutes; fifteen covers that with room for a slow drain. Past it the row
|
|
5610
|
+
* ships text-only, which is the correct degradation for a photograph of a scene
|
|
5611
|
+
* that is a quarter of an hour stale anyway.
|
|
5612
|
+
*/
|
|
5613
|
+
var NC_AUDIO_STILL_TTL_MS = 15 * 6e4;
|
|
5614
|
+
/** A hanging snapshot cap must not pin a capture slot forever. */
|
|
5615
|
+
var SNAPSHOT_TIMEOUT_MS = 8e3;
|
|
5616
|
+
/**
|
|
5617
|
+
* Hard bound on held captures. Reached only if every camera on the hub confirms
|
|
5618
|
+
* an audio rule inside one TTL; the oldest is dropped first, which costs a
|
|
5619
|
+
* fifteen-minute-old picture nobody is waiting for.
|
|
5620
|
+
*/
|
|
5621
|
+
var MAX_CAPTURES = 64;
|
|
5622
|
+
var NcAudioStillShelf = class {
|
|
5623
|
+
deps;
|
|
5624
|
+
/** captureId → the photograph. */
|
|
5625
|
+
captures = /* @__PURE__ */ new Map();
|
|
5626
|
+
/** ownerId → captureId. Several owners may name one capture (the reuse window). */
|
|
5627
|
+
owners = /* @__PURE__ */ new Map();
|
|
5628
|
+
/** deviceId → its newest capture, for the reuse window. */
|
|
5629
|
+
newest = /* @__PURE__ */ new Map();
|
|
5630
|
+
constructor(deps) {
|
|
5631
|
+
this.deps = deps;
|
|
5632
|
+
}
|
|
5633
|
+
/**
|
|
5634
|
+
* Photograph `deviceId` for a match at `atMs`, and return the owner id the
|
|
5635
|
+
* subject should name. SYNCHRONOUS by contract: the caller is on the audio
|
|
5636
|
+
* confirmation path and the outbox row is built from what this returns.
|
|
5637
|
+
*/
|
|
5638
|
+
capture(deviceId, atMs) {
|
|
5639
|
+
const now = this.deps.now();
|
|
5640
|
+
this.prune(now);
|
|
5641
|
+
const ownerId = `${NC_AUDIO_STILL_PREFIX}${(0, node_crypto.randomUUID)()}`;
|
|
5642
|
+
const recent = this.newest.get(deviceId);
|
|
5643
|
+
if (recent !== void 0 && now - recent.at < 1e4 && this.captures.has(recent.captureId)) {
|
|
5644
|
+
this.owners.set(ownerId, recent.captureId);
|
|
5645
|
+
return ownerId;
|
|
5646
|
+
}
|
|
5647
|
+
const captureId = (0, node_crypto.randomUUID)();
|
|
5648
|
+
this.captures.set(captureId, {
|
|
5649
|
+
deviceId,
|
|
5650
|
+
startedAt: now,
|
|
5651
|
+
expiresAt: now + NC_AUDIO_STILL_TTL_MS,
|
|
5652
|
+
files: []
|
|
5653
|
+
});
|
|
5654
|
+
this.newest.set(deviceId, {
|
|
5655
|
+
captureId,
|
|
5656
|
+
at: now
|
|
5657
|
+
});
|
|
5658
|
+
this.owners.set(ownerId, captureId);
|
|
5659
|
+
this.fetch(captureId, deviceId, atMs);
|
|
5660
|
+
return ownerId;
|
|
5661
|
+
}
|
|
5662
|
+
/**
|
|
5663
|
+
* The media an owner id holds, or `undefined` when this shelf never minted
|
|
5664
|
+
* it. An EMPTY array is a different answer: the capture exists and has not
|
|
5665
|
+
* landed (or never will), which is exactly the case the dispatcher's bounded
|
|
5666
|
+
* wait and its `no still could be resolved` line are for.
|
|
5667
|
+
*/
|
|
5668
|
+
get(ownerId) {
|
|
5669
|
+
const captureId = this.owners.get(ownerId);
|
|
5670
|
+
if (captureId === void 0) return void 0;
|
|
5671
|
+
return this.captures.get(captureId)?.files ?? [];
|
|
5672
|
+
}
|
|
5673
|
+
/** Drop expired captures and the owners that named them. */
|
|
5674
|
+
prune(now) {
|
|
5675
|
+
for (const [captureId, held] of this.captures) {
|
|
5676
|
+
if (held.expiresAt > now) continue;
|
|
5677
|
+
this.captures.delete(captureId);
|
|
5678
|
+
if (this.newest.get(held.deviceId)?.captureId === captureId) this.newest.delete(held.deviceId);
|
|
5679
|
+
}
|
|
5680
|
+
while (this.captures.size > MAX_CAPTURES) {
|
|
5681
|
+
const oldest = this.captures.keys().next();
|
|
5682
|
+
if (oldest.done === true) break;
|
|
5683
|
+
const held = this.captures.get(oldest.value);
|
|
5684
|
+
this.captures.delete(oldest.value);
|
|
5685
|
+
if (held !== void 0 && this.newest.get(held.deviceId)?.captureId === oldest.value) this.newest.delete(held.deviceId);
|
|
5686
|
+
}
|
|
5687
|
+
for (const [ownerId, captureId] of this.owners) if (!this.captures.has(captureId)) this.owners.delete(ownerId);
|
|
5688
|
+
}
|
|
5689
|
+
/** Drop everything (shutdown). */
|
|
5690
|
+
clear() {
|
|
5691
|
+
this.captures.clear();
|
|
5692
|
+
this.owners.clear();
|
|
5693
|
+
this.newest.clear();
|
|
5694
|
+
}
|
|
5695
|
+
async fetch(captureId, deviceId, atMs) {
|
|
5696
|
+
try {
|
|
5697
|
+
const shot = await withTimeout$2(this.deps.getSnapshot(deviceId), SNAPSHOT_TIMEOUT_MS);
|
|
5698
|
+
if (shot === null || shot.base64.length === 0) {
|
|
5699
|
+
this.reportMiss(deviceId, "the camera returned no snapshot");
|
|
5700
|
+
return;
|
|
5701
|
+
}
|
|
5702
|
+
const raw = Buffer.from(shot.base64, "base64");
|
|
5703
|
+
let data = raw;
|
|
5704
|
+
try {
|
|
5705
|
+
data = await downscaleFullFrameJpeg(raw, 960, 540);
|
|
5706
|
+
} catch {}
|
|
5707
|
+
const held = this.captures.get(captureId);
|
|
5708
|
+
if (held === void 0) return;
|
|
5709
|
+
this.captures.set(captureId, {
|
|
5710
|
+
...held,
|
|
5711
|
+
files: audioStillMedia(data, atMs)
|
|
5712
|
+
});
|
|
5713
|
+
this.deps.logger.info("audio still captured", {
|
|
5714
|
+
tags: { deviceId },
|
|
5715
|
+
meta: {
|
|
5716
|
+
bytes: data.byteLength,
|
|
5717
|
+
tookMs: this.deps.now() - held.startedAt
|
|
5718
|
+
}
|
|
5719
|
+
});
|
|
5720
|
+
} catch (err) {
|
|
5721
|
+
this.reportMiss(deviceId, err instanceof Error ? err.message : String(err));
|
|
5722
|
+
}
|
|
5723
|
+
}
|
|
5724
|
+
/**
|
|
5725
|
+
* A branch that drops work says so. This one costs the operator the picture
|
|
5726
|
+
* on a notification they DID receive, so it is a warn and it carries the
|
|
5727
|
+
* camera — the only key the question is ever asked with.
|
|
5728
|
+
*/
|
|
5729
|
+
reportMiss(deviceId, reason) {
|
|
5730
|
+
this.deps.logger.warn("the camera did not answer the audio still — this notification ships text-only", {
|
|
5731
|
+
tags: { deviceId },
|
|
5732
|
+
meta: { reason }
|
|
5733
|
+
});
|
|
5734
|
+
}
|
|
5735
|
+
};
|
|
5736
|
+
/**
|
|
5737
|
+
* The photograph, in the three kinds the CLEAN-SCENE ladders ask for.
|
|
5738
|
+
*
|
|
5739
|
+
* `keyFrameSmall` leads because it is the first rung of both `attach: 'best'`
|
|
5740
|
+
* and `attach: 'keyFrame'` on a track owner; `keyFrame` and `fullFrame` answer
|
|
5741
|
+
* `frame: 'full'` and `frame: 'boxed'`'s honest degrade. There is deliberately
|
|
5742
|
+
* no `crop` / `thumbnail`: nothing was boxed, so `frame: 'cropped'` resolves
|
|
5743
|
+
* NOTHING and the notification ships text-only with the ordinary line saying
|
|
5744
|
+
* so. Inventing a centre crop would answer a different question from the one
|
|
5745
|
+
* the operator asked.
|
|
5746
|
+
*/
|
|
5747
|
+
function audioStillMedia(data, timestamp) {
|
|
5748
|
+
const base64 = data.toString("base64");
|
|
5749
|
+
const file = (kind) => ({
|
|
5750
|
+
key: `${NC_AUDIO_STILL_PREFIX}${kind}`,
|
|
5751
|
+
kind,
|
|
5752
|
+
base64,
|
|
5753
|
+
sizeBytes: data.byteLength,
|
|
5754
|
+
timestamp
|
|
5755
|
+
});
|
|
5756
|
+
return [
|
|
5757
|
+
file("keyFrameSmall"),
|
|
5758
|
+
file("keyFrame"),
|
|
5759
|
+
file("fullFrame")
|
|
5760
|
+
];
|
|
5761
|
+
}
|
|
5762
|
+
/** Reject if `promise` does not settle within `ms`. */
|
|
5763
|
+
function withTimeout$2(promise, ms) {
|
|
5764
|
+
return new Promise((resolve, reject) => {
|
|
5765
|
+
const timer = setTimeout(() => {
|
|
5766
|
+
reject(/* @__PURE__ */ new Error(`snapshot cap timed out after ${String(ms)}ms`));
|
|
5767
|
+
}, ms);
|
|
5768
|
+
promise.then((value) => {
|
|
5769
|
+
clearTimeout(timer);
|
|
5770
|
+
resolve(value);
|
|
5771
|
+
}, (err) => {
|
|
5772
|
+
clearTimeout(timer);
|
|
5773
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
5774
|
+
});
|
|
5775
|
+
});
|
|
5776
|
+
}
|
|
5414
5777
|
//#endregion
|
|
5415
5778
|
//#region src/shared/llm-vision/prompt-hygiene.ts
|
|
5416
5779
|
/**
|
|
@@ -6021,6 +6384,298 @@ var NcDeviceStateCache = class {
|
|
|
6021
6384
|
}
|
|
6022
6385
|
};
|
|
6023
6386
|
//#endregion
|
|
6387
|
+
//#region src/shared/frame/box-drawer.ts
|
|
6388
|
+
var DEFAULT_COLOR = require_dist.DEFAULT_EVENT_COLOR;
|
|
6389
|
+
var DEFAULT_QUALITY = 80;
|
|
6390
|
+
var STROKE_WIDTH = 3;
|
|
6391
|
+
function escapeXml$2(s) {
|
|
6392
|
+
return s.replace(/[<>&'"]/g, (ch) => {
|
|
6393
|
+
switch (ch) {
|
|
6394
|
+
case "<": return "<";
|
|
6395
|
+
case ">": return ">";
|
|
6396
|
+
case "&": return "&";
|
|
6397
|
+
case "'": return "'";
|
|
6398
|
+
default: return """;
|
|
6399
|
+
}
|
|
6400
|
+
});
|
|
6401
|
+
}
|
|
6402
|
+
/** Clamp a pixel box to the frame so the rect always stays inside [0,W]×[0,H]. */
|
|
6403
|
+
function clampBox(b, frameWidth, frameHeight) {
|
|
6404
|
+
const x = Math.max(0, Math.min(Math.round(b.x), frameWidth - 1));
|
|
6405
|
+
const y = Math.max(0, Math.min(Math.round(b.y), frameHeight - 1));
|
|
6406
|
+
return {
|
|
6407
|
+
x,
|
|
6408
|
+
y,
|
|
6409
|
+
w: Math.max(1, Math.min(Math.round(b.w), frameWidth - x)),
|
|
6410
|
+
h: Math.max(1, Math.min(Math.round(b.h), frameHeight - y))
|
|
6411
|
+
};
|
|
6412
|
+
}
|
|
6413
|
+
/**
|
|
6414
|
+
* Draw bounding boxes over a raw RGB frame and JPEG-encode it.
|
|
6415
|
+
*
|
|
6416
|
+
* The frame is composited with an SVG overlay (one `<rect>` per box, plus an
|
|
6417
|
+
* optional caption) at full resolution, then optionally downscaled — so the
|
|
6418
|
+
* box stays crisp relative to the scene. Boxes are clamped to frame bounds to
|
|
6419
|
+
* avoid sharp `extract`/region errors on slightly-out-of-range detections.
|
|
6420
|
+
*/
|
|
6421
|
+
async function drawBoxedFrame(frameData, frameWidth, frameHeight, boxes, opts = {}) {
|
|
6422
|
+
const quality = opts.quality ?? DEFAULT_QUALITY;
|
|
6423
|
+
let base = frameData;
|
|
6424
|
+
let baseIsRaw = true;
|
|
6425
|
+
if (boxes.length > 0) {
|
|
6426
|
+
const fontSize = Math.max(12, Math.round(frameHeight / 30));
|
|
6427
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}">${boxes.map((b) => {
|
|
6428
|
+
const c = clampBox(b, frameWidth, frameHeight);
|
|
6429
|
+
const color = b.color ?? DEFAULT_COLOR;
|
|
6430
|
+
const rect = `<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${c.h}" fill="none" stroke="${color}" stroke-width="${STROKE_WIDTH}"/>`;
|
|
6431
|
+
if (!b.label) return rect;
|
|
6432
|
+
const ty = Math.max(fontSize, c.y - 4);
|
|
6433
|
+
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>`;
|
|
6434
|
+
}).join("")}</svg>`;
|
|
6435
|
+
base = await (0, sharp.default)(frameData, { raw: {
|
|
6436
|
+
width: frameWidth,
|
|
6437
|
+
height: frameHeight,
|
|
6438
|
+
channels: 3
|
|
6439
|
+
} }).composite([{
|
|
6440
|
+
input: Buffer.from(svg),
|
|
6441
|
+
top: 0,
|
|
6442
|
+
left: 0
|
|
6443
|
+
}]).png().toBuffer();
|
|
6444
|
+
baseIsRaw = false;
|
|
6445
|
+
}
|
|
6446
|
+
let pipeline = baseIsRaw ? (0, sharp.default)(base, { raw: {
|
|
6447
|
+
width: frameWidth,
|
|
6448
|
+
height: frameHeight,
|
|
6449
|
+
channels: 3
|
|
6450
|
+
} }) : (0, sharp.default)(base);
|
|
6451
|
+
if (opts.maxWidth !== void 0) pipeline = pipeline.resize({
|
|
6452
|
+
width: opts.maxWidth,
|
|
6453
|
+
withoutEnlargement: true
|
|
6454
|
+
});
|
|
6455
|
+
return pipeline.jpeg({ quality }).toBuffer();
|
|
6456
|
+
}
|
|
6457
|
+
/** JPEG quality of the composed artefact — the mosaic's number, same reasons. */
|
|
6458
|
+
var GROUP_FRAME_QUALITY = 82;
|
|
6459
|
+
/**
|
|
6460
|
+
* Draw the members onto the frame. Returns `null` when there is nothing to
|
|
6461
|
+
* draw, or when anything at all goes wrong — the caller then ships the
|
|
6462
|
+
* unannotated frame unchanged.
|
|
6463
|
+
*/
|
|
6464
|
+
async function composeGroupFrame(input) {
|
|
6465
|
+
const freshnessMs = input.freshnessMs ?? 3e3;
|
|
6466
|
+
const fresh = input.boxes.filter((b) => Math.abs(input.at - b.observedAt) <= freshnessMs);
|
|
6467
|
+
const withheld = input.boxes.length - fresh.length;
|
|
6468
|
+
if (fresh.length < 2) {
|
|
6469
|
+
if (withheld > 0) input.logger.info("group frame not drawn — too few members with fresh geometry", {
|
|
6470
|
+
tags: { deviceId: input.deviceId },
|
|
6471
|
+
meta: {
|
|
6472
|
+
groupKey: input.groupKey,
|
|
6473
|
+
members: input.boxes.length,
|
|
6474
|
+
fresh: fresh.length,
|
|
6475
|
+
withheld
|
|
6476
|
+
}
|
|
6477
|
+
});
|
|
6478
|
+
return null;
|
|
6479
|
+
}
|
|
6480
|
+
try {
|
|
6481
|
+
const decoded = await (0, sharp.default)(input.jpeg).raw().toBuffer({ resolveWithObject: true });
|
|
6482
|
+
const { width, height, channels } = decoded.info;
|
|
6483
|
+
if (!(width > 0 && height > 0) || channels !== 3) {
|
|
6484
|
+
input.logger.warn("group frame not drawn — unexpected raster shape", {
|
|
6485
|
+
tags: { deviceId: input.deviceId },
|
|
6486
|
+
meta: {
|
|
6487
|
+
groupKey: input.groupKey,
|
|
6488
|
+
width,
|
|
6489
|
+
height,
|
|
6490
|
+
channels
|
|
6491
|
+
}
|
|
6492
|
+
});
|
|
6493
|
+
return null;
|
|
6494
|
+
}
|
|
6495
|
+
const boxes = fresh.map((b) => ({
|
|
6496
|
+
x: b.bbox.x * width,
|
|
6497
|
+
y: b.bbox.y * height,
|
|
6498
|
+
w: b.bbox.w * width,
|
|
6499
|
+
h: b.bbox.h * height,
|
|
6500
|
+
...b.label !== void 0 && b.label.length > 0 ? { label: b.label } : {}
|
|
6501
|
+
}));
|
|
6502
|
+
const bytes = await drawBoxedFrame(decoded.data, width, height, boxes, { quality: GROUP_FRAME_QUALITY });
|
|
6503
|
+
input.logger.debug("group frame composed", {
|
|
6504
|
+
tags: { deviceId: input.deviceId },
|
|
6505
|
+
meta: {
|
|
6506
|
+
groupKey: input.groupKey,
|
|
6507
|
+
drawn: boxes.length,
|
|
6508
|
+
withheld,
|
|
6509
|
+
width,
|
|
6510
|
+
height
|
|
6511
|
+
}
|
|
6512
|
+
});
|
|
6513
|
+
return {
|
|
6514
|
+
bytes,
|
|
6515
|
+
drawn: boxes.length,
|
|
6516
|
+
withheld
|
|
6517
|
+
};
|
|
6518
|
+
} catch (err) {
|
|
6519
|
+
input.logger.warn("group frame compose failed — shipping the unannotated frame", {
|
|
6520
|
+
tags: { deviceId: input.deviceId },
|
|
6521
|
+
meta: {
|
|
6522
|
+
groupKey: input.groupKey,
|
|
6523
|
+
error: err instanceof Error ? err.message : String(err)
|
|
6524
|
+
}
|
|
6525
|
+
});
|
|
6526
|
+
return null;
|
|
6527
|
+
}
|
|
6528
|
+
}
|
|
6529
|
+
//#endregion
|
|
6530
|
+
//#region src/notification-center/text-compose.ts
|
|
6531
|
+
/** One normalisation, used on both sides of every class lookup — a pipeline
|
|
6532
|
+
* that emitted `Person` must find the key written `class.person`. */
|
|
6533
|
+
function normaliseClass(className) {
|
|
6534
|
+
return className.trim().toLowerCase();
|
|
6535
|
+
}
|
|
6536
|
+
/**
|
|
6537
|
+
* The noun for a class, in the count's plural category.
|
|
6538
|
+
*
|
|
6539
|
+
* A class no locale names falls through to `class.other`, which prints the RAW
|
|
6540
|
+
* name: a custom model's class must appear in the sentence rather than vanish
|
|
6541
|
+
* out of it, and a missing translation is a smaller defect than a missing
|
|
6542
|
+
* detection.
|
|
6543
|
+
*/
|
|
6544
|
+
function ncClassNoun(texts, className, count) {
|
|
6545
|
+
const key = `class.${normaliseClass(className)}`;
|
|
6546
|
+
if (texts.has(key)) return texts.text({
|
|
6547
|
+
key,
|
|
6548
|
+
count,
|
|
6549
|
+
vars: { name: className }
|
|
6550
|
+
});
|
|
6551
|
+
return texts.text({
|
|
6552
|
+
key: "class.other",
|
|
6553
|
+
count,
|
|
6554
|
+
vars: { name: className }
|
|
6555
|
+
});
|
|
6556
|
+
}
|
|
6557
|
+
/** `person (John)` / `persona (John)` — the thing the notification is about. */
|
|
6558
|
+
function ncSubjectText(texts, input) {
|
|
6559
|
+
const classNoun = ncClassNoun(texts, input.className, 1);
|
|
6560
|
+
if (input.label === void 0) return texts.text({
|
|
6561
|
+
key: "detection.subject",
|
|
6562
|
+
vars: { classNoun }
|
|
6563
|
+
});
|
|
6564
|
+
return texts.text({
|
|
6565
|
+
key: subjectLabelKey(input.labelKind),
|
|
6566
|
+
vars: {
|
|
6567
|
+
classNoun,
|
|
6568
|
+
label: input.label
|
|
6569
|
+
}
|
|
6570
|
+
});
|
|
6571
|
+
}
|
|
6572
|
+
/**
|
|
6573
|
+
* Which labelled-subject key a recognition gets. A kind the catalog does not
|
|
6574
|
+
* name falls back to the generic key rather than printing nothing — the same
|
|
6575
|
+
* direction `ncClassNoun` degrades in, and for the same reason.
|
|
6576
|
+
*/
|
|
6577
|
+
function subjectLabelKey(kind) {
|
|
6578
|
+
if (kind === "identity") return "detection.subject.identity";
|
|
6579
|
+
if (kind === "plate") return "detection.subject.plate";
|
|
6580
|
+
return "detection.subject.labelled";
|
|
6581
|
+
}
|
|
6582
|
+
/**
|
|
6583
|
+
* ` in gate, drive` — INCLUDING its leading space, and empty when there are no
|
|
6584
|
+
* zones.
|
|
6585
|
+
*
|
|
6586
|
+
* The space belongs to the fragment rather than to the body key because the
|
|
6587
|
+
* alternative is a body that ends in a trailing space on every zone-less
|
|
6588
|
+
* notification. A translator moving the clause moves the space with it.
|
|
6589
|
+
*/
|
|
6590
|
+
function ncInZonesText(texts, zoneLabels) {
|
|
6591
|
+
if (zoneLabels.length === 0) return "";
|
|
6592
|
+
return texts.text({
|
|
6593
|
+
key: "detection.inZones",
|
|
6594
|
+
vars: { zones: zoneLabels.join(", ") }
|
|
6595
|
+
});
|
|
6596
|
+
}
|
|
6597
|
+
/** The human-readable edge polarity behind `{{op}}` and the occupancy body. */
|
|
6598
|
+
function ncOccupancyOp(texts, occupied) {
|
|
6599
|
+
return texts.text({ key: occupied ? "occupancy.op.occupied" : "occupancy.op.free" });
|
|
6600
|
+
}
|
|
6601
|
+
/**
|
|
6602
|
+
* `1 persona, 2 veicoli e 3 animali` — the language's own conjunction, its own
|
|
6603
|
+
* plural rules, and its own nouns.
|
|
6604
|
+
*
|
|
6605
|
+
* Zero-count classes are dropped: a selected class the window never contained
|
|
6606
|
+
* is a legitimate `0` in `{{count_vehicle}}`, but "0 veicoli" inside a summary
|
|
6607
|
+
* sentence is a line nobody reads. Order is the caller's (busiest first) and is
|
|
6608
|
+
* preserved, so the same night always produces the same sentence.
|
|
6609
|
+
*/
|
|
6610
|
+
function ncDetectionSummary(texts, counts) {
|
|
6611
|
+
const parts = counts.filter((entry) => entry.count > 0).map((entry) => `${entry.count} ${ncClassNoun(texts, entry.className, entry.count)}`);
|
|
6612
|
+
return texts.join(parts);
|
|
6613
|
+
}
|
|
6614
|
+
//#endregion
|
|
6615
|
+
//#region src/notification-center/group/nc-group-text.ts
|
|
6616
|
+
var DEFAULT_MAX_NAMES = 2;
|
|
6617
|
+
/**
|
|
6618
|
+
* `N altre persone` — the remainder phrase for one class, in its own gender.
|
|
6619
|
+
*
|
|
6620
|
+
* Mirrors {@link ncClassNoun} byte for byte, including the fallback: a class no
|
|
6621
|
+
* locale names falls through to `group.others.other`, which prints the raw class
|
|
6622
|
+
* name rather than vanishing. A custom model's class must appear in the sentence.
|
|
6623
|
+
*/
|
|
6624
|
+
function ncGroupOthers(texts, className, count) {
|
|
6625
|
+
const classNoun = ncClassNoun(texts, className, count);
|
|
6626
|
+
const key = `group.others.${className.trim().toLowerCase()}`;
|
|
6627
|
+
if (texts.has(key)) return texts.text({
|
|
6628
|
+
key,
|
|
6629
|
+
count,
|
|
6630
|
+
vars: {
|
|
6631
|
+
count: `${count}`,
|
|
6632
|
+
classNoun
|
|
6633
|
+
}
|
|
6634
|
+
});
|
|
6635
|
+
return texts.text({
|
|
6636
|
+
key: "group.others.other",
|
|
6637
|
+
count,
|
|
6638
|
+
vars: {
|
|
6639
|
+
count: `${count}`,
|
|
6640
|
+
classNoun
|
|
6641
|
+
}
|
|
6642
|
+
});
|
|
6643
|
+
}
|
|
6644
|
+
/** Counts per class, in first-seen order so the same burst always reads the same. */
|
|
6645
|
+
function countByClass(members) {
|
|
6646
|
+
const counts = /* @__PURE__ */ new Map();
|
|
6647
|
+
for (const m of members) counts.set(m.className, (counts.get(m.className) ?? 0) + 1);
|
|
6648
|
+
return [...counts].map(([className, count]) => ({
|
|
6649
|
+
className,
|
|
6650
|
+
count
|
|
6651
|
+
}));
|
|
6652
|
+
}
|
|
6653
|
+
/**
|
|
6654
|
+
* The finished `{{subject}}` phrase for a group.
|
|
6655
|
+
*
|
|
6656
|
+
* Pure: a catalog in, a string out. No clock, no store, no I/O — like every
|
|
6657
|
+
* other composer in `text-compose.ts`, and for the same reason.
|
|
6658
|
+
*/
|
|
6659
|
+
function ncGroupSubjectText(texts, input) {
|
|
6660
|
+
const members = input.members;
|
|
6661
|
+
const only = members.length === 1 ? members[0] : void 0;
|
|
6662
|
+
if (only !== void 0) return ncSubjectText(texts, {
|
|
6663
|
+
className: only.className,
|
|
6664
|
+
...only.label !== void 0 ? { label: only.label } : {},
|
|
6665
|
+
...only.labelKind !== void 0 ? { labelKind: only.labelKind } : {}
|
|
6666
|
+
});
|
|
6667
|
+
if (members.length === 0) return "";
|
|
6668
|
+
const maxNames = input.maxNames !== void 0 && input.maxNames >= 0 ? input.maxNames : DEFAULT_MAX_NAMES;
|
|
6669
|
+
const named = members.filter((m) => m.label !== void 0 && m.label.length > 0);
|
|
6670
|
+
const names = named.slice(0, maxNames).map((m) => m.label ?? "");
|
|
6671
|
+
if (names.length === 0) return ncDetectionSummary(texts, countByClass(members));
|
|
6672
|
+
const spoken = new Set(named.slice(0, maxNames));
|
|
6673
|
+
const remainder = members.filter((m) => !spoken.has(m));
|
|
6674
|
+
if (remainder.length === 0) return texts.join(names);
|
|
6675
|
+
const others = countByClass(remainder).map((c) => ncGroupOthers(texts, c.className, c.count));
|
|
6676
|
+
return texts.join([...names, ...others]);
|
|
6677
|
+
}
|
|
6678
|
+
//#endregion
|
|
6024
6679
|
//#region src/notification-center/render-template.ts
|
|
6025
6680
|
/**
|
|
6026
6681
|
* The ONE `{{var}}` renderer of the notification centre.
|
|
@@ -6485,6 +7140,110 @@ function maskPolygonOverlap(mask, maskWidth, maskHeight, bbox, polygon, _frameWi
|
|
|
6485
7140
|
if (totalMaskPixels === 0) return 0;
|
|
6486
7141
|
return insidePolygon / totalMaskPixels;
|
|
6487
7142
|
}
|
|
7143
|
+
var NcSceneStateCache = class {
|
|
7144
|
+
deps;
|
|
7145
|
+
entries = /* @__PURE__ */ new Map();
|
|
7146
|
+
noted = /* @__PURE__ */ new Map();
|
|
7147
|
+
ids = [];
|
|
7148
|
+
now;
|
|
7149
|
+
constructor(deps) {
|
|
7150
|
+
this.deps = deps;
|
|
7151
|
+
this.now = deps.now ?? (() => Date.now());
|
|
7152
|
+
}
|
|
7153
|
+
/**
|
|
7154
|
+
* The scene ids worth reading — recomputed from the rule set, so a cluster
|
|
7155
|
+
* with no scene-gated rule does no work at all and logs nothing.
|
|
7156
|
+
*/
|
|
7157
|
+
setWatched(ids) {
|
|
7158
|
+
this.ids = [...new Set(ids)].sort();
|
|
7159
|
+
}
|
|
7160
|
+
watched() {
|
|
7161
|
+
return this.ids;
|
|
7162
|
+
}
|
|
7163
|
+
/**
|
|
7164
|
+
* "This scene, in this process, is in this state, NOW."
|
|
7165
|
+
*
|
|
7166
|
+
* Synchronous by contract — the caller is the scene provider's own edge tap
|
|
7167
|
+
* and the next rule evaluation must already see it.
|
|
7168
|
+
*/
|
|
7169
|
+
note(observation) {
|
|
7170
|
+
this.noted.set(observation.sceneId, {
|
|
7171
|
+
observation,
|
|
7172
|
+
at: this.now()
|
|
7173
|
+
});
|
|
7174
|
+
}
|
|
7175
|
+
/** Hot-path read. No I/O, no await — this is called per event. */
|
|
7176
|
+
get(sceneId) {
|
|
7177
|
+
const now = this.now();
|
|
7178
|
+
const noted = this.noted.get(sceneId);
|
|
7179
|
+
if (noted !== void 0) {
|
|
7180
|
+
if (now - noted.at <= 3e5) return noted.observation;
|
|
7181
|
+
this.noted.delete(sceneId);
|
|
7182
|
+
}
|
|
7183
|
+
const entry = this.entries.get(sceneId);
|
|
7184
|
+
if (entry === void 0) return void 0;
|
|
7185
|
+
if (now - entry.at > 3e5) return void 0;
|
|
7186
|
+
return entry.observation;
|
|
7187
|
+
}
|
|
7188
|
+
/**
|
|
7189
|
+
* Re-read every watched scene. Never throws: a gate that could fail the
|
|
7190
|
+
* evaluation tick would take the notification pipeline with it.
|
|
7191
|
+
*
|
|
7192
|
+
* On failure the previous entries are KEPT — their timestamps are not
|
|
7193
|
+
* touched, so they age out on their own rather than vanishing at once.
|
|
7194
|
+
*/
|
|
7195
|
+
refresh() {
|
|
7196
|
+
if (this.ids.length === 0) {
|
|
7197
|
+
this.entries = /* @__PURE__ */ new Map();
|
|
7198
|
+
this.noted.clear();
|
|
7199
|
+
return;
|
|
7200
|
+
}
|
|
7201
|
+
let observations;
|
|
7202
|
+
try {
|
|
7203
|
+
observations = this.deps.readScenes();
|
|
7204
|
+
} catch (err) {
|
|
7205
|
+
this.deps.logger.warn("scene-state refresh failed — keeping the previous states until they age out", { meta: {
|
|
7206
|
+
watched: this.ids.length,
|
|
7207
|
+
error: String(err)
|
|
7208
|
+
} });
|
|
7209
|
+
return;
|
|
7210
|
+
}
|
|
7211
|
+
const at = this.now();
|
|
7212
|
+
const wanted = new Set(this.ids);
|
|
7213
|
+
const next = /* @__PURE__ */ new Map();
|
|
7214
|
+
for (const observation of observations) {
|
|
7215
|
+
if (!wanted.has(observation.sceneId)) continue;
|
|
7216
|
+
next.set(observation.sceneId, {
|
|
7217
|
+
observation,
|
|
7218
|
+
at
|
|
7219
|
+
});
|
|
7220
|
+
}
|
|
7221
|
+
this.entries = next;
|
|
7222
|
+
for (const sceneId of this.ids) {
|
|
7223
|
+
if (next.has(sceneId)) continue;
|
|
7224
|
+
this.deps.logger.info("scene gate: no state for a watched scene", { meta: { sceneId } });
|
|
7225
|
+
}
|
|
7226
|
+
}
|
|
7227
|
+
};
|
|
7228
|
+
/**
|
|
7229
|
+
* Does this observation satisfy the rule's scene condition?
|
|
7230
|
+
*
|
|
7231
|
+
* Exported and pure so the semantics can be tested without a cache, a provider
|
|
7232
|
+
* or a rule — and so there is exactly ONE place that decides what `latched`
|
|
7233
|
+
* absent means. See `NcSceneConditionSchema` for the reasoning behind each
|
|
7234
|
+
* branch.
|
|
7235
|
+
*/
|
|
7236
|
+
function sceneConditionSatisfied(condition, observation) {
|
|
7237
|
+
if (condition.latched ?? observation.emit === "latched") return observation.latched === (condition.requiredState === "diverged");
|
|
7238
|
+
return observation.verdict === condition.requiredState;
|
|
7239
|
+
}
|
|
7240
|
+
/** One-line "why", for the suppression log. Never a bare boolean: a drop the
|
|
7241
|
+
* operator cannot explain is the failure this whole feature is one misstep
|
|
7242
|
+
* away from. */
|
|
7243
|
+
function sceneGateReason(condition, observation) {
|
|
7244
|
+
if (observation === void 0) return "no mirrored state for this scene";
|
|
7245
|
+
return condition.latched ?? observation.emit === "latched" ? `latched=${observation.latched} (wanted ${condition.requiredState})` : `verdict=${observation.verdict} (wanted ${condition.requiredState})`;
|
|
7246
|
+
}
|
|
6488
7247
|
//#endregion
|
|
6489
7248
|
//#region src/notification-center/zone-ownership.ts
|
|
6490
7249
|
var UNCHANGED = { kind: "unchanged" };
|
|
@@ -6640,10 +7399,17 @@ function subjectFromSensorEvent(ev, markerTrackId) {
|
|
|
6640
7399
|
* `minConfidence` condition composes naturally). A level-path audio event (no
|
|
6641
7400
|
* `classification`) yields NO class ⇒ it can never satisfy the audio opt-in
|
|
6642
7401
|
* gate, so only classified audio ever notifies (documented boundary). Audio has
|
|
6643
|
-
* no zones / bbox / label
|
|
6644
|
-
*
|
|
7402
|
+
* no zones / bbox / label, so the object-specific conditions all fail closed
|
|
7403
|
+
* (see the {@link evaluateRule} audio gate + the matchers below).
|
|
7404
|
+
*
|
|
7405
|
+
* `stillOwnerId` is the audio still shelf's owner id — the photograph of the
|
|
7406
|
+
* camera taken at the match (`audio-still.ts`). It rides `trackId` because that
|
|
7407
|
+
* is the field the attachment ladder reads, exactly as a doorbell press rides
|
|
7408
|
+
* its marker track's id; nothing else about the subject is track-scoped, and
|
|
7409
|
+
* `buildEntries` keys an audio row on its record id, never on this. Absent when
|
|
7410
|
+
* no rule that could fire wants a still.
|
|
6645
7411
|
*/
|
|
6646
|
-
function subjectFromAudioEvent(ev) {
|
|
7412
|
+
function subjectFromAudioEvent(ev, stillOwnerId) {
|
|
6647
7413
|
const macro = ev.classification?.className;
|
|
6648
7414
|
return {
|
|
6649
7415
|
kind: "audio-event",
|
|
@@ -6653,6 +7419,7 @@ function subjectFromAudioEvent(ev) {
|
|
|
6653
7419
|
classNames: macro !== void 0 ? [`audio-${macro}`] : [],
|
|
6654
7420
|
...ev.classification?.score !== void 0 ? { confidence: ev.classification.score } : {},
|
|
6655
7421
|
zones: [],
|
|
7422
|
+
...stillOwnerId !== void 0 ? { trackId: stillOwnerId } : {},
|
|
6656
7423
|
source: "pipeline"
|
|
6657
7424
|
};
|
|
6658
7425
|
}
|
|
@@ -6673,8 +7440,12 @@ function subjectFromAudioEvent(ev) {
|
|
|
6673
7440
|
* neither is a score, and lending one to `minConfidence` would let a detection
|
|
6674
7441
|
* condition silently re-judge an audio rule on a number that means something
|
|
6675
7442
|
* else.
|
|
7443
|
+
*
|
|
7444
|
+
* `stillOwnerId` — see {@link subjectFromAudioEvent}. A sound owns no frame, so
|
|
7445
|
+
* the only picture it can carry is a photograph of its camera taken at the
|
|
7446
|
+
* confirmation, held on the still shelf under this id.
|
|
6676
7447
|
*/
|
|
6677
|
-
function subjectFromAudioWindow(hit) {
|
|
7448
|
+
function subjectFromAudioWindow(hit, stillOwnerId) {
|
|
6678
7449
|
const spec = hit.spec;
|
|
6679
7450
|
return {
|
|
6680
7451
|
kind: "audio-window",
|
|
@@ -6683,6 +7454,7 @@ function subjectFromAudioWindow(hit) {
|
|
|
6683
7454
|
timestamp: hit.timestamp,
|
|
6684
7455
|
classNames: hit.labels.map((l) => `audio-${l}`),
|
|
6685
7456
|
zones: [],
|
|
7457
|
+
...stillOwnerId !== void 0 ? { trackId: stillOwnerId } : {},
|
|
6686
7458
|
source: "audio",
|
|
6687
7459
|
audioWindow: audioSubjectFor(hit, spec)
|
|
6688
7460
|
};
|
|
@@ -6821,6 +7593,7 @@ function presentConditionIds(c) {
|
|
|
6821
7593
|
if (c.packagePhase !== void 0 && c.packagePhase !== "both") ids.push("packagePhase");
|
|
6822
7594
|
if (c.customZones !== void 0 && c.customZones.length > 0) ids.push("customZones");
|
|
6823
7595
|
if (c.occupancy !== void 0) ids.push("occupancy");
|
|
7596
|
+
if (c.scene !== void 0) ids.push("scene");
|
|
6824
7597
|
return ids;
|
|
6825
7598
|
}
|
|
6826
7599
|
/**
|
|
@@ -7000,7 +7773,7 @@ function matchesSystemEvent(condition, subject) {
|
|
|
7000
7773
|
if (condition.packageNames !== void 0 && (subject.packageName === void 0 || !condition.packageNames.includes(subject.packageName))) return false;
|
|
7001
7774
|
return true;
|
|
7002
7775
|
}
|
|
7003
|
-
function evaluateRule(rule, subject, deviceState, zoneOwners) {
|
|
7776
|
+
function evaluateRule(rule, subject, deviceState, zoneOwners, sceneStates) {
|
|
7004
7777
|
if (subject.kind === "system-event") {
|
|
7005
7778
|
if (rule.delivery !== "system-event") return fail("delivery");
|
|
7006
7779
|
const condition = rule.conditions.systemEvent;
|
|
@@ -7041,6 +7814,11 @@ function evaluateRule(rule, subject, deviceState, zoneOwners) {
|
|
|
7041
7814
|
if (current === void 0) return fail("deviceState");
|
|
7042
7815
|
if (!toLowerSet(c.deviceState.states).has(current.trim().toLowerCase())) return fail("deviceState");
|
|
7043
7816
|
}
|
|
7817
|
+
if (c.scene !== void 0) {
|
|
7818
|
+
const observation = sceneStates?.(c.scene.sceneId);
|
|
7819
|
+
if (observation === void 0) return fail("scene");
|
|
7820
|
+
if (!sceneConditionSatisfied(c.scene, observation)) return fail("scene");
|
|
7821
|
+
}
|
|
7044
7822
|
if (c.devices !== void 0 && c.devices.length > 0 && !matchesDeviceScope(c.devices, subject)) return fail("devices");
|
|
7045
7823
|
if (c.source !== void 0 && c.source !== "any") {
|
|
7046
7824
|
if ((subject.source ?? "pipeline") !== c.source) return fail("source");
|
|
@@ -7899,298 +8677,6 @@ var NcTextCatalog = class {
|
|
|
7899
8677
|
}
|
|
7900
8678
|
};
|
|
7901
8679
|
//#endregion
|
|
7902
|
-
//#region src/shared/frame/box-drawer.ts
|
|
7903
|
-
var DEFAULT_COLOR = require_dist.DEFAULT_EVENT_COLOR;
|
|
7904
|
-
var DEFAULT_QUALITY = 80;
|
|
7905
|
-
var STROKE_WIDTH = 3;
|
|
7906
|
-
function escapeXml$2(s) {
|
|
7907
|
-
return s.replace(/[<>&'"]/g, (ch) => {
|
|
7908
|
-
switch (ch) {
|
|
7909
|
-
case "<": return "<";
|
|
7910
|
-
case ">": return ">";
|
|
7911
|
-
case "&": return "&";
|
|
7912
|
-
case "'": return "'";
|
|
7913
|
-
default: return """;
|
|
7914
|
-
}
|
|
7915
|
-
});
|
|
7916
|
-
}
|
|
7917
|
-
/** Clamp a pixel box to the frame so the rect always stays inside [0,W]×[0,H]. */
|
|
7918
|
-
function clampBox(b, frameWidth, frameHeight) {
|
|
7919
|
-
const x = Math.max(0, Math.min(Math.round(b.x), frameWidth - 1));
|
|
7920
|
-
const y = Math.max(0, Math.min(Math.round(b.y), frameHeight - 1));
|
|
7921
|
-
return {
|
|
7922
|
-
x,
|
|
7923
|
-
y,
|
|
7924
|
-
w: Math.max(1, Math.min(Math.round(b.w), frameWidth - x)),
|
|
7925
|
-
h: Math.max(1, Math.min(Math.round(b.h), frameHeight - y))
|
|
7926
|
-
};
|
|
7927
|
-
}
|
|
7928
|
-
/**
|
|
7929
|
-
* Draw bounding boxes over a raw RGB frame and JPEG-encode it.
|
|
7930
|
-
*
|
|
7931
|
-
* The frame is composited with an SVG overlay (one `<rect>` per box, plus an
|
|
7932
|
-
* optional caption) at full resolution, then optionally downscaled — so the
|
|
7933
|
-
* box stays crisp relative to the scene. Boxes are clamped to frame bounds to
|
|
7934
|
-
* avoid sharp `extract`/region errors on slightly-out-of-range detections.
|
|
7935
|
-
*/
|
|
7936
|
-
async function drawBoxedFrame(frameData, frameWidth, frameHeight, boxes, opts = {}) {
|
|
7937
|
-
const quality = opts.quality ?? DEFAULT_QUALITY;
|
|
7938
|
-
let base = frameData;
|
|
7939
|
-
let baseIsRaw = true;
|
|
7940
|
-
if (boxes.length > 0) {
|
|
7941
|
-
const fontSize = Math.max(12, Math.round(frameHeight / 30));
|
|
7942
|
-
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}">${boxes.map((b) => {
|
|
7943
|
-
const c = clampBox(b, frameWidth, frameHeight);
|
|
7944
|
-
const color = b.color ?? DEFAULT_COLOR;
|
|
7945
|
-
const rect = `<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${c.h}" fill="none" stroke="${color}" stroke-width="${STROKE_WIDTH}"/>`;
|
|
7946
|
-
if (!b.label) return rect;
|
|
7947
|
-
const ty = Math.max(fontSize, c.y - 4);
|
|
7948
|
-
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>`;
|
|
7949
|
-
}).join("")}</svg>`;
|
|
7950
|
-
base = await (0, sharp.default)(frameData, { raw: {
|
|
7951
|
-
width: frameWidth,
|
|
7952
|
-
height: frameHeight,
|
|
7953
|
-
channels: 3
|
|
7954
|
-
} }).composite([{
|
|
7955
|
-
input: Buffer.from(svg),
|
|
7956
|
-
top: 0,
|
|
7957
|
-
left: 0
|
|
7958
|
-
}]).png().toBuffer();
|
|
7959
|
-
baseIsRaw = false;
|
|
7960
|
-
}
|
|
7961
|
-
let pipeline = baseIsRaw ? (0, sharp.default)(base, { raw: {
|
|
7962
|
-
width: frameWidth,
|
|
7963
|
-
height: frameHeight,
|
|
7964
|
-
channels: 3
|
|
7965
|
-
} }) : (0, sharp.default)(base);
|
|
7966
|
-
if (opts.maxWidth !== void 0) pipeline = pipeline.resize({
|
|
7967
|
-
width: opts.maxWidth,
|
|
7968
|
-
withoutEnlargement: true
|
|
7969
|
-
});
|
|
7970
|
-
return pipeline.jpeg({ quality }).toBuffer();
|
|
7971
|
-
}
|
|
7972
|
-
/** JPEG quality of the composed artefact — the mosaic's number, same reasons. */
|
|
7973
|
-
var GROUP_FRAME_QUALITY = 82;
|
|
7974
|
-
/**
|
|
7975
|
-
* Draw the members onto the frame. Returns `null` when there is nothing to
|
|
7976
|
-
* draw, or when anything at all goes wrong — the caller then ships the
|
|
7977
|
-
* unannotated frame unchanged.
|
|
7978
|
-
*/
|
|
7979
|
-
async function composeGroupFrame(input) {
|
|
7980
|
-
const freshnessMs = input.freshnessMs ?? 3e3;
|
|
7981
|
-
const fresh = input.boxes.filter((b) => Math.abs(input.at - b.observedAt) <= freshnessMs);
|
|
7982
|
-
const withheld = input.boxes.length - fresh.length;
|
|
7983
|
-
if (fresh.length < 2) {
|
|
7984
|
-
if (withheld > 0) input.logger.info("group frame not drawn — too few members with fresh geometry", {
|
|
7985
|
-
tags: { deviceId: input.deviceId },
|
|
7986
|
-
meta: {
|
|
7987
|
-
groupKey: input.groupKey,
|
|
7988
|
-
members: input.boxes.length,
|
|
7989
|
-
fresh: fresh.length,
|
|
7990
|
-
withheld
|
|
7991
|
-
}
|
|
7992
|
-
});
|
|
7993
|
-
return null;
|
|
7994
|
-
}
|
|
7995
|
-
try {
|
|
7996
|
-
const decoded = await (0, sharp.default)(input.jpeg).raw().toBuffer({ resolveWithObject: true });
|
|
7997
|
-
const { width, height, channels } = decoded.info;
|
|
7998
|
-
if (!(width > 0 && height > 0) || channels !== 3) {
|
|
7999
|
-
input.logger.warn("group frame not drawn — unexpected raster shape", {
|
|
8000
|
-
tags: { deviceId: input.deviceId },
|
|
8001
|
-
meta: {
|
|
8002
|
-
groupKey: input.groupKey,
|
|
8003
|
-
width,
|
|
8004
|
-
height,
|
|
8005
|
-
channels
|
|
8006
|
-
}
|
|
8007
|
-
});
|
|
8008
|
-
return null;
|
|
8009
|
-
}
|
|
8010
|
-
const boxes = fresh.map((b) => ({
|
|
8011
|
-
x: b.bbox.x * width,
|
|
8012
|
-
y: b.bbox.y * height,
|
|
8013
|
-
w: b.bbox.w * width,
|
|
8014
|
-
h: b.bbox.h * height,
|
|
8015
|
-
...b.label !== void 0 && b.label.length > 0 ? { label: b.label } : {}
|
|
8016
|
-
}));
|
|
8017
|
-
const bytes = await drawBoxedFrame(decoded.data, width, height, boxes, { quality: GROUP_FRAME_QUALITY });
|
|
8018
|
-
input.logger.debug("group frame composed", {
|
|
8019
|
-
tags: { deviceId: input.deviceId },
|
|
8020
|
-
meta: {
|
|
8021
|
-
groupKey: input.groupKey,
|
|
8022
|
-
drawn: boxes.length,
|
|
8023
|
-
withheld,
|
|
8024
|
-
width,
|
|
8025
|
-
height
|
|
8026
|
-
}
|
|
8027
|
-
});
|
|
8028
|
-
return {
|
|
8029
|
-
bytes,
|
|
8030
|
-
drawn: boxes.length,
|
|
8031
|
-
withheld
|
|
8032
|
-
};
|
|
8033
|
-
} catch (err) {
|
|
8034
|
-
input.logger.warn("group frame compose failed — shipping the unannotated frame", {
|
|
8035
|
-
tags: { deviceId: input.deviceId },
|
|
8036
|
-
meta: {
|
|
8037
|
-
groupKey: input.groupKey,
|
|
8038
|
-
error: err instanceof Error ? err.message : String(err)
|
|
8039
|
-
}
|
|
8040
|
-
});
|
|
8041
|
-
return null;
|
|
8042
|
-
}
|
|
8043
|
-
}
|
|
8044
|
-
//#endregion
|
|
8045
|
-
//#region src/notification-center/text-compose.ts
|
|
8046
|
-
/** One normalisation, used on both sides of every class lookup — a pipeline
|
|
8047
|
-
* that emitted `Person` must find the key written `class.person`. */
|
|
8048
|
-
function normaliseClass(className) {
|
|
8049
|
-
return className.trim().toLowerCase();
|
|
8050
|
-
}
|
|
8051
|
-
/**
|
|
8052
|
-
* The noun for a class, in the count's plural category.
|
|
8053
|
-
*
|
|
8054
|
-
* A class no locale names falls through to `class.other`, which prints the RAW
|
|
8055
|
-
* name: a custom model's class must appear in the sentence rather than vanish
|
|
8056
|
-
* out of it, and a missing translation is a smaller defect than a missing
|
|
8057
|
-
* detection.
|
|
8058
|
-
*/
|
|
8059
|
-
function ncClassNoun(texts, className, count) {
|
|
8060
|
-
const key = `class.${normaliseClass(className)}`;
|
|
8061
|
-
if (texts.has(key)) return texts.text({
|
|
8062
|
-
key,
|
|
8063
|
-
count,
|
|
8064
|
-
vars: { name: className }
|
|
8065
|
-
});
|
|
8066
|
-
return texts.text({
|
|
8067
|
-
key: "class.other",
|
|
8068
|
-
count,
|
|
8069
|
-
vars: { name: className }
|
|
8070
|
-
});
|
|
8071
|
-
}
|
|
8072
|
-
/** `person (John)` / `persona (John)` — the thing the notification is about. */
|
|
8073
|
-
function ncSubjectText(texts, input) {
|
|
8074
|
-
const classNoun = ncClassNoun(texts, input.className, 1);
|
|
8075
|
-
if (input.label === void 0) return texts.text({
|
|
8076
|
-
key: "detection.subject",
|
|
8077
|
-
vars: { classNoun }
|
|
8078
|
-
});
|
|
8079
|
-
return texts.text({
|
|
8080
|
-
key: subjectLabelKey(input.labelKind),
|
|
8081
|
-
vars: {
|
|
8082
|
-
classNoun,
|
|
8083
|
-
label: input.label
|
|
8084
|
-
}
|
|
8085
|
-
});
|
|
8086
|
-
}
|
|
8087
|
-
/**
|
|
8088
|
-
* Which labelled-subject key a recognition gets. A kind the catalog does not
|
|
8089
|
-
* name falls back to the generic key rather than printing nothing — the same
|
|
8090
|
-
* direction `ncClassNoun` degrades in, and for the same reason.
|
|
8091
|
-
*/
|
|
8092
|
-
function subjectLabelKey(kind) {
|
|
8093
|
-
if (kind === "identity") return "detection.subject.identity";
|
|
8094
|
-
if (kind === "plate") return "detection.subject.plate";
|
|
8095
|
-
return "detection.subject.labelled";
|
|
8096
|
-
}
|
|
8097
|
-
/**
|
|
8098
|
-
* ` in gate, drive` — INCLUDING its leading space, and empty when there are no
|
|
8099
|
-
* zones.
|
|
8100
|
-
*
|
|
8101
|
-
* The space belongs to the fragment rather than to the body key because the
|
|
8102
|
-
* alternative is a body that ends in a trailing space on every zone-less
|
|
8103
|
-
* notification. A translator moving the clause moves the space with it.
|
|
8104
|
-
*/
|
|
8105
|
-
function ncInZonesText(texts, zoneLabels) {
|
|
8106
|
-
if (zoneLabels.length === 0) return "";
|
|
8107
|
-
return texts.text({
|
|
8108
|
-
key: "detection.inZones",
|
|
8109
|
-
vars: { zones: zoneLabels.join(", ") }
|
|
8110
|
-
});
|
|
8111
|
-
}
|
|
8112
|
-
/** The human-readable edge polarity behind `{{op}}` and the occupancy body. */
|
|
8113
|
-
function ncOccupancyOp(texts, occupied) {
|
|
8114
|
-
return texts.text({ key: occupied ? "occupancy.op.occupied" : "occupancy.op.free" });
|
|
8115
|
-
}
|
|
8116
|
-
/**
|
|
8117
|
-
* `1 persona, 2 veicoli e 3 animali` — the language's own conjunction, its own
|
|
8118
|
-
* plural rules, and its own nouns.
|
|
8119
|
-
*
|
|
8120
|
-
* Zero-count classes are dropped: a selected class the window never contained
|
|
8121
|
-
* is a legitimate `0` in `{{count_vehicle}}`, but "0 veicoli" inside a summary
|
|
8122
|
-
* sentence is a line nobody reads. Order is the caller's (busiest first) and is
|
|
8123
|
-
* preserved, so the same night always produces the same sentence.
|
|
8124
|
-
*/
|
|
8125
|
-
function ncDetectionSummary(texts, counts) {
|
|
8126
|
-
const parts = counts.filter((entry) => entry.count > 0).map((entry) => `${entry.count} ${ncClassNoun(texts, entry.className, entry.count)}`);
|
|
8127
|
-
return texts.join(parts);
|
|
8128
|
-
}
|
|
8129
|
-
//#endregion
|
|
8130
|
-
//#region src/notification-center/group/nc-group-text.ts
|
|
8131
|
-
var DEFAULT_MAX_NAMES = 2;
|
|
8132
|
-
/**
|
|
8133
|
-
* `N altre persone` — the remainder phrase for one class, in its own gender.
|
|
8134
|
-
*
|
|
8135
|
-
* Mirrors {@link ncClassNoun} byte for byte, including the fallback: a class no
|
|
8136
|
-
* locale names falls through to `group.others.other`, which prints the raw class
|
|
8137
|
-
* name rather than vanishing. A custom model's class must appear in the sentence.
|
|
8138
|
-
*/
|
|
8139
|
-
function ncGroupOthers(texts, className, count) {
|
|
8140
|
-
const classNoun = ncClassNoun(texts, className, count);
|
|
8141
|
-
const key = `group.others.${className.trim().toLowerCase()}`;
|
|
8142
|
-
if (texts.has(key)) return texts.text({
|
|
8143
|
-
key,
|
|
8144
|
-
count,
|
|
8145
|
-
vars: {
|
|
8146
|
-
count: `${count}`,
|
|
8147
|
-
classNoun
|
|
8148
|
-
}
|
|
8149
|
-
});
|
|
8150
|
-
return texts.text({
|
|
8151
|
-
key: "group.others.other",
|
|
8152
|
-
count,
|
|
8153
|
-
vars: {
|
|
8154
|
-
count: `${count}`,
|
|
8155
|
-
classNoun
|
|
8156
|
-
}
|
|
8157
|
-
});
|
|
8158
|
-
}
|
|
8159
|
-
/** Counts per class, in first-seen order so the same burst always reads the same. */
|
|
8160
|
-
function countByClass(members) {
|
|
8161
|
-
const counts = /* @__PURE__ */ new Map();
|
|
8162
|
-
for (const m of members) counts.set(m.className, (counts.get(m.className) ?? 0) + 1);
|
|
8163
|
-
return [...counts].map(([className, count]) => ({
|
|
8164
|
-
className,
|
|
8165
|
-
count
|
|
8166
|
-
}));
|
|
8167
|
-
}
|
|
8168
|
-
/**
|
|
8169
|
-
* The finished `{{subject}}` phrase for a group.
|
|
8170
|
-
*
|
|
8171
|
-
* Pure: a catalog in, a string out. No clock, no store, no I/O — like every
|
|
8172
|
-
* other composer in `text-compose.ts`, and for the same reason.
|
|
8173
|
-
*/
|
|
8174
|
-
function ncGroupSubjectText(texts, input) {
|
|
8175
|
-
const members = input.members;
|
|
8176
|
-
const only = members.length === 1 ? members[0] : void 0;
|
|
8177
|
-
if (only !== void 0) return ncSubjectText(texts, {
|
|
8178
|
-
className: only.className,
|
|
8179
|
-
...only.label !== void 0 ? { label: only.label } : {},
|
|
8180
|
-
...only.labelKind !== void 0 ? { labelKind: only.labelKind } : {}
|
|
8181
|
-
});
|
|
8182
|
-
if (members.length === 0) return "";
|
|
8183
|
-
const maxNames = input.maxNames !== void 0 && input.maxNames >= 0 ? input.maxNames : DEFAULT_MAX_NAMES;
|
|
8184
|
-
const named = members.filter((m) => m.label !== void 0 && m.label.length > 0);
|
|
8185
|
-
const names = named.slice(0, maxNames).map((m) => m.label ?? "");
|
|
8186
|
-
if (names.length === 0) return ncDetectionSummary(texts, countByClass(members));
|
|
8187
|
-
const spoken = new Set(named.slice(0, maxNames));
|
|
8188
|
-
const remainder = members.filter((m) => !spoken.has(m));
|
|
8189
|
-
if (remainder.length === 0) return texts.join(names);
|
|
8190
|
-
const others = countByClass(remainder).map((c) => ncGroupOthers(texts, c.className, c.count));
|
|
8191
|
-
return texts.join([...names, ...others]);
|
|
8192
|
-
}
|
|
8193
|
-
//#endregion
|
|
8194
8680
|
//#region src/notification-center/dispatcher.ts
|
|
8195
8681
|
var DEFAULT_TARGET_CACHE_TTL_MS = 6e4;
|
|
8196
8682
|
/**
|
|
@@ -8753,7 +9239,7 @@ var NcDispatcher = class {
|
|
|
8753
9239
|
const stillOverride = zoneIdsWanted !== void 0 && zoneIdsWanted.length > 0 ? "keyFrame" : void 0;
|
|
8754
9240
|
const firstLook = await this.resolveAttachment(entry, stillOverride);
|
|
8755
9241
|
const footage = await this.renderFootage(entry);
|
|
8756
|
-
const still = firstLook !== null || entry.payload.media === "none" ? firstLook : footage.attempted ? await this.resolveAttachment(entry, stillOverride) : await this.waitForStill(entry, stillOverride);
|
|
9242
|
+
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);
|
|
8757
9243
|
if (still !== null) {
|
|
8758
9244
|
const zoneIds = zoneIdsWanted;
|
|
8759
9245
|
if (zoneIds !== void 0 && zoneIds.length > 0) {
|
|
@@ -9241,17 +9727,24 @@ function incomingFromSensorEvent(event, markerTrackId) {
|
|
|
9241
9727
|
})
|
|
9242
9728
|
};
|
|
9243
9729
|
}
|
|
9244
|
-
/**
|
|
9245
|
-
|
|
9730
|
+
/**
|
|
9731
|
+
* Audio-event persist (classified episode).
|
|
9732
|
+
*
|
|
9733
|
+
* `stillOwnerId` is the audio still shelf's owner id for the photograph taken
|
|
9734
|
+
* at this episode (`audio-still.ts`). Absent when no rule that could fire asked
|
|
9735
|
+
* for a picture — the same shape `incomingFromSensorEvent` carries its marker.
|
|
9736
|
+
*/
|
|
9737
|
+
function incomingFromAudioEvent(event, stillOwnerId) {
|
|
9246
9738
|
return {
|
|
9247
|
-
subject: subjectFromAudioEvent(event),
|
|
9739
|
+
subject: subjectFromAudioEvent(event, stillOwnerId),
|
|
9248
9740
|
kind: "audio-event",
|
|
9249
9741
|
origin: "pipeline",
|
|
9250
9742
|
log: () => ({
|
|
9251
9743
|
tags: { deviceId: event.deviceId },
|
|
9252
9744
|
meta: {
|
|
9253
9745
|
audioEventId: event.id,
|
|
9254
|
-
class: event.classification?.className
|
|
9746
|
+
class: event.classification?.className,
|
|
9747
|
+
...stillOwnerId !== void 0 ? { stillOwner: stillOwnerId } : {}
|
|
9255
9748
|
}
|
|
9256
9749
|
})
|
|
9257
9750
|
};
|
|
@@ -9281,15 +9774,20 @@ function incomingFromPackageEvent(event, phase) {
|
|
|
9281
9774
|
* and a crash in the confirm→outbox window drops the notification rather than
|
|
9282
9775
|
* replaying it. Deliberate: a stale match is a claim about a noise that has
|
|
9283
9776
|
* already stopped.
|
|
9777
|
+
*
|
|
9778
|
+
* `stillOwnerId` — see {@link incomingFromAudioEvent}.
|
|
9284
9779
|
*/
|
|
9285
|
-
function incomingFromAudioWindow(hit) {
|
|
9780
|
+
function incomingFromAudioWindow(hit, stillOwnerId) {
|
|
9286
9781
|
return {
|
|
9287
|
-
subject: subjectFromAudioWindow(hit),
|
|
9782
|
+
subject: subjectFromAudioWindow(hit, stillOwnerId),
|
|
9288
9783
|
kind: "audio-window",
|
|
9289
9784
|
origin: "pipeline",
|
|
9290
9785
|
log: () => ({
|
|
9291
9786
|
tags: { deviceId: hit.deviceId },
|
|
9292
|
-
meta:
|
|
9787
|
+
meta: {
|
|
9788
|
+
...audioHitMeta(hit),
|
|
9789
|
+
...stillOwnerId !== void 0 ? { stillOwner: stillOwnerId } : {}
|
|
9790
|
+
}
|
|
9293
9791
|
})
|
|
9294
9792
|
};
|
|
9295
9793
|
}
|
|
@@ -13230,6 +13728,29 @@ function escapeXml$1(s) {
|
|
|
13230
13728
|
}
|
|
13231
13729
|
});
|
|
13232
13730
|
}
|
|
13731
|
+
/**
|
|
13732
|
+
* The single decision about drawn text — and the home of the DEFAULT.
|
|
13733
|
+
*
|
|
13734
|
+
* `undefined` means `none`, not `all`: a rule stored before this option existed
|
|
13735
|
+
* has no `captions` key, and the operator's request is that such a rule deliver
|
|
13736
|
+
* pictures. The schema materialises the same default on read
|
|
13737
|
+
* (`NcSummaryRuleInputSchema`), so this arm only fires for a caller that never
|
|
13738
|
+
* had a rule — the test bridge and the spec. Both agreeing is the point.
|
|
13739
|
+
*/
|
|
13740
|
+
function mosaicTextPlan(captions) {
|
|
13741
|
+
if (captions === "all") return {
|
|
13742
|
+
tileLabels: true,
|
|
13743
|
+
titleBand: true
|
|
13744
|
+
};
|
|
13745
|
+
if (captions === "tiles") return {
|
|
13746
|
+
tileLabels: true,
|
|
13747
|
+
titleBand: false
|
|
13748
|
+
};
|
|
13749
|
+
return {
|
|
13750
|
+
tileLabels: false,
|
|
13751
|
+
titleBand: false
|
|
13752
|
+
};
|
|
13753
|
+
}
|
|
13233
13754
|
/** Grid shape for `n` tiles: as square as possible, rows never exceeding cols
|
|
13234
13755
|
* by more than one — a 3×3 for nine, a 3×2 for five, never a 1×9 strip. */
|
|
13235
13756
|
function mosaicGrid(n) {
|
|
@@ -13336,22 +13857,25 @@ function titleSvg(width, height, caption) {
|
|
|
13336
13857
|
* treats that as "no mosaic" and still delivers the counts.
|
|
13337
13858
|
*/
|
|
13338
13859
|
async function renderMosaic(request, logger) {
|
|
13860
|
+
const plan = mosaicTextPlan(request.captions);
|
|
13339
13861
|
const decoded = [];
|
|
13340
13862
|
const probeLayout = mosaicLayout({
|
|
13341
13863
|
tileCount: Math.max(1, request.tiles.length),
|
|
13342
13864
|
...request.maxWidth !== void 0 ? { maxWidth: request.maxWidth } : {}
|
|
13343
13865
|
});
|
|
13344
13866
|
for (const tile of request.tiles) try {
|
|
13345
|
-
const
|
|
13867
|
+
const label = plan.tileLabels ? tile.label?.trim() ?? "" : "";
|
|
13868
|
+
const fitted = (0, sharp.default)(tile.jpeg).resize({
|
|
13346
13869
|
width: probeLayout.tileWidth,
|
|
13347
13870
|
height: probeLayout.tileHeight,
|
|
13348
13871
|
fit: "contain",
|
|
13349
13872
|
background: MOSAIC_BACKGROUND
|
|
13350
|
-
})
|
|
13351
|
-
|
|
13873
|
+
});
|
|
13874
|
+
const resized = await (label.length === 0 ? fitted : fitted.composite([{
|
|
13875
|
+
input: tileLabelSvg(probeLayout.tileWidth, probeLayout.tileHeight, label),
|
|
13352
13876
|
top: 0,
|
|
13353
13877
|
left: 0
|
|
13354
|
-
}]).jpeg({ quality: 82 }).toBuffer();
|
|
13878
|
+
}])).jpeg({ quality: 82 }).toBuffer();
|
|
13355
13879
|
decoded.push({
|
|
13356
13880
|
tile,
|
|
13357
13881
|
resized
|
|
@@ -13366,7 +13890,7 @@ async function renderMosaic(request, logger) {
|
|
|
13366
13890
|
});
|
|
13367
13891
|
}
|
|
13368
13892
|
const droppedTiles = request.tiles.length - decoded.length;
|
|
13369
|
-
const caption = request.caption?.trim() ?? "";
|
|
13893
|
+
const caption = plan.titleBand ? request.caption?.trim() ?? "" : "";
|
|
13370
13894
|
const layout = mosaicLayout({
|
|
13371
13895
|
tileCount: Math.max(1, decoded.length),
|
|
13372
13896
|
...request.maxWidth !== void 0 ? { maxWidth: request.maxWidth } : {},
|
|
@@ -14235,18 +14759,20 @@ var NcSummaryProducer = class {
|
|
|
14235
14759
|
};
|
|
14236
14760
|
let rendered;
|
|
14237
14761
|
try {
|
|
14762
|
+
const plan = mosaicTextPlan(rule.captions);
|
|
14238
14763
|
rendered = await renderMosaic({
|
|
14239
14764
|
tiles: tiles.map(({ candidate, jpeg }) => ({
|
|
14240
14765
|
jpeg,
|
|
14241
|
-
label: `${names.get(candidate.deviceId) ?? candidate.deviceId} · ${clockOf$2(candidate.firstSeen)} · ${candidate.className}
|
|
14766
|
+
...plan.tileLabels ? { label: `${names.get(candidate.deviceId) ?? candidate.deviceId} · ${clockOf$2(candidate.firstSeen)} · ${candidate.className}` } : {},
|
|
14242
14767
|
deviceId: candidate.deviceId,
|
|
14243
14768
|
trackId: candidate.trackId
|
|
14244
14769
|
})),
|
|
14245
|
-
|
|
14770
|
+
captions: rule.captions,
|
|
14771
|
+
...plan.titleBand ? { caption: summaryCaptionText({
|
|
14246
14772
|
...rule.captionText !== void 0 ? { captionText: rule.captionText } : {},
|
|
14247
14773
|
ruleName: rule.name,
|
|
14248
14774
|
window
|
|
14249
|
-
})
|
|
14775
|
+
}) } : {}
|
|
14250
14776
|
}, this.deps.logger);
|
|
14251
14777
|
} catch (err) {
|
|
14252
14778
|
this.deps.logger.warn("summary mosaic could not be composed — delivering the counts alone", { meta: {
|
|
@@ -14510,6 +15036,34 @@ var NcSummaryRankSchema = require_dist._enum([
|
|
|
14510
15036
|
"dwell"
|
|
14511
15037
|
]);
|
|
14512
15038
|
/**
|
|
15039
|
+
* How much TEXT is burnt into the mosaic — and the answer is NONE by default.
|
|
15040
|
+
*
|
|
15041
|
+
* `none` is the shipped default and, crucially, the reading of an ABSENT field:
|
|
15042
|
+
* `.default()` on the Input schema materialises it, and the persisted schema
|
|
15043
|
+
* extends Input, so a rule authored before this option existed parses as `none`
|
|
15044
|
+
* on the next read. No migration, no backfill, no rewrite of a stored rule.
|
|
15045
|
+
*
|
|
15046
|
+
* The operator asked for "solo immagini". Three reasons it is the right
|
|
15047
|
+
* default rather than a preference:
|
|
15048
|
+
*
|
|
15049
|
+
* - a caption is PERMANENT in the artefact, exactly like a detection box, and
|
|
15050
|
+
* a mosaic is re-read days later;
|
|
15051
|
+
* - the per-tile strip covers 14% of the tile from the bottom edge up, which
|
|
15052
|
+
* is where feet, wheels and parcels are;
|
|
15053
|
+
* - nothing downstream needs it. The AI pass is given the RAW per-track JPEGs
|
|
15054
|
+
* and gets camera, class and time as PROMPT TEXT — it never reads the
|
|
15055
|
+
* mosaic, so the pixels carry no fact the judge would lose.
|
|
15056
|
+
*
|
|
15057
|
+
* `tiles` and `all` keep the old behaviour reachable for an operator who wants
|
|
15058
|
+
* it; `all` is byte-for-byte what every rule produced before this field.
|
|
15059
|
+
*/
|
|
15060
|
+
var NcSummaryCaptionsSchema = require_dist._enum([
|
|
15061
|
+
"none",
|
|
15062
|
+
"tiles",
|
|
15063
|
+
"all"
|
|
15064
|
+
]);
|
|
15065
|
+
var NC_SUMMARY_DEFAULT_CAPTIONS = "none";
|
|
15066
|
+
/**
|
|
14513
15067
|
* The AI section — DECLARED IN P1, WIRED IN P2.
|
|
14514
15068
|
*
|
|
14515
15069
|
* Nothing in this addon reads it today. It is here so the rule shape does not
|
|
@@ -14543,6 +15097,8 @@ var NcSummaryRuleInputSchema = require_dist.object({
|
|
|
14543
15097
|
barrierSeconds: BarrierSecondsField.default(120),
|
|
14544
15098
|
targets: TargetsField,
|
|
14545
15099
|
template: NcSummaryTemplateSchema.optional(),
|
|
15100
|
+
/** Burned onto the mosaic's title band — and ONLY when `captions` is `all`. */
|
|
15101
|
+
captions: NcSummaryCaptionsSchema.default(NC_SUMMARY_DEFAULT_CAPTIONS),
|
|
14546
15102
|
/** Burned onto the mosaic's title band. `''` is reachable and means "no
|
|
14547
15103
|
* caption" — which is why this is not `.min(1)`. */
|
|
14548
15104
|
captionText: require_dist.string().max(200).optional(),
|
|
@@ -14572,6 +15128,7 @@ var NcSummaryRulePatchSchema = require_dist.object({
|
|
|
14572
15128
|
barrierSeconds: BarrierSecondsField.optional(),
|
|
14573
15129
|
targets: TargetsField.optional(),
|
|
14574
15130
|
template: NcSummaryTemplateSchema.nullable().optional(),
|
|
15131
|
+
captions: NcSummaryCaptionsSchema.optional(),
|
|
14575
15132
|
captionText: require_dist.string().max(200).optional(),
|
|
14576
15133
|
deliverEmpty: require_dist.boolean().optional(),
|
|
14577
15134
|
priority: PriorityField.optional(),
|
|
@@ -15106,14 +15663,19 @@ function isSyntheticId(id) {
|
|
|
15106
15663
|
* snapshot that produced it lists the members being counted and the edge
|
|
15107
15664
|
* names one (`chooseOccupancyMediaOwner`), so the tester stands in for that
|
|
15108
15665
|
* member exactly as it stands in for the marker.
|
|
15666
|
+
* - and, since 2026-08-15, a `trackId` for both AUDIO kinds: a sound owns no
|
|
15667
|
+
* frame, so production photographs the camera at the match and holds the
|
|
15668
|
+
* bytes on the still shelf under an owner id the subject names
|
|
15669
|
+
* (`audio-still.ts`). The tester's RAM-held frame stands in for exactly that
|
|
15670
|
+
* photograph, and — unlike every other kind here — it is taken the same way
|
|
15671
|
+
* production takes it, at the same moment, from the same cap.
|
|
15109
15672
|
*
|
|
15110
|
-
*
|
|
15111
|
-
*
|
|
15112
|
-
*
|
|
15113
|
-
* feature that does not exist.
|
|
15673
|
+
* Only `system-event` remains: an infrastructure event is about no camera at
|
|
15674
|
+
* all, so there is nothing to photograph and a tester that invented a picture
|
|
15675
|
+
* for it would be advertising a feature that does not exist.
|
|
15114
15676
|
*/
|
|
15115
15677
|
function triggerCanCarryStill(kind) {
|
|
15116
|
-
return kind
|
|
15678
|
+
return kind !== "system-event";
|
|
15117
15679
|
}
|
|
15118
15680
|
/** The detection provenance production stamps on a subject of this kind. */
|
|
15119
15681
|
function syntheticSource(kind) {
|
|
@@ -15151,7 +15713,7 @@ function audioSubjectOf(input) {
|
|
|
15151
15713
|
function buildSyntheticEvent(input, recordId, now) {
|
|
15152
15714
|
const kind = input.trigger;
|
|
15153
15715
|
const timestamp = input.timestamp ?? now;
|
|
15154
|
-
const carriesTrackId = kind === "object-event" || kind === "track-end" || kind === "device-event" || kind === "occupancy-event";
|
|
15716
|
+
const carriesTrackId = kind === "object-event" || kind === "track-end" || kind === "device-event" || kind === "occupancy-event" || kind === "audio-event" || kind === "audio-window";
|
|
15155
15717
|
return {
|
|
15156
15718
|
subject: {
|
|
15157
15719
|
kind,
|
|
@@ -17644,6 +18206,22 @@ var NotificationCenter = class NotificationCenter {
|
|
|
17644
18206
|
/** States the `deviceState` rule gate reads. Refreshed on the reload tick;
|
|
17645
18207
|
* read with no I/O on the persist hook. */
|
|
17646
18208
|
deviceStates;
|
|
18209
|
+
/** Scene verdicts + latches the `scene` rule gate reads. Same contract as
|
|
18210
|
+
* {@link deviceStates}: refreshed on the reload tick and on the provider's
|
|
18211
|
+
* in-process edge tap, read with no I/O on the persist hook (D49). */
|
|
18212
|
+
sceneStates;
|
|
18213
|
+
/**
|
|
18214
|
+
* The last scene-gate answer REPORTED per rule, so the suppression line is
|
|
18215
|
+
* written on CHANGE rather than on every event.
|
|
18216
|
+
*
|
|
18217
|
+
* A branch that drops work silently must log it, and a gate that logged on
|
|
18218
|
+
* every detection on a busy camera would be a line nobody reads — which is
|
|
18219
|
+
* the same silence by a different route.
|
|
18220
|
+
*/
|
|
18221
|
+
sceneGateReported = /* @__PURE__ */ new Map();
|
|
18222
|
+
/** The watched scene id set, as last REPORTED — so the line is written when it
|
|
18223
|
+
* changes and not on every reload tick. `null` = never reported. */
|
|
18224
|
+
sceneWatchSignature = null;
|
|
17647
18225
|
/** WHO each device id is (name + type). The system-event intake reads it with
|
|
17648
18226
|
* no I/O; the reload tick refreshes it — see `device-directory.ts`. */
|
|
17649
18227
|
deviceDirectory;
|
|
@@ -17715,6 +18293,12 @@ var NotificationCenter = class NotificationCenter {
|
|
|
17715
18293
|
* is the one thing the synthetic producer promises not to do.
|
|
17716
18294
|
*/
|
|
17717
18295
|
syntheticMedia = /* @__PURE__ */ new Map();
|
|
18296
|
+
/**
|
|
18297
|
+
* The photograph a SOUND rule's notification carries — see `audio-still.ts`.
|
|
18298
|
+
* Null when this wiring has no snapshot cap, which leaves every audio
|
|
18299
|
+
* notification text-only rather than broken.
|
|
18300
|
+
*/
|
|
18301
|
+
audioStills;
|
|
17718
18302
|
/** Per-device rate limit for the "matched NO rule" report — see `reportNoMatch`. */
|
|
17719
18303
|
lastNoMatchReportAt = /* @__PURE__ */ new Map();
|
|
17720
18304
|
noMatchSuppressed = /* @__PURE__ */ new Map();
|
|
@@ -18006,6 +18590,12 @@ var NotificationCenter = class NotificationCenter {
|
|
|
18006
18590
|
this.deps = deps;
|
|
18007
18591
|
this.logger = deps.logger;
|
|
18008
18592
|
this.now = deps.now ?? (() => Date.now());
|
|
18593
|
+
const getSnapshot = deps.getSnapshot;
|
|
18594
|
+
this.audioStills = getSnapshot !== void 0 ? new NcAudioStillShelf({
|
|
18595
|
+
getSnapshot,
|
|
18596
|
+
logger: this.logger.child("audio-still"),
|
|
18597
|
+
now: this.now
|
|
18598
|
+
}) : null;
|
|
18009
18599
|
this.rules = new NcRuleStore({
|
|
18010
18600
|
store: deps.store,
|
|
18011
18601
|
logger: this.logger.child("rules"),
|
|
@@ -18043,6 +18633,11 @@ var NotificationCenter = class NotificationCenter {
|
|
|
18043
18633
|
readStates: deps.readDeviceStates ?? (async () => /* @__PURE__ */ new Map()),
|
|
18044
18634
|
...deps.now !== void 0 ? { now: deps.now } : {}
|
|
18045
18635
|
});
|
|
18636
|
+
this.sceneStates = new NcSceneStateCache({
|
|
18637
|
+
logger: this.logger.child("scene-state"),
|
|
18638
|
+
readScenes: deps.readSceneStates ?? (() => []),
|
|
18639
|
+
...deps.now !== void 0 ? { now: deps.now } : {}
|
|
18640
|
+
});
|
|
18046
18641
|
this.deviceDirectory = new NcDeviceDirectory({
|
|
18047
18642
|
logger: this.logger.child("device-directory"),
|
|
18048
18643
|
listDevices: deps.listDeviceIdentities ?? (async () => {
|
|
@@ -18078,6 +18673,8 @@ var NotificationCenter = class NotificationCenter {
|
|
|
18078
18673
|
getMediaForOwner: async (ownerKind, ownerId) => {
|
|
18079
18674
|
const held = isSyntheticId(ownerId) ? this.syntheticMedia.get(ownerId) : void 0;
|
|
18080
18675
|
if (held !== void 0) return held.files;
|
|
18676
|
+
const audio = isAudioStillId(ownerId) ? this.audioStills?.get(ownerId) : void 0;
|
|
18677
|
+
if (audio !== void 0) return audio;
|
|
18081
18678
|
return deps.dispatcher.getMediaForOwner(ownerKind, ownerId);
|
|
18082
18679
|
},
|
|
18083
18680
|
...deps.now !== void 0 ? { now: deps.now } : {},
|
|
@@ -18242,6 +18839,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
18242
18839
|
await this.deviceDirectory.refresh();
|
|
18243
18840
|
await this.hydrateLiveness();
|
|
18244
18841
|
await this.zoneOwners.refresh();
|
|
18842
|
+
this.sceneStates.refresh();
|
|
18245
18843
|
this.evaluationActive = true;
|
|
18246
18844
|
await this.outbox.load();
|
|
18247
18845
|
await this.seedCooldowns();
|
|
@@ -18281,6 +18879,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
18281
18879
|
}
|
|
18282
18880
|
this.timelapseScheduler?.stop();
|
|
18283
18881
|
this.summaryProducer?.stop();
|
|
18882
|
+
this.audioStills?.clear();
|
|
18284
18883
|
this.evaluationActive = false;
|
|
18285
18884
|
}
|
|
18286
18885
|
/**
|
|
@@ -18610,7 +19209,35 @@ var NotificationCenter = class NotificationCenter {
|
|
|
18610
19209
|
* durable guarantee begins at this hook, matching the device-event boundary.
|
|
18611
19210
|
*/
|
|
18612
19211
|
onAudioEventPersisted(event) {
|
|
18613
|
-
this.consumeEvent(incomingFromAudioEvent(event));
|
|
19212
|
+
this.consumeEvent(incomingFromAudioEvent(event, this.audioStillOwner(event.deviceId, event.timestamp, "legacy")));
|
|
19213
|
+
}
|
|
19214
|
+
/**
|
|
19215
|
+
* Start photographing `deviceId` NOW and return the owner id the audio
|
|
19216
|
+
* subject should name — or `undefined` when nothing would use it.
|
|
19217
|
+
*
|
|
19218
|
+
* **The capture is not awaited.** A sound is transient and a notification
|
|
19219
|
+
* about a scream must not wait on a camera; the bytes land while the outbox
|
|
19220
|
+
* row queues, and the dispatcher's ordinary bounded still-wait picks them up.
|
|
19221
|
+
* That is also why this runs BEFORE the cooldown and the mute are consulted:
|
|
19222
|
+
* by the time a rule's window has been judged the moment is gone.
|
|
19223
|
+
*
|
|
19224
|
+
* **The gate is the RULE's media policy, not the trigger.** Photographing a
|
|
19225
|
+
* camera on every confirmed window for rules that ship text anyway would be a
|
|
19226
|
+
* snapshot at whatever rate the sound happens to occur, for nothing. The rule
|
|
19227
|
+
* cache is an in-memory mirror, so this is not a fallible read (D49) — a
|
|
19228
|
+
* failed one would look exactly like "no rule wants a picture", which is the
|
|
19229
|
+
* direction that DESTROYS the media.
|
|
19230
|
+
*/
|
|
19231
|
+
audioStillOwner(deviceId, atMs, mode) {
|
|
19232
|
+
const shelf = this.audioStills;
|
|
19233
|
+
if (shelf === null || !this.evaluationActive) return void 0;
|
|
19234
|
+
for (const rule of this.rules.listEnabled("immediate")) {
|
|
19235
|
+
if (rule.media.attach === "none") continue;
|
|
19236
|
+
if (!(mode === "condition" ? rule.conditions.audio !== void 0 : rule.conditions.audio === void 0 && referencesAudioClass(rule.conditions.classes))) continue;
|
|
19237
|
+
const devices = rule.conditions.devices;
|
|
19238
|
+
if (devices !== void 0 && devices.length > 0 && !devices.includes(deviceId)) continue;
|
|
19239
|
+
return shelf.capture(deviceId, atMs);
|
|
19240
|
+
}
|
|
18614
19241
|
}
|
|
18615
19242
|
/**
|
|
18616
19243
|
* Called at the package object-event persist site (`PackageDropDetector` —
|
|
@@ -18708,7 +19335,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
18708
19335
|
tags: { deviceId },
|
|
18709
19336
|
meta: audioHitMeta(hit)
|
|
18710
19337
|
});
|
|
18711
|
-
this.consumeEvent(incomingFromAudioWindow(hit));
|
|
19338
|
+
this.consumeEvent(incomingFromAudioWindow(hit, this.audioStillOwner(deviceId, hit.timestamp, "condition")));
|
|
18712
19339
|
}
|
|
18713
19340
|
}
|
|
18714
19341
|
/**
|
|
@@ -19006,6 +19633,73 @@ var NotificationCenter = class NotificationCenter {
|
|
|
19006
19633
|
if (identity.isCamera && identity.detectionActive === false) return "detection-off";
|
|
19007
19634
|
}
|
|
19008
19635
|
/**
|
|
19636
|
+
* SAY that the scene gate suppressed this rule.
|
|
19637
|
+
*
|
|
19638
|
+
* A branch that drops work silently logs it — the three-hour media blackout
|
|
19639
|
+
* produced not one line, and a gate is the same shape of failure: from the
|
|
19640
|
+
* operator's side "the rule stopped notifying" and "the pipeline stopped
|
|
19641
|
+
* working" look identical. `rule did not match` covers it at DEBUG, which is
|
|
19642
|
+
* not a level this installation runs at.
|
|
19643
|
+
*
|
|
19644
|
+
* On CHANGE only, per rule: the alternative is one line per detection on a
|
|
19645
|
+
* busy camera, which is silence by a different route. The `deviceId` tag is
|
|
19646
|
+
* the SUBJECT's — that is the camera whose notification went missing, and the
|
|
19647
|
+
* question is always asked per-camera. The SCENE's own device is named in the
|
|
19648
|
+
* meta beside its id, because the two are frequently different cameras.
|
|
19649
|
+
*/
|
|
19650
|
+
reportSceneSuppression(rule, subject) {
|
|
19651
|
+
const condition = rule.conditions.scene;
|
|
19652
|
+
if (condition === void 0) return;
|
|
19653
|
+
const observation = this.sceneStates.get(condition.sceneId);
|
|
19654
|
+
const reason = sceneGateReason(condition, observation);
|
|
19655
|
+
if (this.sceneGateReported.get(rule.id) === reason) return;
|
|
19656
|
+
this.sceneGateReported.set(rule.id, reason);
|
|
19657
|
+
this.logger.info("rule suppressed by its scene condition", {
|
|
19658
|
+
tags: { deviceId: subject.deviceId },
|
|
19659
|
+
meta: {
|
|
19660
|
+
ruleId: rule.id,
|
|
19661
|
+
rule: rule.name,
|
|
19662
|
+
sceneId: condition.sceneId,
|
|
19663
|
+
sceneLabel: observation?.label,
|
|
19664
|
+
sceneDeviceId: observation?.deviceId ?? condition.deviceId,
|
|
19665
|
+
requiredState: condition.requiredState,
|
|
19666
|
+
reason
|
|
19667
|
+
}
|
|
19668
|
+
});
|
|
19669
|
+
}
|
|
19670
|
+
/**
|
|
19671
|
+
* A confirmed scene transition, straight from the provider in this process.
|
|
19672
|
+
*
|
|
19673
|
+
* Synchronous on purpose and for the same reason {@link onAlarmPublish} is:
|
|
19674
|
+
* the reload tick would otherwise be the only writer, and a rule gated on
|
|
19675
|
+
* "while the bin is out" would keep its old answer for up to thirty seconds
|
|
19676
|
+
* after the bin came back. The tick stays as reconciliation.
|
|
19677
|
+
*
|
|
19678
|
+
* A DIRECT CALL, not a bus subscription. `EnrichmentSceneStateChanged` is
|
|
19679
|
+
* still emitted for the badge and for anyone else, but D8 events are
|
|
19680
|
+
* droppable telemetry and a gate fed by droppable telemetry is a gate that is
|
|
19681
|
+
* sometimes wrong and never says so.
|
|
19682
|
+
*
|
|
19683
|
+
* This never OPENS a gate that the mirror had shut for lack of an entry: it
|
|
19684
|
+
* writes an observation the caller actually made. It is `refresh` that
|
|
19685
|
+
* removes entries, and it never empties on a failed read (D49).
|
|
19686
|
+
*/
|
|
19687
|
+
onSceneEdge(observation) {
|
|
19688
|
+
this.sceneStates.note(observation);
|
|
19689
|
+
}
|
|
19690
|
+
/**
|
|
19691
|
+
* Reconcile the scene mirror now.
|
|
19692
|
+
*
|
|
19693
|
+
* The scene provider is constructed AFTER this centre starts (it needs the
|
|
19694
|
+
* media store and the embedding encoder), so the boot refresh reads an empty
|
|
19695
|
+
* provider. The addon calls this once the provider has hydrated, rather than
|
|
19696
|
+
* leaving every scene-gated rule shut for a whole reload tick after every
|
|
19697
|
+
* respawn.
|
|
19698
|
+
*/
|
|
19699
|
+
refreshSceneStates() {
|
|
19700
|
+
this.sceneStates.refresh();
|
|
19701
|
+
}
|
|
19702
|
+
/**
|
|
19009
19703
|
* Everything the panel reports, on every publish.
|
|
19010
19704
|
*
|
|
19011
19705
|
* Synchronous on purpose. The state half refreshes the `deviceState` gate's
|
|
@@ -19304,8 +19998,9 @@ var NotificationCenter = class NotificationCenter {
|
|
|
19304
19998
|
let noOwnerReported = false;
|
|
19305
19999
|
const rejections = [];
|
|
19306
20000
|
for (const rule of candidates) {
|
|
19307
|
-
const evaluation = evaluateRule(rule, subject, (id) => this.deviceStates.get(id), (id) => this.zoneOwners.get(id));
|
|
20001
|
+
const evaluation = evaluateRule(rule, subject, (id) => this.deviceStates.get(id), (id) => this.zoneOwners.get(id), (sceneId) => this.sceneStates.get(sceneId));
|
|
19308
20002
|
if (!evaluation.matched) {
|
|
20003
|
+
if (evaluation.failedCondition === "scene") this.reportSceneSuppression(rule, subject);
|
|
19309
20004
|
rejections.push(`${rule.name}:${evaluation.failedCondition ?? "unknown"}`);
|
|
19310
20005
|
outcomes.push({
|
|
19311
20006
|
ruleId: rule.id,
|
|
@@ -19330,6 +20025,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
19330
20025
|
});
|
|
19331
20026
|
continue;
|
|
19332
20027
|
}
|
|
20028
|
+
this.sceneGateReported.delete(rule.id);
|
|
19333
20029
|
this.runRuleActions(rule, subject, kind, controls);
|
|
19334
20030
|
const admission = this.admitToGroup(rule, subject, now);
|
|
19335
20031
|
if (admission.outcome === "unchanged") {
|
|
@@ -19456,6 +20152,19 @@ var NotificationCenter = class NotificationCenter {
|
|
|
19456
20152
|
}
|
|
19457
20153
|
});
|
|
19458
20154
|
}
|
|
20155
|
+
if (!noOwnerReported && (kind === "audio-window" || kind === "audio-event") && subject.trackId === void 0 && rule.media.attach !== "none") {
|
|
20156
|
+
noOwnerReported = true;
|
|
20157
|
+
this.logger.warn("audio match has no still owner — this notification ships text-only", {
|
|
20158
|
+
tags: { deviceId: subject.deviceId },
|
|
20159
|
+
meta: {
|
|
20160
|
+
recordId: subject.recordId,
|
|
20161
|
+
kind,
|
|
20162
|
+
ruleId: rule.id,
|
|
20163
|
+
rule: rule.name,
|
|
20164
|
+
snapshotWired: this.audioStills !== null
|
|
20165
|
+
}
|
|
20166
|
+
});
|
|
20167
|
+
}
|
|
19459
20168
|
const userTargets = await this.resolveUserTargets(rule, subject.deviceId);
|
|
19460
20169
|
const entries = this.buildEntries(rule, subject, kind, evaluation.matchedOn, userTargets, origin, {
|
|
19461
20170
|
key,
|
|
@@ -19612,6 +20321,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
19612
20321
|
admitToGroup(rule, subject, now) {
|
|
19613
20322
|
const idleSec = rule.groupIdleSec;
|
|
19614
20323
|
if (idleSec === void 0 || idleSec <= 0) return { outcome: "disabled" };
|
|
20324
|
+
if (subject.kind === "audio-window" || subject.kind === "audio-event") return { outcome: "disabled" };
|
|
19615
20325
|
const trackId = subject.trackId;
|
|
19616
20326
|
if (trackId === void 0) return { outcome: "disabled" };
|
|
19617
20327
|
const member = {
|
|
@@ -19961,10 +20671,13 @@ var NotificationCenter = class NotificationCenter {
|
|
|
19961
20671
|
this.reportAudioWatch(audioWatch);
|
|
19962
20672
|
const gated = [];
|
|
19963
20673
|
const zoneScoped = [];
|
|
20674
|
+
const sceneIds = [];
|
|
19964
20675
|
for (const rule of this.rules.list()) {
|
|
19965
20676
|
if (!rule.enabled) continue;
|
|
19966
20677
|
const ds = rule.conditions.deviceState;
|
|
19967
20678
|
if (ds !== void 0) gated.push(ds.deviceId);
|
|
20679
|
+
const scene = rule.conditions.scene;
|
|
20680
|
+
if (scene !== void 0) sceneIds.push(scene.sceneId);
|
|
19968
20681
|
const zones = rule.conditions.zones;
|
|
19969
20682
|
const devices = rule.conditions.devices;
|
|
19970
20683
|
if (zones !== void 0 && zones.ids.length > 0 && devices !== void 0) zoneScoped.push(...devices);
|
|
@@ -19973,6 +20686,15 @@ var NotificationCenter = class NotificationCenter {
|
|
|
19973
20686
|
if (panel !== null) gated.push(...alarmContactWatchList(this.rules.list(), panel.deviceId, panel.availableModes()));
|
|
19974
20687
|
this.deviceStates.setWatched(gated);
|
|
19975
20688
|
this.zoneOwners.setWatched(zoneScoped);
|
|
20689
|
+
this.sceneStates.setWatched(sceneIds);
|
|
20690
|
+
const signature = [...new Set(sceneIds)].sort().join(",");
|
|
20691
|
+
if (signature !== this.sceneWatchSignature) {
|
|
20692
|
+
this.sceneWatchSignature = signature;
|
|
20693
|
+
this.logger.info("scene gate watched set", { meta: {
|
|
20694
|
+
scenes: this.sceneStates.watched(),
|
|
20695
|
+
gatedRules: sceneIds.length
|
|
20696
|
+
} });
|
|
20697
|
+
}
|
|
19976
20698
|
}
|
|
19977
20699
|
/**
|
|
19978
20700
|
* Feed every excluded sensor's mirrored state back to the panel.
|
|
@@ -20109,6 +20831,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
20109
20831
|
await this.deviceStates.refresh();
|
|
20110
20832
|
await this.deviceDirectory.refresh();
|
|
20111
20833
|
await this.zoneOwners.refresh();
|
|
20834
|
+
this.sceneStates.refresh();
|
|
20112
20835
|
this.observeAlarmContacts();
|
|
20113
20836
|
await this.occupancyStore.pruneExcept(this.activeOccupancyKeys());
|
|
20114
20837
|
}
|
|
@@ -20406,12 +21129,17 @@ var NotificationCenter = class NotificationCenter {
|
|
|
20406
21129
|
const fresh = await this.deps.readDeviceZoneIds(zoneScope);
|
|
20407
21130
|
zoneLookup = (id) => fresh.get(id) ?? this.zoneOwners.get(id);
|
|
20408
21131
|
} catch {}
|
|
21132
|
+
let sceneLookup = (id) => this.sceneStates.get(id);
|
|
21133
|
+
if (rule.conditions.scene !== void 0 && this.deps.readSceneStates !== void 0) try {
|
|
21134
|
+
const fresh = new Map(this.deps.readSceneStates().map((o) => [o.sceneId, o]));
|
|
21135
|
+
sceneLookup = (id) => fresh.get(id) ?? this.sceneStates.get(id);
|
|
21136
|
+
} catch {}
|
|
20409
21137
|
const occupancy = rule.conditions.occupancy;
|
|
20410
|
-
if (rule.delivery === "device-event" && occupancy !== void 0) return this.occupancyDryRun(rule, occupancy, lookup, zoneLookup);
|
|
21138
|
+
if (rule.delivery === "device-event" && occupancy !== void 0) return this.occupancyDryRun(rule, occupancy, lookup, zoneLookup, sceneLookup);
|
|
20411
21139
|
const results = [];
|
|
20412
21140
|
for (const subject of subjects.sort((a, b) => b.timestamp - a.timestamp)) {
|
|
20413
21141
|
if (results.length >= TEST_RULE_MAX_RESULTS) break;
|
|
20414
|
-
const evaluation = evaluateRule(rule, subject, lookup, zoneLookup);
|
|
21142
|
+
const evaluation = evaluateRule(rule, subject, lookup, zoneLookup, sceneLookup);
|
|
20415
21143
|
results.push({
|
|
20416
21144
|
recordId: subject.recordId,
|
|
20417
21145
|
recordKind: subject.kind === "track-end" ? "track" : subject.kind === "audio-event" || subject.kind === "audio-window" ? "object-event" : subject.kind === "occupancy-event" ? "device-event" : subject.kind,
|
|
@@ -20435,7 +21163,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
20435
21163
|
* from the ordinary `evaluateRule` on the ordinary subject — this method
|
|
20436
21164
|
* chooses the cameras and writes the sentence, nothing else.
|
|
20437
21165
|
*/
|
|
20438
|
-
async occupancyDryRun(rule, occupancy, lookup, zoneLookup) {
|
|
21166
|
+
async occupancyDryRun(rule, occupancy, lookup, zoneLookup, sceneLookup) {
|
|
20439
21167
|
const read = this.deps.readOccupancySnapshots;
|
|
20440
21168
|
if (read === void 0) return [];
|
|
20441
21169
|
const now = this.now();
|
|
@@ -20478,7 +21206,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
20478
21206
|
});
|
|
20479
21207
|
continue;
|
|
20480
21208
|
}
|
|
20481
|
-
const evaluation = evaluateRule(rule, probe.subject, lookup, zoneLookup);
|
|
21209
|
+
const evaluation = evaluateRule(rule, probe.subject, lookup, zoneLookup, sceneLookup);
|
|
20482
21210
|
results.push({
|
|
20483
21211
|
...common,
|
|
20484
21212
|
recordId: probe.subject.recordId,
|
|
@@ -20733,6 +21461,10 @@ function planRuleTest(input) {
|
|
|
20733
21461
|
conditionId: "deviceState",
|
|
20734
21462
|
note: `This rule only fires while device #${c.deviceState.deviceId} is in one of its chosen states. That gate reads the REAL device and cannot be simulated — put the device in that state first, or the test fails closed exactly as production would.`
|
|
20735
21463
|
});
|
|
21464
|
+
if (c.scene !== void 0) notes.push({
|
|
21465
|
+
conditionId: "scene",
|
|
21466
|
+
note: `This rule only fires while scene ${c.scene.sceneId} is ${c.scene.requiredState}. That gate reads the scene's REAL state and cannot be simulated — the test answers with whatever the scene is right now, and fails closed while it cannot judge or has not been read yet, exactly as production would.`
|
|
21467
|
+
});
|
|
20736
21468
|
if (c.customZones !== void 0 && c.customZones.length > 0) notes.push({
|
|
20737
21469
|
conditionId: "customZones",
|
|
20738
21470
|
note: "The subject is placed over the first drawn polygon so the condition passes. A rule that needs a specific polygon should be checked in the simulator, where the box can be moved."
|
|
@@ -25978,6 +26710,72 @@ function decideZeroMediaPolicy(input, config = DEFAULT_ZERO_MEDIA_POLICY_CONFIG)
|
|
|
25978
26710
|
}
|
|
25979
26711
|
//#endregion
|
|
25980
26712
|
//#region src/pipeline-analytics/pipeline/delete-track-cascade.ts
|
|
26713
|
+
var EMPTY_CASCADE_TALLY = {
|
|
26714
|
+
tracks: 0,
|
|
26715
|
+
events: 0,
|
|
26716
|
+
media: 0,
|
|
26717
|
+
faces: 0,
|
|
26718
|
+
plates: 0,
|
|
26719
|
+
embeddings: 0,
|
|
26720
|
+
bytesReclaimed: 0
|
|
26721
|
+
};
|
|
26722
|
+
/** Fold a leaf store's receipts into a running tally. */
|
|
26723
|
+
function addLeafCounts(tally, counts) {
|
|
26724
|
+
let next = tally;
|
|
26725
|
+
for (const c of counts) {
|
|
26726
|
+
const bytesReclaimed = next.bytesReclaimed + c.bytes;
|
|
26727
|
+
switch (c.family) {
|
|
26728
|
+
case "events":
|
|
26729
|
+
next = {
|
|
26730
|
+
...next,
|
|
26731
|
+
events: next.events + c.rows,
|
|
26732
|
+
bytesReclaimed
|
|
26733
|
+
};
|
|
26734
|
+
break;
|
|
26735
|
+
case "media":
|
|
26736
|
+
next = {
|
|
26737
|
+
...next,
|
|
26738
|
+
media: next.media + c.rows,
|
|
26739
|
+
bytesReclaimed
|
|
26740
|
+
};
|
|
26741
|
+
break;
|
|
26742
|
+
case "faces":
|
|
26743
|
+
next = {
|
|
26744
|
+
...next,
|
|
26745
|
+
faces: next.faces + c.rows,
|
|
26746
|
+
bytesReclaimed
|
|
26747
|
+
};
|
|
26748
|
+
break;
|
|
26749
|
+
case "plates":
|
|
26750
|
+
next = {
|
|
26751
|
+
...next,
|
|
26752
|
+
plates: next.plates + c.rows,
|
|
26753
|
+
bytesReclaimed
|
|
26754
|
+
};
|
|
26755
|
+
break;
|
|
26756
|
+
case "embeddings":
|
|
26757
|
+
next = {
|
|
26758
|
+
...next,
|
|
26759
|
+
embeddings: next.embeddings + c.rows,
|
|
26760
|
+
bytesReclaimed
|
|
26761
|
+
};
|
|
26762
|
+
break;
|
|
26763
|
+
}
|
|
26764
|
+
}
|
|
26765
|
+
return next;
|
|
26766
|
+
}
|
|
26767
|
+
/** Sum two tallies (per-track results into a per-device one). */
|
|
26768
|
+
function mergeCascadeTallies(a, b) {
|
|
26769
|
+
return {
|
|
26770
|
+
tracks: a.tracks + b.tracks,
|
|
26771
|
+
events: a.events + b.events,
|
|
26772
|
+
media: a.media + b.media,
|
|
26773
|
+
faces: a.faces + b.faces,
|
|
26774
|
+
plates: a.plates + b.plates,
|
|
26775
|
+
embeddings: a.embeddings + b.embeddings,
|
|
26776
|
+
bytesReclaimed: a.bytesReclaimed + b.bytesReclaimed
|
|
26777
|
+
};
|
|
26778
|
+
}
|
|
25981
26779
|
/**
|
|
25982
26780
|
* Extensible whole-track deletion cascade. Runs every registered
|
|
25983
26781
|
* `TrackScopedStore` (each removes its ephemeral rows + owned media for the
|
|
@@ -25994,8 +26792,16 @@ function decideZeroMediaPolicy(input, config = DEFAULT_ZERO_MEDIA_POLICY_CONFIG)
|
|
|
25994
26792
|
* ]
|
|
25995
26793
|
*/
|
|
25996
26794
|
async function cascadeDeleteTracks(registry, trackStore, trackIds) {
|
|
25997
|
-
|
|
25998
|
-
for (const
|
|
26795
|
+
let tally = EMPTY_CASCADE_TALLY;
|
|
26796
|
+
for (const store of registry) tally = addLeafCounts(tally, await store.deleteByTracks(trackIds));
|
|
26797
|
+
for (const trackId of trackIds) {
|
|
26798
|
+
await trackStore.deletePersisted(trackId);
|
|
26799
|
+
tally = {
|
|
26800
|
+
...tally,
|
|
26801
|
+
tracks: tally.tracks + 1
|
|
26802
|
+
};
|
|
26803
|
+
}
|
|
26804
|
+
return tally;
|
|
25999
26805
|
}
|
|
26000
26806
|
/**
|
|
26001
26807
|
* The one registry, in the one order — leaves first, root deleted internally
|
|
@@ -26026,14 +26832,28 @@ function buildAnalyticsCascadeRegistry(stores) {
|
|
|
26026
26832
|
* embeddings) is opted in by the CALLER's registry, not by this legacy shim.
|
|
26027
26833
|
*/
|
|
26028
26834
|
async function cascadeDeleteTrack(stores, trackId) {
|
|
26029
|
-
|
|
26835
|
+
return cascadeDeleteTracks([{ deleteByTracks: async (ids) => {
|
|
26836
|
+
let events = 0;
|
|
26837
|
+
let media = 0;
|
|
26030
26838
|
for (const id of ids) {
|
|
26031
26839
|
const eventIds = await stores.eventStore.deleteByTrack(id);
|
|
26032
|
-
|
|
26840
|
+
events += eventIds.length;
|
|
26841
|
+
if (eventIds.length > 0) media += await stores.mediaStore.deleteForEvents([...eventIds]);
|
|
26033
26842
|
}
|
|
26034
|
-
|
|
26035
|
-
|
|
26036
|
-
|
|
26843
|
+
return [{
|
|
26844
|
+
family: "events",
|
|
26845
|
+
rows: events,
|
|
26846
|
+
bytes: 0
|
|
26847
|
+
}, {
|
|
26848
|
+
family: "media",
|
|
26849
|
+
rows: media,
|
|
26850
|
+
bytes: 0
|
|
26851
|
+
}];
|
|
26852
|
+
} }, { deleteByTracks: async (ids) => [{
|
|
26853
|
+
family: "media",
|
|
26854
|
+
rows: await stores.mediaStore.deleteForTracks([...ids]),
|
|
26855
|
+
bytes: 0
|
|
26856
|
+
}] }], stores.trackStore, [trackId]);
|
|
26037
26857
|
}
|
|
26038
26858
|
/**
|
|
26039
26859
|
* Widened batch whole-track deletion — the ONE engine behind the three §5 entry
|
|
@@ -26044,9 +26864,10 @@ async function cascadeDeleteTrack(stores, trackId) {
|
|
|
26044
26864
|
*/
|
|
26045
26865
|
async function runTrackCascadeBatch(deps, trackIds) {
|
|
26046
26866
|
let deleted = 0;
|
|
26867
|
+
let tally = EMPTY_CASCADE_TALLY;
|
|
26047
26868
|
const failed = [];
|
|
26048
26869
|
for (const trackId of trackIds) try {
|
|
26049
|
-
await cascadeDeleteTracks(deps.registry, deps.trackStore, [trackId]);
|
|
26870
|
+
tally = mergeCascadeTallies(tally, await cascadeDeleteTracks(deps.registry, deps.trackStore, [trackId]));
|
|
26050
26871
|
deps.onTrackCleanup(deps.deviceId, trackId);
|
|
26051
26872
|
deleted += 1;
|
|
26052
26873
|
} catch (err) {
|
|
@@ -26055,7 +26876,8 @@ async function runTrackCascadeBatch(deps, trackIds) {
|
|
|
26055
26876
|
}
|
|
26056
26877
|
return {
|
|
26057
26878
|
deleted,
|
|
26058
|
-
failed
|
|
26879
|
+
failed,
|
|
26880
|
+
tally
|
|
26059
26881
|
};
|
|
26060
26882
|
}
|
|
26061
26883
|
//#endregion
|
|
@@ -27468,6 +28290,88 @@ var PlateGalleryProvider = class {
|
|
|
27468
28290
|
}
|
|
27469
28291
|
};
|
|
27470
28292
|
//#endregion
|
|
28293
|
+
//#region src/pipeline-analytics/retention/orphan-audit-actions.ts
|
|
28294
|
+
/**
|
|
28295
|
+
* The operator button for the orphan audit.
|
|
28296
|
+
*
|
|
28297
|
+
* The audit engine, its five bounds and its resumable cursor already existed
|
|
28298
|
+
* and had run in `report` mode for weeks; what it had no way to be was
|
|
28299
|
+
* *asked*. Its only trigger was a 6-hourly timer reading a durable setting, so
|
|
28300
|
+
* draining a standing backlog meant flipping a persistent mode, waiting up to
|
|
28301
|
+
* six hours, and remembering to flip it back — with nothing returned to look
|
|
28302
|
+
* at. On 2026-08-14 the backlog was 74,700 media rows / 18.9 GB plus 23,296
|
|
28303
|
+
* object events and 4,543 CLIP vectors (D161), and that is not a thing to
|
|
28304
|
+
* drain by leaving a switch on.
|
|
28305
|
+
*
|
|
28306
|
+
* Three properties this action does NOT leave to the caller's care:
|
|
28307
|
+
*
|
|
28308
|
+
* 1. **Dry run is the default.** `mode` omitted means `report`: it counts,
|
|
28309
|
+
* logs and returns, and deletes nothing — regardless of what the durable
|
|
28310
|
+
* `orphanAuditMode` setting says. `reclaim` is a word that has to be
|
|
28311
|
+
* typed. The engine's own default is the same for the same reason.
|
|
28312
|
+
* 2. **It is paced.** `pageSize` bounds one read, `maxRowsPerScope` bounds
|
|
28313
|
+
* one pass, `maxReclaimPerScope` bounds the damage, and the engine yields
|
|
28314
|
+
* the thread between pages — `better-sqlite3` is synchronous and shares
|
|
28315
|
+
* hub-main with every live frame.
|
|
28316
|
+
* 3. **It answers.** The report comes back per collection with rows AND
|
|
28317
|
+
* bytes, so "did it do anything" is not a question anyone has to infer
|
|
28318
|
+
* from a log line.
|
|
28319
|
+
*/
|
|
28320
|
+
/**
|
|
28321
|
+
* `report` counts; `reclaim` deletes.
|
|
28322
|
+
*
|
|
28323
|
+
* `.default('report')` is load-bearing, not decoration: the settings form
|
|
28324
|
+
* sends `{}` for a button with no fields, and the safe interpretation of an
|
|
28325
|
+
* unstated intent here is the one that destroys nothing.
|
|
28326
|
+
*/
|
|
28327
|
+
var OrphanAuditActionModeSchema = require_dist._enum(["report", "reclaim"]).default("report");
|
|
28328
|
+
var OrphanAuditActionInputSchema = require_dist.object({
|
|
28329
|
+
mode: OrphanAuditActionModeSchema,
|
|
28330
|
+
/**
|
|
28331
|
+
* Start every scope from the top instead of resuming its persisted cursor.
|
|
28332
|
+
*
|
|
28333
|
+
* What draining a backlog wants, and never the default — the cursor exists
|
|
28334
|
+
* so a 6-hourly pass makes progress instead of re-walking the same head.
|
|
28335
|
+
*/
|
|
28336
|
+
restart: require_dist.boolean().optional(),
|
|
28337
|
+
/** Rows read per statement. Smaller = more yields = a gentler pass. */
|
|
28338
|
+
pageSize: require_dist.number().int().min(50).max(5e3).optional(),
|
|
28339
|
+
/** Rows EXAMINED per collection in this pass — the pacing bound. */
|
|
28340
|
+
maxRowsPerScope: require_dist.number().int().min(1).max(2e5).optional(),
|
|
28341
|
+
/** Rows DELETED per collection in this pass — the blast-radius bound. */
|
|
28342
|
+
maxReclaimPerScope: require_dist.number().int().min(1).max(1e5).optional(),
|
|
28343
|
+
/**
|
|
28344
|
+
* Minutes of immunity for a recent row. Protective ONLY — raising it can
|
|
28345
|
+
* exclusively save rows, never select them, which is what keeps this off
|
|
28346
|
+
* the one-age-keyed-sweep guard's books.
|
|
28347
|
+
*/
|
|
28348
|
+
graceMinutes: require_dist.number().int().min(1).max(10080).optional()
|
|
28349
|
+
});
|
|
28350
|
+
var OrphanAuditScopeReportSchema = require_dist.object({
|
|
28351
|
+
collection: require_dist.string(),
|
|
28352
|
+
examined: require_dist.number(),
|
|
28353
|
+
orphans: require_dist.number(),
|
|
28354
|
+
reclaimed: require_dist.number(),
|
|
28355
|
+
bytesReclaimed: require_dist.number(),
|
|
28356
|
+
exempt: require_dist.number(),
|
|
28357
|
+
withinGrace: require_dist.number(),
|
|
28358
|
+
unresolvable: require_dist.number(),
|
|
28359
|
+
stoppedBy: require_dist.string(),
|
|
28360
|
+
/** Where the NEXT pass resumes. `0` = the scope was walked to the end. */
|
|
28361
|
+
nextCursor: require_dist.number()
|
|
28362
|
+
});
|
|
28363
|
+
var OrphanAuditActionResultSchema = require_dist.object({
|
|
28364
|
+
/** `false` when a pass was already in flight — nothing was done. */
|
|
28365
|
+
ran: require_dist.boolean(),
|
|
28366
|
+
mode: require_dist._enum(["report", "reclaim"]),
|
|
28367
|
+
scopes: require_dist.array(OrphanAuditScopeReportSchema),
|
|
28368
|
+
totalOrphans: require_dist.number(),
|
|
28369
|
+
totalReclaimed: require_dist.number(),
|
|
28370
|
+
totalBytesReclaimed: require_dist.number(),
|
|
28371
|
+
durationMs: require_dist.number()
|
|
28372
|
+
});
|
|
28373
|
+
var orphanAuditActions = require_dist.defineCustomActions({ "retention.orphanAudit": require_dist.customAction(OrphanAuditActionInputSchema, OrphanAuditActionResultSchema, { auth: "admin" }) });
|
|
28374
|
+
//#endregion
|
|
27471
28375
|
//#region src/pipeline-analytics/videoclips-provider.ts
|
|
27472
28376
|
var SOURCE = "analytics";
|
|
27473
28377
|
function clipIdFor(eventId, startMs, endMs) {
|
|
@@ -33211,13 +34115,25 @@ var StoragePressureTracker = class {
|
|
|
33211
34115
|
};
|
|
33212
34116
|
//#endregion
|
|
33213
34117
|
//#region src/pipeline-analytics/store/media-store.ts
|
|
34118
|
+
/**
|
|
34119
|
+
* `scene` is its own owner kind rather than borrowing `event`.
|
|
34120
|
+
*
|
|
34121
|
+
* Scene reference thumbnails were written as `ownerKind: 'event'` with an
|
|
34122
|
+
* `ownerId` of `scene-<monitorId>-…`, which resolves through
|
|
34123
|
+
* `resolveMediaOwner` to `{ kind: 'event', eventId: 'scene-…' }` — an event row
|
|
34124
|
+
* that has never existed. The orphan audit therefore classified every
|
|
34125
|
+
* operator-captured reference picture as collectable. It is the same class of
|
|
34126
|
+
* mistake `identity` and `vehicle` are exempt for: a blob the OPERATOR curated,
|
|
34127
|
+
* which nothing else can regenerate and no cascade owns.
|
|
34128
|
+
*/
|
|
33214
34129
|
var OWNER_KINDS = [
|
|
33215
34130
|
"event",
|
|
33216
34131
|
"track",
|
|
33217
34132
|
"face",
|
|
33218
34133
|
"identity",
|
|
33219
34134
|
"plate",
|
|
33220
|
-
"vehicle"
|
|
34135
|
+
"vehicle",
|
|
34136
|
+
"scene"
|
|
33221
34137
|
];
|
|
33222
34138
|
/**
|
|
33223
34139
|
* Narrow an arbitrary string to an `OwnerKind`.
|
|
@@ -33678,21 +34594,33 @@ var MediaStore = class {
|
|
|
33678
34594
|
* Best-effort per row; returns the number of index rows removed.
|
|
33679
34595
|
*/
|
|
33680
34596
|
async deleteForOwner(ownerKind, ownerIds) {
|
|
34597
|
+
return (await this.gated(() => this.deleteForOwnerUnchecked(ownerKind, ownerIds))).rows;
|
|
34598
|
+
}
|
|
34599
|
+
/**
|
|
34600
|
+
* The same delete, reporting the BYTES it released as well as the row count.
|
|
34601
|
+
*
|
|
34602
|
+
* `sizeBytes` is already on the index row, so the number costs nothing — it
|
|
34603
|
+
* was simply never returned, which is why every retention ops-log row this
|
|
34604
|
+
* addon has ever written says `bytesReclaimed: 0` (D161).
|
|
34605
|
+
*/
|
|
34606
|
+
async deleteForOwnerCounted(ownerKind, ownerIds) {
|
|
33681
34607
|
return this.gated(() => this.deleteForOwnerUnchecked(ownerKind, ownerIds));
|
|
33682
34608
|
}
|
|
33683
34609
|
async deleteForOwnerUnchecked(ownerKind, ownerIds) {
|
|
33684
|
-
let
|
|
34610
|
+
let rows = 0;
|
|
34611
|
+
let bytes = 0;
|
|
33685
34612
|
for (const ownerId of ownerIds) {
|
|
33686
|
-
const
|
|
34613
|
+
const found = await this.store.query.query({
|
|
33687
34614
|
collection: MEDIA_COLLECTION,
|
|
33688
34615
|
filter: { where: {
|
|
33689
34616
|
ownerKind,
|
|
33690
34617
|
ownerId
|
|
33691
34618
|
} }
|
|
33692
34619
|
});
|
|
33693
|
-
for (const row of
|
|
34620
|
+
for (const row of found) {
|
|
33694
34621
|
const rowData = row.data;
|
|
33695
34622
|
const path = String(rowData["path"] ?? "");
|
|
34623
|
+
const sizeBytes = Number(rowData["sizeBytes"]);
|
|
33696
34624
|
try {
|
|
33697
34625
|
if (path) await this.storage.delete({
|
|
33698
34626
|
location: mediaRowLocation(rowData),
|
|
@@ -33704,7 +34632,8 @@ var MediaStore = class {
|
|
|
33704
34632
|
collection: MEDIA_COLLECTION,
|
|
33705
34633
|
key: row.id
|
|
33706
34634
|
});
|
|
33707
|
-
|
|
34635
|
+
rows++;
|
|
34636
|
+
if (Number.isFinite(sizeBytes)) bytes += sizeBytes;
|
|
33708
34637
|
} catch (err) {
|
|
33709
34638
|
this.logger.debug("media delete-for-owner failed", { meta: {
|
|
33710
34639
|
key: row.id,
|
|
@@ -33713,7 +34642,10 @@ var MediaStore = class {
|
|
|
33713
34642
|
}
|
|
33714
34643
|
}
|
|
33715
34644
|
}
|
|
33716
|
-
return
|
|
34645
|
+
return {
|
|
34646
|
+
rows,
|
|
34647
|
+
bytes
|
|
34648
|
+
};
|
|
33717
34649
|
}
|
|
33718
34650
|
/**
|
|
33719
34651
|
* On-demand footprint aggregation for the events-management UI: sum
|
|
@@ -33852,11 +34784,20 @@ var MediaStore = class {
|
|
|
33852
34784
|
* batch.
|
|
33853
34785
|
*/
|
|
33854
34786
|
async deleteByTracks(trackIds) {
|
|
33855
|
-
await this.gated(async () => {
|
|
33856
|
-
await this.deleteForOwnerUnchecked("track", trackIds);
|
|
33857
|
-
await this.deleteForOwnerUnchecked("face", trackIds.map((id) => FACE_MEDIA_OWNER_PREFIX + id));
|
|
34787
|
+
const own = await this.gated(async () => {
|
|
34788
|
+
const tracks = await this.deleteForOwnerUnchecked("track", trackIds);
|
|
34789
|
+
const faces = await this.deleteForOwnerUnchecked("face", trackIds.map((id) => FACE_MEDIA_OWNER_PREFIX + id));
|
|
34790
|
+
return {
|
|
34791
|
+
rows: tracks.rows + faces.rows,
|
|
34792
|
+
bytes: tracks.bytes + faces.bytes
|
|
34793
|
+
};
|
|
33858
34794
|
});
|
|
33859
|
-
await this.
|
|
34795
|
+
const plates = await this.deleteForOwnerCounted("plate", trackIds.map((id) => PLATE_MEDIA_OWNER_PREFIX + id));
|
|
34796
|
+
return [{
|
|
34797
|
+
family: "media",
|
|
34798
|
+
rows: own.rows + plates.rows,
|
|
34799
|
+
bytes: own.bytes + plates.bytes
|
|
34800
|
+
}];
|
|
33860
34801
|
}
|
|
33861
34802
|
/**
|
|
33862
34803
|
* A stable page of media INDEX rows for the orphan audit — no blobs, no
|
|
@@ -38294,11 +39235,21 @@ var EventStore = class {
|
|
|
38294
39235
|
error: String(err)
|
|
38295
39236
|
} });
|
|
38296
39237
|
}
|
|
39238
|
+
let mediaRows = 0;
|
|
38297
39239
|
if (eventIds.length > 0 && this.media !== void 0) try {
|
|
38298
|
-
await this.media.deleteForEvents(eventIds);
|
|
39240
|
+
mediaRows = await this.media.deleteForEvents(eventIds);
|
|
38299
39241
|
} catch (err) {
|
|
38300
39242
|
this.logger.warn("EventStore.deleteByTracks: event media delete failed", { meta: { error: String(err) } });
|
|
38301
39243
|
}
|
|
39244
|
+
return [{
|
|
39245
|
+
family: "events",
|
|
39246
|
+
rows: eventIds.length,
|
|
39247
|
+
bytes: 0
|
|
39248
|
+
}, {
|
|
39249
|
+
family: "media",
|
|
39250
|
+
rows: mediaRows,
|
|
39251
|
+
bytes: 0
|
|
39252
|
+
}];
|
|
38302
39253
|
}
|
|
38303
39254
|
};
|
|
38304
39255
|
function slimMotion(id, data) {
|
|
@@ -38689,6 +39640,7 @@ var FaceStore = class {
|
|
|
38689
39640
|
* aborts the batch. (Crop media of these rows is removed by `MediaStore`.)
|
|
38690
39641
|
*/
|
|
38691
39642
|
async deleteByTracks(trackIds) {
|
|
39643
|
+
let removed = 0;
|
|
38692
39644
|
for (const trackId of trackIds) try {
|
|
38693
39645
|
const eligible = (await this.store.query.query({
|
|
38694
39646
|
collection: FACES_COLLECTION,
|
|
@@ -38699,6 +39651,7 @@ var FaceStore = class {
|
|
|
38699
39651
|
collection: FACES_COLLECTION,
|
|
38700
39652
|
key: row.id
|
|
38701
39653
|
});
|
|
39654
|
+
removed++;
|
|
38702
39655
|
} catch (err) {
|
|
38703
39656
|
this.logger.warn("FaceStore.deleteByTracks delete failed", { meta: {
|
|
38704
39657
|
faceId: row.id,
|
|
@@ -38711,6 +39664,11 @@ var FaceStore = class {
|
|
|
38711
39664
|
error: String(err)
|
|
38712
39665
|
} });
|
|
38713
39666
|
}
|
|
39667
|
+
return [{
|
|
39668
|
+
family: "faces",
|
|
39669
|
+
rows: removed,
|
|
39670
|
+
bytes: 0
|
|
39671
|
+
}];
|
|
38714
39672
|
}
|
|
38715
39673
|
/** Delete a single buffered face row by id (its crop media is removed by the
|
|
38716
39674
|
* caller — the FaceStore owns rows, not blobs). Best-effort. */
|
|
@@ -39646,6 +40604,7 @@ var PlateStore = class {
|
|
|
39646
40604
|
* the batch. (Crop media of these rows is removed by `MediaStore`.)
|
|
39647
40605
|
*/
|
|
39648
40606
|
async deleteByTracks(trackIds) {
|
|
40607
|
+
let removed = 0;
|
|
39649
40608
|
for (const trackId of trackIds) try {
|
|
39650
40609
|
const eligible = (await this.store.query.query({
|
|
39651
40610
|
collection: PLATES_COLLECTION,
|
|
@@ -39656,6 +40615,7 @@ var PlateStore = class {
|
|
|
39656
40615
|
collection: PLATES_COLLECTION,
|
|
39657
40616
|
key: row.id
|
|
39658
40617
|
});
|
|
40618
|
+
removed++;
|
|
39659
40619
|
} catch (err) {
|
|
39660
40620
|
this.logger.warn("PlateStore.deleteByTracks delete failed", { meta: {
|
|
39661
40621
|
plateId: row.id,
|
|
@@ -39668,6 +40628,11 @@ var PlateStore = class {
|
|
|
39668
40628
|
error: String(err)
|
|
39669
40629
|
} });
|
|
39670
40630
|
}
|
|
40631
|
+
return [{
|
|
40632
|
+
family: "plates",
|
|
40633
|
+
rows: removed,
|
|
40634
|
+
bytes: 0
|
|
40635
|
+
}];
|
|
39671
40636
|
}
|
|
39672
40637
|
/**
|
|
39673
40638
|
* Hold the unassigned plate buffer to `maxPerDevice` newest reads per camera.
|
|
@@ -40789,6 +41754,36 @@ var TrackStore = class {
|
|
|
40789
41754
|
dropActive(trackId) {
|
|
40790
41755
|
this.active.delete(trackId);
|
|
40791
41756
|
}
|
|
41757
|
+
/**
|
|
41758
|
+
* Close a still-ACTIVE track NOW: persist its row, then drop the in-RAM
|
|
41759
|
+
* entry. The early-close counterpart of {@link expireStale}, for a caller
|
|
41760
|
+
* that knows the track is over before its TTL says so.
|
|
41761
|
+
*
|
|
41762
|
+
* **This exists because {@link dropActive} orphans everything the track
|
|
41763
|
+
* owns.** A track's media, object events and CLIP vector are keyed by trackId
|
|
41764
|
+
* and written throughout its life; the ROW is what retention cascades from,
|
|
41765
|
+
* and it is written only here or at expiry. Forgetting a lived-in track
|
|
41766
|
+
* therefore leaves a full media set — key frame, thumbnail, first/last frame,
|
|
41767
|
+
* the snapshot filmstrip — with no owner any sweep can ever reach. Measured on
|
|
41768
|
+
* the live hub 2026-08-14: 74,700 such media rows, 18.9 GB, growing
|
|
41769
|
+
* ~1.15 GB/day, plus 23,296 object events and 4,543 CLIP vectors (D161).
|
|
41770
|
+
*
|
|
41771
|
+
* `dropActive` is still correct for a birth RETRACTED before it lived —
|
|
41772
|
+
* nothing has been written under that id yet. It is never correct for a track
|
|
41773
|
+
* that ran.
|
|
41774
|
+
*
|
|
41775
|
+
* Returns whether an active entry was found. THROWS if the persist fails: the
|
|
41776
|
+
* caller must log it, because a swallowed failure here IS the leak.
|
|
41777
|
+
*/
|
|
41778
|
+
async closeActive(trackId) {
|
|
41779
|
+
const t = this.active.get(trackId);
|
|
41780
|
+
if (t === void 0) return false;
|
|
41781
|
+
t.active = false;
|
|
41782
|
+
const record = cloneTrack(t);
|
|
41783
|
+
await this.persistCompleted(record);
|
|
41784
|
+
this.active.delete(trackId);
|
|
41785
|
+
return true;
|
|
41786
|
+
}
|
|
40792
41787
|
/** Delete the persisted track row (keyed by trackId) and drop the in-RAM
|
|
40793
41788
|
* active entry if present. Used by the whole-track deletion cascade. */
|
|
40794
41789
|
async deletePersisted(trackId) {
|
|
@@ -41920,7 +42915,11 @@ function auditableCollections() {
|
|
|
41920
42915
|
* exemption lives here now, and the orphan collector — the only sweep that
|
|
41921
42916
|
* walks media it was not handed by name — reads it.
|
|
41922
42917
|
*/
|
|
41923
|
-
var RETENTION_EXEMPT_OWNER_KINDS = new Set([
|
|
42918
|
+
var RETENTION_EXEMPT_OWNER_KINDS = new Set([
|
|
42919
|
+
"identity",
|
|
42920
|
+
"vehicle",
|
|
42921
|
+
"scene"
|
|
42922
|
+
]);
|
|
41924
42923
|
function isRetentionExemptOwnerKind(kind) {
|
|
41925
42924
|
return RETENTION_EXEMPT_OWNER_KINDS.has(kind);
|
|
41926
42925
|
}
|
|
@@ -42139,6 +43138,7 @@ async function auditScope(scope, deps, limits, cursor) {
|
|
|
42139
43138
|
} });
|
|
42140
43139
|
}
|
|
42141
43140
|
}
|
|
43141
|
+
if (deps.breathe) await deps.breathe();
|
|
42142
43142
|
if (ceilingHit) {
|
|
42143
43143
|
stoppedBy = "reclaim-ceiling";
|
|
42144
43144
|
offset += rows.length - reclaimedInPage;
|
|
@@ -44858,22 +45858,6 @@ async function projectSensorMarkers(deps, data, timestamp) {
|
|
|
44858
45858
|
}
|
|
44859
45859
|
return landed;
|
|
44860
45860
|
}
|
|
44861
|
-
/** JPEG quality for the downscaled full frame — matches the crop path. */
|
|
44862
|
-
var FULL_FRAME_QUALITY = 80;
|
|
44863
|
-
/**
|
|
44864
|
-
* Downscale an already-encoded JPEG full frame to FIT WITHIN
|
|
44865
|
-
* {@link FULL_FRAME_MAX_WIDTH}×{@link FULL_FRAME_MAX_HEIGHT}, preserving aspect
|
|
44866
|
-
* ratio (`fit: 'inside'`) and never enlarging a source already smaller than the
|
|
44867
|
-
* box. Re-encodes as JPEG. Used before persisting a synthetic sensor/control
|
|
44868
|
-
* track's whole-scene snapshot so a raw native-resolution frame (a 4K bedroom
|
|
44869
|
-
* at night) is never stored or served — the privacy fix moved to CAPTURE time.
|
|
44870
|
-
*/
|
|
44871
|
-
async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
|
|
44872
|
-
return (0, sharp.default)(Buffer.from(jpeg)).resize(maxWidth, maxHeight, {
|
|
44873
|
-
fit: "inside",
|
|
44874
|
-
withoutEnlargement: true
|
|
44875
|
-
}).jpeg({ quality: FULL_FRAME_QUALITY }).toBuffer();
|
|
44876
|
-
}
|
|
44877
45861
|
//#endregion
|
|
44878
45862
|
//#region src/pipeline-analytics/services/synthetic-track.ts
|
|
44879
45863
|
/**
|
|
@@ -46914,7 +47898,11 @@ var ObjectEmbeddingStore = class {
|
|
|
46914
47898
|
* breaks recognition (design §2/§4).
|
|
46915
47899
|
*/
|
|
46916
47900
|
async deleteByTracks(trackIds) {
|
|
46917
|
-
if (trackIds.length === 0) return
|
|
47901
|
+
if (trackIds.length === 0) return [{
|
|
47902
|
+
family: "embeddings",
|
|
47903
|
+
rows: 0,
|
|
47904
|
+
bytes: 0
|
|
47905
|
+
}];
|
|
46918
47906
|
try {
|
|
46919
47907
|
await this.vectorStore.deleteByIds.mutate({
|
|
46920
47908
|
index: OBJECT_CLIP_INDEX,
|
|
@@ -46925,7 +47913,17 @@ var ObjectEmbeddingStore = class {
|
|
|
46925
47913
|
count: trackIds.length,
|
|
46926
47914
|
error: String(err)
|
|
46927
47915
|
} });
|
|
47916
|
+
return [{
|
|
47917
|
+
family: "embeddings",
|
|
47918
|
+
rows: 0,
|
|
47919
|
+
bytes: 0
|
|
47920
|
+
}];
|
|
46928
47921
|
}
|
|
47922
|
+
return [{
|
|
47923
|
+
family: "embeddings",
|
|
47924
|
+
rows: trackIds.length,
|
|
47925
|
+
bytes: 0
|
|
47926
|
+
}];
|
|
46929
47927
|
}
|
|
46930
47928
|
};
|
|
46931
47929
|
/** Build the vector-store metadata for one embedding. */
|
|
@@ -47799,7 +48797,8 @@ var NO_PUBLICATION = {
|
|
|
47799
48797
|
var customActions = {
|
|
47800
48798
|
...ncActions,
|
|
47801
48799
|
...embeddingActions,
|
|
47802
|
-
...debugActions
|
|
48800
|
+
...debugActions,
|
|
48801
|
+
...orphanAuditActions
|
|
47803
48802
|
};
|
|
47804
48803
|
/**
|
|
47805
48804
|
* Assist threshold when the caller does not pin one. Packages have their own
|
|
@@ -48777,7 +49776,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
48777
49776
|
customActions: {
|
|
48778
49777
|
...ncActions,
|
|
48779
49778
|
...embeddingActions,
|
|
48780
|
-
...debugActions
|
|
49779
|
+
...debugActions,
|
|
49780
|
+
...orphanAuditActions
|
|
48781
49781
|
},
|
|
48782
49782
|
actionHandlers: {
|
|
48783
49783
|
...ncHandlers,
|
|
@@ -49402,6 +50402,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
49402
50402
|
addonId: device.addonId
|
|
49403
50403
|
};
|
|
49404
50404
|
},
|
|
50405
|
+
getSnapshot: (deviceId) => api.snapshot.getSnapshot.query({ deviceId }),
|
|
49405
50406
|
identityIdsByName: async () => {
|
|
49406
50407
|
const byName = /* @__PURE__ */ new Map();
|
|
49407
50408
|
const identities = await stores.identityStore.listIdentities();
|
|
@@ -49495,6 +50496,14 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
49495
50496
|
}
|
|
49496
50497
|
return out;
|
|
49497
50498
|
},
|
|
50499
|
+
readSceneStates: () => (this.sceneProvider?.listAllEnabled() ?? []).map(({ deviceId, monitor }) => ({
|
|
50500
|
+
sceneId: monitor.id,
|
|
50501
|
+
deviceId,
|
|
50502
|
+
label: monitor.label,
|
|
50503
|
+
verdict: monitor.verdict,
|
|
50504
|
+
latched: monitor.latched,
|
|
50505
|
+
emit: monitor.emit
|
|
50506
|
+
})),
|
|
49498
50507
|
readOccupancySnapshots: async (deviceIds) => {
|
|
49499
50508
|
const za = this.zoneAnalytics;
|
|
49500
50509
|
if (za === null) return /* @__PURE__ */ new Map();
|
|
@@ -50270,13 +51279,16 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
50270
51279
|
if (media === null) throw new Error("media store not ready");
|
|
50271
51280
|
return media.putReplacing({
|
|
50272
51281
|
deviceId,
|
|
50273
|
-
ownerKind: "
|
|
51282
|
+
ownerKind: "scene",
|
|
50274
51283
|
ownerId,
|
|
50275
51284
|
kind: "thumbnailSmall",
|
|
50276
51285
|
timestamp,
|
|
50277
51286
|
data
|
|
50278
51287
|
});
|
|
50279
51288
|
},
|
|
51289
|
+
dropMedia: async (mediaId) => {
|
|
51290
|
+
await this.mediaStore?.deleteByKey(mediaId);
|
|
51291
|
+
},
|
|
50280
51292
|
mirrorSlice: (deviceId, status) => {
|
|
50281
51293
|
this.mirrorSceneSlice(deviceId, status);
|
|
50282
51294
|
},
|
|
@@ -50293,6 +51305,16 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
50293
51305
|
data: payload
|
|
50294
51306
|
});
|
|
50295
51307
|
},
|
|
51308
|
+
onSceneEdge: (edge) => {
|
|
51309
|
+
this.notificationCenter?.onSceneEdge({
|
|
51310
|
+
sceneId: edge.monitorId,
|
|
51311
|
+
deviceId: edge.deviceId,
|
|
51312
|
+
label: edge.monitorLabel,
|
|
51313
|
+
verdict: edge.verdict,
|
|
51314
|
+
latched: edge.latched,
|
|
51315
|
+
emit: edge.emit
|
|
51316
|
+
});
|
|
51317
|
+
},
|
|
50296
51318
|
currentCondition: async (deviceId) => resolveCondition({
|
|
50297
51319
|
...await this.sceneConditionContext(deviceId),
|
|
50298
51320
|
now: /* @__PURE__ */ new Date()
|
|
@@ -50301,6 +51323,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
50301
51323
|
});
|
|
50302
51324
|
provider.hydrate();
|
|
50303
51325
|
this.sceneProvider = provider;
|
|
51326
|
+
this.notificationCenter?.refreshSceneStates();
|
|
50304
51327
|
if (!this.isPostProcessingNode) {
|
|
50305
51328
|
log.info("scene evaluation INERT on this node — CRUD only");
|
|
50306
51329
|
return;
|
|
@@ -52762,7 +53785,17 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
52762
53785
|
};
|
|
52763
53786
|
if (plausible) this.stationaryRegistry?.promote(entry);
|
|
52764
53787
|
processor.dropTrack(track.trackId);
|
|
52765
|
-
this.trackStore?.
|
|
53788
|
+
this.trackStore?.closeActive(track.trackId).catch((err) => {
|
|
53789
|
+
this.ctx.logger.warn("stationary promotion: closing the source track failed — its media, events and vector are now unowned", {
|
|
53790
|
+
tags: { deviceId },
|
|
53791
|
+
meta: {
|
|
53792
|
+
deviceId,
|
|
53793
|
+
trackId: track.trackId,
|
|
53794
|
+
entryId: entry.id,
|
|
53795
|
+
error: String(err)
|
|
53796
|
+
}
|
|
53797
|
+
});
|
|
53798
|
+
});
|
|
52766
53799
|
const faceEnd = this.faceRecognizer?.onTrackEnd(deviceId, track.trackId);
|
|
52767
53800
|
const dropKeyFrame = () => {
|
|
52768
53801
|
this.residents.dropKeyFrameKey(track.trackId);
|
|
@@ -53352,19 +54385,19 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
53352
54385
|
* stops at a per-scope row ceiling, RESUMES from a persisted cursor next run,
|
|
53353
54386
|
* and refuses to start if the previous run has not finished.
|
|
53354
54387
|
*/
|
|
53355
|
-
async runOrphanAuditPass() {
|
|
53356
|
-
if (this.shuttingDown) return;
|
|
54388
|
+
async runOrphanAuditPass(overrides) {
|
|
54389
|
+
if (this.shuttingDown) return null;
|
|
53357
54390
|
if (this.orphanAuditRunning) {
|
|
53358
54391
|
this.ctx.logger.info("analytics orphan audit skipped — previous run still in flight");
|
|
53359
|
-
return;
|
|
54392
|
+
return null;
|
|
53360
54393
|
}
|
|
53361
54394
|
const trackStore = this.trackStore;
|
|
53362
54395
|
const eventStore = this.eventStore;
|
|
53363
54396
|
const mediaStore = this.mediaStore;
|
|
53364
|
-
if (!trackStore || !eventStore || !mediaStore) return;
|
|
54397
|
+
if (!trackStore || !eventStore || !mediaStore) return null;
|
|
53365
54398
|
this.orphanAuditRunning = true;
|
|
53366
54399
|
try {
|
|
53367
|
-
const limits = await this.orphanAuditLimits();
|
|
54400
|
+
const limits = await this.orphanAuditLimits(overrides);
|
|
53368
54401
|
const deps = {
|
|
53369
54402
|
page: async (collection, offset, limit) => {
|
|
53370
54403
|
if (collection === "pipeline-analytics:media") return mediaStore.pageForAudit(offset, limit);
|
|
@@ -53396,13 +54429,14 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
53396
54429
|
});
|
|
53397
54430
|
},
|
|
53398
54431
|
now: () => Date.now(),
|
|
53399
|
-
logger: this.ctx.logger
|
|
54432
|
+
logger: this.ctx.logger,
|
|
54433
|
+
breathe: () => new Promise((resolve) => setImmediate(resolve))
|
|
53400
54434
|
};
|
|
53401
54435
|
const { report, cursors } = await runOrphanAudit({
|
|
53402
54436
|
scopes: buildOrphanScopes(),
|
|
53403
54437
|
deps,
|
|
53404
54438
|
limits,
|
|
53405
|
-
cursors: this.orphanAuditCursors
|
|
54439
|
+
cursors: overrides?.restart === true ? /* @__PURE__ */ new Map() : this.orphanAuditCursors
|
|
53406
54440
|
});
|
|
53407
54441
|
this.orphanAuditCursors = cursors;
|
|
53408
54442
|
for (const scope of report.scopes) {
|
|
@@ -53437,8 +54471,10 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
53437
54471
|
bytesReclaimed: report.totalBytesReclaimed,
|
|
53438
54472
|
detail: `orphan audit (${report.mode}): ${report.totalOrphans} orphaned rows across ${report.scopes.length} collections, ${report.totalReclaimed} reclaimed in ${report.durationMs}ms`
|
|
53439
54473
|
});
|
|
54474
|
+
return report;
|
|
53440
54475
|
} catch (err) {
|
|
53441
54476
|
this.ctx.logger.warn("analytics orphan audit failed", { meta: { error: String(err) } });
|
|
54477
|
+
return null;
|
|
53442
54478
|
} finally {
|
|
53443
54479
|
this.orphanAuditRunning = false;
|
|
53444
54480
|
}
|
|
@@ -53501,15 +54537,65 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
53501
54537
|
* raising them is raising the blast radius of a bug, and that should take a
|
|
53502
54538
|
* release.
|
|
53503
54539
|
*/
|
|
53504
|
-
async orphanAuditLimits() {
|
|
54540
|
+
async orphanAuditLimits(overrides) {
|
|
53505
54541
|
const mode = await this.orphanAuditModeState.get();
|
|
53506
54542
|
const pageSize = await this.orphanAuditPageSizeState.get();
|
|
54543
|
+
const graceMinutes = overrides?.graceMinutes;
|
|
54544
|
+
return {
|
|
54545
|
+
mode: overrides !== void 0 ? overrides.mode ?? "report" : mode === "reclaim" ? "reclaim" : "report",
|
|
54546
|
+
graceMs: graceMinutes === void 0 ? DEFAULT_ORPHAN_LIMITS.graceMs : graceMinutes * 6e4,
|
|
54547
|
+
pageSize: overrides?.pageSize ?? pageSize,
|
|
54548
|
+
maxRowsPerScope: overrides?.maxRowsPerScope ?? DEFAULT_ORPHAN_LIMITS.maxRowsPerScope,
|
|
54549
|
+
maxReclaimPerScope: overrides?.maxReclaimPerScope ?? DEFAULT_ORPHAN_LIMITS.maxReclaimPerScope
|
|
54550
|
+
};
|
|
54551
|
+
}
|
|
54552
|
+
/**
|
|
54553
|
+
* One operator-driven orphan-audit pass — the entry point behind
|
|
54554
|
+
* `retention.orphanAudit`.
|
|
54555
|
+
*
|
|
54556
|
+
* DRY RUN BY DEFAULT: an input with no `mode` reports and deletes nothing,
|
|
54557
|
+
* whatever `orphanAuditMode` is set to. Reclaiming is a word the caller has to
|
|
54558
|
+
* type. Draining the standing 18.9 GB means running this with
|
|
54559
|
+
* `mode: 'reclaim'` repeatedly until `totalOrphans` reaches 0 (D161).
|
|
54560
|
+
*/
|
|
54561
|
+
async runOrphanAuditNow(overrides) {
|
|
54562
|
+
const report = await this.runOrphanAuditPass(overrides);
|
|
54563
|
+
if (report === null) return {
|
|
54564
|
+
ran: false,
|
|
54565
|
+
mode: overrides.mode ?? "report",
|
|
54566
|
+
scopes: [],
|
|
54567
|
+
totalOrphans: 0,
|
|
54568
|
+
totalReclaimed: 0,
|
|
54569
|
+
totalBytesReclaimed: 0,
|
|
54570
|
+
durationMs: 0
|
|
54571
|
+
};
|
|
54572
|
+
this.ctx.logger.info("analytics orphan audit (operator)", { meta: {
|
|
54573
|
+
mode: report.mode,
|
|
54574
|
+
orphans: report.totalOrphans,
|
|
54575
|
+
reclaimed: report.totalReclaimed,
|
|
54576
|
+
bytesReclaimed: report.totalBytesReclaimed,
|
|
54577
|
+
durationMs: report.durationMs,
|
|
54578
|
+
restart: overrides.restart === true
|
|
54579
|
+
} });
|
|
53507
54580
|
return {
|
|
53508
|
-
|
|
53509
|
-
|
|
53510
|
-
|
|
53511
|
-
|
|
53512
|
-
|
|
54581
|
+
ran: true,
|
|
54582
|
+
mode: report.mode,
|
|
54583
|
+
scopes: report.scopes.map((scope) => ({
|
|
54584
|
+
collection: scope.collection,
|
|
54585
|
+
examined: scope.examined,
|
|
54586
|
+
orphans: scope.orphans,
|
|
54587
|
+
reclaimed: scope.reclaimed,
|
|
54588
|
+
bytesReclaimed: scope.bytesReclaimed,
|
|
54589
|
+
exempt: scope.exempt,
|
|
54590
|
+
withinGrace: scope.withinGrace,
|
|
54591
|
+
unresolvable: scope.unresolvable,
|
|
54592
|
+
stoppedBy: scope.stoppedBy,
|
|
54593
|
+
nextCursor: scope.nextCursor
|
|
54594
|
+
})),
|
|
54595
|
+
totalOrphans: report.totalOrphans,
|
|
54596
|
+
totalReclaimed: report.totalReclaimed,
|
|
54597
|
+
totalBytesReclaimed: report.totalBytesReclaimed,
|
|
54598
|
+
durationMs: report.durationMs
|
|
53513
54599
|
};
|
|
53514
54600
|
}
|
|
53515
54601
|
/**
|
|
@@ -53709,7 +54795,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
53709
54795
|
indexed
|
|
53710
54796
|
};
|
|
53711
54797
|
},
|
|
53712
|
-
"embedding.wipe": async () => this.wipeObjectEmbeddings()
|
|
54798
|
+
"embedding.wipe": async () => this.wipeObjectEmbeddings(),
|
|
54799
|
+
"retention.orphanAudit": async (input) => this.runOrphanAuditNow(input)
|
|
53713
54800
|
};
|
|
53714
54801
|
}
|
|
53715
54802
|
/** Progress of the detached rebuild — the only way to observe a long pass. */
|
|
@@ -53968,8 +55055,14 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
53968
55055
|
* Track-centric time-based retention (design §5.1). Drains every persisted
|
|
53969
55056
|
* track for the device whose `lastSeen < cutoffMs`, page by page, through the
|
|
53970
55057
|
* widened cascade — enrolled faces/plates + identity media are exempt (design
|
|
53971
|
-
* §4).
|
|
53972
|
-
*
|
|
55058
|
+
* §4).
|
|
55059
|
+
*
|
|
55060
|
+
* **Every family count here is REAL.** It used to return `tracks` and a
|
|
55061
|
+
* literal 0 for every leaf, because `TrackScopedStore.deleteByTracks` returned
|
|
55062
|
+
* `void` — so `bytesReclaimed: 0` went onto all 3,732 ops-log rows this addon
|
|
55063
|
+
* had ever written and 18.9 GB of leaked media read as "nothing to reclaim"
|
|
55064
|
+
* (D161). The bytes ride {@link lastPruneBytes} rather than
|
|
55065
|
+
* `TrackCascadeCounts`, which is a cap output shape.
|
|
53973
55066
|
*/
|
|
53974
55067
|
async pruneTracksBefore(input) {
|
|
53975
55068
|
const empty = {
|
|
@@ -53980,26 +55073,32 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
53980
55073
|
plates: 0,
|
|
53981
55074
|
embeddings: 0
|
|
53982
55075
|
};
|
|
55076
|
+
this.lastPruneBytes = 0;
|
|
53983
55077
|
const cascade = this.buildTrackCascadeRegistry();
|
|
53984
55078
|
if (!cascade) return empty;
|
|
55079
|
+
let tally = EMPTY_CASCADE_TALLY;
|
|
53985
55080
|
const { deleted: totalDeleted, skipped } = await drainTracksBefore({
|
|
53986
55081
|
stopped: () => this.shuttingDown,
|
|
53987
55082
|
listIdsBefore: (offset, limit) => cascade.trackStore.listIdsBefore(input.deviceId, input.cutoffMs, limit, offset),
|
|
53988
|
-
cascade: (ids) =>
|
|
53989
|
-
|
|
53990
|
-
|
|
53991
|
-
|
|
53992
|
-
|
|
53993
|
-
|
|
53994
|
-
|
|
53995
|
-
|
|
53996
|
-
|
|
53997
|
-
|
|
53998
|
-
|
|
53999
|
-
|
|
54000
|
-
|
|
54001
|
-
|
|
54002
|
-
|
|
55083
|
+
cascade: async (ids) => {
|
|
55084
|
+
const result = await runTrackCascadeBatch({
|
|
55085
|
+
registry: cascade.registry,
|
|
55086
|
+
trackStore: cascade.trackStore,
|
|
55087
|
+
deviceId: input.deviceId,
|
|
55088
|
+
onTrackCleanup: (deviceId, trackId) => this.clearLiveTrackState(deviceId, trackId),
|
|
55089
|
+
onFailure: (trackId, err) => {
|
|
55090
|
+
this.ctx.logger.debug("pruneTracksBefore track failed", {
|
|
55091
|
+
tags: { deviceId: input.deviceId },
|
|
55092
|
+
meta: {
|
|
55093
|
+
trackId,
|
|
55094
|
+
error: String(err)
|
|
55095
|
+
}
|
|
55096
|
+
});
|
|
55097
|
+
}
|
|
55098
|
+
}, ids);
|
|
55099
|
+
tally = mergeCascadeTallies(tally, result.tally);
|
|
55100
|
+
return { deleted: result.deleted };
|
|
55101
|
+
}
|
|
54003
55102
|
});
|
|
54004
55103
|
if (skipped > 0) this.ctx.logger.warn("analytics track retention skipped undeletable rows", {
|
|
54005
55104
|
tags: { deviceId: input.deviceId },
|
|
@@ -54010,20 +55109,41 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
54010
55109
|
pruned: totalDeleted
|
|
54011
55110
|
}
|
|
54012
55111
|
});
|
|
55112
|
+
this.lastPruneBytes = tally.bytesReclaimed;
|
|
54013
55113
|
if (totalDeleted > 0) this.ctx.logger.info("analytics track retention prune", {
|
|
54014
55114
|
tags: { deviceId: input.deviceId },
|
|
54015
55115
|
meta: {
|
|
54016
55116
|
deviceId: input.deviceId,
|
|
54017
55117
|
cutoffMs: input.cutoffMs,
|
|
54018
|
-
tracks: totalDeleted
|
|
55118
|
+
tracks: totalDeleted,
|
|
55119
|
+
events: tally.events,
|
|
55120
|
+
media: tally.media,
|
|
55121
|
+
faces: tally.faces,
|
|
55122
|
+
plates: tally.plates,
|
|
55123
|
+
embeddings: tally.embeddings,
|
|
55124
|
+
bytesReclaimed: tally.bytesReclaimed
|
|
54019
55125
|
}
|
|
54020
55126
|
});
|
|
54021
55127
|
return {
|
|
54022
55128
|
...empty,
|
|
54023
|
-
tracks: totalDeleted
|
|
55129
|
+
tracks: totalDeleted,
|
|
55130
|
+
events: tally.events,
|
|
55131
|
+
media: tally.media,
|
|
55132
|
+
faces: tally.faces,
|
|
55133
|
+
plates: tally.plates,
|
|
55134
|
+
embeddings: tally.embeddings
|
|
54024
55135
|
};
|
|
54025
55136
|
}
|
|
54026
55137
|
/**
|
|
55138
|
+
* Bytes released by the most recent {@link pruneTracksBefore}.
|
|
55139
|
+
*
|
|
55140
|
+
* A field rather than a return value because `TrackCascadeCounts` is a cap
|
|
55141
|
+
* OUTPUT shape and widening it is a codegen change; the ops-log row already
|
|
55142
|
+
* has a `bytesReclaimed` column and this is what fills it. Reset to 0 at the
|
|
55143
|
+
* top of every prune, so a stale value can never be reported as a fresh one.
|
|
55144
|
+
*/
|
|
55145
|
+
lastPruneBytes = 0;
|
|
55146
|
+
/**
|
|
54027
55147
|
* Operator "clean slate" for a device (design §5.3): prune EVERY persisted
|
|
54028
55148
|
* track via the same cascade as `pruneTracksBefore` with `cutoffMs = now`.
|
|
54029
55149
|
* Enrolled gallery + identity media are exempt.
|
|
@@ -54077,8 +55197,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
54077
55197
|
reason: "retention",
|
|
54078
55198
|
deviceId,
|
|
54079
55199
|
itemsAffected: counts.tracks,
|
|
54080
|
-
bytesReclaimed:
|
|
54081
|
-
detail: `track retention cascade: ${counts.tracks} tracks, ${counts.events} events, ${counts.media} media`
|
|
55200
|
+
bytesReclaimed: this.lastPruneBytes,
|
|
55201
|
+
detail: `track retention cascade: ${counts.tracks} tracks, ${counts.events} events, ${counts.media} media, ${counts.faces} faces, ${counts.plates} plates, ${counts.embeddings} vectors`
|
|
54082
55202
|
});
|
|
54083
55203
|
return counts;
|
|
54084
55204
|
},
|