@camstack/addon-post-analysis 1.2.77 → 1.2.78
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{dist-CxeFHSSV.mjs → dist-CA2KdMY9.mjs} +52 -3
- package/dist/{dist-Bs6oboqZ.js → dist-CFOT1Yoz.js} +57 -2
- 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-DteUZxxp.mjs} +2 -2
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-B4LMjxKe.mjs +26 -0
- package/dist/pipeline-analytics/{hostInit-DTI0jC8p.mjs → hostInit-BTue_6nN.mjs} +2 -2
- package/dist/pipeline-analytics/index.js +958 -446
- package/dist/pipeline-analytics/index.mjs +921 -409
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DFckpC3K.mjs +0 -26
|
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
|
|
|
2
2
|
__esModule: { value: true },
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
|
-
const require_dist = require("../dist-
|
|
5
|
+
const require_dist = require("../dist-CFOT1Yoz.js");
|
|
6
6
|
let node_fs = require("node:fs");
|
|
7
7
|
node_fs = require_dist.__toESM(node_fs, 1);
|
|
8
8
|
let node_path = require("node:path");
|
|
@@ -3390,19 +3390,26 @@ function cosineSimilarity(a, b) {
|
|
|
3390
3390
|
}
|
|
3391
3391
|
/**
|
|
3392
3392
|
* Select the references a live crop is scored against for a given condition —
|
|
3393
|
-
* **exact condition match,
|
|
3394
|
-
*
|
|
3395
|
-
* There used to be
|
|
3396
|
-
* condition had none. For a named-states monitor that was a defensible
|
|
3397
|
-
* than blind". For a latched alarm it is a false positive every single
|
|
3398
|
-
* the day reference is compared against an IR frame, the cosine
|
|
3399
|
-
* scene latches `diverged`, and the operator is told the bin was
|
|
3400
|
-
* 21:40. Cross-condition cosines are not comparable, so the honest
|
|
3401
|
-
* have never seen this scene in this light" is *I don't know* —
|
|
3402
|
-
* in the direction that raises an alarm.
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
3393
|
+
* **exact condition match, and by default NO fallback.**
|
|
3394
|
+
*
|
|
3395
|
+
* There used to be an unconditional silent fallback to every reference when the
|
|
3396
|
+
* current condition had none. For a named-states monitor that was a defensible
|
|
3397
|
+
* "better than blind". For a latched alarm it is a false positive every single
|
|
3398
|
+
* night: the day reference is compared against an IR frame, the cosine
|
|
3399
|
+
* collapses, the scene latches `diverged`, and the operator is told the bin was
|
|
3400
|
+
* taken at 21:40. Cross-condition cosines are not comparable, so the honest
|
|
3401
|
+
* answer to "I have never seen this scene in this light" is *I don't know* —
|
|
3402
|
+
* never a guess in the direction that raises an alarm.
|
|
3403
|
+
*
|
|
3404
|
+
* The fallback survives only as an explicit per-scene opt-in, and even then it
|
|
3405
|
+
* is a LAST resort: when the exact condition has references of its own they
|
|
3406
|
+
* win outright, so opting in never widens a comparison the operator already
|
|
3407
|
+
* covered properly.
|
|
3408
|
+
*/
|
|
3409
|
+
function selectConditionRefs(references, currentCondition, crossConditionFallback) {
|
|
3410
|
+
const exact = references.filter((r) => r.condition === currentCondition);
|
|
3411
|
+
if (exact.length > 0 || !crossConditionFallback) return exact;
|
|
3412
|
+
return references;
|
|
3406
3413
|
}
|
|
3407
3414
|
/** A reference is comparable only when its `modelId` equals the live encoder's
|
|
3408
3415
|
* model AND its dimension equals the encoder's embedding dim (face-matcher
|
|
@@ -3418,11 +3425,12 @@ function comparable(r, encoderModelId, encoderDim) {
|
|
|
3418
3425
|
* Returns `{ bestStateId: null, bestScore: null }` when NOTHING was comparable
|
|
3419
3426
|
* — see `ScoreResult.bestScore`.
|
|
3420
3427
|
*/
|
|
3421
|
-
function scoreStates(states, liveEmbedding, currentCondition, encoderModelId, encoderDim) {
|
|
3428
|
+
function scoreStates(states, liveEmbedding, currentCondition, encoderModelId, encoderDim, options) {
|
|
3422
3429
|
let bestStateId = null;
|
|
3423
3430
|
let bestScore = null;
|
|
3431
|
+
const crossConditionFallback = options?.crossConditionFallback === true;
|
|
3424
3432
|
for (const s of states) {
|
|
3425
|
-
const refs = selectConditionRefs(s.references, currentCondition);
|
|
3433
|
+
const refs = selectConditionRefs(s.references, currentCondition, crossConditionFallback);
|
|
3426
3434
|
let stateScore = null;
|
|
3427
3435
|
for (const r of refs) {
|
|
3428
3436
|
if (!comparable(r, encoderModelId, encoderDim)) continue;
|
|
@@ -3608,7 +3616,8 @@ var SceneEngine = class {
|
|
|
3608
3616
|
lastCountedAt: 0,
|
|
3609
3617
|
lastAvailability: "ok",
|
|
3610
3618
|
lastVerdict: currentStateId === null ? "unknown" : "matched",
|
|
3611
|
-
lastUnavailable: null
|
|
3619
|
+
lastUnavailable: null,
|
|
3620
|
+
suspendedCondition: null
|
|
3612
3621
|
});
|
|
3613
3622
|
}
|
|
3614
3623
|
/** Force an out-of-cycle check for a single monitor. */
|
|
@@ -3631,7 +3640,8 @@ var SceneEngine = class {
|
|
|
3631
3640
|
lastCountedAt: 0,
|
|
3632
3641
|
lastAvailability: monitor.availability,
|
|
3633
3642
|
lastVerdict: monitor.verdict,
|
|
3634
|
-
lastUnavailable: monitor.unavailable
|
|
3643
|
+
lastUnavailable: monitor.unavailable,
|
|
3644
|
+
suspendedCondition: monitor.suspendedCondition
|
|
3635
3645
|
};
|
|
3636
3646
|
rt.lastCheckAt = now;
|
|
3637
3647
|
this.runtime.set(k, rt);
|
|
@@ -3682,6 +3692,7 @@ var SceneEngine = class {
|
|
|
3682
3692
|
availability: "unavailable",
|
|
3683
3693
|
unavailableReason: llmResult.reason,
|
|
3684
3694
|
coveredConditions: [],
|
|
3695
|
+
suspendedCondition: null,
|
|
3685
3696
|
transitioned: false,
|
|
3686
3697
|
checkedAt: now
|
|
3687
3698
|
});
|
|
@@ -3735,12 +3746,20 @@ var SceneEngine = class {
|
|
|
3735
3746
|
if (monitor.check.mode !== "similarity") return;
|
|
3736
3747
|
const { embedding } = await this.deps.encoder.encode(crop, cropWidth, cropHeight);
|
|
3737
3748
|
const info = await this.encoderInfo();
|
|
3738
|
-
const { bestStateId, bestScore } = scoreStates(monitor.states, embedding, condition, info.modelId, info.embeddingDim);
|
|
3749
|
+
const { bestStateId, bestScore } = scoreStates(monitor.states, embedding, condition, info.modelId, info.embeddingDim, { crossConditionFallback: monitor.onUncoveredCondition === "judge-anyway" });
|
|
3739
3750
|
const covered = [...coveredConditions(monitor.states, info.modelId, info.embeddingDim)];
|
|
3740
3751
|
if (bestScore === null) {
|
|
3741
3752
|
const cause = unknownCause(monitor, condition);
|
|
3742
3753
|
rt.lastAvailability = "ok";
|
|
3743
3754
|
this.runtime.set(k, rt);
|
|
3755
|
+
if (cause === "no-reference-for-condition" && covered.length > 0 && monitor.onUncoveredCondition === "skip") {
|
|
3756
|
+
await this.reportSuspended(deviceId, monitor, rt, {
|
|
3757
|
+
condition,
|
|
3758
|
+
covered,
|
|
3759
|
+
now
|
|
3760
|
+
});
|
|
3761
|
+
return;
|
|
3762
|
+
}
|
|
3744
3763
|
await this.reportUnknown(deviceId, monitor, rt, {
|
|
3745
3764
|
condition,
|
|
3746
3765
|
cause,
|
|
@@ -3808,9 +3827,11 @@ var SceneEngine = class {
|
|
|
3808
3827
|
rt.lastAvailability = "ok";
|
|
3809
3828
|
this.runtime.set(k, rt);
|
|
3810
3829
|
const verdict = projectVerdict(step.currentStateId, monitor.baselineStateId);
|
|
3811
|
-
const
|
|
3830
|
+
const resumed = rt.suspendedCondition !== null;
|
|
3831
|
+
const changed = step.transitioned || verdict !== rt.lastVerdict || rt.lastAvailability !== monitor.availability || rt.lastUnavailable !== null || resumed;
|
|
3812
3832
|
rt.lastVerdict = verdict;
|
|
3813
3833
|
rt.lastUnavailable = null;
|
|
3834
|
+
rt.suspendedCondition = null;
|
|
3814
3835
|
this.runtime.set(k, rt);
|
|
3815
3836
|
if (changed) await this.deps.applyEngineResult(deviceId, monitor.id, {
|
|
3816
3837
|
currentStateId: step.currentStateId,
|
|
@@ -3822,20 +3843,61 @@ var SceneEngine = class {
|
|
|
3822
3843
|
availability: "ok",
|
|
3823
3844
|
unavailableReason: null,
|
|
3824
3845
|
coveredConditions: covered,
|
|
3846
|
+
suspendedCondition: null,
|
|
3825
3847
|
transitioned: step.transitioned,
|
|
3826
3848
|
checkedAt: now
|
|
3827
3849
|
});
|
|
3828
3850
|
}
|
|
3829
3851
|
/**
|
|
3852
|
+
* Announce that this scene is SITTING OUT the current light — once per
|
|
3853
|
+
* transition into it, never once per tick. At a 60 s cadence a per-tick line
|
|
3854
|
+
* would be ~480 lines a night per scene, which is how a log stops being read
|
|
3855
|
+
* at all.
|
|
3856
|
+
*
|
|
3857
|
+
* `info`, not `warn`: nothing is wrong. The operator chose not to capture
|
|
3858
|
+
* this light, or has not got round to it, and either way the scene is doing
|
|
3859
|
+
* exactly what it was configured to do. A branch that drops work still has to
|
|
3860
|
+
* SAY so — silence reads as "never happened" — but saying it at warning level
|
|
3861
|
+
* would train the operator to ignore the one channel that reports real
|
|
3862
|
+
* faults.
|
|
3863
|
+
*/
|
|
3864
|
+
async reportSuspended(deviceId, monitor, rt, input) {
|
|
3865
|
+
if (rt.suspendedCondition === input.condition) return;
|
|
3866
|
+
rt.suspendedCondition = input.condition;
|
|
3867
|
+
this.deps.logger.info("scene checks paused — nothing captured in this light", {
|
|
3868
|
+
tags: { deviceId },
|
|
3869
|
+
meta: {
|
|
3870
|
+
monitorId: monitor.id,
|
|
3871
|
+
condition: input.condition,
|
|
3872
|
+
covered: input.covered.join(",")
|
|
3873
|
+
}
|
|
3874
|
+
});
|
|
3875
|
+
await this.deps.applyEngineResult(deviceId, monitor.id, {
|
|
3876
|
+
currentStateId: rt.hysteresis.currentStateId,
|
|
3877
|
+
previousStateId: rt.hysteresis.currentStateId,
|
|
3878
|
+
lastConfidence: null,
|
|
3879
|
+
currentCondition: input.condition,
|
|
3880
|
+
verdict: rt.lastVerdict,
|
|
3881
|
+
unavailable: rt.lastUnavailable,
|
|
3882
|
+
availability: "ok",
|
|
3883
|
+
unavailableReason: null,
|
|
3884
|
+
coveredConditions: input.covered,
|
|
3885
|
+
suspendedCondition: input.condition,
|
|
3886
|
+
transitioned: false,
|
|
3887
|
+
checkedAt: input.now
|
|
3888
|
+
});
|
|
3889
|
+
}
|
|
3890
|
+
/**
|
|
3830
3891
|
* Push an `unknown` verdict — once per transition, not once per poll. The
|
|
3831
3892
|
* held state is deliberately NOT destroyed: a scene that cannot judge is a
|
|
3832
3893
|
* scene that has not observed anything, and an observation it could not make
|
|
3833
3894
|
* must never spend or clear hysteresis credit in either direction.
|
|
3834
3895
|
*/
|
|
3835
3896
|
async reportUnknown(deviceId, monitor, rt, input) {
|
|
3836
|
-
if (rt.lastVerdict === "unknown" && rt.lastUnavailable === input.cause) return;
|
|
3897
|
+
if (rt.lastVerdict === "unknown" && rt.lastUnavailable === input.cause && rt.suspendedCondition === null) return;
|
|
3837
3898
|
rt.lastVerdict = "unknown";
|
|
3838
3899
|
rt.lastUnavailable = input.cause;
|
|
3900
|
+
rt.suspendedCondition = null;
|
|
3839
3901
|
this.deps.logger.warn("scene cannot judge — reporting unknown", {
|
|
3840
3902
|
tags: { deviceId },
|
|
3841
3903
|
meta: {
|
|
@@ -3855,6 +3917,7 @@ var SceneEngine = class {
|
|
|
3855
3917
|
availability: "ok",
|
|
3856
3918
|
unavailableReason: `no comparable reference for "${input.condition}"`,
|
|
3857
3919
|
coveredConditions: input.covered,
|
|
3920
|
+
suspendedCondition: null,
|
|
3858
3921
|
transitioned: false,
|
|
3859
3922
|
checkedAt: input.now
|
|
3860
3923
|
});
|
|
@@ -4577,6 +4640,8 @@ var SceneMonitorProvider = class {
|
|
|
4577
4640
|
minObservationSpacingSec: 120,
|
|
4578
4641
|
anchorThreshold: require_dist.SCENE_DEFAULT_ANCHOR_THRESHOLD,
|
|
4579
4642
|
autoRestore: false,
|
|
4643
|
+
onUncoveredCondition: require_dist.SCENE_DEFAULT_UNCOVERED_POLICY,
|
|
4644
|
+
suspendedCondition: null,
|
|
4580
4645
|
unavailable: "no-reference-for-condition",
|
|
4581
4646
|
coveredConditions: []
|
|
4582
4647
|
};
|
|
@@ -4606,7 +4671,9 @@ var SceneMonitorProvider = class {
|
|
|
4606
4671
|
this.refresh(input.deviceId);
|
|
4607
4672
|
}
|
|
4608
4673
|
async deleteScene(input) {
|
|
4674
|
+
const doomed = this.deps.store.getScene(input.deviceId, input.monitorId);
|
|
4609
4675
|
await this.deps.store.deleteScene(input.deviceId, input.monitorId);
|
|
4676
|
+
await this.dropOrphanedMedia(doomed?.monitor.states.flatMap((s) => s.references) ?? []);
|
|
4610
4677
|
this.deps.logger.info("scene deleted", {
|
|
4611
4678
|
tags: { deviceId: input.deviceId },
|
|
4612
4679
|
meta: { monitorId: input.monitorId }
|
|
@@ -4629,7 +4696,9 @@ var SceneMonitorProvider = class {
|
|
|
4629
4696
|
const stateId = input.stateId ?? monitor.states[0]?.id ?? (0, node_crypto.randomUUID)();
|
|
4630
4697
|
const existing = monitor.states.find((s) => s.id === stateId);
|
|
4631
4698
|
const label = input.label ?? existing?.label ?? monitor.label;
|
|
4632
|
-
const
|
|
4699
|
+
const kept = [...existing?.references ?? [], reference];
|
|
4700
|
+
const references = boundReferences(kept, condition);
|
|
4701
|
+
await this.dropOrphanedMedia(kept.filter((r) => !references.includes(r)));
|
|
4633
4702
|
const state = {
|
|
4634
4703
|
id: stateId,
|
|
4635
4704
|
label,
|
|
@@ -4663,6 +4732,7 @@ var SceneMonitorProvider = class {
|
|
|
4663
4732
|
}
|
|
4664
4733
|
async deleteReference(input) {
|
|
4665
4734
|
const monitor = this.requireScene(input.deviceId, input.monitorId);
|
|
4735
|
+
const removed = monitor.states.find((s) => s.id === input.stateId)?.references[input.index];
|
|
4666
4736
|
const states = monitor.states.map((s) => {
|
|
4667
4737
|
if (s.id !== input.stateId) return s;
|
|
4668
4738
|
const references = s.references.filter((_, i) => i !== input.index);
|
|
@@ -4676,6 +4746,17 @@ var SceneMonitorProvider = class {
|
|
|
4676
4746
|
...monitor,
|
|
4677
4747
|
states
|
|
4678
4748
|
}, this.now());
|
|
4749
|
+
await this.dropOrphanedMedia(removed === void 0 ? [] : [removed]);
|
|
4750
|
+
this.deps.logger.info("scene reference deleted", {
|
|
4751
|
+
tags: { deviceId: input.deviceId },
|
|
4752
|
+
meta: {
|
|
4753
|
+
monitorId: input.monitorId,
|
|
4754
|
+
stateId: input.stateId,
|
|
4755
|
+
index: input.index,
|
|
4756
|
+
condition: removed?.condition ?? "(none)",
|
|
4757
|
+
remaining: states.reduce((n, s) => n + s.references.length, 0)
|
|
4758
|
+
}
|
|
4759
|
+
});
|
|
4679
4760
|
this.refresh(input.deviceId);
|
|
4680
4761
|
}
|
|
4681
4762
|
async recheckNow(input) {
|
|
@@ -4760,6 +4841,15 @@ var SceneMonitorProvider = class {
|
|
|
4760
4841
|
const row = this.deps.store.getScene(deviceId, monitorId);
|
|
4761
4842
|
if (row === void 0) return;
|
|
4762
4843
|
const monitor = row.monitor;
|
|
4844
|
+
if (result.suspendedCondition !== null) {
|
|
4845
|
+
await this.deps.store.putScene(deviceId, {
|
|
4846
|
+
...monitor,
|
|
4847
|
+
suspendedCondition: result.suspendedCondition,
|
|
4848
|
+
coveredConditions: [...result.coveredConditions]
|
|
4849
|
+
}, result.checkedAt);
|
|
4850
|
+
this.refresh(deviceId);
|
|
4851
|
+
return;
|
|
4852
|
+
}
|
|
4763
4853
|
const previous = this.deps.store.getLatch(deviceId, monitorId);
|
|
4764
4854
|
const wasLatched = previous?.latched ?? monitor.latched;
|
|
4765
4855
|
const wasVerdict = previous?.verdict ?? monitor.verdict;
|
|
@@ -4786,6 +4876,7 @@ var SceneMonitorProvider = class {
|
|
|
4786
4876
|
const merged = {
|
|
4787
4877
|
...monitor,
|
|
4788
4878
|
coveredConditions: [...result.coveredConditions],
|
|
4879
|
+
suspendedCondition: null,
|
|
4789
4880
|
availability: result.availability,
|
|
4790
4881
|
unavailableReason: result.unavailableReason
|
|
4791
4882
|
};
|
|
@@ -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 } : {},
|
|
@@ -14346,18 +14759,20 @@ var NcSummaryProducer = class {
|
|
|
14346
14759
|
};
|
|
14347
14760
|
let rendered;
|
|
14348
14761
|
try {
|
|
14762
|
+
const plan = mosaicTextPlan(rule.captions);
|
|
14349
14763
|
rendered = await renderMosaic({
|
|
14350
14764
|
tiles: tiles.map(({ candidate, jpeg }) => ({
|
|
14351
14765
|
jpeg,
|
|
14352
|
-
label: `${names.get(candidate.deviceId) ?? candidate.deviceId} · ${clockOf$2(candidate.firstSeen)} · ${candidate.className}
|
|
14766
|
+
...plan.tileLabels ? { label: `${names.get(candidate.deviceId) ?? candidate.deviceId} · ${clockOf$2(candidate.firstSeen)} · ${candidate.className}` } : {},
|
|
14353
14767
|
deviceId: candidate.deviceId,
|
|
14354
14768
|
trackId: candidate.trackId
|
|
14355
14769
|
})),
|
|
14356
|
-
|
|
14770
|
+
captions: rule.captions,
|
|
14771
|
+
...plan.titleBand ? { caption: summaryCaptionText({
|
|
14357
14772
|
...rule.captionText !== void 0 ? { captionText: rule.captionText } : {},
|
|
14358
14773
|
ruleName: rule.name,
|
|
14359
14774
|
window
|
|
14360
|
-
})
|
|
14775
|
+
}) } : {}
|
|
14361
14776
|
}, this.deps.logger);
|
|
14362
14777
|
} catch (err) {
|
|
14363
14778
|
this.deps.logger.warn("summary mosaic could not be composed — delivering the counts alone", { meta: {
|
|
@@ -14621,6 +15036,34 @@ var NcSummaryRankSchema = require_dist._enum([
|
|
|
14621
15036
|
"dwell"
|
|
14622
15037
|
]);
|
|
14623
15038
|
/**
|
|
15039
|
+
* How much TEXT is burnt into the mosaic — and the answer is NONE by default.
|
|
15040
|
+
*
|
|
15041
|
+
* `none` is the shipped default and, crucially, the reading of an ABSENT field:
|
|
15042
|
+
* `.default()` on the Input schema materialises it, and the persisted schema
|
|
15043
|
+
* extends Input, so a rule authored before this option existed parses as `none`
|
|
15044
|
+
* on the next read. No migration, no backfill, no rewrite of a stored rule.
|
|
15045
|
+
*
|
|
15046
|
+
* The operator asked for "solo immagini". Three reasons it is the right
|
|
15047
|
+
* default rather than a preference:
|
|
15048
|
+
*
|
|
15049
|
+
* - a caption is PERMANENT in the artefact, exactly like a detection box, and
|
|
15050
|
+
* a mosaic is re-read days later;
|
|
15051
|
+
* - the per-tile strip covers 14% of the tile from the bottom edge up, which
|
|
15052
|
+
* is where feet, wheels and parcels are;
|
|
15053
|
+
* - nothing downstream needs it. The AI pass is given the RAW per-track JPEGs
|
|
15054
|
+
* and gets camera, class and time as PROMPT TEXT — it never reads the
|
|
15055
|
+
* mosaic, so the pixels carry no fact the judge would lose.
|
|
15056
|
+
*
|
|
15057
|
+
* `tiles` and `all` keep the old behaviour reachable for an operator who wants
|
|
15058
|
+
* it; `all` is byte-for-byte what every rule produced before this field.
|
|
15059
|
+
*/
|
|
15060
|
+
var NcSummaryCaptionsSchema = require_dist._enum([
|
|
15061
|
+
"none",
|
|
15062
|
+
"tiles",
|
|
15063
|
+
"all"
|
|
15064
|
+
]);
|
|
15065
|
+
var NC_SUMMARY_DEFAULT_CAPTIONS = "none";
|
|
15066
|
+
/**
|
|
14624
15067
|
* The AI section — DECLARED IN P1, WIRED IN P2.
|
|
14625
15068
|
*
|
|
14626
15069
|
* Nothing in this addon reads it today. It is here so the rule shape does not
|
|
@@ -14654,6 +15097,8 @@ var NcSummaryRuleInputSchema = require_dist.object({
|
|
|
14654
15097
|
barrierSeconds: BarrierSecondsField.default(120),
|
|
14655
15098
|
targets: TargetsField,
|
|
14656
15099
|
template: NcSummaryTemplateSchema.optional(),
|
|
15100
|
+
/** Burned onto the mosaic's title band — and ONLY when `captions` is `all`. */
|
|
15101
|
+
captions: NcSummaryCaptionsSchema.default(NC_SUMMARY_DEFAULT_CAPTIONS),
|
|
14657
15102
|
/** Burned onto the mosaic's title band. `''` is reachable and means "no
|
|
14658
15103
|
* caption" — which is why this is not `.min(1)`. */
|
|
14659
15104
|
captionText: require_dist.string().max(200).optional(),
|
|
@@ -14683,6 +15128,7 @@ var NcSummaryRulePatchSchema = require_dist.object({
|
|
|
14683
15128
|
barrierSeconds: BarrierSecondsField.optional(),
|
|
14684
15129
|
targets: TargetsField.optional(),
|
|
14685
15130
|
template: NcSummaryTemplateSchema.nullable().optional(),
|
|
15131
|
+
captions: NcSummaryCaptionsSchema.optional(),
|
|
14686
15132
|
captionText: require_dist.string().max(200).optional(),
|
|
14687
15133
|
deliverEmpty: require_dist.boolean().optional(),
|
|
14688
15134
|
priority: PriorityField.optional(),
|
|
@@ -15217,14 +15663,19 @@ function isSyntheticId(id) {
|
|
|
15217
15663
|
* snapshot that produced it lists the members being counted and the edge
|
|
15218
15664
|
* names one (`chooseOccupancyMediaOwner`), so the tester stands in for that
|
|
15219
15665
|
* member exactly as it stands in for the marker.
|
|
15666
|
+
* - and, since 2026-08-15, a `trackId` for both AUDIO kinds: a sound owns no
|
|
15667
|
+
* frame, so production photographs the camera at the match and holds the
|
|
15668
|
+
* bytes on the still shelf under an owner id the subject names
|
|
15669
|
+
* (`audio-still.ts`). The tester's RAM-held frame stands in for exactly that
|
|
15670
|
+
* photograph, and — unlike every other kind here — it is taken the same way
|
|
15671
|
+
* production takes it, at the same moment, from the same cap.
|
|
15220
15672
|
*
|
|
15221
|
-
*
|
|
15222
|
-
*
|
|
15223
|
-
*
|
|
15224
|
-
* feature that does not exist.
|
|
15673
|
+
* Only `system-event` remains: an infrastructure event is about no camera at
|
|
15674
|
+
* all, so there is nothing to photograph and a tester that invented a picture
|
|
15675
|
+
* for it would be advertising a feature that does not exist.
|
|
15225
15676
|
*/
|
|
15226
15677
|
function triggerCanCarryStill(kind) {
|
|
15227
|
-
return kind
|
|
15678
|
+
return kind !== "system-event";
|
|
15228
15679
|
}
|
|
15229
15680
|
/** The detection provenance production stamps on a subject of this kind. */
|
|
15230
15681
|
function syntheticSource(kind) {
|
|
@@ -15262,7 +15713,7 @@ function audioSubjectOf(input) {
|
|
|
15262
15713
|
function buildSyntheticEvent(input, recordId, now) {
|
|
15263
15714
|
const kind = input.trigger;
|
|
15264
15715
|
const timestamp = input.timestamp ?? now;
|
|
15265
|
-
const carriesTrackId = kind === "object-event" || kind === "track-end" || kind === "device-event" || kind === "occupancy-event";
|
|
15716
|
+
const carriesTrackId = kind === "object-event" || kind === "track-end" || kind === "device-event" || kind === "occupancy-event" || kind === "audio-event" || kind === "audio-window";
|
|
15266
15717
|
return {
|
|
15267
15718
|
subject: {
|
|
15268
15719
|
kind,
|
|
@@ -17842,6 +18293,12 @@ var NotificationCenter = class NotificationCenter {
|
|
|
17842
18293
|
* is the one thing the synthetic producer promises not to do.
|
|
17843
18294
|
*/
|
|
17844
18295
|
syntheticMedia = /* @__PURE__ */ new Map();
|
|
18296
|
+
/**
|
|
18297
|
+
* The photograph a SOUND rule's notification carries — see `audio-still.ts`.
|
|
18298
|
+
* Null when this wiring has no snapshot cap, which leaves every audio
|
|
18299
|
+
* notification text-only rather than broken.
|
|
18300
|
+
*/
|
|
18301
|
+
audioStills;
|
|
17845
18302
|
/** Per-device rate limit for the "matched NO rule" report — see `reportNoMatch`. */
|
|
17846
18303
|
lastNoMatchReportAt = /* @__PURE__ */ new Map();
|
|
17847
18304
|
noMatchSuppressed = /* @__PURE__ */ new Map();
|
|
@@ -18133,6 +18590,12 @@ var NotificationCenter = class NotificationCenter {
|
|
|
18133
18590
|
this.deps = deps;
|
|
18134
18591
|
this.logger = deps.logger;
|
|
18135
18592
|
this.now = deps.now ?? (() => Date.now());
|
|
18593
|
+
const getSnapshot = deps.getSnapshot;
|
|
18594
|
+
this.audioStills = getSnapshot !== void 0 ? new NcAudioStillShelf({
|
|
18595
|
+
getSnapshot,
|
|
18596
|
+
logger: this.logger.child("audio-still"),
|
|
18597
|
+
now: this.now
|
|
18598
|
+
}) : null;
|
|
18136
18599
|
this.rules = new NcRuleStore({
|
|
18137
18600
|
store: deps.store,
|
|
18138
18601
|
logger: this.logger.child("rules"),
|
|
@@ -18210,6 +18673,8 @@ var NotificationCenter = class NotificationCenter {
|
|
|
18210
18673
|
getMediaForOwner: async (ownerKind, ownerId) => {
|
|
18211
18674
|
const held = isSyntheticId(ownerId) ? this.syntheticMedia.get(ownerId) : void 0;
|
|
18212
18675
|
if (held !== void 0) return held.files;
|
|
18676
|
+
const audio = isAudioStillId(ownerId) ? this.audioStills?.get(ownerId) : void 0;
|
|
18677
|
+
if (audio !== void 0) return audio;
|
|
18213
18678
|
return deps.dispatcher.getMediaForOwner(ownerKind, ownerId);
|
|
18214
18679
|
},
|
|
18215
18680
|
...deps.now !== void 0 ? { now: deps.now } : {},
|
|
@@ -18414,6 +18879,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
18414
18879
|
}
|
|
18415
18880
|
this.timelapseScheduler?.stop();
|
|
18416
18881
|
this.summaryProducer?.stop();
|
|
18882
|
+
this.audioStills?.clear();
|
|
18417
18883
|
this.evaluationActive = false;
|
|
18418
18884
|
}
|
|
18419
18885
|
/**
|
|
@@ -18743,7 +19209,35 @@ var NotificationCenter = class NotificationCenter {
|
|
|
18743
19209
|
* durable guarantee begins at this hook, matching the device-event boundary.
|
|
18744
19210
|
*/
|
|
18745
19211
|
onAudioEventPersisted(event) {
|
|
18746
|
-
this.consumeEvent(incomingFromAudioEvent(event));
|
|
19212
|
+
this.consumeEvent(incomingFromAudioEvent(event, this.audioStillOwner(event.deviceId, event.timestamp, "legacy")));
|
|
19213
|
+
}
|
|
19214
|
+
/**
|
|
19215
|
+
* Start photographing `deviceId` NOW and return the owner id the audio
|
|
19216
|
+
* subject should name — or `undefined` when nothing would use it.
|
|
19217
|
+
*
|
|
19218
|
+
* **The capture is not awaited.** A sound is transient and a notification
|
|
19219
|
+
* about a scream must not wait on a camera; the bytes land while the outbox
|
|
19220
|
+
* row queues, and the dispatcher's ordinary bounded still-wait picks them up.
|
|
19221
|
+
* That is also why this runs BEFORE the cooldown and the mute are consulted:
|
|
19222
|
+
* by the time a rule's window has been judged the moment is gone.
|
|
19223
|
+
*
|
|
19224
|
+
* **The gate is the RULE's media policy, not the trigger.** Photographing a
|
|
19225
|
+
* camera on every confirmed window for rules that ship text anyway would be a
|
|
19226
|
+
* snapshot at whatever rate the sound happens to occur, for nothing. The rule
|
|
19227
|
+
* cache is an in-memory mirror, so this is not a fallible read (D49) — a
|
|
19228
|
+
* failed one would look exactly like "no rule wants a picture", which is the
|
|
19229
|
+
* direction that DESTROYS the media.
|
|
19230
|
+
*/
|
|
19231
|
+
audioStillOwner(deviceId, atMs, mode) {
|
|
19232
|
+
const shelf = this.audioStills;
|
|
19233
|
+
if (shelf === null || !this.evaluationActive) return void 0;
|
|
19234
|
+
for (const rule of this.rules.listEnabled("immediate")) {
|
|
19235
|
+
if (rule.media.attach === "none") continue;
|
|
19236
|
+
if (!(mode === "condition" ? rule.conditions.audio !== void 0 : rule.conditions.audio === void 0 && referencesAudioClass(rule.conditions.classes))) continue;
|
|
19237
|
+
const devices = rule.conditions.devices;
|
|
19238
|
+
if (devices !== void 0 && devices.length > 0 && !devices.includes(deviceId)) continue;
|
|
19239
|
+
return shelf.capture(deviceId, atMs);
|
|
19240
|
+
}
|
|
18747
19241
|
}
|
|
18748
19242
|
/**
|
|
18749
19243
|
* Called at the package object-event persist site (`PackageDropDetector` —
|
|
@@ -18841,7 +19335,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
18841
19335
|
tags: { deviceId },
|
|
18842
19336
|
meta: audioHitMeta(hit)
|
|
18843
19337
|
});
|
|
18844
|
-
this.consumeEvent(incomingFromAudioWindow(hit));
|
|
19338
|
+
this.consumeEvent(incomingFromAudioWindow(hit, this.audioStillOwner(deviceId, hit.timestamp, "condition")));
|
|
18845
19339
|
}
|
|
18846
19340
|
}
|
|
18847
19341
|
/**
|
|
@@ -19658,6 +20152,19 @@ var NotificationCenter = class NotificationCenter {
|
|
|
19658
20152
|
}
|
|
19659
20153
|
});
|
|
19660
20154
|
}
|
|
20155
|
+
if (!noOwnerReported && (kind === "audio-window" || kind === "audio-event") && subject.trackId === void 0 && rule.media.attach !== "none") {
|
|
20156
|
+
noOwnerReported = true;
|
|
20157
|
+
this.logger.warn("audio match has no still owner — this notification ships text-only", {
|
|
20158
|
+
tags: { deviceId: subject.deviceId },
|
|
20159
|
+
meta: {
|
|
20160
|
+
recordId: subject.recordId,
|
|
20161
|
+
kind,
|
|
20162
|
+
ruleId: rule.id,
|
|
20163
|
+
rule: rule.name,
|
|
20164
|
+
snapshotWired: this.audioStills !== null
|
|
20165
|
+
}
|
|
20166
|
+
});
|
|
20167
|
+
}
|
|
19661
20168
|
const userTargets = await this.resolveUserTargets(rule, subject.deviceId);
|
|
19662
20169
|
const entries = this.buildEntries(rule, subject, kind, evaluation.matchedOn, userTargets, origin, {
|
|
19663
20170
|
key,
|
|
@@ -19814,6 +20321,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
19814
20321
|
admitToGroup(rule, subject, now) {
|
|
19815
20322
|
const idleSec = rule.groupIdleSec;
|
|
19816
20323
|
if (idleSec === void 0 || idleSec <= 0) return { outcome: "disabled" };
|
|
20324
|
+
if (subject.kind === "audio-window" || subject.kind === "audio-event") return { outcome: "disabled" };
|
|
19817
20325
|
const trackId = subject.trackId;
|
|
19818
20326
|
if (trackId === void 0) return { outcome: "disabled" };
|
|
19819
20327
|
const member = {
|
|
@@ -23466,88 +23974,6 @@ var embeddingActions = require_dist.defineCustomActions({
|
|
|
23466
23974
|
"embedding.wipe": require_dist.customAction(NoInputSchema, WipeResultSchema, { auth: "admin" })
|
|
23467
23975
|
});
|
|
23468
23976
|
//#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
23977
|
//#region src/pipeline-analytics/map-with-concurrency.ts
|
|
23552
23978
|
/**
|
|
23553
23979
|
* Apply `mapper` to every item with at most `limit` in flight at once.
|
|
@@ -27864,6 +28290,88 @@ var PlateGalleryProvider = class {
|
|
|
27864
28290
|
}
|
|
27865
28291
|
};
|
|
27866
28292
|
//#endregion
|
|
28293
|
+
//#region src/pipeline-analytics/retention/orphan-audit-actions.ts
|
|
28294
|
+
/**
|
|
28295
|
+
* The operator button for the orphan audit.
|
|
28296
|
+
*
|
|
28297
|
+
* The audit engine, its five bounds and its resumable cursor already existed
|
|
28298
|
+
* and had run in `report` mode for weeks; what it had no way to be was
|
|
28299
|
+
* *asked*. Its only trigger was a 6-hourly timer reading a durable setting, so
|
|
28300
|
+
* draining a standing backlog meant flipping a persistent mode, waiting up to
|
|
28301
|
+
* six hours, and remembering to flip it back — with nothing returned to look
|
|
28302
|
+
* at. On 2026-08-14 the backlog was 74,700 media rows / 18.9 GB plus 23,296
|
|
28303
|
+
* object events and 4,543 CLIP vectors (D161), and that is not a thing to
|
|
28304
|
+
* drain by leaving a switch on.
|
|
28305
|
+
*
|
|
28306
|
+
* Three properties this action does NOT leave to the caller's care:
|
|
28307
|
+
*
|
|
28308
|
+
* 1. **Dry run is the default.** `mode` omitted means `report`: it counts,
|
|
28309
|
+
* logs and returns, and deletes nothing — regardless of what the durable
|
|
28310
|
+
* `orphanAuditMode` setting says. `reclaim` is a word that has to be
|
|
28311
|
+
* typed. The engine's own default is the same for the same reason.
|
|
28312
|
+
* 2. **It is paced.** `pageSize` bounds one read, `maxRowsPerScope` bounds
|
|
28313
|
+
* one pass, `maxReclaimPerScope` bounds the damage, and the engine yields
|
|
28314
|
+
* the thread between pages — `better-sqlite3` is synchronous and shares
|
|
28315
|
+
* hub-main with every live frame.
|
|
28316
|
+
* 3. **It answers.** The report comes back per collection with rows AND
|
|
28317
|
+
* bytes, so "did it do anything" is not a question anyone has to infer
|
|
28318
|
+
* from a log line.
|
|
28319
|
+
*/
|
|
28320
|
+
/**
|
|
28321
|
+
* `report` counts; `reclaim` deletes.
|
|
28322
|
+
*
|
|
28323
|
+
* `.default('report')` is load-bearing, not decoration: the settings form
|
|
28324
|
+
* sends `{}` for a button with no fields, and the safe interpretation of an
|
|
28325
|
+
* unstated intent here is the one that destroys nothing.
|
|
28326
|
+
*/
|
|
28327
|
+
var OrphanAuditActionModeSchema = require_dist._enum(["report", "reclaim"]).default("report");
|
|
28328
|
+
var OrphanAuditActionInputSchema = require_dist.object({
|
|
28329
|
+
mode: OrphanAuditActionModeSchema,
|
|
28330
|
+
/**
|
|
28331
|
+
* Start every scope from the top instead of resuming its persisted cursor.
|
|
28332
|
+
*
|
|
28333
|
+
* What draining a backlog wants, and never the default — the cursor exists
|
|
28334
|
+
* so a 6-hourly pass makes progress instead of re-walking the same head.
|
|
28335
|
+
*/
|
|
28336
|
+
restart: require_dist.boolean().optional(),
|
|
28337
|
+
/** Rows read per statement. Smaller = more yields = a gentler pass. */
|
|
28338
|
+
pageSize: require_dist.number().int().min(50).max(5e3).optional(),
|
|
28339
|
+
/** Rows EXAMINED per collection in this pass — the pacing bound. */
|
|
28340
|
+
maxRowsPerScope: require_dist.number().int().min(1).max(2e5).optional(),
|
|
28341
|
+
/** Rows DELETED per collection in this pass — the blast-radius bound. */
|
|
28342
|
+
maxReclaimPerScope: require_dist.number().int().min(1).max(1e5).optional(),
|
|
28343
|
+
/**
|
|
28344
|
+
* Minutes of immunity for a recent row. Protective ONLY — raising it can
|
|
28345
|
+
* exclusively save rows, never select them, which is what keeps this off
|
|
28346
|
+
* the one-age-keyed-sweep guard's books.
|
|
28347
|
+
*/
|
|
28348
|
+
graceMinutes: require_dist.number().int().min(1).max(10080).optional()
|
|
28349
|
+
});
|
|
28350
|
+
var OrphanAuditScopeReportSchema = require_dist.object({
|
|
28351
|
+
collection: require_dist.string(),
|
|
28352
|
+
examined: require_dist.number(),
|
|
28353
|
+
orphans: require_dist.number(),
|
|
28354
|
+
reclaimed: require_dist.number(),
|
|
28355
|
+
bytesReclaimed: require_dist.number(),
|
|
28356
|
+
exempt: require_dist.number(),
|
|
28357
|
+
withinGrace: require_dist.number(),
|
|
28358
|
+
unresolvable: require_dist.number(),
|
|
28359
|
+
stoppedBy: require_dist.string(),
|
|
28360
|
+
/** Where the NEXT pass resumes. `0` = the scope was walked to the end. */
|
|
28361
|
+
nextCursor: require_dist.number()
|
|
28362
|
+
});
|
|
28363
|
+
var OrphanAuditActionResultSchema = require_dist.object({
|
|
28364
|
+
/** `false` when a pass was already in flight — nothing was done. */
|
|
28365
|
+
ran: require_dist.boolean(),
|
|
28366
|
+
mode: require_dist._enum(["report", "reclaim"]),
|
|
28367
|
+
scopes: require_dist.array(OrphanAuditScopeReportSchema),
|
|
28368
|
+
totalOrphans: require_dist.number(),
|
|
28369
|
+
totalReclaimed: require_dist.number(),
|
|
28370
|
+
totalBytesReclaimed: require_dist.number(),
|
|
28371
|
+
durationMs: require_dist.number()
|
|
28372
|
+
});
|
|
28373
|
+
var orphanAuditActions = require_dist.defineCustomActions({ "retention.orphanAudit": require_dist.customAction(OrphanAuditActionInputSchema, OrphanAuditActionResultSchema, { auth: "admin" }) });
|
|
28374
|
+
//#endregion
|
|
27867
28375
|
//#region src/pipeline-analytics/videoclips-provider.ts
|
|
27868
28376
|
var SOURCE = "analytics";
|
|
27869
28377
|
function clipIdFor(eventId, startMs, endMs) {
|
|
@@ -33607,13 +34115,25 @@ var StoragePressureTracker = class {
|
|
|
33607
34115
|
};
|
|
33608
34116
|
//#endregion
|
|
33609
34117
|
//#region src/pipeline-analytics/store/media-store.ts
|
|
34118
|
+
/**
|
|
34119
|
+
* `scene` is its own owner kind rather than borrowing `event`.
|
|
34120
|
+
*
|
|
34121
|
+
* Scene reference thumbnails were written as `ownerKind: 'event'` with an
|
|
34122
|
+
* `ownerId` of `scene-<monitorId>-…`, which resolves through
|
|
34123
|
+
* `resolveMediaOwner` to `{ kind: 'event', eventId: 'scene-…' }` — an event row
|
|
34124
|
+
* that has never existed. The orphan audit therefore classified every
|
|
34125
|
+
* operator-captured reference picture as collectable. It is the same class of
|
|
34126
|
+
* mistake `identity` and `vehicle` are exempt for: a blob the OPERATOR curated,
|
|
34127
|
+
* which nothing else can regenerate and no cascade owns.
|
|
34128
|
+
*/
|
|
33610
34129
|
var OWNER_KINDS = [
|
|
33611
34130
|
"event",
|
|
33612
34131
|
"track",
|
|
33613
34132
|
"face",
|
|
33614
34133
|
"identity",
|
|
33615
34134
|
"plate",
|
|
33616
|
-
"vehicle"
|
|
34135
|
+
"vehicle",
|
|
34136
|
+
"scene"
|
|
33617
34137
|
];
|
|
33618
34138
|
/**
|
|
33619
34139
|
* Narrow an arbitrary string to an `OwnerKind`.
|
|
@@ -42395,7 +42915,11 @@ function auditableCollections() {
|
|
|
42395
42915
|
* exemption lives here now, and the orphan collector — the only sweep that
|
|
42396
42916
|
* walks media it was not handed by name — reads it.
|
|
42397
42917
|
*/
|
|
42398
|
-
var RETENTION_EXEMPT_OWNER_KINDS = new Set([
|
|
42918
|
+
var RETENTION_EXEMPT_OWNER_KINDS = new Set([
|
|
42919
|
+
"identity",
|
|
42920
|
+
"vehicle",
|
|
42921
|
+
"scene"
|
|
42922
|
+
]);
|
|
42399
42923
|
function isRetentionExemptOwnerKind(kind) {
|
|
42400
42924
|
return RETENTION_EXEMPT_OWNER_KINDS.has(kind);
|
|
42401
42925
|
}
|
|
@@ -45334,22 +45858,6 @@ async function projectSensorMarkers(deps, data, timestamp) {
|
|
|
45334
45858
|
}
|
|
45335
45859
|
return landed;
|
|
45336
45860
|
}
|
|
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
45861
|
//#endregion
|
|
45354
45862
|
//#region src/pipeline-analytics/services/synthetic-track.ts
|
|
45355
45863
|
/**
|
|
@@ -49894,6 +50402,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
49894
50402
|
addonId: device.addonId
|
|
49895
50403
|
};
|
|
49896
50404
|
},
|
|
50405
|
+
getSnapshot: (deviceId) => api.snapshot.getSnapshot.query({ deviceId }),
|
|
49897
50406
|
identityIdsByName: async () => {
|
|
49898
50407
|
const byName = /* @__PURE__ */ new Map();
|
|
49899
50408
|
const identities = await stores.identityStore.listIdentities();
|
|
@@ -50770,13 +51279,16 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
50770
51279
|
if (media === null) throw new Error("media store not ready");
|
|
50771
51280
|
return media.putReplacing({
|
|
50772
51281
|
deviceId,
|
|
50773
|
-
ownerKind: "
|
|
51282
|
+
ownerKind: "scene",
|
|
50774
51283
|
ownerId,
|
|
50775
51284
|
kind: "thumbnailSmall",
|
|
50776
51285
|
timestamp,
|
|
50777
51286
|
data
|
|
50778
51287
|
});
|
|
50779
51288
|
},
|
|
51289
|
+
dropMedia: async (mediaId) => {
|
|
51290
|
+
await this.mediaStore?.deleteByKey(mediaId);
|
|
51291
|
+
},
|
|
50780
51292
|
mirrorSlice: (deviceId, status) => {
|
|
50781
51293
|
this.mirrorSceneSlice(deviceId, status);
|
|
50782
51294
|
},
|