@camstack/addon-post-analysis 1.2.77 → 1.2.79
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{dist-Bs6oboqZ.js → dist-CWoaZE47.js} +274 -6
- package/dist/{dist-CxeFHSSV.mjs → dist-D6_m_zg1.mjs} +269 -7
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/pipeline-analytics/_stub.js +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-BP6Na43Q.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-DhzYNXUk.mjs} +2 -2
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BeCNd7Q3.mjs +26 -0
- package/dist/pipeline-analytics/{hostInit-DTI0jC8p.mjs → hostInit-CcziTvBY.mjs} +2 -2
- package/dist/pipeline-analytics/index.js +1180 -473
- package/dist/pipeline-analytics/index.mjs +1143 -436
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DFckpC3K.mjs +0 -26
|
@@ -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-CWoaZE47.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
|
};
|
|
@@ -4889,8 +4980,29 @@ var SceneMonitorProvider = class {
|
|
|
4889
4980
|
updatedAt: now
|
|
4890
4981
|
};
|
|
4891
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
|
+
}
|
|
4892
5003
|
/** Snapshot → ROI crop + whole-frame anchor → two embeddings + a thumbnail. */
|
|
4893
5004
|
async encodeReference(deviceId, monitor, condition) {
|
|
5005
|
+
const capturedAt = this.now();
|
|
4894
5006
|
const snap = await this.deps.getSnapshot(deviceId, true);
|
|
4895
5007
|
if (snap === null) throw new Error("no snapshot available for this camera");
|
|
4896
5008
|
const encoded = Buffer.from(snap.base64, "base64");
|
|
@@ -4908,15 +5020,15 @@ var SceneMonitorProvider = class {
|
|
|
4908
5020
|
});
|
|
4909
5021
|
const thumbnailMediaId = await this.deps.putMedia?.({
|
|
4910
5022
|
deviceId,
|
|
4911
|
-
ownerId: `scene-${monitor.id}-${condition}`,
|
|
5023
|
+
ownerId: `scene-${monitor.id}-${condition}-${capturedAt}`,
|
|
4912
5024
|
data: roi.crop,
|
|
4913
|
-
timestamp:
|
|
5025
|
+
timestamp: capturedAt
|
|
4914
5026
|
}).catch(() => void 0);
|
|
4915
5027
|
return {
|
|
4916
5028
|
embedding,
|
|
4917
5029
|
modelId: info.modelId,
|
|
4918
5030
|
condition,
|
|
4919
|
-
capturedAt
|
|
5031
|
+
capturedAt,
|
|
4920
5032
|
...thumbnailMediaId !== void 0 ? { thumbnailMediaId } : {},
|
|
4921
5033
|
...anchor !== null ? { anchorEmbedding: anchor } : {}
|
|
4922
5034
|
};
|
|
@@ -5412,6 +5524,256 @@ function observeLabel(deviceId, spec, sample, now) {
|
|
|
5412
5524
|
spec
|
|
5413
5525
|
};
|
|
5414
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
|
+
}
|
|
5415
5777
|
//#endregion
|
|
5416
5778
|
//#region src/shared/llm-vision/prompt-hygiene.ts
|
|
5417
5779
|
/**
|
|
@@ -6022,6 +6384,298 @@ var NcDeviceStateCache = class {
|
|
|
6022
6384
|
}
|
|
6023
6385
|
};
|
|
6024
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
|
|
6025
6679
|
//#region src/notification-center/render-template.ts
|
|
6026
6680
|
/**
|
|
6027
6681
|
* The ONE `{{var}}` renderer of the notification centre.
|
|
@@ -6745,10 +7399,17 @@ function subjectFromSensorEvent(ev, markerTrackId) {
|
|
|
6745
7399
|
* `minConfidence` condition composes naturally). A level-path audio event (no
|
|
6746
7400
|
* `classification`) yields NO class ⇒ it can never satisfy the audio opt-in
|
|
6747
7401
|
* gate, so only classified audio ever notifies (documented boundary). Audio has
|
|
6748
|
-
* no zones / bbox / label
|
|
6749
|
-
*
|
|
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.
|
|
6750
7411
|
*/
|
|
6751
|
-
function subjectFromAudioEvent(ev) {
|
|
7412
|
+
function subjectFromAudioEvent(ev, stillOwnerId) {
|
|
6752
7413
|
const macro = ev.classification?.className;
|
|
6753
7414
|
return {
|
|
6754
7415
|
kind: "audio-event",
|
|
@@ -6758,6 +7419,7 @@ function subjectFromAudioEvent(ev) {
|
|
|
6758
7419
|
classNames: macro !== void 0 ? [`audio-${macro}`] : [],
|
|
6759
7420
|
...ev.classification?.score !== void 0 ? { confidence: ev.classification.score } : {},
|
|
6760
7421
|
zones: [],
|
|
7422
|
+
...stillOwnerId !== void 0 ? { trackId: stillOwnerId } : {},
|
|
6761
7423
|
source: "pipeline"
|
|
6762
7424
|
};
|
|
6763
7425
|
}
|
|
@@ -6778,8 +7440,12 @@ function subjectFromAudioEvent(ev) {
|
|
|
6778
7440
|
* neither is a score, and lending one to `minConfidence` would let a detection
|
|
6779
7441
|
* condition silently re-judge an audio rule on a number that means something
|
|
6780
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.
|
|
6781
7447
|
*/
|
|
6782
|
-
function subjectFromAudioWindow(hit) {
|
|
7448
|
+
function subjectFromAudioWindow(hit, stillOwnerId) {
|
|
6783
7449
|
const spec = hit.spec;
|
|
6784
7450
|
return {
|
|
6785
7451
|
kind: "audio-window",
|
|
@@ -6788,6 +7454,7 @@ function subjectFromAudioWindow(hit) {
|
|
|
6788
7454
|
timestamp: hit.timestamp,
|
|
6789
7455
|
classNames: hit.labels.map((l) => `audio-${l}`),
|
|
6790
7456
|
zones: [],
|
|
7457
|
+
...stillOwnerId !== void 0 ? { trackId: stillOwnerId } : {},
|
|
6791
7458
|
source: "audio",
|
|
6792
7459
|
audioWindow: audioSubjectFor(hit, spec)
|
|
6793
7460
|
};
|
|
@@ -8010,298 +8677,6 @@ var NcTextCatalog = class {
|
|
|
8010
8677
|
}
|
|
8011
8678
|
};
|
|
8012
8679
|
//#endregion
|
|
8013
|
-
//#region src/shared/frame/box-drawer.ts
|
|
8014
|
-
var DEFAULT_COLOR = require_dist.DEFAULT_EVENT_COLOR;
|
|
8015
|
-
var DEFAULT_QUALITY = 80;
|
|
8016
|
-
var STROKE_WIDTH = 3;
|
|
8017
|
-
function escapeXml$2(s) {
|
|
8018
|
-
return s.replace(/[<>&'"]/g, (ch) => {
|
|
8019
|
-
switch (ch) {
|
|
8020
|
-
case "<": return "<";
|
|
8021
|
-
case ">": return ">";
|
|
8022
|
-
case "&": return "&";
|
|
8023
|
-
case "'": return "'";
|
|
8024
|
-
default: return """;
|
|
8025
|
-
}
|
|
8026
|
-
});
|
|
8027
|
-
}
|
|
8028
|
-
/** Clamp a pixel box to the frame so the rect always stays inside [0,W]×[0,H]. */
|
|
8029
|
-
function clampBox(b, frameWidth, frameHeight) {
|
|
8030
|
-
const x = Math.max(0, Math.min(Math.round(b.x), frameWidth - 1));
|
|
8031
|
-
const y = Math.max(0, Math.min(Math.round(b.y), frameHeight - 1));
|
|
8032
|
-
return {
|
|
8033
|
-
x,
|
|
8034
|
-
y,
|
|
8035
|
-
w: Math.max(1, Math.min(Math.round(b.w), frameWidth - x)),
|
|
8036
|
-
h: Math.max(1, Math.min(Math.round(b.h), frameHeight - y))
|
|
8037
|
-
};
|
|
8038
|
-
}
|
|
8039
|
-
/**
|
|
8040
|
-
* Draw bounding boxes over a raw RGB frame and JPEG-encode it.
|
|
8041
|
-
*
|
|
8042
|
-
* The frame is composited with an SVG overlay (one `<rect>` per box, plus an
|
|
8043
|
-
* optional caption) at full resolution, then optionally downscaled — so the
|
|
8044
|
-
* box stays crisp relative to the scene. Boxes are clamped to frame bounds to
|
|
8045
|
-
* avoid sharp `extract`/region errors on slightly-out-of-range detections.
|
|
8046
|
-
*/
|
|
8047
|
-
async function drawBoxedFrame(frameData, frameWidth, frameHeight, boxes, opts = {}) {
|
|
8048
|
-
const quality = opts.quality ?? DEFAULT_QUALITY;
|
|
8049
|
-
let base = frameData;
|
|
8050
|
-
let baseIsRaw = true;
|
|
8051
|
-
if (boxes.length > 0) {
|
|
8052
|
-
const fontSize = Math.max(12, Math.round(frameHeight / 30));
|
|
8053
|
-
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}">${boxes.map((b) => {
|
|
8054
|
-
const c = clampBox(b, frameWidth, frameHeight);
|
|
8055
|
-
const color = b.color ?? DEFAULT_COLOR;
|
|
8056
|
-
const rect = `<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${c.h}" fill="none" stroke="${color}" stroke-width="${STROKE_WIDTH}"/>`;
|
|
8057
|
-
if (!b.label) return rect;
|
|
8058
|
-
const ty = Math.max(fontSize, c.y - 4);
|
|
8059
|
-
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>`;
|
|
8060
|
-
}).join("")}</svg>`;
|
|
8061
|
-
base = await (0, sharp.default)(frameData, { raw: {
|
|
8062
|
-
width: frameWidth,
|
|
8063
|
-
height: frameHeight,
|
|
8064
|
-
channels: 3
|
|
8065
|
-
} }).composite([{
|
|
8066
|
-
input: Buffer.from(svg),
|
|
8067
|
-
top: 0,
|
|
8068
|
-
left: 0
|
|
8069
|
-
}]).png().toBuffer();
|
|
8070
|
-
baseIsRaw = false;
|
|
8071
|
-
}
|
|
8072
|
-
let pipeline = baseIsRaw ? (0, sharp.default)(base, { raw: {
|
|
8073
|
-
width: frameWidth,
|
|
8074
|
-
height: frameHeight,
|
|
8075
|
-
channels: 3
|
|
8076
|
-
} }) : (0, sharp.default)(base);
|
|
8077
|
-
if (opts.maxWidth !== void 0) pipeline = pipeline.resize({
|
|
8078
|
-
width: opts.maxWidth,
|
|
8079
|
-
withoutEnlargement: true
|
|
8080
|
-
});
|
|
8081
|
-
return pipeline.jpeg({ quality }).toBuffer();
|
|
8082
|
-
}
|
|
8083
|
-
/** JPEG quality of the composed artefact — the mosaic's number, same reasons. */
|
|
8084
|
-
var GROUP_FRAME_QUALITY = 82;
|
|
8085
|
-
/**
|
|
8086
|
-
* Draw the members onto the frame. Returns `null` when there is nothing to
|
|
8087
|
-
* draw, or when anything at all goes wrong — the caller then ships the
|
|
8088
|
-
* unannotated frame unchanged.
|
|
8089
|
-
*/
|
|
8090
|
-
async function composeGroupFrame(input) {
|
|
8091
|
-
const freshnessMs = input.freshnessMs ?? 3e3;
|
|
8092
|
-
const fresh = input.boxes.filter((b) => Math.abs(input.at - b.observedAt) <= freshnessMs);
|
|
8093
|
-
const withheld = input.boxes.length - fresh.length;
|
|
8094
|
-
if (fresh.length < 2) {
|
|
8095
|
-
if (withheld > 0) input.logger.info("group frame not drawn — too few members with fresh geometry", {
|
|
8096
|
-
tags: { deviceId: input.deviceId },
|
|
8097
|
-
meta: {
|
|
8098
|
-
groupKey: input.groupKey,
|
|
8099
|
-
members: input.boxes.length,
|
|
8100
|
-
fresh: fresh.length,
|
|
8101
|
-
withheld
|
|
8102
|
-
}
|
|
8103
|
-
});
|
|
8104
|
-
return null;
|
|
8105
|
-
}
|
|
8106
|
-
try {
|
|
8107
|
-
const decoded = await (0, sharp.default)(input.jpeg).raw().toBuffer({ resolveWithObject: true });
|
|
8108
|
-
const { width, height, channels } = decoded.info;
|
|
8109
|
-
if (!(width > 0 && height > 0) || channels !== 3) {
|
|
8110
|
-
input.logger.warn("group frame not drawn — unexpected raster shape", {
|
|
8111
|
-
tags: { deviceId: input.deviceId },
|
|
8112
|
-
meta: {
|
|
8113
|
-
groupKey: input.groupKey,
|
|
8114
|
-
width,
|
|
8115
|
-
height,
|
|
8116
|
-
channels
|
|
8117
|
-
}
|
|
8118
|
-
});
|
|
8119
|
-
return null;
|
|
8120
|
-
}
|
|
8121
|
-
const boxes = fresh.map((b) => ({
|
|
8122
|
-
x: b.bbox.x * width,
|
|
8123
|
-
y: b.bbox.y * height,
|
|
8124
|
-
w: b.bbox.w * width,
|
|
8125
|
-
h: b.bbox.h * height,
|
|
8126
|
-
...b.label !== void 0 && b.label.length > 0 ? { label: b.label } : {}
|
|
8127
|
-
}));
|
|
8128
|
-
const bytes = await drawBoxedFrame(decoded.data, width, height, boxes, { quality: GROUP_FRAME_QUALITY });
|
|
8129
|
-
input.logger.debug("group frame composed", {
|
|
8130
|
-
tags: { deviceId: input.deviceId },
|
|
8131
|
-
meta: {
|
|
8132
|
-
groupKey: input.groupKey,
|
|
8133
|
-
drawn: boxes.length,
|
|
8134
|
-
withheld,
|
|
8135
|
-
width,
|
|
8136
|
-
height
|
|
8137
|
-
}
|
|
8138
|
-
});
|
|
8139
|
-
return {
|
|
8140
|
-
bytes,
|
|
8141
|
-
drawn: boxes.length,
|
|
8142
|
-
withheld
|
|
8143
|
-
};
|
|
8144
|
-
} catch (err) {
|
|
8145
|
-
input.logger.warn("group frame compose failed — shipping the unannotated frame", {
|
|
8146
|
-
tags: { deviceId: input.deviceId },
|
|
8147
|
-
meta: {
|
|
8148
|
-
groupKey: input.groupKey,
|
|
8149
|
-
error: err instanceof Error ? err.message : String(err)
|
|
8150
|
-
}
|
|
8151
|
-
});
|
|
8152
|
-
return null;
|
|
8153
|
-
}
|
|
8154
|
-
}
|
|
8155
|
-
//#endregion
|
|
8156
|
-
//#region src/notification-center/text-compose.ts
|
|
8157
|
-
/** One normalisation, used on both sides of every class lookup — a pipeline
|
|
8158
|
-
* that emitted `Person` must find the key written `class.person`. */
|
|
8159
|
-
function normaliseClass(className) {
|
|
8160
|
-
return className.trim().toLowerCase();
|
|
8161
|
-
}
|
|
8162
|
-
/**
|
|
8163
|
-
* The noun for a class, in the count's plural category.
|
|
8164
|
-
*
|
|
8165
|
-
* A class no locale names falls through to `class.other`, which prints the RAW
|
|
8166
|
-
* name: a custom model's class must appear in the sentence rather than vanish
|
|
8167
|
-
* out of it, and a missing translation is a smaller defect than a missing
|
|
8168
|
-
* detection.
|
|
8169
|
-
*/
|
|
8170
|
-
function ncClassNoun(texts, className, count) {
|
|
8171
|
-
const key = `class.${normaliseClass(className)}`;
|
|
8172
|
-
if (texts.has(key)) return texts.text({
|
|
8173
|
-
key,
|
|
8174
|
-
count,
|
|
8175
|
-
vars: { name: className }
|
|
8176
|
-
});
|
|
8177
|
-
return texts.text({
|
|
8178
|
-
key: "class.other",
|
|
8179
|
-
count,
|
|
8180
|
-
vars: { name: className }
|
|
8181
|
-
});
|
|
8182
|
-
}
|
|
8183
|
-
/** `person (John)` / `persona (John)` — the thing the notification is about. */
|
|
8184
|
-
function ncSubjectText(texts, input) {
|
|
8185
|
-
const classNoun = ncClassNoun(texts, input.className, 1);
|
|
8186
|
-
if (input.label === void 0) return texts.text({
|
|
8187
|
-
key: "detection.subject",
|
|
8188
|
-
vars: { classNoun }
|
|
8189
|
-
});
|
|
8190
|
-
return texts.text({
|
|
8191
|
-
key: subjectLabelKey(input.labelKind),
|
|
8192
|
-
vars: {
|
|
8193
|
-
classNoun,
|
|
8194
|
-
label: input.label
|
|
8195
|
-
}
|
|
8196
|
-
});
|
|
8197
|
-
}
|
|
8198
|
-
/**
|
|
8199
|
-
* Which labelled-subject key a recognition gets. A kind the catalog does not
|
|
8200
|
-
* name falls back to the generic key rather than printing nothing — the same
|
|
8201
|
-
* direction `ncClassNoun` degrades in, and for the same reason.
|
|
8202
|
-
*/
|
|
8203
|
-
function subjectLabelKey(kind) {
|
|
8204
|
-
if (kind === "identity") return "detection.subject.identity";
|
|
8205
|
-
if (kind === "plate") return "detection.subject.plate";
|
|
8206
|
-
return "detection.subject.labelled";
|
|
8207
|
-
}
|
|
8208
|
-
/**
|
|
8209
|
-
* ` in gate, drive` — INCLUDING its leading space, and empty when there are no
|
|
8210
|
-
* zones.
|
|
8211
|
-
*
|
|
8212
|
-
* The space belongs to the fragment rather than to the body key because the
|
|
8213
|
-
* alternative is a body that ends in a trailing space on every zone-less
|
|
8214
|
-
* notification. A translator moving the clause moves the space with it.
|
|
8215
|
-
*/
|
|
8216
|
-
function ncInZonesText(texts, zoneLabels) {
|
|
8217
|
-
if (zoneLabels.length === 0) return "";
|
|
8218
|
-
return texts.text({
|
|
8219
|
-
key: "detection.inZones",
|
|
8220
|
-
vars: { zones: zoneLabels.join(", ") }
|
|
8221
|
-
});
|
|
8222
|
-
}
|
|
8223
|
-
/** The human-readable edge polarity behind `{{op}}` and the occupancy body. */
|
|
8224
|
-
function ncOccupancyOp(texts, occupied) {
|
|
8225
|
-
return texts.text({ key: occupied ? "occupancy.op.occupied" : "occupancy.op.free" });
|
|
8226
|
-
}
|
|
8227
|
-
/**
|
|
8228
|
-
* `1 persona, 2 veicoli e 3 animali` — the language's own conjunction, its own
|
|
8229
|
-
* plural rules, and its own nouns.
|
|
8230
|
-
*
|
|
8231
|
-
* Zero-count classes are dropped: a selected class the window never contained
|
|
8232
|
-
* is a legitimate `0` in `{{count_vehicle}}`, but "0 veicoli" inside a summary
|
|
8233
|
-
* sentence is a line nobody reads. Order is the caller's (busiest first) and is
|
|
8234
|
-
* preserved, so the same night always produces the same sentence.
|
|
8235
|
-
*/
|
|
8236
|
-
function ncDetectionSummary(texts, counts) {
|
|
8237
|
-
const parts = counts.filter((entry) => entry.count > 0).map((entry) => `${entry.count} ${ncClassNoun(texts, entry.className, entry.count)}`);
|
|
8238
|
-
return texts.join(parts);
|
|
8239
|
-
}
|
|
8240
|
-
//#endregion
|
|
8241
|
-
//#region src/notification-center/group/nc-group-text.ts
|
|
8242
|
-
var DEFAULT_MAX_NAMES = 2;
|
|
8243
|
-
/**
|
|
8244
|
-
* `N altre persone` — the remainder phrase for one class, in its own gender.
|
|
8245
|
-
*
|
|
8246
|
-
* Mirrors {@link ncClassNoun} byte for byte, including the fallback: a class no
|
|
8247
|
-
* locale names falls through to `group.others.other`, which prints the raw class
|
|
8248
|
-
* name rather than vanishing. A custom model's class must appear in the sentence.
|
|
8249
|
-
*/
|
|
8250
|
-
function ncGroupOthers(texts, className, count) {
|
|
8251
|
-
const classNoun = ncClassNoun(texts, className, count);
|
|
8252
|
-
const key = `group.others.${className.trim().toLowerCase()}`;
|
|
8253
|
-
if (texts.has(key)) return texts.text({
|
|
8254
|
-
key,
|
|
8255
|
-
count,
|
|
8256
|
-
vars: {
|
|
8257
|
-
count: `${count}`,
|
|
8258
|
-
classNoun
|
|
8259
|
-
}
|
|
8260
|
-
});
|
|
8261
|
-
return texts.text({
|
|
8262
|
-
key: "group.others.other",
|
|
8263
|
-
count,
|
|
8264
|
-
vars: {
|
|
8265
|
-
count: `${count}`,
|
|
8266
|
-
classNoun
|
|
8267
|
-
}
|
|
8268
|
-
});
|
|
8269
|
-
}
|
|
8270
|
-
/** Counts per class, in first-seen order so the same burst always reads the same. */
|
|
8271
|
-
function countByClass(members) {
|
|
8272
|
-
const counts = /* @__PURE__ */ new Map();
|
|
8273
|
-
for (const m of members) counts.set(m.className, (counts.get(m.className) ?? 0) + 1);
|
|
8274
|
-
return [...counts].map(([className, count]) => ({
|
|
8275
|
-
className,
|
|
8276
|
-
count
|
|
8277
|
-
}));
|
|
8278
|
-
}
|
|
8279
|
-
/**
|
|
8280
|
-
* The finished `{{subject}}` phrase for a group.
|
|
8281
|
-
*
|
|
8282
|
-
* Pure: a catalog in, a string out. No clock, no store, no I/O — like every
|
|
8283
|
-
* other composer in `text-compose.ts`, and for the same reason.
|
|
8284
|
-
*/
|
|
8285
|
-
function ncGroupSubjectText(texts, input) {
|
|
8286
|
-
const members = input.members;
|
|
8287
|
-
const only = members.length === 1 ? members[0] : void 0;
|
|
8288
|
-
if (only !== void 0) return ncSubjectText(texts, {
|
|
8289
|
-
className: only.className,
|
|
8290
|
-
...only.label !== void 0 ? { label: only.label } : {},
|
|
8291
|
-
...only.labelKind !== void 0 ? { labelKind: only.labelKind } : {}
|
|
8292
|
-
});
|
|
8293
|
-
if (members.length === 0) return "";
|
|
8294
|
-
const maxNames = input.maxNames !== void 0 && input.maxNames >= 0 ? input.maxNames : DEFAULT_MAX_NAMES;
|
|
8295
|
-
const named = members.filter((m) => m.label !== void 0 && m.label.length > 0);
|
|
8296
|
-
const names = named.slice(0, maxNames).map((m) => m.label ?? "");
|
|
8297
|
-
if (names.length === 0) return ncDetectionSummary(texts, countByClass(members));
|
|
8298
|
-
const spoken = new Set(named.slice(0, maxNames));
|
|
8299
|
-
const remainder = members.filter((m) => !spoken.has(m));
|
|
8300
|
-
if (remainder.length === 0) return texts.join(names);
|
|
8301
|
-
const others = countByClass(remainder).map((c) => ncGroupOthers(texts, c.className, c.count));
|
|
8302
|
-
return texts.join([...names, ...others]);
|
|
8303
|
-
}
|
|
8304
|
-
//#endregion
|
|
8305
8680
|
//#region src/notification-center/dispatcher.ts
|
|
8306
8681
|
var DEFAULT_TARGET_CACHE_TTL_MS = 6e4;
|
|
8307
8682
|
/**
|
|
@@ -8864,7 +9239,7 @@ var NcDispatcher = class {
|
|
|
8864
9239
|
const stillOverride = zoneIdsWanted !== void 0 && zoneIdsWanted.length > 0 ? "keyFrame" : void 0;
|
|
8865
9240
|
const firstLook = await this.resolveAttachment(entry, stillOverride);
|
|
8866
9241
|
const footage = await this.renderFootage(entry);
|
|
8867
|
-
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);
|
|
8868
9243
|
if (still !== null) {
|
|
8869
9244
|
const zoneIds = zoneIdsWanted;
|
|
8870
9245
|
if (zoneIds !== void 0 && zoneIds.length > 0) {
|
|
@@ -9352,17 +9727,24 @@ function incomingFromSensorEvent(event, markerTrackId) {
|
|
|
9352
9727
|
})
|
|
9353
9728
|
};
|
|
9354
9729
|
}
|
|
9355
|
-
/**
|
|
9356
|
-
|
|
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) {
|
|
9357
9738
|
return {
|
|
9358
|
-
subject: subjectFromAudioEvent(event),
|
|
9739
|
+
subject: subjectFromAudioEvent(event, stillOwnerId),
|
|
9359
9740
|
kind: "audio-event",
|
|
9360
9741
|
origin: "pipeline",
|
|
9361
9742
|
log: () => ({
|
|
9362
9743
|
tags: { deviceId: event.deviceId },
|
|
9363
9744
|
meta: {
|
|
9364
9745
|
audioEventId: event.id,
|
|
9365
|
-
class: event.classification?.className
|
|
9746
|
+
class: event.classification?.className,
|
|
9747
|
+
...stillOwnerId !== void 0 ? { stillOwner: stillOwnerId } : {}
|
|
9366
9748
|
}
|
|
9367
9749
|
})
|
|
9368
9750
|
};
|
|
@@ -9392,15 +9774,20 @@ function incomingFromPackageEvent(event, phase) {
|
|
|
9392
9774
|
* and a crash in the confirm→outbox window drops the notification rather than
|
|
9393
9775
|
* replaying it. Deliberate: a stale match is a claim about a noise that has
|
|
9394
9776
|
* already stopped.
|
|
9777
|
+
*
|
|
9778
|
+
* `stillOwnerId` — see {@link incomingFromAudioEvent}.
|
|
9395
9779
|
*/
|
|
9396
|
-
function incomingFromAudioWindow(hit) {
|
|
9780
|
+
function incomingFromAudioWindow(hit, stillOwnerId) {
|
|
9397
9781
|
return {
|
|
9398
|
-
subject: subjectFromAudioWindow(hit),
|
|
9782
|
+
subject: subjectFromAudioWindow(hit, stillOwnerId),
|
|
9399
9783
|
kind: "audio-window",
|
|
9400
9784
|
origin: "pipeline",
|
|
9401
9785
|
log: () => ({
|
|
9402
9786
|
tags: { deviceId: hit.deviceId },
|
|
9403
|
-
meta:
|
|
9787
|
+
meta: {
|
|
9788
|
+
...audioHitMeta(hit),
|
|
9789
|
+
...stillOwnerId !== void 0 ? { stillOwner: stillOwnerId } : {}
|
|
9790
|
+
}
|
|
9404
9791
|
})
|
|
9405
9792
|
};
|
|
9406
9793
|
}
|
|
@@ -13341,6 +13728,29 @@ function escapeXml$1(s) {
|
|
|
13341
13728
|
}
|
|
13342
13729
|
});
|
|
13343
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
|
+
}
|
|
13344
13754
|
/** Grid shape for `n` tiles: as square as possible, rows never exceeding cols
|
|
13345
13755
|
* by more than one — a 3×3 for nine, a 3×2 for five, never a 1×9 strip. */
|
|
13346
13756
|
function mosaicGrid(n) {
|
|
@@ -13447,22 +13857,25 @@ function titleSvg(width, height, caption) {
|
|
|
13447
13857
|
* treats that as "no mosaic" and still delivers the counts.
|
|
13448
13858
|
*/
|
|
13449
13859
|
async function renderMosaic(request, logger) {
|
|
13860
|
+
const plan = mosaicTextPlan(request.captions);
|
|
13450
13861
|
const decoded = [];
|
|
13451
13862
|
const probeLayout = mosaicLayout({
|
|
13452
13863
|
tileCount: Math.max(1, request.tiles.length),
|
|
13453
13864
|
...request.maxWidth !== void 0 ? { maxWidth: request.maxWidth } : {}
|
|
13454
13865
|
});
|
|
13455
13866
|
for (const tile of request.tiles) try {
|
|
13456
|
-
const
|
|
13867
|
+
const label = plan.tileLabels ? tile.label?.trim() ?? "" : "";
|
|
13868
|
+
const fitted = (0, sharp.default)(tile.jpeg).resize({
|
|
13457
13869
|
width: probeLayout.tileWidth,
|
|
13458
13870
|
height: probeLayout.tileHeight,
|
|
13459
13871
|
fit: "contain",
|
|
13460
13872
|
background: MOSAIC_BACKGROUND
|
|
13461
|
-
})
|
|
13462
|
-
|
|
13873
|
+
});
|
|
13874
|
+
const resized = await (label.length === 0 ? fitted : fitted.composite([{
|
|
13875
|
+
input: tileLabelSvg(probeLayout.tileWidth, probeLayout.tileHeight, label),
|
|
13463
13876
|
top: 0,
|
|
13464
13877
|
left: 0
|
|
13465
|
-
}]).jpeg({ quality: 82 }).toBuffer();
|
|
13878
|
+
}])).jpeg({ quality: 82 }).toBuffer();
|
|
13466
13879
|
decoded.push({
|
|
13467
13880
|
tile,
|
|
13468
13881
|
resized
|
|
@@ -13477,7 +13890,7 @@ async function renderMosaic(request, logger) {
|
|
|
13477
13890
|
});
|
|
13478
13891
|
}
|
|
13479
13892
|
const droppedTiles = request.tiles.length - decoded.length;
|
|
13480
|
-
const caption = request.caption?.trim() ?? "";
|
|
13893
|
+
const caption = plan.titleBand ? request.caption?.trim() ?? "" : "";
|
|
13481
13894
|
const layout = mosaicLayout({
|
|
13482
13895
|
tileCount: Math.max(1, decoded.length),
|
|
13483
13896
|
...request.maxWidth !== void 0 ? { maxWidth: request.maxWidth } : {},
|
|
@@ -13731,12 +14144,15 @@ function dwellSeconds(candidate) {
|
|
|
13731
14144
|
* branch that discards work silently reads as "it never happened".
|
|
13732
14145
|
*/
|
|
13733
14146
|
function filterSummaryCandidates(candidates, filters, zoneOwner) {
|
|
14147
|
+
const moving = filters?.includeStationary === true ? candidates : candidates.filter((candidate) => candidate.stationary !== true);
|
|
14148
|
+
const droppedStationary = candidates.length - moving.length;
|
|
13734
14149
|
if (filters === void 0) return {
|
|
13735
|
-
kept: [...
|
|
14150
|
+
kept: [...moving],
|
|
13736
14151
|
droppedByClass: 0,
|
|
13737
14152
|
droppedByZone: 0,
|
|
13738
14153
|
droppedByImportance: 0,
|
|
13739
|
-
droppedByDwell: 0
|
|
14154
|
+
droppedByDwell: 0,
|
|
14155
|
+
droppedStationary
|
|
13740
14156
|
};
|
|
13741
14157
|
const include = filters.classes;
|
|
13742
14158
|
const exclude = new Set(filters.classesExclude ?? []);
|
|
@@ -13745,7 +14161,7 @@ function filterSummaryCandidates(candidates, filters, zoneOwner) {
|
|
|
13745
14161
|
let droppedByZone = 0;
|
|
13746
14162
|
let droppedByImportance = 0;
|
|
13747
14163
|
let droppedByDwell = 0;
|
|
13748
|
-
for (const candidate of
|
|
14164
|
+
for (const candidate of moving) {
|
|
13749
14165
|
const own = classesOf(candidate);
|
|
13750
14166
|
if (own.some((c) => exclude.has(c))) {
|
|
13751
14167
|
droppedByClass += 1;
|
|
@@ -13781,7 +14197,8 @@ function filterSummaryCandidates(candidates, filters, zoneOwner) {
|
|
|
13781
14197
|
droppedByClass,
|
|
13782
14198
|
droppedByZone,
|
|
13783
14199
|
droppedByImportance,
|
|
13784
|
-
droppedByDwell
|
|
14200
|
+
droppedByDwell,
|
|
14201
|
+
droppedStationary
|
|
13785
14202
|
};
|
|
13786
14203
|
}
|
|
13787
14204
|
/**
|
|
@@ -14156,7 +14573,8 @@ var NcSummaryProducer = class {
|
|
|
14156
14573
|
droppedByClass: filtered.droppedByClass,
|
|
14157
14574
|
droppedByZone: filtered.droppedByZone,
|
|
14158
14575
|
droppedByImportance: filtered.droppedByImportance,
|
|
14159
|
-
droppedByDwell: filtered.droppedByDwell
|
|
14576
|
+
droppedByDwell: filtered.droppedByDwell,
|
|
14577
|
+
droppedStationary: filtered.droppedStationary
|
|
14160
14578
|
} });
|
|
14161
14579
|
if (opts.stamp) await this.deps.store.markGenerated(rule.id, window.endMs);
|
|
14162
14580
|
return {
|
|
@@ -14164,7 +14582,8 @@ var NcSummaryProducer = class {
|
|
|
14164
14582
|
window,
|
|
14165
14583
|
tiles: 0,
|
|
14166
14584
|
cameras: 0,
|
|
14167
|
-
matched: 0
|
|
14585
|
+
matched: 0,
|
|
14586
|
+
droppedStationary: filtered.droppedStationary
|
|
14168
14587
|
};
|
|
14169
14588
|
}
|
|
14170
14589
|
const loaded = await this.loadTiles(rule, selection.tiles);
|
|
@@ -14187,6 +14606,7 @@ var NcSummaryProducer = class {
|
|
|
14187
14606
|
produced: 0,
|
|
14188
14607
|
window,
|
|
14189
14608
|
tiles: loaded.length,
|
|
14609
|
+
droppedStationary: filtered.droppedStationary,
|
|
14190
14610
|
skippedByAi: true,
|
|
14191
14611
|
...ai.aiFailure !== void 0 ? { aiFailure: ai.aiFailure } : {},
|
|
14192
14612
|
...ai.aiImages !== void 0 ? { aiImages: ai.aiImages } : {}
|
|
@@ -14217,6 +14637,8 @@ var NcSummaryProducer = class {
|
|
|
14217
14637
|
...meta,
|
|
14218
14638
|
scanned: candidates.length,
|
|
14219
14639
|
matched: filtered.kept.length,
|
|
14640
|
+
droppedStationary: filtered.droppedStationary,
|
|
14641
|
+
droppedByClass: filtered.droppedByClass,
|
|
14220
14642
|
tiles: loaded.length,
|
|
14221
14643
|
cameras: new Set(loaded.map((t) => t.candidate.deviceId)).size,
|
|
14222
14644
|
camerasDropped: selection.camerasDropped,
|
|
@@ -14235,6 +14657,7 @@ var NcSummaryProducer = class {
|
|
|
14235
14657
|
tiles: loaded.length,
|
|
14236
14658
|
cameras: new Set(loaded.map((t) => t.candidate.deviceId)).size,
|
|
14237
14659
|
matched: filtered.kept.length,
|
|
14660
|
+
droppedStationary: filtered.droppedStationary,
|
|
14238
14661
|
enqueued,
|
|
14239
14662
|
...mosaic.bytes > 0 ? { mosaicBytes: mosaic.bytes } : {},
|
|
14240
14663
|
...mosaic.ref !== null ? { mosaicUrl: mosaic.ref.url } : {},
|
|
@@ -14346,18 +14769,20 @@ var NcSummaryProducer = class {
|
|
|
14346
14769
|
};
|
|
14347
14770
|
let rendered;
|
|
14348
14771
|
try {
|
|
14772
|
+
const plan = mosaicTextPlan(rule.captions);
|
|
14349
14773
|
rendered = await renderMosaic({
|
|
14350
14774
|
tiles: tiles.map(({ candidate, jpeg }) => ({
|
|
14351
14775
|
jpeg,
|
|
14352
|
-
label: `${names.get(candidate.deviceId) ?? candidate.deviceId} · ${clockOf$2(candidate.firstSeen)} · ${candidate.className}
|
|
14776
|
+
...plan.tileLabels ? { label: `${names.get(candidate.deviceId) ?? candidate.deviceId} · ${clockOf$2(candidate.firstSeen)} · ${candidate.className}` } : {},
|
|
14353
14777
|
deviceId: candidate.deviceId,
|
|
14354
14778
|
trackId: candidate.trackId
|
|
14355
14779
|
})),
|
|
14356
|
-
|
|
14780
|
+
captions: rule.captions,
|
|
14781
|
+
...plan.titleBand ? { caption: summaryCaptionText({
|
|
14357
14782
|
...rule.captionText !== void 0 ? { captionText: rule.captionText } : {},
|
|
14358
14783
|
ruleName: rule.name,
|
|
14359
14784
|
window
|
|
14360
|
-
})
|
|
14785
|
+
}) } : {}
|
|
14361
14786
|
}, this.deps.logger);
|
|
14362
14787
|
} catch (err) {
|
|
14363
14788
|
this.deps.logger.warn("summary mosaic could not be composed — delivering the counts alone", { meta: {
|
|
@@ -14605,7 +15030,26 @@ var NcSummaryFiltersSchema = require_dist.object({
|
|
|
14605
15030
|
classesExclude: require_dist.array(require_dist.string().min(1).max(40)).max(20).optional(),
|
|
14606
15031
|
zones: require_dist.array(require_dist.string()).max(40).optional(),
|
|
14607
15032
|
minImportance: require_dist.number().min(0).max(1).optional(),
|
|
14608
|
-
minDwellSeconds: require_dist.number().min(0).max(3600).optional()
|
|
15033
|
+
minDwellSeconds: require_dist.number().min(0).max(3600).optional(),
|
|
15034
|
+
/**
|
|
15035
|
+
* Put PARKED objects in the digest too. Off unless explicitly turned on, and
|
|
15036
|
+
* an ABSENT field reads as off — the soft migration, no backfill.
|
|
15037
|
+
*
|
|
15038
|
+
* A digest is a record of PASSAGES: the operator's words are "the last
|
|
15039
|
+
* summaries still include an enormous number of stationary tracks". Since
|
|
15040
|
+
* D161 a track promoted to the stationary registry is persisted rather than
|
|
15041
|
+
* dropped (it owns media the retention sweep has to reach), so the car parked
|
|
15042
|
+
* in front of camera 617 now enters every window it overlaps and, ranked on
|
|
15043
|
+
* importance and dwell, wins tiles from the passages the digest is about. The
|
|
15044
|
+
* measured effect on the live hub was 1-2 tiles becoming 10.
|
|
15045
|
+
*
|
|
15046
|
+
* `.optional()` and NOT `.default(false)`: a `.default()` here would be
|
|
15047
|
+
* materialised by `NcSummaryRulePatchSchema` on any patch that named
|
|
15048
|
+
* `filters`, which is the `.partial()` trap this module's docblock describes.
|
|
15049
|
+
* `filterSummaryCandidates` reads `=== true`, so absent and false are the
|
|
15050
|
+
* same answer and neither needs a stored value.
|
|
15051
|
+
*/
|
|
15052
|
+
includeStationary: require_dist.boolean().optional()
|
|
14609
15053
|
});
|
|
14610
15054
|
/**
|
|
14611
15055
|
* How tiles are chosen when the window holds more tracks than the budget.
|
|
@@ -14621,6 +15065,34 @@ var NcSummaryRankSchema = require_dist._enum([
|
|
|
14621
15065
|
"dwell"
|
|
14622
15066
|
]);
|
|
14623
15067
|
/**
|
|
15068
|
+
* How much TEXT is burnt into the mosaic — and the answer is NONE by default.
|
|
15069
|
+
*
|
|
15070
|
+
* `none` is the shipped default and, crucially, the reading of an ABSENT field:
|
|
15071
|
+
* `.default()` on the Input schema materialises it, and the persisted schema
|
|
15072
|
+
* extends Input, so a rule authored before this option existed parses as `none`
|
|
15073
|
+
* on the next read. No migration, no backfill, no rewrite of a stored rule.
|
|
15074
|
+
*
|
|
15075
|
+
* The operator asked for "solo immagini". Three reasons it is the right
|
|
15076
|
+
* default rather than a preference:
|
|
15077
|
+
*
|
|
15078
|
+
* - a caption is PERMANENT in the artefact, exactly like a detection box, and
|
|
15079
|
+
* a mosaic is re-read days later;
|
|
15080
|
+
* - the per-tile strip covers 14% of the tile from the bottom edge up, which
|
|
15081
|
+
* is where feet, wheels and parcels are;
|
|
15082
|
+
* - nothing downstream needs it. The AI pass is given the RAW per-track JPEGs
|
|
15083
|
+
* and gets camera, class and time as PROMPT TEXT — it never reads the
|
|
15084
|
+
* mosaic, so the pixels carry no fact the judge would lose.
|
|
15085
|
+
*
|
|
15086
|
+
* `tiles` and `all` keep the old behaviour reachable for an operator who wants
|
|
15087
|
+
* it; `all` is byte-for-byte what every rule produced before this field.
|
|
15088
|
+
*/
|
|
15089
|
+
var NcSummaryCaptionsSchema = require_dist._enum([
|
|
15090
|
+
"none",
|
|
15091
|
+
"tiles",
|
|
15092
|
+
"all"
|
|
15093
|
+
]);
|
|
15094
|
+
var NC_SUMMARY_DEFAULT_CAPTIONS = "none";
|
|
15095
|
+
/**
|
|
14624
15096
|
* The AI section — DECLARED IN P1, WIRED IN P2.
|
|
14625
15097
|
*
|
|
14626
15098
|
* Nothing in this addon reads it today. It is here so the rule shape does not
|
|
@@ -14654,6 +15126,8 @@ var NcSummaryRuleInputSchema = require_dist.object({
|
|
|
14654
15126
|
barrierSeconds: BarrierSecondsField.default(120),
|
|
14655
15127
|
targets: TargetsField,
|
|
14656
15128
|
template: NcSummaryTemplateSchema.optional(),
|
|
15129
|
+
/** Burned onto the mosaic's title band — and ONLY when `captions` is `all`. */
|
|
15130
|
+
captions: NcSummaryCaptionsSchema.default(NC_SUMMARY_DEFAULT_CAPTIONS),
|
|
14657
15131
|
/** Burned onto the mosaic's title band. `''` is reachable and means "no
|
|
14658
15132
|
* caption" — which is why this is not `.min(1)`. */
|
|
14659
15133
|
captionText: require_dist.string().max(200).optional(),
|
|
@@ -14683,6 +15157,7 @@ var NcSummaryRulePatchSchema = require_dist.object({
|
|
|
14683
15157
|
barrierSeconds: BarrierSecondsField.optional(),
|
|
14684
15158
|
targets: TargetsField.optional(),
|
|
14685
15159
|
template: NcSummaryTemplateSchema.nullable().optional(),
|
|
15160
|
+
captions: NcSummaryCaptionsSchema.optional(),
|
|
14686
15161
|
captionText: require_dist.string().max(200).optional(),
|
|
14687
15162
|
deliverEmpty: require_dist.boolean().optional(),
|
|
14688
15163
|
priority: PriorityField.optional(),
|
|
@@ -15217,14 +15692,19 @@ function isSyntheticId(id) {
|
|
|
15217
15692
|
* snapshot that produced it lists the members being counted and the edge
|
|
15218
15693
|
* names one (`chooseOccupancyMediaOwner`), so the tester stands in for that
|
|
15219
15694
|
* member exactly as it stands in for the marker.
|
|
15695
|
+
* - and, since 2026-08-15, a `trackId` for both AUDIO kinds: a sound owns no
|
|
15696
|
+
* frame, so production photographs the camera at the match and holds the
|
|
15697
|
+
* bytes on the still shelf under an owner id the subject names
|
|
15698
|
+
* (`audio-still.ts`). The tester's RAM-held frame stands in for exactly that
|
|
15699
|
+
* photograph, and — unlike every other kind here — it is taken the same way
|
|
15700
|
+
* production takes it, at the same moment, from the same cap.
|
|
15220
15701
|
*
|
|
15221
|
-
*
|
|
15222
|
-
*
|
|
15223
|
-
*
|
|
15224
|
-
* feature that does not exist.
|
|
15702
|
+
* Only `system-event` remains: an infrastructure event is about no camera at
|
|
15703
|
+
* all, so there is nothing to photograph and a tester that invented a picture
|
|
15704
|
+
* for it would be advertising a feature that does not exist.
|
|
15225
15705
|
*/
|
|
15226
15706
|
function triggerCanCarryStill(kind) {
|
|
15227
|
-
return kind
|
|
15707
|
+
return kind !== "system-event";
|
|
15228
15708
|
}
|
|
15229
15709
|
/** The detection provenance production stamps on a subject of this kind. */
|
|
15230
15710
|
function syntheticSource(kind) {
|
|
@@ -15262,7 +15742,7 @@ function audioSubjectOf(input) {
|
|
|
15262
15742
|
function buildSyntheticEvent(input, recordId, now) {
|
|
15263
15743
|
const kind = input.trigger;
|
|
15264
15744
|
const timestamp = input.timestamp ?? now;
|
|
15265
|
-
const carriesTrackId = kind === "object-event" || kind === "track-end" || kind === "device-event" || kind === "occupancy-event";
|
|
15745
|
+
const carriesTrackId = kind === "object-event" || kind === "track-end" || kind === "device-event" || kind === "occupancy-event" || kind === "audio-event" || kind === "audio-window";
|
|
15266
15746
|
return {
|
|
15267
15747
|
subject: {
|
|
15268
15748
|
kind,
|
|
@@ -17842,6 +18322,12 @@ var NotificationCenter = class NotificationCenter {
|
|
|
17842
18322
|
* is the one thing the synthetic producer promises not to do.
|
|
17843
18323
|
*/
|
|
17844
18324
|
syntheticMedia = /* @__PURE__ */ new Map();
|
|
18325
|
+
/**
|
|
18326
|
+
* The photograph a SOUND rule's notification carries — see `audio-still.ts`.
|
|
18327
|
+
* Null when this wiring has no snapshot cap, which leaves every audio
|
|
18328
|
+
* notification text-only rather than broken.
|
|
18329
|
+
*/
|
|
18330
|
+
audioStills;
|
|
17845
18331
|
/** Per-device rate limit for the "matched NO rule" report — see `reportNoMatch`. */
|
|
17846
18332
|
lastNoMatchReportAt = /* @__PURE__ */ new Map();
|
|
17847
18333
|
noMatchSuppressed = /* @__PURE__ */ new Map();
|
|
@@ -18133,6 +18619,12 @@ var NotificationCenter = class NotificationCenter {
|
|
|
18133
18619
|
this.deps = deps;
|
|
18134
18620
|
this.logger = deps.logger;
|
|
18135
18621
|
this.now = deps.now ?? (() => Date.now());
|
|
18622
|
+
const getSnapshot = deps.getSnapshot;
|
|
18623
|
+
this.audioStills = getSnapshot !== void 0 ? new NcAudioStillShelf({
|
|
18624
|
+
getSnapshot,
|
|
18625
|
+
logger: this.logger.child("audio-still"),
|
|
18626
|
+
now: this.now
|
|
18627
|
+
}) : null;
|
|
18136
18628
|
this.rules = new NcRuleStore({
|
|
18137
18629
|
store: deps.store,
|
|
18138
18630
|
logger: this.logger.child("rules"),
|
|
@@ -18210,6 +18702,8 @@ var NotificationCenter = class NotificationCenter {
|
|
|
18210
18702
|
getMediaForOwner: async (ownerKind, ownerId) => {
|
|
18211
18703
|
const held = isSyntheticId(ownerId) ? this.syntheticMedia.get(ownerId) : void 0;
|
|
18212
18704
|
if (held !== void 0) return held.files;
|
|
18705
|
+
const audio = isAudioStillId(ownerId) ? this.audioStills?.get(ownerId) : void 0;
|
|
18706
|
+
if (audio !== void 0) return audio;
|
|
18213
18707
|
return deps.dispatcher.getMediaForOwner(ownerKind, ownerId);
|
|
18214
18708
|
},
|
|
18215
18709
|
...deps.now !== void 0 ? { now: deps.now } : {},
|
|
@@ -18414,6 +18908,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
18414
18908
|
}
|
|
18415
18909
|
this.timelapseScheduler?.stop();
|
|
18416
18910
|
this.summaryProducer?.stop();
|
|
18911
|
+
this.audioStills?.clear();
|
|
18417
18912
|
this.evaluationActive = false;
|
|
18418
18913
|
}
|
|
18419
18914
|
/**
|
|
@@ -18743,7 +19238,35 @@ var NotificationCenter = class NotificationCenter {
|
|
|
18743
19238
|
* durable guarantee begins at this hook, matching the device-event boundary.
|
|
18744
19239
|
*/
|
|
18745
19240
|
onAudioEventPersisted(event) {
|
|
18746
|
-
this.consumeEvent(incomingFromAudioEvent(event));
|
|
19241
|
+
this.consumeEvent(incomingFromAudioEvent(event, this.audioStillOwner(event.deviceId, event.timestamp, "legacy")));
|
|
19242
|
+
}
|
|
19243
|
+
/**
|
|
19244
|
+
* Start photographing `deviceId` NOW and return the owner id the audio
|
|
19245
|
+
* subject should name — or `undefined` when nothing would use it.
|
|
19246
|
+
*
|
|
19247
|
+
* **The capture is not awaited.** A sound is transient and a notification
|
|
19248
|
+
* about a scream must not wait on a camera; the bytes land while the outbox
|
|
19249
|
+
* row queues, and the dispatcher's ordinary bounded still-wait picks them up.
|
|
19250
|
+
* That is also why this runs BEFORE the cooldown and the mute are consulted:
|
|
19251
|
+
* by the time a rule's window has been judged the moment is gone.
|
|
19252
|
+
*
|
|
19253
|
+
* **The gate is the RULE's media policy, not the trigger.** Photographing a
|
|
19254
|
+
* camera on every confirmed window for rules that ship text anyway would be a
|
|
19255
|
+
* snapshot at whatever rate the sound happens to occur, for nothing. The rule
|
|
19256
|
+
* cache is an in-memory mirror, so this is not a fallible read (D49) — a
|
|
19257
|
+
* failed one would look exactly like "no rule wants a picture", which is the
|
|
19258
|
+
* direction that DESTROYS the media.
|
|
19259
|
+
*/
|
|
19260
|
+
audioStillOwner(deviceId, atMs, mode) {
|
|
19261
|
+
const shelf = this.audioStills;
|
|
19262
|
+
if (shelf === null || !this.evaluationActive) return void 0;
|
|
19263
|
+
for (const rule of this.rules.listEnabled("immediate")) {
|
|
19264
|
+
if (rule.media.attach === "none") continue;
|
|
19265
|
+
if (!(mode === "condition" ? rule.conditions.audio !== void 0 : rule.conditions.audio === void 0 && referencesAudioClass(rule.conditions.classes))) continue;
|
|
19266
|
+
const devices = rule.conditions.devices;
|
|
19267
|
+
if (devices !== void 0 && devices.length > 0 && !devices.includes(deviceId)) continue;
|
|
19268
|
+
return shelf.capture(deviceId, atMs);
|
|
19269
|
+
}
|
|
18747
19270
|
}
|
|
18748
19271
|
/**
|
|
18749
19272
|
* Called at the package object-event persist site (`PackageDropDetector` —
|
|
@@ -18841,7 +19364,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
18841
19364
|
tags: { deviceId },
|
|
18842
19365
|
meta: audioHitMeta(hit)
|
|
18843
19366
|
});
|
|
18844
|
-
this.consumeEvent(incomingFromAudioWindow(hit));
|
|
19367
|
+
this.consumeEvent(incomingFromAudioWindow(hit, this.audioStillOwner(deviceId, hit.timestamp, "condition")));
|
|
18845
19368
|
}
|
|
18846
19369
|
}
|
|
18847
19370
|
/**
|
|
@@ -19658,6 +20181,19 @@ var NotificationCenter = class NotificationCenter {
|
|
|
19658
20181
|
}
|
|
19659
20182
|
});
|
|
19660
20183
|
}
|
|
20184
|
+
if (!noOwnerReported && (kind === "audio-window" || kind === "audio-event") && subject.trackId === void 0 && rule.media.attach !== "none") {
|
|
20185
|
+
noOwnerReported = true;
|
|
20186
|
+
this.logger.warn("audio match has no still owner — this notification ships text-only", {
|
|
20187
|
+
tags: { deviceId: subject.deviceId },
|
|
20188
|
+
meta: {
|
|
20189
|
+
recordId: subject.recordId,
|
|
20190
|
+
kind,
|
|
20191
|
+
ruleId: rule.id,
|
|
20192
|
+
rule: rule.name,
|
|
20193
|
+
snapshotWired: this.audioStills !== null
|
|
20194
|
+
}
|
|
20195
|
+
});
|
|
20196
|
+
}
|
|
19661
20197
|
const userTargets = await this.resolveUserTargets(rule, subject.deviceId);
|
|
19662
20198
|
const entries = this.buildEntries(rule, subject, kind, evaluation.matchedOn, userTargets, origin, {
|
|
19663
20199
|
key,
|
|
@@ -19814,6 +20350,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
19814
20350
|
admitToGroup(rule, subject, now) {
|
|
19815
20351
|
const idleSec = rule.groupIdleSec;
|
|
19816
20352
|
if (idleSec === void 0 || idleSec <= 0) return { outcome: "disabled" };
|
|
20353
|
+
if (subject.kind === "audio-window" || subject.kind === "audio-event") return { outcome: "disabled" };
|
|
19817
20354
|
const trackId = subject.trackId;
|
|
19818
20355
|
if (trackId === void 0) return { outcome: "disabled" };
|
|
19819
20356
|
const member = {
|
|
@@ -21311,6 +21848,14 @@ var ncActions = require_dist.defineCustomActions({
|
|
|
21311
21848
|
tiles: require_dist.number().int().optional(),
|
|
21312
21849
|
cameras: require_dist.number().int().optional(),
|
|
21313
21850
|
matched: require_dist.number().int().optional(),
|
|
21851
|
+
/**
|
|
21852
|
+
* PARKED objects the window held and the rule dropped (`filters
|
|
21853
|
+
* .includeStationary`). Without it a Test that returns two tiles out of
|
|
21854
|
+
* forty tracks cannot tell the operator whether the night was quiet or
|
|
21855
|
+
* whether it was the car park — and that is the exact question this
|
|
21856
|
+
* option exists to answer.
|
|
21857
|
+
*/
|
|
21858
|
+
droppedStationary: require_dist.number().int().optional(),
|
|
21314
21859
|
enqueued: require_dist.number().int().optional(),
|
|
21315
21860
|
mosaicBytes: require_dist.number().int().optional(),
|
|
21316
21861
|
mosaicUrl: require_dist.string().optional(),
|
|
@@ -21777,6 +22322,7 @@ function makeNcActionHandlers(deps) {
|
|
|
21777
22322
|
...out.tiles !== void 0 ? { tiles: out.tiles } : {},
|
|
21778
22323
|
...out.cameras !== void 0 ? { cameras: out.cameras } : {},
|
|
21779
22324
|
...out.matched !== void 0 ? { matched: out.matched } : {},
|
|
22325
|
+
...out.droppedStationary !== void 0 ? { droppedStationary: out.droppedStationary } : {},
|
|
21780
22326
|
...out.enqueued !== void 0 ? { enqueued: out.enqueued } : {},
|
|
21781
22327
|
...out.mosaicBytes !== void 0 ? { mosaicBytes: out.mosaicBytes } : {},
|
|
21782
22328
|
...out.mosaicUrl !== void 0 ? { mosaicUrl: out.mosaicUrl } : {},
|
|
@@ -23466,88 +24012,6 @@ var embeddingActions = require_dist.defineCustomActions({
|
|
|
23466
24012
|
"embedding.wipe": require_dist.customAction(NoInputSchema, WipeResultSchema, { auth: "admin" })
|
|
23467
24013
|
});
|
|
23468
24014
|
//#endregion
|
|
23469
|
-
//#region src/pipeline-analytics/retention/orphan-audit-actions.ts
|
|
23470
|
-
/**
|
|
23471
|
-
* The operator button for the orphan audit.
|
|
23472
|
-
*
|
|
23473
|
-
* The audit engine, its five bounds and its resumable cursor already existed
|
|
23474
|
-
* and had run in `report` mode for weeks; what it had no way to be was
|
|
23475
|
-
* *asked*. Its only trigger was a 6-hourly timer reading a durable setting, so
|
|
23476
|
-
* draining a standing backlog meant flipping a persistent mode, waiting up to
|
|
23477
|
-
* six hours, and remembering to flip it back — with nothing returned to look
|
|
23478
|
-
* at. On 2026-08-14 the backlog was 74,700 media rows / 18.9 GB plus 23,296
|
|
23479
|
-
* object events and 4,543 CLIP vectors (D161), and that is not a thing to
|
|
23480
|
-
* drain by leaving a switch on.
|
|
23481
|
-
*
|
|
23482
|
-
* Three properties this action does NOT leave to the caller's care:
|
|
23483
|
-
*
|
|
23484
|
-
* 1. **Dry run is the default.** `mode` omitted means `report`: it counts,
|
|
23485
|
-
* logs and returns, and deletes nothing — regardless of what the durable
|
|
23486
|
-
* `orphanAuditMode` setting says. `reclaim` is a word that has to be
|
|
23487
|
-
* typed. The engine's own default is the same for the same reason.
|
|
23488
|
-
* 2. **It is paced.** `pageSize` bounds one read, `maxRowsPerScope` bounds
|
|
23489
|
-
* one pass, `maxReclaimPerScope` bounds the damage, and the engine yields
|
|
23490
|
-
* the thread between pages — `better-sqlite3` is synchronous and shares
|
|
23491
|
-
* hub-main with every live frame.
|
|
23492
|
-
* 3. **It answers.** The report comes back per collection with rows AND
|
|
23493
|
-
* bytes, so "did it do anything" is not a question anyone has to infer
|
|
23494
|
-
* from a log line.
|
|
23495
|
-
*/
|
|
23496
|
-
/**
|
|
23497
|
-
* `report` counts; `reclaim` deletes.
|
|
23498
|
-
*
|
|
23499
|
-
* `.default('report')` is load-bearing, not decoration: the settings form
|
|
23500
|
-
* sends `{}` for a button with no fields, and the safe interpretation of an
|
|
23501
|
-
* unstated intent here is the one that destroys nothing.
|
|
23502
|
-
*/
|
|
23503
|
-
var OrphanAuditActionModeSchema = require_dist._enum(["report", "reclaim"]).default("report");
|
|
23504
|
-
var OrphanAuditActionInputSchema = require_dist.object({
|
|
23505
|
-
mode: OrphanAuditActionModeSchema,
|
|
23506
|
-
/**
|
|
23507
|
-
* Start every scope from the top instead of resuming its persisted cursor.
|
|
23508
|
-
*
|
|
23509
|
-
* What draining a backlog wants, and never the default — the cursor exists
|
|
23510
|
-
* so a 6-hourly pass makes progress instead of re-walking the same head.
|
|
23511
|
-
*/
|
|
23512
|
-
restart: require_dist.boolean().optional(),
|
|
23513
|
-
/** Rows read per statement. Smaller = more yields = a gentler pass. */
|
|
23514
|
-
pageSize: require_dist.number().int().min(50).max(5e3).optional(),
|
|
23515
|
-
/** Rows EXAMINED per collection in this pass — the pacing bound. */
|
|
23516
|
-
maxRowsPerScope: require_dist.number().int().min(1).max(2e5).optional(),
|
|
23517
|
-
/** Rows DELETED per collection in this pass — the blast-radius bound. */
|
|
23518
|
-
maxReclaimPerScope: require_dist.number().int().min(1).max(1e5).optional(),
|
|
23519
|
-
/**
|
|
23520
|
-
* Minutes of immunity for a recent row. Protective ONLY — raising it can
|
|
23521
|
-
* exclusively save rows, never select them, which is what keeps this off
|
|
23522
|
-
* the one-age-keyed-sweep guard's books.
|
|
23523
|
-
*/
|
|
23524
|
-
graceMinutes: require_dist.number().int().min(1).max(10080).optional()
|
|
23525
|
-
});
|
|
23526
|
-
var OrphanAuditScopeReportSchema = require_dist.object({
|
|
23527
|
-
collection: require_dist.string(),
|
|
23528
|
-
examined: require_dist.number(),
|
|
23529
|
-
orphans: require_dist.number(),
|
|
23530
|
-
reclaimed: require_dist.number(),
|
|
23531
|
-
bytesReclaimed: require_dist.number(),
|
|
23532
|
-
exempt: require_dist.number(),
|
|
23533
|
-
withinGrace: require_dist.number(),
|
|
23534
|
-
unresolvable: require_dist.number(),
|
|
23535
|
-
stoppedBy: require_dist.string(),
|
|
23536
|
-
/** Where the NEXT pass resumes. `0` = the scope was walked to the end. */
|
|
23537
|
-
nextCursor: require_dist.number()
|
|
23538
|
-
});
|
|
23539
|
-
var OrphanAuditActionResultSchema = require_dist.object({
|
|
23540
|
-
/** `false` when a pass was already in flight — nothing was done. */
|
|
23541
|
-
ran: require_dist.boolean(),
|
|
23542
|
-
mode: require_dist._enum(["report", "reclaim"]),
|
|
23543
|
-
scopes: require_dist.array(OrphanAuditScopeReportSchema),
|
|
23544
|
-
totalOrphans: require_dist.number(),
|
|
23545
|
-
totalReclaimed: require_dist.number(),
|
|
23546
|
-
totalBytesReclaimed: require_dist.number(),
|
|
23547
|
-
durationMs: require_dist.number()
|
|
23548
|
-
});
|
|
23549
|
-
var orphanAuditActions = require_dist.defineCustomActions({ "retention.orphanAudit": require_dist.customAction(OrphanAuditActionInputSchema, OrphanAuditActionResultSchema, { auth: "admin" }) });
|
|
23550
|
-
//#endregion
|
|
23551
24015
|
//#region src/pipeline-analytics/map-with-concurrency.ts
|
|
23552
24016
|
/**
|
|
23553
24017
|
* Apply `mapper` to every item with at most `limit` in flight at once.
|
|
@@ -25862,7 +26326,9 @@ var FrameProcessor = class {
|
|
|
25862
26326
|
}) : {
|
|
25863
26327
|
suppressedIndices: /* @__PURE__ */ new Set(),
|
|
25864
26328
|
confirmed: [],
|
|
25865
|
-
wokenEntryIds: []
|
|
26329
|
+
wokenEntryIds: [],
|
|
26330
|
+
armedEntryIds: [],
|
|
26331
|
+
disarmedEntryIds: []
|
|
25866
26332
|
};
|
|
25867
26333
|
const trackerInput = gate.suppressedIndices.size > 0 ? filteredDetections.filter((_, i) => !gate.suppressedIndices.has(i)) : filteredDetections;
|
|
25868
26334
|
const trackedDetections = this.tracker.update(trackerInput, timestamp, {
|
|
@@ -26072,6 +26538,8 @@ var FrameProcessor = class {
|
|
|
26072
26538
|
rawTrackedDetections: trackedDetections,
|
|
26073
26539
|
stationaryConfirmed: gate.confirmed,
|
|
26074
26540
|
stationaryWoken: gate.wokenEntryIds,
|
|
26541
|
+
stationaryArmed: gate.armedEntryIds,
|
|
26542
|
+
stationaryDisarmed: gate.disarmedEntryIds,
|
|
26075
26543
|
riderFolds
|
|
26076
26544
|
};
|
|
26077
26545
|
}
|
|
@@ -27864,6 +28332,88 @@ var PlateGalleryProvider = class {
|
|
|
27864
28332
|
}
|
|
27865
28333
|
};
|
|
27866
28334
|
//#endregion
|
|
28335
|
+
//#region src/pipeline-analytics/retention/orphan-audit-actions.ts
|
|
28336
|
+
/**
|
|
28337
|
+
* The operator button for the orphan audit.
|
|
28338
|
+
*
|
|
28339
|
+
* The audit engine, its five bounds and its resumable cursor already existed
|
|
28340
|
+
* and had run in `report` mode for weeks; what it had no way to be was
|
|
28341
|
+
* *asked*. Its only trigger was a 6-hourly timer reading a durable setting, so
|
|
28342
|
+
* draining a standing backlog meant flipping a persistent mode, waiting up to
|
|
28343
|
+
* six hours, and remembering to flip it back — with nothing returned to look
|
|
28344
|
+
* at. On 2026-08-14 the backlog was 74,700 media rows / 18.9 GB plus 23,296
|
|
28345
|
+
* object events and 4,543 CLIP vectors (D161), and that is not a thing to
|
|
28346
|
+
* drain by leaving a switch on.
|
|
28347
|
+
*
|
|
28348
|
+
* Three properties this action does NOT leave to the caller's care:
|
|
28349
|
+
*
|
|
28350
|
+
* 1. **Dry run is the default.** `mode` omitted means `report`: it counts,
|
|
28351
|
+
* logs and returns, and deletes nothing — regardless of what the durable
|
|
28352
|
+
* `orphanAuditMode` setting says. `reclaim` is a word that has to be
|
|
28353
|
+
* typed. The engine's own default is the same for the same reason.
|
|
28354
|
+
* 2. **It is paced.** `pageSize` bounds one read, `maxRowsPerScope` bounds
|
|
28355
|
+
* one pass, `maxReclaimPerScope` bounds the damage, and the engine yields
|
|
28356
|
+
* the thread between pages — `better-sqlite3` is synchronous and shares
|
|
28357
|
+
* hub-main with every live frame.
|
|
28358
|
+
* 3. **It answers.** The report comes back per collection with rows AND
|
|
28359
|
+
* bytes, so "did it do anything" is not a question anyone has to infer
|
|
28360
|
+
* from a log line.
|
|
28361
|
+
*/
|
|
28362
|
+
/**
|
|
28363
|
+
* `report` counts; `reclaim` deletes.
|
|
28364
|
+
*
|
|
28365
|
+
* `.default('report')` is load-bearing, not decoration: the settings form
|
|
28366
|
+
* sends `{}` for a button with no fields, and the safe interpretation of an
|
|
28367
|
+
* unstated intent here is the one that destroys nothing.
|
|
28368
|
+
*/
|
|
28369
|
+
var OrphanAuditActionModeSchema = require_dist._enum(["report", "reclaim"]).default("report");
|
|
28370
|
+
var OrphanAuditActionInputSchema = require_dist.object({
|
|
28371
|
+
mode: OrphanAuditActionModeSchema,
|
|
28372
|
+
/**
|
|
28373
|
+
* Start every scope from the top instead of resuming its persisted cursor.
|
|
28374
|
+
*
|
|
28375
|
+
* What draining a backlog wants, and never the default — the cursor exists
|
|
28376
|
+
* so a 6-hourly pass makes progress instead of re-walking the same head.
|
|
28377
|
+
*/
|
|
28378
|
+
restart: require_dist.boolean().optional(),
|
|
28379
|
+
/** Rows read per statement. Smaller = more yields = a gentler pass. */
|
|
28380
|
+
pageSize: require_dist.number().int().min(50).max(5e3).optional(),
|
|
28381
|
+
/** Rows EXAMINED per collection in this pass — the pacing bound. */
|
|
28382
|
+
maxRowsPerScope: require_dist.number().int().min(1).max(2e5).optional(),
|
|
28383
|
+
/** Rows DELETED per collection in this pass — the blast-radius bound. */
|
|
28384
|
+
maxReclaimPerScope: require_dist.number().int().min(1).max(1e5).optional(),
|
|
28385
|
+
/**
|
|
28386
|
+
* Minutes of immunity for a recent row. Protective ONLY — raising it can
|
|
28387
|
+
* exclusively save rows, never select them, which is what keeps this off
|
|
28388
|
+
* the one-age-keyed-sweep guard's books.
|
|
28389
|
+
*/
|
|
28390
|
+
graceMinutes: require_dist.number().int().min(1).max(10080).optional()
|
|
28391
|
+
});
|
|
28392
|
+
var OrphanAuditScopeReportSchema = require_dist.object({
|
|
28393
|
+
collection: require_dist.string(),
|
|
28394
|
+
examined: require_dist.number(),
|
|
28395
|
+
orphans: require_dist.number(),
|
|
28396
|
+
reclaimed: require_dist.number(),
|
|
28397
|
+
bytesReclaimed: require_dist.number(),
|
|
28398
|
+
exempt: require_dist.number(),
|
|
28399
|
+
withinGrace: require_dist.number(),
|
|
28400
|
+
unresolvable: require_dist.number(),
|
|
28401
|
+
stoppedBy: require_dist.string(),
|
|
28402
|
+
/** Where the NEXT pass resumes. `0` = the scope was walked to the end. */
|
|
28403
|
+
nextCursor: require_dist.number()
|
|
28404
|
+
});
|
|
28405
|
+
var OrphanAuditActionResultSchema = require_dist.object({
|
|
28406
|
+
/** `false` when a pass was already in flight — nothing was done. */
|
|
28407
|
+
ran: require_dist.boolean(),
|
|
28408
|
+
mode: require_dist._enum(["report", "reclaim"]),
|
|
28409
|
+
scopes: require_dist.array(OrphanAuditScopeReportSchema),
|
|
28410
|
+
totalOrphans: require_dist.number(),
|
|
28411
|
+
totalReclaimed: require_dist.number(),
|
|
28412
|
+
totalBytesReclaimed: require_dist.number(),
|
|
28413
|
+
durationMs: require_dist.number()
|
|
28414
|
+
});
|
|
28415
|
+
var orphanAuditActions = require_dist.defineCustomActions({ "retention.orphanAudit": require_dist.customAction(OrphanAuditActionInputSchema, OrphanAuditActionResultSchema, { auth: "admin" }) });
|
|
28416
|
+
//#endregion
|
|
27867
28417
|
//#region src/pipeline-analytics/videoclips-provider.ts
|
|
27868
28418
|
var SOURCE = "analytics";
|
|
27869
28419
|
function clipIdFor(eventId, startMs, endMs) {
|
|
@@ -33607,13 +34157,25 @@ var StoragePressureTracker = class {
|
|
|
33607
34157
|
};
|
|
33608
34158
|
//#endregion
|
|
33609
34159
|
//#region src/pipeline-analytics/store/media-store.ts
|
|
34160
|
+
/**
|
|
34161
|
+
* `scene` is its own owner kind rather than borrowing `event`.
|
|
34162
|
+
*
|
|
34163
|
+
* Scene reference thumbnails were written as `ownerKind: 'event'` with an
|
|
34164
|
+
* `ownerId` of `scene-<monitorId>-…`, which resolves through
|
|
34165
|
+
* `resolveMediaOwner` to `{ kind: 'event', eventId: 'scene-…' }` — an event row
|
|
34166
|
+
* that has never existed. The orphan audit therefore classified every
|
|
34167
|
+
* operator-captured reference picture as collectable. It is the same class of
|
|
34168
|
+
* mistake `identity` and `vehicle` are exempt for: a blob the OPERATOR curated,
|
|
34169
|
+
* which nothing else can regenerate and no cascade owns.
|
|
34170
|
+
*/
|
|
33610
34171
|
var OWNER_KINDS = [
|
|
33611
34172
|
"event",
|
|
33612
34173
|
"track",
|
|
33613
34174
|
"face",
|
|
33614
34175
|
"identity",
|
|
33615
34176
|
"plate",
|
|
33616
|
-
"vehicle"
|
|
34177
|
+
"vehicle",
|
|
34178
|
+
"scene"
|
|
33617
34179
|
];
|
|
33618
34180
|
/**
|
|
33619
34181
|
* Narrow an arbitrary string to an `OwnerKind`.
|
|
@@ -35542,20 +36104,38 @@ function centroidInside(b, p) {
|
|
|
35542
36104
|
* box → SUPPRESS (detector box jitter — the box shrank/grew around the same
|
|
35543
36105
|
* centre; still the parked object, must not spawn a duplicate track).
|
|
35544
36106
|
* - `wakeAssocIou` ≤ best IoU < `suppressIou`, centroid OUTSIDE the parked box
|
|
35545
|
-
* →
|
|
35546
|
-
* suppressed, so it spawns a fresh moving track.
|
|
36107
|
+
* → DEPARTING (the object's box slid off its spot).
|
|
35547
36108
|
* - best IoU < `wakeAssocIou` → MISS: no detection overlaps this entry (a
|
|
35548
|
-
* different object, or the parked object is momentarily undetected).
|
|
35549
|
-
*
|
|
36109
|
+
* different object, or the parked object is momentarily undetected). The TTL
|
|
36110
|
+
* sweep retires it only if the absence persists.
|
|
36111
|
+
*
|
|
36112
|
+
* ## A departure is never acted on from ONE read (D49)
|
|
36113
|
+
*
|
|
36114
|
+
* Retiring an entry DESTROYS work: the object's registry identity, and with it
|
|
36115
|
+
* the suppression that stops it re-spawning a track every 30 s. So a single
|
|
36116
|
+
* departing read only ARMS the entry — the detection is still suppressed and
|
|
36117
|
+
* the entry survives. The entry is retired only when a SECOND read agrees:
|
|
36118
|
+
* departing again, or missing entirely (the object really has left the spot).
|
|
36119
|
+
* A confirming read DISARMS it.
|
|
36120
|
+
*
|
|
36121
|
+
* The caller owns the armed set and passes it back in; this function stays
|
|
36122
|
+
* pure. Cost of the extra read: a genuinely departing object spawns its track
|
|
36123
|
+
* one frame later. Cost of NOT having it, measured on the live hub over 24 h:
|
|
36124
|
+
* 65% of all stationary entries were retired within 300 s — below the TTL, so
|
|
36125
|
+
* by a wake — and 38% of the tracks that promotion then re-created had never
|
|
36126
|
+
* moved in their entire life. One bad box per parked object was enough.
|
|
35550
36127
|
*
|
|
35551
36128
|
* A detection can suppress at most one spawn even if it overlaps two entries
|
|
35552
36129
|
* (`suppressedIndices` is a set).
|
|
35553
36130
|
*/
|
|
35554
36131
|
function partitionDetectionsAgainstRegistry(input) {
|
|
35555
36132
|
const { entries, detections, config } = input;
|
|
36133
|
+
const armed = input.armedEntryIds ?? EMPTY_ARMED;
|
|
35556
36134
|
const suppressed = /* @__PURE__ */ new Set();
|
|
35557
36135
|
const confirmed = [];
|
|
35558
36136
|
const woken = [];
|
|
36137
|
+
const toArm = [];
|
|
36138
|
+
const toDisarm = [];
|
|
35559
36139
|
for (const entry of entries) {
|
|
35560
36140
|
let bestIou = 0;
|
|
35561
36141
|
let bestIdx = -1;
|
|
@@ -35568,23 +36148,37 @@ function partitionDetectionsAgainstRegistry(input) {
|
|
|
35568
36148
|
bestIdx = di;
|
|
35569
36149
|
}
|
|
35570
36150
|
}
|
|
35571
|
-
if (bestIdx < 0 || bestIou < config.wakeAssocIou)
|
|
36151
|
+
if (bestIdx < 0 || bestIou < config.wakeAssocIou) {
|
|
36152
|
+
if (armed.has(entry.id)) woken.push(entry.id);
|
|
36153
|
+
continue;
|
|
36154
|
+
}
|
|
35572
36155
|
const detCentroid = centroid(detections[bestIdx].bbox);
|
|
35573
|
-
|
|
36156
|
+
const strong = bestIou >= config.suppressIou;
|
|
36157
|
+
if (strong || centroidInside(entry.bbox, detCentroid)) {
|
|
35574
36158
|
suppressed.add(bestIdx);
|
|
35575
36159
|
confirmed.push({
|
|
35576
36160
|
entryId: entry.id,
|
|
35577
36161
|
className: entry.className,
|
|
35578
|
-
bbox: { ...entry.bbox }
|
|
36162
|
+
bbox: { ...entry.bbox },
|
|
36163
|
+
observedBbox: { ...detections[bestIdx].bbox },
|
|
36164
|
+
strong
|
|
35579
36165
|
});
|
|
35580
|
-
|
|
36166
|
+
if (armed.has(entry.id)) toDisarm.push(entry.id);
|
|
36167
|
+
} else if (armed.has(entry.id)) woken.push(entry.id);
|
|
36168
|
+
else {
|
|
36169
|
+
toArm.push(entry.id);
|
|
36170
|
+
suppressed.add(bestIdx);
|
|
36171
|
+
}
|
|
35581
36172
|
}
|
|
35582
36173
|
return {
|
|
35583
36174
|
suppressedIndices: suppressed,
|
|
35584
36175
|
confirmed,
|
|
35585
|
-
wokenEntryIds: woken
|
|
36176
|
+
wokenEntryIds: woken,
|
|
36177
|
+
armedEntryIds: toArm,
|
|
36178
|
+
disarmedEntryIds: toDisarm
|
|
35586
36179
|
};
|
|
35587
36180
|
}
|
|
36181
|
+
var EMPTY_ARMED = /* @__PURE__ */ new Set();
|
|
35588
36182
|
/**
|
|
35589
36183
|
* A track is promoted when, over the recent `windowMs`, its centroid barely
|
|
35590
36184
|
* moved (both net displacement and path span below the static thresholds) AND
|
|
@@ -36157,6 +36751,21 @@ var StationaryObjectRegistry = class {
|
|
|
36157
36751
|
* between motion sessions; that silence is not evidence the object left,
|
|
36158
36752
|
* so quiet minutes must not age the entries (see {@link sweep}). */
|
|
36159
36753
|
lastFrameAtByDevice = /* @__PURE__ */ new Map();
|
|
36754
|
+
/**
|
|
36755
|
+
* Entries carrying ONE unconfirmed departure read, per device (D49, D174).
|
|
36756
|
+
*
|
|
36757
|
+
* Retiring an entry destroys work — the object's registry identity and the
|
|
36758
|
+
* suppression that keeps it from re-spawning a track every promotion window.
|
|
36759
|
+
* A single frame whose box slid off the parked spot is a fallible read (a
|
|
36760
|
+
* partial occlusion, a shadow, one bad box), so it only ARMS the entry here.
|
|
36761
|
+
* The retirement happens on the next frame that AGREES; a confirming frame
|
|
36762
|
+
* clears the strike.
|
|
36763
|
+
*/
|
|
36764
|
+
departureStrikes = /* @__PURE__ */ new Map();
|
|
36765
|
+
/** Detections absorbed by an existing entry since the last sweep, per device.
|
|
36766
|
+
* Rolled up into ONE log line per sweep — this fires on every frame of every
|
|
36767
|
+
* parked object, so it can never be a line of its own. */
|
|
36768
|
+
absorbedSinceSweep = /* @__PURE__ */ new Map();
|
|
36160
36769
|
constructor(deps) {
|
|
36161
36770
|
this.logger = deps.logger;
|
|
36162
36771
|
this.ledger = new DurableLedger({
|
|
@@ -36229,12 +36838,15 @@ var StationaryObjectRegistry = class {
|
|
|
36229
36838
|
if (entries.length === 0) return {
|
|
36230
36839
|
suppressedIndices: /* @__PURE__ */ new Set(),
|
|
36231
36840
|
confirmed: [],
|
|
36232
|
-
wokenEntryIds: []
|
|
36841
|
+
wokenEntryIds: [],
|
|
36842
|
+
armedEntryIds: [],
|
|
36843
|
+
disarmedEntryIds: []
|
|
36233
36844
|
};
|
|
36234
36845
|
return partitionDetectionsAgainstRegistry({
|
|
36235
36846
|
entries,
|
|
36236
36847
|
detections: input.detections,
|
|
36237
|
-
config: config ?? this.matchConfig
|
|
36848
|
+
config: config ?? this.matchConfig,
|
|
36849
|
+
...this.departureStrikes.has(input.deviceId) ? { armedEntryIds: this.departureStrikes.get(input.deviceId) } : {}
|
|
36238
36850
|
});
|
|
36239
36851
|
}
|
|
36240
36852
|
/** Fold a frame's gate result back into state: advance confirmed entries'
|
|
@@ -36251,15 +36863,41 @@ var StationaryObjectRegistry = class {
|
|
|
36251
36863
|
if (!e) continue;
|
|
36252
36864
|
this.ledger.stage({
|
|
36253
36865
|
...e,
|
|
36254
|
-
lastConfirmedAt: input.timestamp
|
|
36866
|
+
lastConfirmedAt: input.timestamp,
|
|
36867
|
+
...c.strong ? { bbox: { ...c.observedBbox } } : {}
|
|
36255
36868
|
});
|
|
36256
36869
|
this.dirty.add(c.entryId);
|
|
36257
36870
|
}
|
|
36871
|
+
this.absorbedSinceSweep.set(input.deviceId, (this.absorbedSinceSweep.get(input.deviceId) ?? 0) + input.confirmed.length);
|
|
36872
|
+
if (input.armedEntryIds && input.armedEntryIds.length > 0) {
|
|
36873
|
+
let armed = this.departureStrikes.get(input.deviceId);
|
|
36874
|
+
if (!armed) {
|
|
36875
|
+
armed = /* @__PURE__ */ new Set();
|
|
36876
|
+
this.departureStrikes.set(input.deviceId, armed);
|
|
36877
|
+
}
|
|
36878
|
+
for (const id of input.armedEntryIds) if (ids.has(id)) armed.add(id);
|
|
36879
|
+
}
|
|
36880
|
+
const armed = this.departureStrikes.get(input.deviceId);
|
|
36881
|
+
if (armed && input.disarmedEntryIds) {
|
|
36882
|
+
for (const id of input.disarmedEntryIds) armed.delete(id);
|
|
36883
|
+
if (armed.size === 0) this.departureStrikes.delete(input.deviceId);
|
|
36884
|
+
}
|
|
36258
36885
|
for (const id of input.wokenEntryIds) {
|
|
36259
36886
|
if (!ids.has(id)) continue;
|
|
36260
36887
|
const e = this.ledger.get(id);
|
|
36261
36888
|
if (!e) continue;
|
|
36262
36889
|
this.dropEntry(input.deviceId, id);
|
|
36890
|
+
this.logger.info("stationary entry retired", {
|
|
36891
|
+
tags: { deviceId: input.deviceId },
|
|
36892
|
+
meta: {
|
|
36893
|
+
deviceId: input.deviceId,
|
|
36894
|
+
entryId: id,
|
|
36895
|
+
className: e.className,
|
|
36896
|
+
reason: "departed",
|
|
36897
|
+
parkedForMs: input.timestamp - e.becameStationaryAt,
|
|
36898
|
+
...e.sourceTrackId !== void 0 ? { sourceTrackId: e.sourceTrackId } : {}
|
|
36899
|
+
}
|
|
36900
|
+
});
|
|
36263
36901
|
this.onChange?.({
|
|
36264
36902
|
phase: "departed",
|
|
36265
36903
|
entry: e,
|
|
@@ -36306,6 +36944,18 @@ var StationaryObjectRegistry = class {
|
|
|
36306
36944
|
if (observedAt - e.lastConfirmedAt > ttl) {
|
|
36307
36945
|
this.dropEntry(deviceId, id);
|
|
36308
36946
|
retired.push(e);
|
|
36947
|
+
this.logger.info("stationary entry retired", {
|
|
36948
|
+
tags: { deviceId },
|
|
36949
|
+
meta: {
|
|
36950
|
+
deviceId,
|
|
36951
|
+
entryId: id,
|
|
36952
|
+
className: e.className,
|
|
36953
|
+
reason: "ttl",
|
|
36954
|
+
ttlMs: ttl,
|
|
36955
|
+
unconfirmedForMs: observedAt - e.lastConfirmedAt,
|
|
36956
|
+
parkedForMs: observedAt - e.becameStationaryAt
|
|
36957
|
+
}
|
|
36958
|
+
});
|
|
36309
36959
|
this.onChange?.({
|
|
36310
36960
|
phase: "departed",
|
|
36311
36961
|
entry: e,
|
|
@@ -36315,6 +36965,18 @@ var StationaryObjectRegistry = class {
|
|
|
36315
36965
|
}
|
|
36316
36966
|
if (ids.size === 0) this.idsByDevice.delete(deviceId);
|
|
36317
36967
|
}
|
|
36968
|
+
for (const [deviceId, absorbed] of this.absorbedSinceSweep) {
|
|
36969
|
+
if (absorbed === 0) continue;
|
|
36970
|
+
this.logger.info("stationary registry absorbed detections", {
|
|
36971
|
+
tags: { deviceId },
|
|
36972
|
+
meta: {
|
|
36973
|
+
deviceId,
|
|
36974
|
+
absorbed,
|
|
36975
|
+
entries: this.count(deviceId)
|
|
36976
|
+
}
|
|
36977
|
+
});
|
|
36978
|
+
}
|
|
36979
|
+
this.absorbedSinceSweep.clear();
|
|
36318
36980
|
for (const id of [...this.dirty]) {
|
|
36319
36981
|
this.dirty.delete(id);
|
|
36320
36982
|
const entry = this.ledger.get(id);
|
|
@@ -36327,6 +36989,8 @@ var StationaryObjectRegistry = class {
|
|
|
36327
36989
|
* Used on device unbind; a rebind reloads from the store. */
|
|
36328
36990
|
forgetDevice(deviceId) {
|
|
36329
36991
|
this.lastFrameAtByDevice.delete(deviceId);
|
|
36992
|
+
this.departureStrikes.delete(deviceId);
|
|
36993
|
+
this.absorbedSinceSweep.delete(deviceId);
|
|
36330
36994
|
const ids = this.idsByDevice.get(deviceId);
|
|
36331
36995
|
if (!ids) return;
|
|
36332
36996
|
for (const id of ids) {
|
|
@@ -36338,6 +37002,8 @@ var StationaryObjectRegistry = class {
|
|
|
36338
37002
|
/** Delete every persisted + in-memory entry for a device (operator wipe). */
|
|
36339
37003
|
async clearDevice(deviceId) {
|
|
36340
37004
|
this.lastFrameAtByDevice.delete(deviceId);
|
|
37005
|
+
this.departureStrikes.delete(deviceId);
|
|
37006
|
+
this.absorbedSinceSweep.delete(deviceId);
|
|
36341
37007
|
const ids = this.idsByDevice.get(deviceId);
|
|
36342
37008
|
if (ids) {
|
|
36343
37009
|
for (const id of [...ids]) {
|
|
@@ -36355,10 +37021,17 @@ var StationaryObjectRegistry = class {
|
|
|
36355
37021
|
}
|
|
36356
37022
|
set.add(id);
|
|
36357
37023
|
}
|
|
36358
|
-
/** Retire one entry: index, staged advance and durable row
|
|
37024
|
+
/** Retire one entry: index, staged advance, pending strike and durable row
|
|
37025
|
+
* all go. A strike that outlived its entry would arm the NEXT entry to reuse
|
|
37026
|
+
* the id — the registry mints UUIDs, but the invariant is cheap to keep. */
|
|
36359
37027
|
dropEntry(deviceId, id) {
|
|
36360
37028
|
this.idsByDevice.get(deviceId)?.delete(id);
|
|
36361
37029
|
this.dirty.delete(id);
|
|
37030
|
+
const armed = this.departureStrikes.get(deviceId);
|
|
37031
|
+
if (armed) {
|
|
37032
|
+
armed.delete(id);
|
|
37033
|
+
if (armed.size === 0) this.departureStrikes.delete(deviceId);
|
|
37034
|
+
}
|
|
36362
37035
|
this.ledger.forget(id);
|
|
36363
37036
|
}
|
|
36364
37037
|
};
|
|
@@ -40402,6 +41075,26 @@ function isAfterCursor(row, cursor) {
|
|
|
40402
41075
|
}
|
|
40403
41076
|
//#endregion
|
|
40404
41077
|
//#region src/pipeline-analytics/store/track-store.ts
|
|
41078
|
+
/**
|
|
41079
|
+
* WHY a track's row was written EARLY — i.e. by {@link TrackStore.closeActive}
|
|
41080
|
+
* rather than by the ordinary TTL expiry.
|
|
41081
|
+
*
|
|
41082
|
+
* `'stationary-promotion'` is the only member and means: the tracker decided
|
|
41083
|
+
* this subject had PARKED, handed it to the stationary registry, and the row
|
|
41084
|
+
* was written so its media, events and vector keep an owner (D161). It is not a
|
|
41085
|
+
* quality signal and it never hides the track — a parked car is still a track,
|
|
41086
|
+
* still on the events page, still swept by the same retention. It exists so a
|
|
41087
|
+
* consumer that wants PASSAGES can say so exactly, instead of re-deriving
|
|
41088
|
+
* "did it move" from a trajectory downstream and getting it wrong on the
|
|
41089
|
+
* subject that stood still for one honest reason.
|
|
41090
|
+
*
|
|
41091
|
+
* An ABSENT value means "expired normally, or written before this marker
|
|
41092
|
+
* existed". Never read an absence as a positive — same rule as `hasFace`.
|
|
41093
|
+
*/
|
|
41094
|
+
var TRACK_CLOSE_REASONS = ["stationary-promotion"];
|
|
41095
|
+
function parseCloseReason(value) {
|
|
41096
|
+
return TRACK_CLOSE_REASONS.find((reason) => reason === value);
|
|
41097
|
+
}
|
|
40405
41098
|
var DEFAULT_CONFIG = {
|
|
40406
41099
|
ttlMs: 3e4,
|
|
40407
41100
|
maxPositionHistory: 300
|
|
@@ -40624,6 +41317,10 @@ var TRACKS_COLUMNS = [
|
|
|
40624
41317
|
{
|
|
40625
41318
|
name: "frameHeight",
|
|
40626
41319
|
type: "INTEGER"
|
|
41320
|
+
},
|
|
41321
|
+
{
|
|
41322
|
+
name: "closedReason",
|
|
41323
|
+
type: "TEXT"
|
|
40627
41324
|
}
|
|
40628
41325
|
];
|
|
40629
41326
|
var TRACKS_INDEXES = [{
|
|
@@ -40683,7 +41380,8 @@ function cloneTrack(t) {
|
|
|
40683
41380
|
} : {},
|
|
40684
41381
|
...t.debug !== void 0 ? { debug: t.debug } : {},
|
|
40685
41382
|
...t.hasFace !== void 0 ? { hasFace: t.hasFace } : {},
|
|
40686
|
-
...t.hasRider !== void 0 ? { hasRider: t.hasRider } : {}
|
|
41383
|
+
...t.hasRider !== void 0 ? { hasRider: t.hasRider } : {},
|
|
41384
|
+
...t.closedReason !== void 0 ? { closedReason: t.closedReason } : {}
|
|
40687
41385
|
};
|
|
40688
41386
|
}
|
|
40689
41387
|
var TrackStore = class {
|
|
@@ -41252,13 +41950,22 @@ var TrackStore = class {
|
|
|
41252
41950
|
* nothing has been written under that id yet. It is never correct for a track
|
|
41253
41951
|
* that ran.
|
|
41254
41952
|
*
|
|
41953
|
+
* **The row SAYS why it was closed early** (`reason` → the `closedReason`
|
|
41954
|
+
* column). Persisting a promoted track put its footprint back under the
|
|
41955
|
+
* retention sweep, and it also put the parked car back in front of every
|
|
41956
|
+
* consumer that reads the window's tracks: the operator's nightly digest went
|
|
41957
|
+
* from 1-2 tiles to 10, most of them the same stationary car. The marker is
|
|
41958
|
+
* written HERE, at the one place that knows, so a consumer can ask for
|
|
41959
|
+
* passages exactly instead of guessing at movement from a stored trajectory.
|
|
41960
|
+
*
|
|
41255
41961
|
* Returns whether an active entry was found. THROWS if the persist fails: the
|
|
41256
41962
|
* caller must log it, because a swallowed failure here IS the leak.
|
|
41257
41963
|
*/
|
|
41258
|
-
async closeActive(trackId) {
|
|
41964
|
+
async closeActive(trackId, reason) {
|
|
41259
41965
|
const t = this.active.get(trackId);
|
|
41260
41966
|
if (t === void 0) return false;
|
|
41261
41967
|
t.active = false;
|
|
41968
|
+
t.closedReason = reason;
|
|
41262
41969
|
const record = cloneTrack(t);
|
|
41263
41970
|
await this.persistCompleted(record);
|
|
41264
41971
|
this.active.delete(trackId);
|
|
@@ -41840,6 +42547,7 @@ var TrackStore = class {
|
|
|
41840
42547
|
...t.debug !== void 0 ? { debug: t.debug } : {},
|
|
41841
42548
|
...t.hasFace !== void 0 ? { hasFace: t.hasFace } : {},
|
|
41842
42549
|
...t.hasRider !== void 0 ? { hasRider: t.hasRider } : {},
|
|
42550
|
+
...t.closedReason !== void 0 ? { closedReason: t.closedReason } : {},
|
|
41843
42551
|
...envelope !== null && dims !== void 0 ? {
|
|
41844
42552
|
envMinX: envelope.minX,
|
|
41845
42553
|
envMinY: envelope.minY,
|
|
@@ -41877,6 +42585,7 @@ var TrackStore = class {
|
|
|
41877
42585
|
const hasFace = data["hasFace"];
|
|
41878
42586
|
/** Same read as `hasFace` — NULL / missing stays ABSENT, never `false`. */
|
|
41879
42587
|
const hasRider = data["hasRider"];
|
|
42588
|
+
const closedReason = parseCloseReason(data["closedReason"]);
|
|
41880
42589
|
const envMinX = data["envMinX"];
|
|
41881
42590
|
const envMinY = data["envMinY"];
|
|
41882
42591
|
const envMaxX = data["envMaxX"];
|
|
@@ -41914,6 +42623,7 @@ var TrackStore = class {
|
|
|
41914
42623
|
...typeof debug === "boolean" ? { debug } : {},
|
|
41915
42624
|
...typeof hasFace === "boolean" ? { hasFace } : {},
|
|
41916
42625
|
...typeof hasRider === "boolean" ? { hasRider } : {},
|
|
42626
|
+
...closedReason !== void 0 ? { closedReason } : {},
|
|
41917
42627
|
...envelope !== null ? { envelope } : {}
|
|
41918
42628
|
};
|
|
41919
42629
|
}
|
|
@@ -42395,7 +43105,11 @@ function auditableCollections() {
|
|
|
42395
43105
|
* exemption lives here now, and the orphan collector — the only sweep that
|
|
42396
43106
|
* walks media it was not handed by name — reads it.
|
|
42397
43107
|
*/
|
|
42398
|
-
var RETENTION_EXEMPT_OWNER_KINDS = new Set([
|
|
43108
|
+
var RETENTION_EXEMPT_OWNER_KINDS = new Set([
|
|
43109
|
+
"identity",
|
|
43110
|
+
"vehicle",
|
|
43111
|
+
"scene"
|
|
43112
|
+
]);
|
|
42399
43113
|
function isRetentionExemptOwnerKind(kind) {
|
|
42400
43114
|
return RETENTION_EXEMPT_OWNER_KINDS.has(kind);
|
|
42401
43115
|
}
|
|
@@ -45334,22 +46048,6 @@ async function projectSensorMarkers(deps, data, timestamp) {
|
|
|
45334
46048
|
}
|
|
45335
46049
|
return landed;
|
|
45336
46050
|
}
|
|
45337
|
-
/** JPEG quality for the downscaled full frame — matches the crop path. */
|
|
45338
|
-
var FULL_FRAME_QUALITY = 80;
|
|
45339
|
-
/**
|
|
45340
|
-
* Downscale an already-encoded JPEG full frame to FIT WITHIN
|
|
45341
|
-
* {@link FULL_FRAME_MAX_WIDTH}×{@link FULL_FRAME_MAX_HEIGHT}, preserving aspect
|
|
45342
|
-
* ratio (`fit: 'inside'`) and never enlarging a source already smaller than the
|
|
45343
|
-
* box. Re-encodes as JPEG. Used before persisting a synthetic sensor/control
|
|
45344
|
-
* track's whole-scene snapshot so a raw native-resolution frame (a 4K bedroom
|
|
45345
|
-
* at night) is never stored or served — the privacy fix moved to CAPTURE time.
|
|
45346
|
-
*/
|
|
45347
|
-
async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
|
|
45348
|
-
return (0, sharp.default)(Buffer.from(jpeg)).resize(maxWidth, maxHeight, {
|
|
45349
|
-
fit: "inside",
|
|
45350
|
-
withoutEnlargement: true
|
|
45351
|
-
}).jpeg({ quality: FULL_FRAME_QUALITY }).toBuffer();
|
|
45352
|
-
}
|
|
45353
46051
|
//#endregion
|
|
45354
46052
|
//#region src/pipeline-analytics/services/synthetic-track.ts
|
|
45355
46053
|
/**
|
|
@@ -49894,6 +50592,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
49894
50592
|
addonId: device.addonId
|
|
49895
50593
|
};
|
|
49896
50594
|
},
|
|
50595
|
+
getSnapshot: (deviceId) => api.snapshot.getSnapshot.query({ deviceId }),
|
|
49897
50596
|
identityIdsByName: async () => {
|
|
49898
50597
|
const byName = /* @__PURE__ */ new Map();
|
|
49899
50598
|
const identities = await stores.identityStore.listIdentities();
|
|
@@ -50112,7 +50811,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
50112
50811
|
lastSeen: t.lastSeen,
|
|
50113
50812
|
...t.importance !== void 0 ? { importance: t.importance } : {},
|
|
50114
50813
|
zones: [...t.zonesVisited],
|
|
50115
|
-
...t.classes !== void 0 ? { classes: [...t.classes] } : {}
|
|
50814
|
+
...t.classes !== void 0 ? { classes: [...t.classes] } : {},
|
|
50815
|
+
...t.closedReason === "stationary-promotion" ? { stationary: true } : {}
|
|
50116
50816
|
}));
|
|
50117
50817
|
},
|
|
50118
50818
|
listMediaInfo: async (trackId) => {
|
|
@@ -50770,13 +51470,16 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
50770
51470
|
if (media === null) throw new Error("media store not ready");
|
|
50771
51471
|
return media.putReplacing({
|
|
50772
51472
|
deviceId,
|
|
50773
|
-
ownerKind: "
|
|
51473
|
+
ownerKind: "scene",
|
|
50774
51474
|
ownerId,
|
|
50775
51475
|
kind: "thumbnailSmall",
|
|
50776
51476
|
timestamp,
|
|
50777
51477
|
data
|
|
50778
51478
|
});
|
|
50779
51479
|
},
|
|
51480
|
+
dropMedia: async (mediaId) => {
|
|
51481
|
+
await this.mediaStore?.deleteByKey(mediaId);
|
|
51482
|
+
},
|
|
50780
51483
|
mirrorSlice: (deviceId, status) => {
|
|
50781
51484
|
this.mirrorSceneSlice(deviceId, status);
|
|
50782
51485
|
},
|
|
@@ -51213,10 +51916,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
51213
51916
|
this.trackStore?.markRiderFolded(fold.trackId);
|
|
51214
51917
|
}
|
|
51215
51918
|
if (source === "pipeline") this.stationaryRegistry?.noteFrame(deviceId, result.timestamp);
|
|
51216
|
-
if (this.stationaryRegistry && (result.stationaryConfirmed.length > 0 || result.stationaryWoken.length > 0)) this.stationaryRegistry.applyFrameOutcome({
|
|
51919
|
+
if (this.stationaryRegistry && (result.stationaryConfirmed.length > 0 || result.stationaryWoken.length > 0 || result.stationaryArmed.length > 0 || result.stationaryDisarmed.length > 0)) this.stationaryRegistry.applyFrameOutcome({
|
|
51217
51920
|
deviceId,
|
|
51218
51921
|
confirmed: result.stationaryConfirmed,
|
|
51219
51922
|
wokenEntryIds: result.stationaryWoken,
|
|
51923
|
+
armedEntryIds: result.stationaryArmed,
|
|
51924
|
+
disarmedEntryIds: result.stationaryDisarmed,
|
|
51220
51925
|
timestamp: result.timestamp
|
|
51221
51926
|
});
|
|
51222
51927
|
const stationaryViews = this.stationaryRegistry?.listViews(deviceId) ?? [];
|
|
@@ -53273,7 +53978,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
53273
53978
|
};
|
|
53274
53979
|
if (plausible) this.stationaryRegistry?.promote(entry);
|
|
53275
53980
|
processor.dropTrack(track.trackId);
|
|
53276
|
-
this.trackStore?.closeActive(track.trackId).catch((err) => {
|
|
53981
|
+
this.trackStore?.closeActive(track.trackId, "stationary-promotion").catch((err) => {
|
|
53277
53982
|
this.ctx.logger.warn("stationary promotion: closing the source track failed — its media, events and vector are now unowned", {
|
|
53278
53983
|
tags: { deviceId },
|
|
53279
53984
|
meta: {
|
|
@@ -53376,7 +54081,9 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
53376
54081
|
if (!s.enabled) return {
|
|
53377
54082
|
suppressedIndices: /* @__PURE__ */ new Set(),
|
|
53378
54083
|
confirmed: [],
|
|
53379
|
-
wokenEntryIds: []
|
|
54084
|
+
wokenEntryIds: [],
|
|
54085
|
+
armedEntryIds: [],
|
|
54086
|
+
disarmedEntryIds: []
|
|
53380
54087
|
};
|
|
53381
54088
|
const matchConfig = {
|
|
53382
54089
|
suppressIou: s.suppressIou,
|