@camstack/addon-post-analysis 1.2.278 → 1.2.280
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-BUtk8EU2.js → dist-VeiAavYR.js} +122 -9
- package/dist/{dist-D0jvZ8dq.mjs → dist-Z6AO1MsY.mjs} +122 -9
- 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-CkyY7n6X.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-C_C1P6Bk.mjs} +2 -2
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DBnj0lb3.mjs +26 -0
- package/dist/pipeline-analytics/{hostInit-nmySmpNF.mjs → hostInit-B5__n3rE.mjs} +2 -2
- package/dist/pipeline-analytics/index.js +1522 -637
- package/dist/pipeline-analytics/index.mjs +1514 -629
- 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_types__loadShare__.js-BwQ85CFd.mjs +0 -26
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as RECORDING_SOURCE_CAMSTACK_ADDON, $t as CamProfileSchema, At as isScheduleActive, B as NcRuleUpdateInputSchema, Bt as readDeviceStateFrom, C as MAX_BIRTH_DECISION_RECORDS, Ct as faceGalleryCapability, D as NC_ALARM_SYSTEM_EVENT_KINDS, Dt as isEventOwnerType, E as MediaFileKindEnum, Et as isDetectionMacroClass, F as NcConditionDescriptorSchema, Ft as occupancyScope, G as NcSystemEventKindSchema, Gt as storageOccupancyCapability, H as NcSnoozeInputSchema, Ht as resolveLocationMode, I as NcRuleInputSchema, J as NcTemplatePreviewSchema, Jt as templateVarsFor, K as NcTaxonomySchema, Kt as subKindsOf, L as NcRulePatchSchema, Lt as pickClusterStepModels, M as NC_TEMPLATE_VARS, Mt as kebabToCamel, N as NcAlarmConfigSchema, Nt as mayWriteToLocation, O as NC_CONDITION_CATALOG, Ot as isKnownTemplateVar, P as NcAlarmSettingsPatchSchema, Pt as notificationRulesCapability, Q as RECORDING_EXPORT_MAX_READ_BYTES, Qt as BaseAddon, R as NcRuleSchema, Rt as pipelineAnalyticsCapability, S as MAX_ARCHIVED_DEBUG_NOTES, St as evictionPolicyOfLocation, T as MOTION_CLOSE_AFTER_MS, U as NcSnoozeSchema, V as NcScheduleSchema, Vt as readTimelapseGeneratedAt, W as NcSnoozeSuppressedSchema, Wt as sceneMonitorCapability, X as OpsLogEntrySchema, Xt as zoneAnalyticsCapability, Y as NcTemplateVarDescriptorSchema, Yt as vectorDimFromBase64, Zt as errMsg$1, _ as FIRST_LEVEL_MACRO_CLASSES, _n as string, _t as cosineSimilarity$1, a as BirthDecisionRecordSchema, an as isDeviceScopedCap, at as SceneMonitorSchema, b as LabelAttributionSchema, bt as encodeVectorBase64, c as CoverStateSchema, cn as _enum, ct as TimelapseRulePatchSchema, dn as discriminatedUnion, dt as addonWidgetsSourceCapability, en as DeviceType, et as RetrainStatusSchema, f as DETECTION_MACRO_CLASSES, fn as literal, ft as alarmPanelCapability, g as EVENT_OWNER_TYPES, gn as record, gt as buildEventKindDescriptor, h as EVENT_KIND_BY_CAP, hn as partialRecord, ht as audioModeOf, i as BaseDevice, in as hydrateSchema, it as SCENE_DIVERGED, j as NC_TAXONOMY, jt as isSourceCap, k as NC_DEFAULT_SNOOZE_MINUTES, kt as isMediaOwnerType, l as DEFAULT_EVENT_COLOR, ln as array, lt as TimelapseRuleSchema, m as DeclaredDevices, mn as object, mt as audioMetricsCapability, n as AlarmArmModeSchema, nn as customAction, nt as SCENE_DEFAULT_ANCHOR_THRESHOLD, o as CLUSTER_MODEL_SCOPED_STEPS, on as nodePin, ot as TIMELAPSE_DENSE_FLOOR_SEC, pn as number, pt as assertTimelapseCadences, q as NcTemplatePreviewInputSchema, qt as systemEventFilterApplies, r as ArchivedDebugNoteSchema, rn as defineCustomActions, rt as SCENE_DEFAULT_UNCOVERED_POLICY, s as COCO_TO_MACRO, sn as sleep$1, st as TimelapseRuleInputSchema, t as AUDIO_MACRO_LABELS, tn as createEvent, un as boolean, ut as TrackSourceSchema, v as FULL_IMAGE_BBOX, vn as unknown, vt as deriveRecordingMode, wt as failureContributionCapability, x as LockStateSchema, xt as evaluateSensorEdge, y as FailureCounters, yn as EventCategory, z as NcRuleTargetSchema, zt as plateGalleryCapability } from "../dist-
|
|
1
|
+
import { $ as RECORDING_SOURCE_CAMSTACK_ADDON, $t as CamProfileSchema, At as isScheduleActive, B as NcRuleUpdateInputSchema, Bt as readDeviceStateFrom, C as MAX_BIRTH_DECISION_RECORDS, Ct as faceGalleryCapability, D as NC_ALARM_SYSTEM_EVENT_KINDS, Dt as isEventOwnerType, E as MediaFileKindEnum, Et as isDetectionMacroClass, F as NcConditionDescriptorSchema, Ft as occupancyScope, G as NcSystemEventKindSchema, Gt as storageOccupancyCapability, H as NcSnoozeInputSchema, Ht as resolveLocationMode, I as NcRuleInputSchema, J as NcTemplatePreviewSchema, Jt as templateVarsFor, K as NcTaxonomySchema, Kt as subKindsOf, L as NcRulePatchSchema, Lt as pickClusterStepModels, M as NC_TEMPLATE_VARS, Mt as kebabToCamel, N as NcAlarmConfigSchema, Nt as mayWriteToLocation, O as NC_CONDITION_CATALOG, Ot as isKnownTemplateVar, P as NcAlarmSettingsPatchSchema, Pt as notificationRulesCapability, Q as RECORDING_EXPORT_MAX_READ_BYTES, Qt as BaseAddon, R as NcRuleSchema, Rt as pipelineAnalyticsCapability, S as MAX_ARCHIVED_DEBUG_NOTES, St as evictionPolicyOfLocation, T as MOTION_CLOSE_AFTER_MS, U as NcSnoozeSchema, V as NcScheduleSchema, Vt as readTimelapseGeneratedAt, W as NcSnoozeSuppressedSchema, Wt as sceneMonitorCapability, X as OpsLogEntrySchema, Xt as zoneAnalyticsCapability, Y as NcTemplateVarDescriptorSchema, Yt as vectorDimFromBase64, Zt as errMsg$1, _ as FIRST_LEVEL_MACRO_CLASSES, _n as string, _t as cosineSimilarity$1, a as BirthDecisionRecordSchema, an as isDeviceScopedCap, at as SceneMonitorSchema, b as LabelAttributionSchema, bt as encodeVectorBase64, c as CoverStateSchema, cn as _enum, ct as TimelapseRulePatchSchema, dn as discriminatedUnion, dt as addonWidgetsSourceCapability, en as DeviceType, et as RetrainStatusSchema, f as DETECTION_MACRO_CLASSES, fn as literal, ft as alarmPanelCapability, g as EVENT_OWNER_TYPES, gn as record, gt as buildEventKindDescriptor, h as EVENT_KIND_BY_CAP, hn as partialRecord, ht as audioModeOf, i as BaseDevice, in as hydrateSchema, it as SCENE_DIVERGED, j as NC_TAXONOMY, jt as isSourceCap, k as NC_DEFAULT_SNOOZE_MINUTES, kt as isMediaOwnerType, l as DEFAULT_EVENT_COLOR, ln as array, lt as TimelapseRuleSchema, m as DeclaredDevices, mn as object, mt as audioMetricsCapability, n as AlarmArmModeSchema, nn as customAction, nt as SCENE_DEFAULT_ANCHOR_THRESHOLD, o as CLUSTER_MODEL_SCOPED_STEPS, on as nodePin, ot as TIMELAPSE_DENSE_FLOOR_SEC, pn as number, pt as assertTimelapseCadences, q as NcTemplatePreviewInputSchema, qt as systemEventFilterApplies, r as ArchivedDebugNoteSchema, rn as defineCustomActions, rt as SCENE_DEFAULT_UNCOVERED_POLICY, s as COCO_TO_MACRO, sn as sleep$1, st as TimelapseRuleInputSchema, t as AUDIO_MACRO_LABELS, tn as createEvent, un as boolean, ut as TrackSourceSchema, v as FULL_IMAGE_BBOX, vn as unknown, vt as deriveRecordingMode, wt as failureContributionCapability, x as LockStateSchema, xt as evaluateSensorEdge, y as FailureCounters, yn as EventCategory, z as NcRuleTargetSchema, zt as plateGalleryCapability } from "../dist-Z6AO1MsY.mjs";
|
|
2
2
|
import { t as __exportAll } from "../embedding-encoder/index.mjs";
|
|
3
3
|
import * as fs from "node:fs";
|
|
4
4
|
import { promises } from "node:fs";
|
|
@@ -9540,15 +9540,20 @@ function readSensorEventType(value) {
|
|
|
9540
9540
|
const eventType = last["eventType"];
|
|
9541
9541
|
return typeof eventType === "string" && eventType.length > 0 ? eventType : void 0;
|
|
9542
9542
|
}
|
|
9543
|
+
/** A LINKED SET subject (ADR 0632) — see {@link NcRuleSubject.linkedSensorId}. */
|
|
9544
|
+
function isLinkedSetSubject(subject) {
|
|
9545
|
+
return subject.linkedSensorId !== void 0;
|
|
9546
|
+
}
|
|
9543
9547
|
/**
|
|
9544
9548
|
* Build the subject for a `device-event` evaluation (a persisted SensorEvent —
|
|
9545
|
-
* one row per linked camera; `deviceId` is the CAMERA)
|
|
9549
|
+
* one row per linked camera; `deviceId` is the CAMERA), or, with `linked`, the
|
|
9550
|
+
* ONE sensor-level evaluation of a linked set.
|
|
9546
9551
|
*
|
|
9547
9552
|
* BOTH ids ride along. The camera is what the notification shows; the sensor is
|
|
9548
9553
|
* what the operator named in the rule. Carrying only the camera is what made a
|
|
9549
9554
|
* sensor-scoped rule unmatched — see {@link NcRuleSubject.sourceDeviceId}.
|
|
9550
9555
|
*/
|
|
9551
|
-
function subjectFromSensorEvent(ev, markerTrackId) {
|
|
9556
|
+
function subjectFromSensorEvent(ev, markerTrackId, linked) {
|
|
9552
9557
|
const eventType = readSensorEventType(ev.value);
|
|
9553
9558
|
const sensorEdge = sensorEdgeOf(ev.kind, ev.value);
|
|
9554
9559
|
return {
|
|
@@ -9563,7 +9568,14 @@ function subjectFromSensorEvent(ev, markerTrackId) {
|
|
|
9563
9568
|
source: "sensor",
|
|
9564
9569
|
sensorKind: ev.kind,
|
|
9565
9570
|
...eventType !== void 0 ? { eventType } : {},
|
|
9566
|
-
...sensorEdge !== void 0 ? { sensorEdge } : {}
|
|
9571
|
+
...sensorEdge !== void 0 ? { sensorEdge } : {},
|
|
9572
|
+
...linked !== void 0 ? {
|
|
9573
|
+
linkedSensorId: ev.sourceDeviceId,
|
|
9574
|
+
linkedCameraIds: [...linked.linkedCameraIds],
|
|
9575
|
+
rowCameraIds: [...linked.rowCameraIds],
|
|
9576
|
+
mutedRowCameraIds: [...linked.mutedRowCameraIds]
|
|
9577
|
+
} : {},
|
|
9578
|
+
...linked?.linkedStills !== void 0 ? { linkedStills: linked.linkedStills } : {}
|
|
9567
9579
|
};
|
|
9568
9580
|
}
|
|
9569
9581
|
/**
|
|
@@ -9885,6 +9897,21 @@ function zonesVisitedBy(subject) {
|
|
|
9885
9897
|
if (subject.crossing !== void 0) visited.add(subject.crossing.zoneId);
|
|
9886
9898
|
return visited;
|
|
9887
9899
|
}
|
|
9900
|
+
/**
|
|
9901
|
+
* Could this `device-event` rule fire on a sensor change of `sensorKind`?
|
|
9902
|
+
* The SAME two refusals {@link evaluateRule} makes before anything else a
|
|
9903
|
+
* sensor subject is judged on — an `occupancy` condition (such a rule targets
|
|
9904
|
+
* only occupancy edges, which ride the same delivery) and a `sensorKinds` list
|
|
9905
|
+
* that excludes the kind — for a caller that must decide from the rules alone,
|
|
9906
|
+
* before the subject exists (the linked-camera capture gate). Coarse in one
|
|
9907
|
+
* direction only: `true` may still fail later; `false` never would have fired.
|
|
9908
|
+
*/
|
|
9909
|
+
function deviceEventRuleMayFireOnKind(rule, sensorKind) {
|
|
9910
|
+
const c = rule.conditions;
|
|
9911
|
+
if (c.occupancy !== void 0) return false;
|
|
9912
|
+
if (c.sensorKinds === void 0 || c.sensorKinds.length === 0) return true;
|
|
9913
|
+
return toLowerSet(c.sensorKinds).has(sensorKind.trim().toLowerCase());
|
|
9914
|
+
}
|
|
9888
9915
|
function toLowerSet(values) {
|
|
9889
9916
|
return new Set(values.map((v) => v.trim().toLowerCase()));
|
|
9890
9917
|
}
|
|
@@ -10285,13 +10312,17 @@ function systemEventCooldownScope(systemEvent) {
|
|
|
10285
10312
|
* the operator actually cares about and the one they pick in the Devices
|
|
10286
10313
|
* tab. Before this, that rule matched nothing, ever, silently.
|
|
10287
10314
|
*
|
|
10288
|
-
*
|
|
10289
|
-
*
|
|
10290
|
-
*
|
|
10291
|
-
*
|
|
10292
|
-
*
|
|
10315
|
+
* A sensor linked to MORE than one camera is evaluated once, at the sensor
|
|
10316
|
+
* level (ADR 0632), with `deviceId` = the primary camera. That evaluation
|
|
10317
|
+
* matches the SENSOR and the cameras that took a timeline row for it
|
|
10318
|
+
* (`rowCameraIds`) — exactly the rules the per-camera rows would have matched
|
|
10319
|
+
* (D630 §6). The primary is only the camera the notification leads with: one
|
|
10320
|
+
* that links the sensor without opting it in keeps its rules silent, and an
|
|
10321
|
+
* opted-in camera that is not the primary still fires its own. The other
|
|
10322
|
+
* linked cameras ride `linkedCameraIds` for the MEDIA only.
|
|
10293
10323
|
*/
|
|
10294
10324
|
function matchesDeviceScope(scope, subject) {
|
|
10325
|
+
if (subject.linkedSensorId !== void 0) return scope.includes(subject.linkedSensorId) || (subject.rowCameraIds ?? []).some((cameraId) => scope.includes(cameraId));
|
|
10295
10326
|
if (scope.includes(subject.deviceId)) return true;
|
|
10296
10327
|
return subject.sourceDeviceId !== void 0 && scope.includes(subject.sourceDeviceId);
|
|
10297
10328
|
}
|
|
@@ -10514,23 +10545,34 @@ function incomingFromTrack(track, info) {
|
|
|
10514
10545
|
};
|
|
10515
10546
|
}
|
|
10516
10547
|
/**
|
|
10517
|
-
* Sensor-event persist (one row per linked camera)
|
|
10548
|
+
* Sensor-event persist (one row per linked camera), or — with `linked` — the
|
|
10549
|
+
* ONE sensor-level evaluation of a sensor linked to several cameras (ADR 0632),
|
|
10550
|
+
* whose `event.deviceId` is the primary camera.
|
|
10518
10551
|
*
|
|
10519
10552
|
* `markerTrackId` is the synthetic marker track the same state change
|
|
10520
10553
|
* projected onto this camera — the notification's media owner, and the only
|
|
10521
10554
|
* frame a device event has. Absent when no marker landed.
|
|
10555
|
+
*
|
|
10556
|
+
* A sensor-level evaluation is a per-EVENT line, so it is tagged with the
|
|
10557
|
+
* SENSOR; the primary camera rides in the meta. `mutedCameraIds` are the
|
|
10558
|
+
* linked cameras the centre left out (D62) — for the line's counts only.
|
|
10522
10559
|
*/
|
|
10523
|
-
function incomingFromSensorEvent(event, markerTrackId) {
|
|
10560
|
+
function incomingFromSensorEvent(event, markerTrackId, linked, mutedCameraIds = []) {
|
|
10524
10561
|
return {
|
|
10525
|
-
subject: subjectFromSensorEvent(event, markerTrackId),
|
|
10562
|
+
subject: subjectFromSensorEvent(event, markerTrackId, linked),
|
|
10526
10563
|
kind: "device-event",
|
|
10527
10564
|
origin: "sensor",
|
|
10528
10565
|
log: () => ({
|
|
10529
|
-
tags: { deviceId: event.deviceId },
|
|
10566
|
+
tags: { deviceId: linked !== void 0 ? event.sourceDeviceId : event.deviceId },
|
|
10530
10567
|
meta: {
|
|
10531
10568
|
sensorEventId: event.id,
|
|
10532
10569
|
kind: event.kind,
|
|
10533
|
-
...markerTrackId !== void 0 ? { trackId: markerTrackId } : {}
|
|
10570
|
+
...markerTrackId !== void 0 ? { trackId: markerTrackId } : {},
|
|
10571
|
+
...linked !== void 0 ? {
|
|
10572
|
+
...event.deviceId !== event.sourceDeviceId ? { primaryCameraId: event.deviceId } : {},
|
|
10573
|
+
linkedCameras: linked.linkedCameraIds.length + mutedCameraIds.length,
|
|
10574
|
+
mutedCameras: mutedCameraIds.length
|
|
10575
|
+
} : {}
|
|
10534
10576
|
}
|
|
10535
10577
|
})
|
|
10536
10578
|
};
|
|
@@ -16982,7 +17024,8 @@ var NcDispatcher = class {
|
|
|
16982
17024
|
const analyse = this.deps.ruleAi;
|
|
16983
17025
|
const policy = entry.payload.ai;
|
|
16984
17026
|
if (analyse === void 0 || policy === void 0) return null;
|
|
16985
|
-
const
|
|
17027
|
+
const tiles = policy.shape === "detection-track" ? [] : await this.linkedAnalysisFrames(entry);
|
|
17028
|
+
const frames = policy.shape === "detection-track" ? await this.trackNarrationFrames(entry) : tiles.length > 0 ? tiles : shippedFrames(built.notification);
|
|
16986
17029
|
if (frames.length === 0) {
|
|
16987
17030
|
this.deps.logger.debug("notification AI skipped — no picture to describe", {
|
|
16988
17031
|
tags: { deviceId: entry.deviceId },
|
|
@@ -17023,6 +17066,43 @@ var NcDispatcher = class {
|
|
|
17023
17066
|
return null;
|
|
17024
17067
|
}
|
|
17025
17068
|
}
|
|
17069
|
+
/** A linked set's raw per-camera tiles (`subject.analysisTiles`), in
|
|
17070
|
+
* camera order — one frame per camera whose picture landed. Best-effort: an
|
|
17071
|
+
* owner that answers nothing or throws is left out (a blank frame is never
|
|
17072
|
+
* handed to a model), and no owners at all is `[]`. */
|
|
17073
|
+
async linkedAnalysisFrames(entry) {
|
|
17074
|
+
const tiles = entry.payload.subject.analysisTiles ?? [];
|
|
17075
|
+
const frames = [];
|
|
17076
|
+
for (const { ownerId, cameraId } of tiles) {
|
|
17077
|
+
const file = await this.linkedStillFile(entry, ownerId, cameraId);
|
|
17078
|
+
const bytes = file === void 0 ? null : decodeStillBytes(file.base64);
|
|
17079
|
+
if (bytes !== null) frames.push({
|
|
17080
|
+
bytes,
|
|
17081
|
+
mime: "image/jpeg"
|
|
17082
|
+
});
|
|
17083
|
+
}
|
|
17084
|
+
return frames;
|
|
17085
|
+
}
|
|
17086
|
+
/** One linked-camera shelf owner's still — `keyFrameSmall`, else whatever it
|
|
17087
|
+
* holds. `undefined` when it holds nothing or the read throws (said at DEBUG
|
|
17088
|
+
* on `cameraId`, the camera the picture is of: the caller decides whether
|
|
17089
|
+
* the absence is worth a line of its own). */
|
|
17090
|
+
async linkedStillFile(entry, ownerId, cameraId) {
|
|
17091
|
+
try {
|
|
17092
|
+
const files = await this.deps.getMediaForOwner("track", ownerId);
|
|
17093
|
+
return files.find((f) => f.kind === "keyFrameSmall") ?? files[0];
|
|
17094
|
+
} catch (err) {
|
|
17095
|
+
this.deps.logger.debug("linked camera picture read failed", {
|
|
17096
|
+
tags: { deviceId: cameraId },
|
|
17097
|
+
meta: {
|
|
17098
|
+
recordId: entry.recordId,
|
|
17099
|
+
ownerId,
|
|
17100
|
+
error: String(err)
|
|
17101
|
+
}
|
|
17102
|
+
});
|
|
17103
|
+
return;
|
|
17104
|
+
}
|
|
17105
|
+
}
|
|
17026
17106
|
/** The whole-scene frames spanning a CLOSED track, oldest first. Best-effort
|
|
17027
17107
|
* in every direction: no track id, a throwing read or an empty store all
|
|
17028
17108
|
* leave the pass with nothing, which skips it. */
|
|
@@ -17031,11 +17111,8 @@ var NcDispatcher = class {
|
|
|
17031
17111
|
if (trackId === void 0) return [];
|
|
17032
17112
|
try {
|
|
17033
17113
|
return pickTrackNarrationFrames(await this.deps.getMediaForOwner("track", trackId), 3).flatMap((file) => {
|
|
17034
|
-
const
|
|
17035
|
-
|
|
17036
|
-
const bytes = new Uint8Array(raw.byteLength);
|
|
17037
|
-
bytes.set(raw);
|
|
17038
|
-
return [{
|
|
17114
|
+
const bytes = decodeStillBytes(file.base64);
|
|
17115
|
+
return bytes === null ? [] : [{
|
|
17039
17116
|
bytes,
|
|
17040
17117
|
mime: "image/jpeg",
|
|
17041
17118
|
atMs: file.timestamp
|
|
@@ -17483,7 +17560,7 @@ var NcDispatcher = class {
|
|
|
17483
17560
|
const deviceId = entry.payload.subject.deviceId;
|
|
17484
17561
|
const common = {
|
|
17485
17562
|
deviceId: deviceId > 0 ? deviceId : null,
|
|
17486
|
-
deviceIds: deviceId > 0 ? [deviceId] : [],
|
|
17563
|
+
deviceIds: entry.payload.subject.linkedCameraIds !== void 0 ? [...entry.payload.subject.linkedCameraIds] : deviceId > 0 ? [deviceId] : [],
|
|
17487
17564
|
capturedAt: entry.payload.subject.timestamp,
|
|
17488
17565
|
ruleId: entry.ruleId,
|
|
17489
17566
|
ruleName: entry.payload.ruleName
|
|
@@ -17531,6 +17608,7 @@ var NcDispatcher = class {
|
|
|
17531
17608
|
const footagePending = this.renderFootage(entry);
|
|
17532
17609
|
const still = firstLook !== null || entry.payload.media === "none" ? firstLook : await this.waitForStill(entry, stillOverride);
|
|
17533
17610
|
const footage = await footagePending;
|
|
17611
|
+
const extras = await this.linkedExtraStills(entry);
|
|
17534
17612
|
if (still !== null) {
|
|
17535
17613
|
const zoneIds = zoneIdsWanted;
|
|
17536
17614
|
if (zoneIds !== void 0 && zoneIds.length > 0) {
|
|
@@ -17548,7 +17626,17 @@ var NcDispatcher = class {
|
|
|
17548
17626
|
name: "group.jpg"
|
|
17549
17627
|
} : still);
|
|
17550
17628
|
}
|
|
17551
|
-
} else if (entry.payload.media !== "none") this.deps.logger.info("no still could be resolved —
|
|
17629
|
+
} else if (entry.payload.media !== "none" && extras.length > 0) this.deps.logger.info("no still of the primary camera could be resolved — the other linked cameras' stills ship without it", {
|
|
17630
|
+
tags: { deviceId: entry.payload.subject.deviceId },
|
|
17631
|
+
meta: {
|
|
17632
|
+
recordKind: entry.recordKind,
|
|
17633
|
+
recordId: entry.recordId,
|
|
17634
|
+
policy: entry.payload.media,
|
|
17635
|
+
...entry.payload.subject.trackId !== void 0 ? { owner: `track:${entry.payload.subject.trackId}` } : {},
|
|
17636
|
+
otherCameras: extras.length
|
|
17637
|
+
}
|
|
17638
|
+
});
|
|
17639
|
+
else if (entry.payload.media !== "none") this.deps.logger.info("no still could be resolved — this notification ships text-only", {
|
|
17552
17640
|
tags: { deviceId: entry.payload.subject.deviceId },
|
|
17553
17641
|
meta: {
|
|
17554
17642
|
recordKind: entry.recordKind,
|
|
@@ -17559,6 +17647,43 @@ var NcDispatcher = class {
|
|
|
17559
17647
|
}
|
|
17560
17648
|
});
|
|
17561
17649
|
out.push(...footage.attachments);
|
|
17650
|
+
out.push(...extras);
|
|
17651
|
+
return out;
|
|
17652
|
+
}
|
|
17653
|
+
/**
|
|
17654
|
+
* `each` (ADR 0632): the OTHER linked cameras' stills, in camera order —
|
|
17655
|
+
* AFTER the primary still and the primary's footage, so a target with little
|
|
17656
|
+
* room keeps both and the other cameras fill only what is left (the degrade
|
|
17657
|
+
* engine drops by position). A camera whose picture never landed is skipped
|
|
17658
|
+
* and named ON THAT CAMERA: a blank attachment would read as a broken camera.
|
|
17659
|
+
* The camera is the one the row CARRIES with each still (the one the shelf
|
|
17660
|
+
* photographed) — never inferred from a position in `linkedCameraIds`, which
|
|
17661
|
+
* a mute between capture and evaluation shifts. Named `camera-<id>.jpg`.
|
|
17662
|
+
*/
|
|
17663
|
+
async linkedExtraStills(entry) {
|
|
17664
|
+
const subject = entry.payload.subject;
|
|
17665
|
+
const out = [];
|
|
17666
|
+
for (const { ownerId, cameraId } of subject.extraStills ?? []) {
|
|
17667
|
+
const file = await this.linkedStillFile(entry, ownerId, cameraId);
|
|
17668
|
+
const bytes = file === void 0 ? null : decodeStillBytes(file.base64);
|
|
17669
|
+
if (bytes === null) {
|
|
17670
|
+
this.deps.logger.info("linked camera picture missing at send — skipped", {
|
|
17671
|
+
tags: { deviceId: cameraId },
|
|
17672
|
+
meta: {
|
|
17673
|
+
recordId: entry.recordId,
|
|
17674
|
+
ruleId: entry.ruleId,
|
|
17675
|
+
ownerId
|
|
17676
|
+
}
|
|
17677
|
+
});
|
|
17678
|
+
continue;
|
|
17679
|
+
}
|
|
17680
|
+
out.push({
|
|
17681
|
+
mediaType: "image",
|
|
17682
|
+
bytes,
|
|
17683
|
+
mime: "image/jpeg",
|
|
17684
|
+
name: `camera-${String(cameraId)}.jpg`
|
|
17685
|
+
});
|
|
17686
|
+
}
|
|
17562
17687
|
return out;
|
|
17563
17688
|
}
|
|
17564
17689
|
/**
|
|
@@ -18037,10 +18162,8 @@ var NcDispatcher = class {
|
|
|
18037
18162
|
const shortlist = await this.narrowToTriggerZone(entry, inBand, freshness);
|
|
18038
18163
|
if (shortlist.length === 0) continue;
|
|
18039
18164
|
for (const file of shortlist) {
|
|
18040
|
-
const
|
|
18041
|
-
if (
|
|
18042
|
-
const bytes = new Uint8Array(raw.byteLength);
|
|
18043
|
-
bytes.set(raw);
|
|
18165
|
+
const bytes = decodeStillBytes(file.base64);
|
|
18166
|
+
if (bytes === null) continue;
|
|
18044
18167
|
return {
|
|
18045
18168
|
mediaType: "image",
|
|
18046
18169
|
bytes,
|
|
@@ -18273,6 +18396,18 @@ function copyBytes(bytes) {
|
|
|
18273
18396
|
out.set(bytes);
|
|
18274
18397
|
return out;
|
|
18275
18398
|
}
|
|
18399
|
+
/**
|
|
18400
|
+
* A stored file's base64 as bytes backed by their OWN `ArrayBuffer`, or `null`
|
|
18401
|
+
* when it decodes to nothing (a blank picture is never a picture).
|
|
18402
|
+
*
|
|
18403
|
+
* The copy is the point: a pooled `Buffer` view throws `Trying to deserialize
|
|
18404
|
+
* unknown typed array` on the far side of a transport, and the attachment and
|
|
18405
|
+
* frame types both demand `Uint8Array<ArrayBuffer>`.
|
|
18406
|
+
*/
|
|
18407
|
+
function decodeStillBytes(base64) {
|
|
18408
|
+
const raw = Buffer.from(base64, "base64");
|
|
18409
|
+
return raw.byteLength === 0 ? null : copyBytes(raw);
|
|
18410
|
+
}
|
|
18276
18411
|
/** Does this rule ever cut footage from the broker's clip ring? */
|
|
18277
18412
|
function wantsFootage(rule) {
|
|
18278
18413
|
return rule.media.gif === true || rule.media.clip === true;
|
|
@@ -20109,6 +20244,916 @@ var DeviceLivenessHoldoff = class {
|
|
|
20109
20244
|
}
|
|
20110
20245
|
};
|
|
20111
20246
|
//#endregion
|
|
20247
|
+
//#region src/notification-center/linked-camera-media.ts
|
|
20248
|
+
/** ADR 0632: absent = mosaic, applied HERE and never as a Zod default. */
|
|
20249
|
+
function linkedCameraMode(media) {
|
|
20250
|
+
return media.linkedCameras ?? "mosaic";
|
|
20251
|
+
}
|
|
20252
|
+
/** Which shelf owners one RULE's row names. The model always gets the raw tiles
|
|
20253
|
+
* (like the digest, D154) — a composite is a worse input than its parts. */
|
|
20254
|
+
function linkedMediaOwners(stills, mode) {
|
|
20255
|
+
const tiles = stills.cameraOwnerIds.flatMap((ownerId, i) => {
|
|
20256
|
+
const cameraId = stills.cameraIds[i];
|
|
20257
|
+
return cameraId === void 0 ? [] : [{
|
|
20258
|
+
ownerId,
|
|
20259
|
+
cameraId
|
|
20260
|
+
}];
|
|
20261
|
+
});
|
|
20262
|
+
const [primary] = stills.cameraOwnerIds;
|
|
20263
|
+
if (mode === "each" && primary !== void 0) return {
|
|
20264
|
+
trackId: primary,
|
|
20265
|
+
extraStills: tiles.slice(1),
|
|
20266
|
+
analysisTiles: tiles
|
|
20267
|
+
};
|
|
20268
|
+
return {
|
|
20269
|
+
trackId: stills.mosaicOwnerId,
|
|
20270
|
+
extraStills: [],
|
|
20271
|
+
analysisTiles: tiles
|
|
20272
|
+
};
|
|
20273
|
+
}
|
|
20274
|
+
/** JPEG quality for the downscaled full frame — matches the crop path. */
|
|
20275
|
+
var FULL_FRAME_QUALITY = 80;
|
|
20276
|
+
/**
|
|
20277
|
+
* Downscale an already-encoded JPEG full frame to FIT WITHIN
|
|
20278
|
+
* {@link FULL_FRAME_MAX_WIDTH}×{@link FULL_FRAME_MAX_HEIGHT}, preserving aspect
|
|
20279
|
+
* ratio (`fit: 'inside'`) and never enlarging a source already smaller than the
|
|
20280
|
+
* box. Re-encodes as JPEG. Used before persisting a synthetic sensor/control
|
|
20281
|
+
* track's whole-scene snapshot so a raw native-resolution frame (a 4K bedroom
|
|
20282
|
+
* at night) is never stored or served — the privacy fix moved to CAPTURE time.
|
|
20283
|
+
*/
|
|
20284
|
+
async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
|
|
20285
|
+
return sharp(Buffer.from(jpeg)).resize(maxWidth, maxHeight, {
|
|
20286
|
+
fit: "inside",
|
|
20287
|
+
withoutEnlargement: true
|
|
20288
|
+
}).jpeg({ quality: FULL_FRAME_QUALITY }).toBuffer();
|
|
20289
|
+
}
|
|
20290
|
+
//#endregion
|
|
20291
|
+
//#region src/notification-center/still-shelf.ts
|
|
20292
|
+
/**
|
|
20293
|
+
* The still shelf — the PHOTOGRAPH a notification carries when nothing it can
|
|
20294
|
+
* name owns a frame.
|
|
20295
|
+
*
|
|
20296
|
+
* ## Why a trigger needs one at all
|
|
20297
|
+
*
|
|
20298
|
+
* The attachment ladder resolves media by OWNER, and most triggers have one: an
|
|
20299
|
+
* object or package event owns its crops, a closed track owns its best shot, a
|
|
20300
|
+
* doorbell press owns the marker track the same press projected
|
|
20301
|
+
* (`sensor-marker-projector.ts`), an occupancy edge names one of the objects it
|
|
20302
|
+
* counted (`chooseOccupancyMediaOwner`). Two triggers own nothing, and for the
|
|
20303
|
+
* same reason in both cases — the subject is an ABSENCE:
|
|
20304
|
+
*
|
|
20305
|
+
* - an **audio** match: nothing was boxed, nothing was tracked, and —
|
|
20306
|
+
* deliberately — nothing is persisted at all. A confirmed window is a claim
|
|
20307
|
+
* about sound that has already stopped, and `event-intake.ts` states why
|
|
20308
|
+
* replaying it later would be wrong.
|
|
20309
|
+
* - an **occupancy** edge whose scope is EMPTY — "posto libero". The vehicle
|
|
20310
|
+
* whose departure IS the news has left, so `chooseOccupancyMediaOwner` names
|
|
20311
|
+
* nobody and the ladder logs `no still could be resolved … owners=[]`.
|
|
20312
|
+
*
|
|
20313
|
+
* So the only honest picture is a PHOTOGRAPH of the camera taken at the moment
|
|
20314
|
+
* of the trigger. Not of the sound, not of the object that left — of what the
|
|
20315
|
+
* camera can see now that it happened. For a freed parking space that is
|
|
20316
|
+
* exactly the answer the operator wants: the space, empty.
|
|
20317
|
+
*
|
|
20318
|
+
* **One shelf, not two.** The mechanism is identical down to the reuse window,
|
|
20319
|
+
* and the only thing that differs between the two triggers is the GATE deciding
|
|
20320
|
+
* that a photograph is owed at all — which belongs at the trigger site, where
|
|
20321
|
+
* the rules are, and not here. A twin module would be a second derivation of
|
|
20322
|
+
* one thing. The trigger rides along as {@link NcStillTrigger} only so the logs
|
|
20323
|
+
* can say which absence they are about.
|
|
20324
|
+
*
|
|
20325
|
+
* ## Three properties, and each one is a decision
|
|
20326
|
+
*
|
|
20327
|
+
* **It is not a record.** The bytes live here, in RAM, under an owner id and a
|
|
20328
|
+
* TTL that covers the outbox's whole retry horizon — and nowhere else. The
|
|
20329
|
+
* alternative was the doorbell's: materialise a synthetic marker track through
|
|
20330
|
+
* `SyntheticTrackMaterializer` and let the notification name it. That would put
|
|
20331
|
+
* a durable Track on the camera's timeline for every confirmed window and every
|
|
20332
|
+
* emptied zone, feeding the digest's `listTracks`, retention, and the audio-
|
|
20333
|
+
* marker feature's own operator ceilings (`audio-marker-projector.ts` exists
|
|
20334
|
+
* precisely to bound how many audio markers a camera may emit). A notification
|
|
20335
|
+
* must not manufacture timeline history as a side effect of wanting a picture.
|
|
20336
|
+
*
|
|
20337
|
+
* **The capture STARTS immediately and is never awaited.** The subject is
|
|
20338
|
+
* transient — a scream is over before a snapshot round-trip completes, and a
|
|
20339
|
+
* freed space is about to be taken by the next car — so the fetch is kicked off
|
|
20340
|
+
* at the trigger, before the rule evaluation runs, and the owner id is minted
|
|
20341
|
+
* synchronously so the outbox row can name it. The bytes land while the row
|
|
20342
|
+
* waits in the queue, and the dispatcher's existing bounded still-wait (or the
|
|
20343
|
+
* pause its own footage render already costs) picks them up. A camera that
|
|
20344
|
+
* never answers costs the picture and never the notification.
|
|
20345
|
+
*
|
|
20346
|
+
* **Two triggers seconds apart share ONE capture.** A barking dog confirms
|
|
20347
|
+
* repeatedly and a label-mode rule has no re-arm timer at all (D157) — the
|
|
20348
|
+
* rule's cooldown is its only brake, and the cooldown is applied AFTER this.
|
|
20349
|
+
* Without a reuse window this would photograph a camera at whatever rate the
|
|
20350
|
+
* sound happens to occur. Each trigger still gets its OWN owner id, so two
|
|
20351
|
+
* outbox rows are never mistaken for one subject; they merely point at the same
|
|
20352
|
+
* frame, which is the truth — the scene did not change in ten seconds. The
|
|
20353
|
+
* window is per CAMERA and trigger-agnostic for the same reason: a sound and an
|
|
20354
|
+
* emptied zone ten seconds apart are two claims about one scene.
|
|
20355
|
+
*
|
|
20356
|
+
* Nothing here is silent: a capture that lands and a camera that refuses each
|
|
20357
|
+
* emit one line carrying `tags: { deviceId }`, because "why did 617 get a photo
|
|
20358
|
+
* and 615 not" is the only form that question is ever asked in.
|
|
20359
|
+
*/
|
|
20360
|
+
/**
|
|
20361
|
+
* The owner-id namespace. It is what routes a lookup here instead of to the
|
|
20362
|
+
* media store, and it is the reason the dispatcher needs no audio or occupancy
|
|
20363
|
+
* branch — `getMediaForOwner` answers for all of them under one signature.
|
|
20364
|
+
*/
|
|
20365
|
+
var NC_STILL_SHELF_PREFIX = "nc-still:";
|
|
20366
|
+
/** True for an owner id this shelf minted. */
|
|
20367
|
+
function isStillShelfId(id) {
|
|
20368
|
+
return id.startsWith(NC_STILL_SHELF_PREFIX);
|
|
20369
|
+
}
|
|
20370
|
+
/**
|
|
20371
|
+
* How long the bytes are held.
|
|
20372
|
+
*
|
|
20373
|
+
* The outbox retries 8 times with a 5 s → 300 s backoff, which tops out around
|
|
20374
|
+
* ten minutes; fifteen covers that with room for a slow drain. Past it the row
|
|
20375
|
+
* ships text-only, which is the correct degradation for a photograph of a scene
|
|
20376
|
+
* that is a quarter of an hour stale anyway.
|
|
20377
|
+
*/
|
|
20378
|
+
var NC_STILL_SHELF_TTL_MS = 15 * 6e4;
|
|
20379
|
+
/** A hanging snapshot cap must not pin a capture slot forever. */
|
|
20380
|
+
var SNAPSHOT_TIMEOUT_MS = 8e3;
|
|
20381
|
+
/**
|
|
20382
|
+
* Hard bound on held captures. Reached only if every camera on the hub triggers
|
|
20383
|
+
* one inside a single TTL; the oldest is dropped first, which costs a
|
|
20384
|
+
* fifteen-minute-old picture nobody is waiting for.
|
|
20385
|
+
*/
|
|
20386
|
+
var MAX_CAPTURES = 64;
|
|
20387
|
+
var NcStillShelf = class {
|
|
20388
|
+
deps;
|
|
20389
|
+
/** captureId → the photograph. */
|
|
20390
|
+
captures = /* @__PURE__ */ new Map();
|
|
20391
|
+
/** ownerId → captureId. Several owners may name one capture (the reuse window). */
|
|
20392
|
+
owners = /* @__PURE__ */ new Map();
|
|
20393
|
+
/** deviceId → its newest capture, for the reuse window. */
|
|
20394
|
+
newest = /* @__PURE__ */ new Map();
|
|
20395
|
+
constructor(deps) {
|
|
20396
|
+
this.deps = deps;
|
|
20397
|
+
}
|
|
20398
|
+
/**
|
|
20399
|
+
* Photograph `deviceId` for a trigger at `atMs`, and return the owner id the
|
|
20400
|
+
* subject should name. SYNCHRONOUS by contract: the caller is on the trigger
|
|
20401
|
+
* path and the outbox row is built from what this returns.
|
|
20402
|
+
*/
|
|
20403
|
+
capture(deviceId, atMs, trigger) {
|
|
20404
|
+
const now = this.deps.now();
|
|
20405
|
+
this.prune(now);
|
|
20406
|
+
const ownerId = `${NC_STILL_SHELF_PREFIX}${randomUUID()}`;
|
|
20407
|
+
const recent = this.newest.get(deviceId);
|
|
20408
|
+
if (recent !== void 0 && now - recent.at < 1e4 && this.captures.has(recent.captureId)) {
|
|
20409
|
+
this.owners.set(ownerId, recent.captureId);
|
|
20410
|
+
return ownerId;
|
|
20411
|
+
}
|
|
20412
|
+
const captureId = randomUUID();
|
|
20413
|
+
this.captures.set(captureId, {
|
|
20414
|
+
deviceId,
|
|
20415
|
+
trigger,
|
|
20416
|
+
startedAt: now,
|
|
20417
|
+
expiresAt: now + NC_STILL_SHELF_TTL_MS,
|
|
20418
|
+
files: []
|
|
20419
|
+
});
|
|
20420
|
+
this.newest.set(deviceId, {
|
|
20421
|
+
captureId,
|
|
20422
|
+
at: now
|
|
20423
|
+
});
|
|
20424
|
+
this.owners.set(ownerId, captureId);
|
|
20425
|
+
this.fetch(captureId, deviceId, atMs, trigger);
|
|
20426
|
+
return ownerId;
|
|
20427
|
+
}
|
|
20428
|
+
/**
|
|
20429
|
+
* The media an owner id holds, or `undefined` when this shelf never minted
|
|
20430
|
+
* it. An EMPTY array is a different answer: the capture exists and has not
|
|
20431
|
+
* landed (or never will), which is exactly the case the dispatcher's bounded
|
|
20432
|
+
* wait and its `no still could be resolved` line are for.
|
|
20433
|
+
*/
|
|
20434
|
+
get(ownerId) {
|
|
20435
|
+
const captureId = this.owners.get(ownerId);
|
|
20436
|
+
if (captureId === void 0) return void 0;
|
|
20437
|
+
return this.captures.get(captureId)?.files ?? [];
|
|
20438
|
+
}
|
|
20439
|
+
/** Drop expired captures and the owners that named them. */
|
|
20440
|
+
prune(now) {
|
|
20441
|
+
for (const [captureId, held] of this.captures) {
|
|
20442
|
+
if (held.expiresAt > now) continue;
|
|
20443
|
+
this.captures.delete(captureId);
|
|
20444
|
+
if (this.newest.get(held.deviceId)?.captureId === captureId) this.newest.delete(held.deviceId);
|
|
20445
|
+
}
|
|
20446
|
+
while (this.captures.size > MAX_CAPTURES) {
|
|
20447
|
+
const oldest = this.captures.keys().next();
|
|
20448
|
+
if (oldest.done === true) break;
|
|
20449
|
+
const held = this.captures.get(oldest.value);
|
|
20450
|
+
this.captures.delete(oldest.value);
|
|
20451
|
+
if (held !== void 0 && this.newest.get(held.deviceId)?.captureId === oldest.value) this.newest.delete(held.deviceId);
|
|
20452
|
+
}
|
|
20453
|
+
for (const [ownerId, captureId] of this.owners) if (!this.captures.has(captureId)) this.owners.delete(ownerId);
|
|
20454
|
+
}
|
|
20455
|
+
/** Drop everything (shutdown). */
|
|
20456
|
+
clear() {
|
|
20457
|
+
this.captures.clear();
|
|
20458
|
+
this.owners.clear();
|
|
20459
|
+
this.newest.clear();
|
|
20460
|
+
}
|
|
20461
|
+
async fetch(captureId, deviceId, atMs, trigger) {
|
|
20462
|
+
try {
|
|
20463
|
+
const shot = await withTimeout$4(this.deps.getSnapshot(deviceId), SNAPSHOT_TIMEOUT_MS);
|
|
20464
|
+
if (shot === null || shot.base64.length === 0) {
|
|
20465
|
+
this.reportMiss(deviceId, trigger, "the camera returned no snapshot");
|
|
20466
|
+
return;
|
|
20467
|
+
}
|
|
20468
|
+
const raw = Buffer.from(shot.base64, "base64");
|
|
20469
|
+
let data = raw;
|
|
20470
|
+
try {
|
|
20471
|
+
data = await downscaleFullFrameJpeg(raw, 960, 540);
|
|
20472
|
+
} catch {}
|
|
20473
|
+
const held = this.captures.get(captureId);
|
|
20474
|
+
if (held === void 0) return;
|
|
20475
|
+
this.captures.set(captureId, {
|
|
20476
|
+
...held,
|
|
20477
|
+
files: stillShelfMedia(data, atMs)
|
|
20478
|
+
});
|
|
20479
|
+
this.deps.logger.info(`${trigger} still captured`, {
|
|
20480
|
+
tags: { deviceId },
|
|
20481
|
+
meta: {
|
|
20482
|
+
trigger,
|
|
20483
|
+
bytes: data.byteLength,
|
|
20484
|
+
tookMs: this.deps.now() - held.startedAt
|
|
20485
|
+
}
|
|
20486
|
+
});
|
|
20487
|
+
} catch (err) {
|
|
20488
|
+
this.reportMiss(deviceId, trigger, err instanceof Error ? err.message : String(err));
|
|
20489
|
+
}
|
|
20490
|
+
}
|
|
20491
|
+
/**
|
|
20492
|
+
* A branch that drops work says so. This one costs the operator the picture
|
|
20493
|
+
* on a notification they DID receive, so it is a warn and it carries the
|
|
20494
|
+
* camera — the only key the question is ever asked with.
|
|
20495
|
+
*/
|
|
20496
|
+
reportMiss(deviceId, trigger, reason) {
|
|
20497
|
+
this.deps.logger.warn(`the camera did not answer the ${trigger} still — this notification ships text-only`, {
|
|
20498
|
+
tags: { deviceId },
|
|
20499
|
+
meta: {
|
|
20500
|
+
trigger,
|
|
20501
|
+
reason
|
|
20502
|
+
}
|
|
20503
|
+
});
|
|
20504
|
+
}
|
|
20505
|
+
};
|
|
20506
|
+
/**
|
|
20507
|
+
* The photograph, in the three kinds the CLEAN-SCENE ladders ask for.
|
|
20508
|
+
*
|
|
20509
|
+
* `keyFrameSmall` leads because it is the first rung of both `attach: 'best'`
|
|
20510
|
+
* and `attach: 'keyFrame'` on a track owner; `keyFrame` and `fullFrame` answer
|
|
20511
|
+
* `frame: 'full'` and `frame: 'boxed'`'s honest degrade. There is deliberately
|
|
20512
|
+
* no `crop` / `thumbnail`: nothing was boxed, so `frame: 'cropped'` resolves
|
|
20513
|
+
* NOTHING and the notification ships text-only with the ordinary line saying
|
|
20514
|
+
* so. Inventing a centre crop would answer a different question from the one
|
|
20515
|
+
* the operator asked.
|
|
20516
|
+
*/
|
|
20517
|
+
function stillShelfMedia(data, timestamp) {
|
|
20518
|
+
const base64 = data.toString("base64");
|
|
20519
|
+
const file = (kind) => ({
|
|
20520
|
+
mediaId: nextUnstoredMediaId(),
|
|
20521
|
+
kind,
|
|
20522
|
+
base64,
|
|
20523
|
+
sizeBytes: data.byteLength,
|
|
20524
|
+
timestamp
|
|
20525
|
+
});
|
|
20526
|
+
return [
|
|
20527
|
+
file("keyFrameSmall"),
|
|
20528
|
+
file("keyFrame"),
|
|
20529
|
+
file("fullFrame")
|
|
20530
|
+
];
|
|
20531
|
+
}
|
|
20532
|
+
/** Reject if `promise` does not settle within `ms`. */
|
|
20533
|
+
function withTimeout$4(promise, ms) {
|
|
20534
|
+
return new Promise((resolve, reject) => {
|
|
20535
|
+
const timer = setTimeout(() => {
|
|
20536
|
+
reject(/* @__PURE__ */ new Error(`snapshot cap timed out after ${String(ms)}ms`));
|
|
20537
|
+
}, ms);
|
|
20538
|
+
promise.then((value) => {
|
|
20539
|
+
clearTimeout(timer);
|
|
20540
|
+
resolve(value);
|
|
20541
|
+
}, (err) => {
|
|
20542
|
+
clearTimeout(timer);
|
|
20543
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
20544
|
+
});
|
|
20545
|
+
});
|
|
20546
|
+
}
|
|
20547
|
+
/** The canvas behind a tile whose aspect does not fill its cell. */
|
|
20548
|
+
var MOSAIC_BACKGROUND = {
|
|
20549
|
+
r: 17,
|
|
20550
|
+
g: 17,
|
|
20551
|
+
b: 17
|
|
20552
|
+
};
|
|
20553
|
+
/** Tile aspect — cameras are 16:9 and a square cell would letterbox every one. */
|
|
20554
|
+
var TILE_ASPECT = 9 / 16;
|
|
20555
|
+
/** Height of the per-tile label strip, as a fraction of the tile height. */
|
|
20556
|
+
var LABEL_BAND_FRACTION = .14;
|
|
20557
|
+
var LABEL_MIN_PX = 11;
|
|
20558
|
+
/** Height of the mosaic's own title band, as a fraction of a tile's height. */
|
|
20559
|
+
var TITLE_BAND_FRACTION = .22;
|
|
20560
|
+
var TITLE_MIN_PX = 16;
|
|
20561
|
+
/**
|
|
20562
|
+
* Average glyph width as a fraction of the font size, for bold sans-serif.
|
|
20563
|
+
*
|
|
20564
|
+
* An ESTIMATE, and it has to be: librsvg lays the text out long after this
|
|
20565
|
+
* string is written, so there are no metrics to measure against. The same
|
|
20566
|
+
* number and the same reasoning as `preview-frame.ts#fitTitle` — deliberately
|
|
20567
|
+
* GENEROUS, because a label that shrank slightly more than it had to is
|
|
20568
|
+
* invisible and one that overflowed runs into the tile beside it. The first
|
|
20569
|
+
* live mosaic did exactly that: `Videocamera cortile · 07:49 · animal` was cut
|
|
20570
|
+
* to `…· anima` at the tile edge, mid-word.
|
|
20571
|
+
*/
|
|
20572
|
+
var GLYPH_ADVANCE = .56;
|
|
20573
|
+
/** Fraction of the band a label may occupy, leaving the padding either side. */
|
|
20574
|
+
var LABEL_USABLE_FRACTION = .94;
|
|
20575
|
+
/** `<`, `>`, `&`, `'`, `"` in a camera name or an operator caption would each
|
|
20576
|
+
* turn the overlay into a document librsvg refuses. */
|
|
20577
|
+
function escapeXml$1(s) {
|
|
20578
|
+
return s.replace(/[<>&'"]/g, (ch) => {
|
|
20579
|
+
switch (ch) {
|
|
20580
|
+
case "<": return "<";
|
|
20581
|
+
case ">": return ">";
|
|
20582
|
+
case "&": return "&";
|
|
20583
|
+
case "'": return "'";
|
|
20584
|
+
default: return """;
|
|
20585
|
+
}
|
|
20586
|
+
});
|
|
20587
|
+
}
|
|
20588
|
+
/**
|
|
20589
|
+
* The single decision about drawn text — and the home of the DEFAULT.
|
|
20590
|
+
*
|
|
20591
|
+
* `undefined` means `none`, not `all`: a rule stored before this option existed
|
|
20592
|
+
* has no `captions` key, and the operator's request is that such a rule deliver
|
|
20593
|
+
* pictures. The schema materialises the same default on read
|
|
20594
|
+
* (`NcSummaryRuleInputSchema`), so this arm only fires for a caller that never
|
|
20595
|
+
* had a rule — the test bridge and the spec. Both agreeing is the point.
|
|
20596
|
+
*/
|
|
20597
|
+
function mosaicTextPlan(captions) {
|
|
20598
|
+
if (captions === "all") return {
|
|
20599
|
+
tileLabels: true,
|
|
20600
|
+
titleBand: true
|
|
20601
|
+
};
|
|
20602
|
+
if (captions === "tiles") return {
|
|
20603
|
+
tileLabels: true,
|
|
20604
|
+
titleBand: false
|
|
20605
|
+
};
|
|
20606
|
+
return {
|
|
20607
|
+
tileLabels: false,
|
|
20608
|
+
titleBand: false
|
|
20609
|
+
};
|
|
20610
|
+
}
|
|
20611
|
+
/** Grid shape for `n` tiles: as square as possible, rows never exceeding cols
|
|
20612
|
+
* by more than one — a 3×3 for nine, a 3×2 for five, never a 1×9 strip.
|
|
20613
|
+
*
|
|
20614
|
+
* `rows` is the load-bearing half — it is how many rows {@link mosaicRowCounts}
|
|
20615
|
+
* distributes into. `cols` describes the WIDEST row, and is no longer a cell
|
|
20616
|
+
* count: see {@link mosaicRowCounts} for why a `cols × rows` grid was the bug. */
|
|
20617
|
+
function mosaicGrid(n) {
|
|
20618
|
+
const count = Math.max(1, Math.floor(n));
|
|
20619
|
+
const cols = Math.ceil(Math.sqrt(count));
|
|
20620
|
+
return {
|
|
20621
|
+
cols,
|
|
20622
|
+
rows: Math.ceil(count / cols)
|
|
20623
|
+
};
|
|
20624
|
+
}
|
|
20625
|
+
/**
|
|
20626
|
+
* How many tiles each ROW carries, top to bottom — and the reason the mosaic
|
|
20627
|
+
* can no longer have a hole in it.
|
|
20628
|
+
*
|
|
20629
|
+
* The layout used to be a `cols × rows` GRID, and a grid holds more cells than
|
|
20630
|
+
* tiles for every count that is not a perfect fit: 3 into a 2×2, 5 into a 3×2,
|
|
20631
|
+
* 7 and 8 into a 3×3. The composite only ever wrote the tiles, so the spare
|
|
20632
|
+
* cells stayed the canvas — `MOSAIC_BACKGROUND`, rgb 17 — and the operator's
|
|
20633
|
+
* three-tile digest was a quarter black. Measured on the shipped renderer:
|
|
20634
|
+
* 24.8% of a three-tile mosaic was background. His words: *"se ci sono 3
|
|
20635
|
+
* elementi non dovrebbe renderizzare buchi neri"*.
|
|
20636
|
+
*
|
|
20637
|
+
* Counting ROWS instead removes the failure by construction rather than by
|
|
20638
|
+
* patching it. Every row spans the full width and divides it among the tiles it
|
|
20639
|
+
* actually has, so a row is full whatever the remainder and there is no such
|
|
20640
|
+
* thing as a spare cell. The shapes fall out of it, and they are the ones asked
|
|
20641
|
+
* for: 1 ⇒ full frame, 2 ⇒ side by side, 3 ⇒ one big over two small, 4 ⇒ 2×2.
|
|
20642
|
+
*
|
|
20643
|
+
* **The fewer-tiled row comes FIRST.** Tiles arrive in the selection's order,
|
|
20644
|
+
* so row 0 is the head of the ranking; putting the remainder at the top would
|
|
20645
|
+
* make the WORST tile the big one.
|
|
20646
|
+
*
|
|
20647
|
+
* Every cell keeps {@link TILE_ASPECT}, so rows differ in height — which is
|
|
20648
|
+
* also what stops the letterbox: a full-width hero tile is 16:9 exactly, not a
|
|
20649
|
+
* 16:9 picture centred in a double-height cell with bars over and under it.
|
|
20650
|
+
*/
|
|
20651
|
+
function mosaicRowCounts(n) {
|
|
20652
|
+
const count = Math.max(1, Math.floor(n));
|
|
20653
|
+
const { rows } = mosaicGrid(count);
|
|
20654
|
+
const base = Math.floor(count / rows);
|
|
20655
|
+
const remainder = count % rows;
|
|
20656
|
+
const counts = [];
|
|
20657
|
+
for (let row = 0; row < rows; row += 1) counts.push(row < rows - remainder ? base : base + 1);
|
|
20658
|
+
return counts;
|
|
20659
|
+
}
|
|
20660
|
+
/**
|
|
20661
|
+
* Where every tile lands. Pure — no bytes, no sharp, no clock.
|
|
20662
|
+
*
|
|
20663
|
+
* Exported because the rectangles are DATA the artefact should carry: a viewer
|
|
20664
|
+
* that wants a tappable mosaic reads these rather than re-deriving a layout
|
|
20665
|
+
* that could drift from the one actually rendered.
|
|
20666
|
+
*
|
|
20667
|
+
* The rectangles COVER the picture area exactly — that is the property the
|
|
20668
|
+
* spec asserts, and it is the one the old grid broke.
|
|
20669
|
+
*/
|
|
20670
|
+
function mosaicLayout(input) {
|
|
20671
|
+
const rowCounts = mosaicRowCounts(input.tileCount);
|
|
20672
|
+
const width = Math.max(1, Math.floor(input.maxWidth ?? 1920));
|
|
20673
|
+
const firstRowCount = rowCounts[0] ?? 1;
|
|
20674
|
+
const tileWidth = Math.floor(width / firstRowCount);
|
|
20675
|
+
const tileHeight = Math.round(tileWidth * TILE_ASPECT);
|
|
20676
|
+
const titleHeight = input.withTitle === true ? Math.round(tileHeight * TITLE_BAND_FRACTION) : 0;
|
|
20677
|
+
const tiles = [];
|
|
20678
|
+
let y = titleHeight;
|
|
20679
|
+
let index = 0;
|
|
20680
|
+
for (const count of rowCounts) {
|
|
20681
|
+
const nominalWidth = Math.floor(width / count);
|
|
20682
|
+
const rowHeight = Math.round(nominalWidth * TILE_ASPECT);
|
|
20683
|
+
let x = 0;
|
|
20684
|
+
for (let inRow = 0; inRow < count; inRow += 1) {
|
|
20685
|
+
const w = inRow === count - 1 ? width - x : nominalWidth;
|
|
20686
|
+
tiles.push({
|
|
20687
|
+
index,
|
|
20688
|
+
x,
|
|
20689
|
+
y,
|
|
20690
|
+
w,
|
|
20691
|
+
h: rowHeight
|
|
20692
|
+
});
|
|
20693
|
+
x += w;
|
|
20694
|
+
index += 1;
|
|
20695
|
+
}
|
|
20696
|
+
y += rowHeight;
|
|
20697
|
+
}
|
|
20698
|
+
return {
|
|
20699
|
+
width,
|
|
20700
|
+
height: y,
|
|
20701
|
+
cols: Math.max(...rowCounts),
|
|
20702
|
+
rows: rowCounts.length,
|
|
20703
|
+
rowCounts,
|
|
20704
|
+
tileWidth,
|
|
20705
|
+
tileHeight,
|
|
20706
|
+
titleHeight,
|
|
20707
|
+
tiles
|
|
20708
|
+
};
|
|
20709
|
+
}
|
|
20710
|
+
/**
|
|
20711
|
+
* Shrink, then ELIDE, until the line fits `usablePx`.
|
|
20712
|
+
*
|
|
20713
|
+
* Shrinking is tried first because a slightly smaller label still says
|
|
20714
|
+
* everything; only past `minPx` is the tail cut, and then with an ellipsis so
|
|
20715
|
+
* the reader can SEE that something was cut. The failure this replaces was
|
|
20716
|
+
* silent: SVG simply clips at the viewport, so an overlong label lost its last
|
|
20717
|
+
* word mid-glyph and looked like a rendering bug rather than a truncation.
|
|
20718
|
+
*
|
|
20719
|
+
* Pure and exported so the arithmetic is testable without rendering a pixel.
|
|
20720
|
+
*/
|
|
20721
|
+
function fitLine(text, usablePx, basePx, minPx) {
|
|
20722
|
+
const flat = text.trim().replace(/\s+/g, " ");
|
|
20723
|
+
const fits = (s, fontPx) => s.length * fontPx * GLYPH_ADVANCE <= usablePx;
|
|
20724
|
+
let fontPx = Math.max(minPx, Math.round(basePx));
|
|
20725
|
+
while (fontPx > minPx && !fits(flat, fontPx)) fontPx = Math.max(minPx, fontPx - 1);
|
|
20726
|
+
if (fits(flat, fontPx)) return {
|
|
20727
|
+
text: flat,
|
|
20728
|
+
fontPx
|
|
20729
|
+
};
|
|
20730
|
+
const budget = Math.max(1, Math.floor(usablePx / (fontPx * GLYPH_ADVANCE)));
|
|
20731
|
+
return {
|
|
20732
|
+
text: `${flat.slice(0, Math.max(1, budget - 1)).trimEnd()}…`,
|
|
20733
|
+
fontPx
|
|
20734
|
+
};
|
|
20735
|
+
}
|
|
20736
|
+
/** The label strip drawn across the bottom of one tile. */
|
|
20737
|
+
function tileLabelSvg(width, height, label) {
|
|
20738
|
+
const bandHeight = Math.max(18, Math.round(height * LABEL_BAND_FRACTION));
|
|
20739
|
+
const basePx = Math.round(bandHeight * .62);
|
|
20740
|
+
const padding = Math.round(basePx * .6);
|
|
20741
|
+
const fitted = fitLine(label, width * LABEL_USABLE_FRACTION - padding, basePx, LABEL_MIN_PX);
|
|
20742
|
+
const baseline = height - Math.round((bandHeight - fitted.fontPx) / 2) - Math.round(fitted.fontPx * .18);
|
|
20743
|
+
return Buffer.from(`<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
|
|
20744
|
+
<rect x="0" y="${height - bandHeight}" width="${width}" height="${bandHeight}" fill="black" fill-opacity="0.55"/>
|
|
20745
|
+
<text x="${padding}" y="${baseline}" font-size="${fitted.fontPx}" font-family="sans-serif" font-weight="bold" fill="white">${escapeXml$1(fitted.text)}</text>
|
|
20746
|
+
</svg>`);
|
|
20747
|
+
}
|
|
20748
|
+
/** The mosaic's own title band. */
|
|
20749
|
+
function titleSvg(width, height, caption) {
|
|
20750
|
+
const fitted = fitLine(caption, width * LABEL_USABLE_FRACTION, Math.round(height * .5), TITLE_MIN_PX);
|
|
20751
|
+
const baseline = Math.round(height / 2 + fitted.fontPx * .36);
|
|
20752
|
+
return Buffer.from(`<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
|
|
20753
|
+
<rect x="0" y="0" width="${width}" height="${height}" fill="#111111"/>
|
|
20754
|
+
<text x="${Math.round(width / 2)}" y="${baseline}" font-size="${fitted.fontPx}" font-family="sans-serif" font-weight="bold" text-anchor="middle" fill="white">${escapeXml$1(fitted.text)}</text>
|
|
20755
|
+
</svg>`);
|
|
20756
|
+
}
|
|
20757
|
+
/**
|
|
20758
|
+
* Compose the mosaic.
|
|
20759
|
+
*
|
|
20760
|
+
* Every tile is fitted with `contain` onto the cell background rather than
|
|
20761
|
+
* cropped: a `cover` fit would silently cut the subject out of a portrait crop,
|
|
20762
|
+
* and a tile that does not show what it is about is worse than a letterboxed
|
|
20763
|
+
* one.
|
|
20764
|
+
*
|
|
20765
|
+
* A tile sharp cannot read is DROPPED and counted, never composited as a blank
|
|
20766
|
+
* cell — a black square in a digest reads as "the camera saw nothing", which is
|
|
20767
|
+
* the opposite of what happened. The layout is computed from the SURVIVORS, and
|
|
20768
|
+
* because {@link mosaicRowCounts} counts rows rather than grid cells there is no
|
|
20769
|
+
* remainder to leave empty either.
|
|
20770
|
+
*
|
|
20771
|
+
* @throws when every tile was dropped, and when sharp cannot produce the final
|
|
20772
|
+
* canvas at all; the caller treats both as "no mosaic" and still
|
|
20773
|
+
* delivers the counts.
|
|
20774
|
+
*/
|
|
20775
|
+
async function renderMosaic(request, logger) {
|
|
20776
|
+
const plan = mosaicTextPlan(request.captions);
|
|
20777
|
+
const usable = [];
|
|
20778
|
+
for (const tile of request.tiles) try {
|
|
20779
|
+
await sharp(tile.jpeg).metadata();
|
|
20780
|
+
usable.push(tile);
|
|
20781
|
+
} catch (err) {
|
|
20782
|
+
logger.warn(`${request.purpose} mosaic: a tile could not be decoded and was dropped`, {
|
|
20783
|
+
tags: { deviceId: tile.deviceId },
|
|
20784
|
+
meta: {
|
|
20785
|
+
...request.purpose === "summary" ? { trackId: tile.sourceId } : { sourceId: tile.sourceId },
|
|
20786
|
+
error: String(err)
|
|
20787
|
+
}
|
|
20788
|
+
});
|
|
20789
|
+
}
|
|
20790
|
+
const droppedTiles = request.tiles.length - usable.length;
|
|
20791
|
+
if (usable.length === 0) throw new Error(`${request.purpose} mosaic: no tile could be decoded (${request.tiles.length} offered)`);
|
|
20792
|
+
const caption = plan.titleBand ? request.caption?.trim() ?? "" : "";
|
|
20793
|
+
const layout = mosaicLayout({
|
|
20794
|
+
tileCount: usable.length,
|
|
20795
|
+
...request.maxWidth !== void 0 ? { maxWidth: request.maxWidth } : {},
|
|
20796
|
+
withTitle: caption.length > 0
|
|
20797
|
+
});
|
|
20798
|
+
const composites = await Promise.all(usable.map(async (tile, index) => {
|
|
20799
|
+
const rect = layout.tiles[index];
|
|
20800
|
+
if (rect === void 0) throw new Error(`${request.purpose} mosaic: layout lost a tile`);
|
|
20801
|
+
const label = plan.tileLabels ? tile.label?.trim() ?? "" : "";
|
|
20802
|
+
const fitted = sharp(tile.jpeg).resize({
|
|
20803
|
+
width: rect.w,
|
|
20804
|
+
height: rect.h,
|
|
20805
|
+
fit: "contain",
|
|
20806
|
+
background: MOSAIC_BACKGROUND
|
|
20807
|
+
});
|
|
20808
|
+
return {
|
|
20809
|
+
input: await (label.length === 0 ? fitted : fitted.composite([{
|
|
20810
|
+
input: tileLabelSvg(rect.w, rect.h, label),
|
|
20811
|
+
top: 0,
|
|
20812
|
+
left: 0
|
|
20813
|
+
}])).jpeg({ quality: 82 }).toBuffer(),
|
|
20814
|
+
top: rect.y,
|
|
20815
|
+
left: rect.x
|
|
20816
|
+
};
|
|
20817
|
+
}));
|
|
20818
|
+
if (caption.length > 0 && layout.titleHeight > 0) composites.unshift({
|
|
20819
|
+
input: titleSvg(layout.width, layout.titleHeight, caption),
|
|
20820
|
+
top: 0,
|
|
20821
|
+
left: 0
|
|
20822
|
+
});
|
|
20823
|
+
return {
|
|
20824
|
+
jpeg: await sharp({ create: {
|
|
20825
|
+
width: layout.width,
|
|
20826
|
+
height: layout.height,
|
|
20827
|
+
channels: 3,
|
|
20828
|
+
background: MOSAIC_BACKGROUND
|
|
20829
|
+
} }).composite(composites).jpeg({ quality: 82 }).toBuffer(),
|
|
20830
|
+
layout,
|
|
20831
|
+
droppedTiles
|
|
20832
|
+
};
|
|
20833
|
+
}
|
|
20834
|
+
//#endregion
|
|
20835
|
+
//#region src/notification-center/linked-stills.ts
|
|
20836
|
+
/**
|
|
20837
|
+
* The linked-camera shelf — the photographs a SENSOR notification carries of
|
|
20838
|
+
* every camera that links the sensor (ADR 0632).
|
|
20839
|
+
*
|
|
20840
|
+
* Same three properties as the still shelf (`still-shelf.ts`), for the same
|
|
20841
|
+
* reasons: not a record (RAM, TTL, no timeline track), the capture starts at the
|
|
20842
|
+
* trigger and is never awaited (owner ids minted synchronously), and nothing is
|
|
20843
|
+
* silent (one line per camera with its outcome, one per event on the sensor).
|
|
20844
|
+
* A separate module because the unit is a SET: exclusion, freshness and the
|
|
20845
|
+
* one composition are decisions about the set, and none of them belongs in a
|
|
20846
|
+
* shelf that photographs one camera for an absence.
|
|
20847
|
+
*
|
|
20848
|
+
* Two reads can only ever ADD a camera back, never take one away on their own
|
|
20849
|
+
* failure (D49): a wrapper-state read (run in parallel with the capture) that
|
|
20850
|
+
* misses or throws keeps the tile, and a freshness read that misses or throws
|
|
20851
|
+
* keeps every tile. Only a read that ANSWERED
|
|
20852
|
+
* `sleeping` / `disabled` / … or a capture time older than
|
|
20853
|
+
* {@link SENSOR_LINKED_MAX_FRAME_AGE_MS} removes a tile — and says so, on that
|
|
20854
|
+
* camera.
|
|
20855
|
+
*/
|
|
20856
|
+
var NC_LINKED_MOSAIC_PREFIX = "nc-mosaic:";
|
|
20857
|
+
var NC_LINKED_CAMERA_PREFIX = "nc-linked:";
|
|
20858
|
+
function isLinkedStillId(id) {
|
|
20859
|
+
return id.startsWith("nc-mosaic:") || id.startsWith("nc-linked:");
|
|
20860
|
+
}
|
|
20861
|
+
/** Per camera — the marker's own bound (`SENSOR_MARKER_SNAPSHOT_TIMEOUT_MS`). */
|
|
20862
|
+
var SENSOR_LINKED_CAPTURE_TIMEOUT_MS = 2500;
|
|
20863
|
+
/** The capture-free wrapper-state read, per camera. It runs IN PARALLEL with
|
|
20864
|
+
* the capture and under the capture's own bound, so it costs no extra time and
|
|
20865
|
+
* a slow-but-honest answer is still heard: a blocking reason EXCLUDES the tile
|
|
20866
|
+
* even when the capture returned bytes (they are a state card, not a
|
|
20867
|
+
* photograph). A read that misses the bound or fails is UNKNOWN and keeps the
|
|
20868
|
+
* tile (D49). */
|
|
20869
|
+
var SENSOR_LINKED_STATE_READ_TIMEOUT_MS = SENSOR_LINKED_CAPTURE_TIMEOUT_MS;
|
|
20870
|
+
Math.max(SENSOR_LINKED_STATE_READ_TIMEOUT_MS, SENSOR_LINKED_CAPTURE_TIMEOUT_MS) + 400;
|
|
20871
|
+
/** Hard bound on sets held at once, on top of the TTL. */
|
|
20872
|
+
var MAX_SETS = 32;
|
|
20873
|
+
function isLandedTile(r) {
|
|
20874
|
+
return r.outcome === "landed" && r.jpeg !== void 0;
|
|
20875
|
+
}
|
|
20876
|
+
var LinkedReadTimeout = class extends Error {
|
|
20877
|
+
constructor(what, ms) {
|
|
20878
|
+
super(`${what} timed out after ${String(ms)}ms`);
|
|
20879
|
+
this.name = "LinkedReadTimeout";
|
|
20880
|
+
}
|
|
20881
|
+
};
|
|
20882
|
+
var NcLinkedStillShelf = class {
|
|
20883
|
+
deps;
|
|
20884
|
+
sets = /* @__PURE__ */ new Map();
|
|
20885
|
+
owners = /* @__PURE__ */ new Map();
|
|
20886
|
+
constructor(deps) {
|
|
20887
|
+
this.deps = deps;
|
|
20888
|
+
}
|
|
20889
|
+
/** SYNCHRONOUS: the evaluation that follows names these owners. */
|
|
20890
|
+
captureSet(sensorId, cameraIds, atMs) {
|
|
20891
|
+
const now = this.deps.now();
|
|
20892
|
+
this.prune(now);
|
|
20893
|
+
const setId = randomUUID();
|
|
20894
|
+
const stills = {
|
|
20895
|
+
sensorId,
|
|
20896
|
+
cameraIds: [...cameraIds],
|
|
20897
|
+
mosaicOwnerId: `${NC_LINKED_MOSAIC_PREFIX}${randomUUID()}`,
|
|
20898
|
+
cameraOwnerIds: cameraIds.map(() => `${NC_LINKED_CAMERA_PREFIX}${randomUUID()}`)
|
|
20899
|
+
};
|
|
20900
|
+
this.sets.set(setId, {
|
|
20901
|
+
stills,
|
|
20902
|
+
expiresAt: now + NC_STILL_SHELF_TTL_MS,
|
|
20903
|
+
atMs,
|
|
20904
|
+
tiles: /* @__PURE__ */ new Map(),
|
|
20905
|
+
mosaic: []
|
|
20906
|
+
});
|
|
20907
|
+
this.owners.set(stills.mosaicOwnerId, {
|
|
20908
|
+
setId,
|
|
20909
|
+
camera: "mosaic"
|
|
20910
|
+
});
|
|
20911
|
+
stills.cameraOwnerIds.forEach((ownerId, i) => {
|
|
20912
|
+
const cameraId = cameraIds[i];
|
|
20913
|
+
if (cameraId !== void 0) this.owners.set(ownerId, {
|
|
20914
|
+
setId,
|
|
20915
|
+
camera: cameraId
|
|
20916
|
+
});
|
|
20917
|
+
});
|
|
20918
|
+
this.run(setId, stills, atMs, now).catch((err) => {
|
|
20919
|
+
this.deps.logger.warn("sensor notification pictures failed — it ships text-only", {
|
|
20920
|
+
tags: { deviceId: sensorId },
|
|
20921
|
+
meta: { error: String(err) }
|
|
20922
|
+
});
|
|
20923
|
+
});
|
|
20924
|
+
return stills;
|
|
20925
|
+
}
|
|
20926
|
+
/** `undefined` = not an owner this shelf minted (or pruned); `[]` = nothing (yet). */
|
|
20927
|
+
get(ownerId) {
|
|
20928
|
+
const ref = this.owners.get(ownerId);
|
|
20929
|
+
if (ref === void 0) return void 0;
|
|
20930
|
+
const held = this.sets.get(ref.setId);
|
|
20931
|
+
if (held === void 0) return [];
|
|
20932
|
+
if (ref.camera === "mosaic") return held.mosaic;
|
|
20933
|
+
return held.tiles.get(ref.camera) ?? [];
|
|
20934
|
+
}
|
|
20935
|
+
prune(now) {
|
|
20936
|
+
for (const [setId, held] of this.sets) if (held.expiresAt <= now) this.sets.delete(setId);
|
|
20937
|
+
while (this.sets.size > MAX_SETS) {
|
|
20938
|
+
const oldest = this.sets.keys().next();
|
|
20939
|
+
if (oldest.done === true) break;
|
|
20940
|
+
this.sets.delete(oldest.value);
|
|
20941
|
+
}
|
|
20942
|
+
for (const [ownerId, ref] of this.owners) if (!this.sets.has(ref.setId)) this.owners.delete(ownerId);
|
|
20943
|
+
}
|
|
20944
|
+
clear() {
|
|
20945
|
+
this.sets.clear();
|
|
20946
|
+
this.owners.clear();
|
|
20947
|
+
}
|
|
20948
|
+
async run(setId, stills, atMs, startedAt) {
|
|
20949
|
+
const results = await Promise.all(stills.cameraIds.map((id) => this.captureOne(id, startedAt)));
|
|
20950
|
+
const judged = await this.judgeFreshness(stills.sensorId, results, atMs);
|
|
20951
|
+
for (const r of judged) this.logTile(stills.sensorId, r);
|
|
20952
|
+
const landed = judged.filter(isLandedTile);
|
|
20953
|
+
const composed = await this.compose(stills.sensorId, landed, atMs);
|
|
20954
|
+
const undrawable = landed.length - composed.drawn;
|
|
20955
|
+
this.deps.logger.info(composed.mosaic.length === 0 ? "sensor notification has no linked picture — it ships text-only" : "sensor notification pictures ready", {
|
|
20956
|
+
tags: { deviceId: stills.sensorId },
|
|
20957
|
+
meta: {
|
|
20958
|
+
cameras: stills.cameraIds.length,
|
|
20959
|
+
landed: composed.drawn,
|
|
20960
|
+
...undrawable > 0 ? { undrawable } : {},
|
|
20961
|
+
dropped: Object.fromEntries(judged.filter((r) => r.outcome !== "landed").map((r) => [String(r.cameraId), r.outcome])),
|
|
20962
|
+
tookMs: this.deps.now() - startedAt
|
|
20963
|
+
}
|
|
20964
|
+
});
|
|
20965
|
+
const held = this.sets.get(setId);
|
|
20966
|
+
if (held === void 0) return;
|
|
20967
|
+
this.sets.set(setId, {
|
|
20968
|
+
...held,
|
|
20969
|
+
tiles: new Map(composed.servable.map((r) => [r.cameraId, stillShelfMedia(r.jpeg, atMs)])),
|
|
20970
|
+
mosaic: composed.mosaic
|
|
20971
|
+
});
|
|
20972
|
+
}
|
|
20973
|
+
async captureOne(cameraId, startedAt) {
|
|
20974
|
+
const took = () => this.deps.now() - startedAt;
|
|
20975
|
+
if (this.deps.isOnline(cameraId) === false) return {
|
|
20976
|
+
cameraId,
|
|
20977
|
+
outcome: "excluded",
|
|
20978
|
+
reason: "offline",
|
|
20979
|
+
tookMs: took()
|
|
20980
|
+
};
|
|
20981
|
+
const [verdict, capture] = await Promise.all([this.wrapperVerdict(cameraId), this.captureStill(cameraId)]);
|
|
20982
|
+
if (verdict.reason !== null) return {
|
|
20983
|
+
cameraId,
|
|
20984
|
+
outcome: "excluded",
|
|
20985
|
+
reason: verdict.reason,
|
|
20986
|
+
tookMs: took()
|
|
20987
|
+
};
|
|
20988
|
+
return {
|
|
20989
|
+
cameraId,
|
|
20990
|
+
...capture,
|
|
20991
|
+
...verdict.unknown !== null ? { stateUnknown: verdict.unknown } : {},
|
|
20992
|
+
tookMs: took()
|
|
20993
|
+
};
|
|
20994
|
+
}
|
|
20995
|
+
async captureStill(cameraId) {
|
|
20996
|
+
try {
|
|
20997
|
+
const shot = await withTimeout$3(this.deps.getSnapshot(cameraId), SENSOR_LINKED_CAPTURE_TIMEOUT_MS, "snapshot cap");
|
|
20998
|
+
if (shot === null || shot.base64.length === 0) return { outcome: "empty" };
|
|
20999
|
+
const jpeg = await this.decodableStill(Buffer.from(shot.base64, "base64"));
|
|
21000
|
+
if (jpeg === null) return {
|
|
21001
|
+
outcome: "error",
|
|
21002
|
+
reason: "undecodable"
|
|
21003
|
+
};
|
|
21004
|
+
return {
|
|
21005
|
+
outcome: "landed",
|
|
21006
|
+
jpeg
|
|
21007
|
+
};
|
|
21008
|
+
} catch (err) {
|
|
21009
|
+
return {
|
|
21010
|
+
outcome: err instanceof LinkedReadTimeout ? "timeout" : "error",
|
|
21011
|
+
reason: String(err)
|
|
21012
|
+
};
|
|
21013
|
+
}
|
|
21014
|
+
}
|
|
21015
|
+
/**
|
|
21016
|
+
* The downscaled still, or null when the bytes are not a picture. A resize
|
|
21017
|
+
* that fails on bytes sharp can still READ keeps them raw (a worse picture,
|
|
21018
|
+
* never a missing one); bytes it cannot read never ship — a decode failure is
|
|
21019
|
+
* a fact about the bytes, not a failed read.
|
|
21020
|
+
*/
|
|
21021
|
+
async decodableStill(raw) {
|
|
21022
|
+
try {
|
|
21023
|
+
return await downscaleFullFrameJpeg(raw, 960, 540);
|
|
21024
|
+
} catch {
|
|
21025
|
+
try {
|
|
21026
|
+
await sharp(raw).metadata();
|
|
21027
|
+
return raw;
|
|
21028
|
+
} catch {
|
|
21029
|
+
return null;
|
|
21030
|
+
}
|
|
21031
|
+
}
|
|
21032
|
+
}
|
|
21033
|
+
/** A failed, slow or empty read is UNKNOWN and never excludes (D49); the
|
|
21034
|
+
* camera's own outcome line then carries `stateUnknown` with the cause. */
|
|
21035
|
+
async wrapperVerdict(cameraId) {
|
|
21036
|
+
const read = this.deps.getSnapshotState;
|
|
21037
|
+
if (read === void 0) return {
|
|
21038
|
+
reason: null,
|
|
21039
|
+
unknown: null
|
|
21040
|
+
};
|
|
21041
|
+
try {
|
|
21042
|
+
const state = await withTimeout$3(read(cameraId), SENSOR_LINKED_STATE_READ_TIMEOUT_MS, "snapshot state read");
|
|
21043
|
+
return state === void 0 ? {
|
|
21044
|
+
reason: null,
|
|
21045
|
+
unknown: "empty"
|
|
21046
|
+
} : {
|
|
21047
|
+
reason: state.reason,
|
|
21048
|
+
unknown: null
|
|
21049
|
+
};
|
|
21050
|
+
} catch (err) {
|
|
21051
|
+
return {
|
|
21052
|
+
reason: null,
|
|
21053
|
+
unknown: err instanceof LinkedReadTimeout ? "timeout" : "error"
|
|
21054
|
+
};
|
|
21055
|
+
}
|
|
21056
|
+
}
|
|
21057
|
+
async judgeFreshness(sensorId, results, atMs) {
|
|
21058
|
+
const read = this.deps.getFrameCapturedAt;
|
|
21059
|
+
const landedIds = results.filter((r) => r.outcome === "landed").map((r) => r.cameraId);
|
|
21060
|
+
if (read === void 0 || landedIds.length === 0) return results;
|
|
21061
|
+
let capturedAt;
|
|
21062
|
+
try {
|
|
21063
|
+
capturedAt = await withTimeout$3(read(landedIds), 400, "frame freshness read");
|
|
21064
|
+
} catch (err) {
|
|
21065
|
+
this.deps.logger.warn("linked pictures freshness unverified — keeping every tile", {
|
|
21066
|
+
tags: { deviceId: sensorId },
|
|
21067
|
+
meta: { error: String(err) }
|
|
21068
|
+
});
|
|
21069
|
+
return results;
|
|
21070
|
+
}
|
|
21071
|
+
return results.map((r) => {
|
|
21072
|
+
if (r.outcome !== "landed") return r;
|
|
21073
|
+
const at = capturedAt.get(r.cameraId);
|
|
21074
|
+
if (at === void 0 || at === null) return r;
|
|
21075
|
+
if (at >= atMs - 1e4) return r;
|
|
21076
|
+
return {
|
|
21077
|
+
cameraId: r.cameraId,
|
|
21078
|
+
outcome: "stale",
|
|
21079
|
+
reason: `frame ${String(atMs - at)} ms older than the event`,
|
|
21080
|
+
tookMs: r.tookMs
|
|
21081
|
+
};
|
|
21082
|
+
});
|
|
21083
|
+
}
|
|
21084
|
+
async compose(sensorId, landed, atMs) {
|
|
21085
|
+
const first = landed[0];
|
|
21086
|
+
if (first === void 0) return {
|
|
21087
|
+
mosaic: [],
|
|
21088
|
+
servable: [],
|
|
21089
|
+
drawn: 0
|
|
21090
|
+
};
|
|
21091
|
+
if (landed.length === 1) return {
|
|
21092
|
+
mosaic: stillShelfMedia(first.jpeg, atMs),
|
|
21093
|
+
servable: landed,
|
|
21094
|
+
drawn: 1
|
|
21095
|
+
};
|
|
21096
|
+
try {
|
|
21097
|
+
const { jpeg, droppedTiles } = await renderMosaic({
|
|
21098
|
+
purpose: "sensor",
|
|
21099
|
+
tiles: landed.map((r) => ({
|
|
21100
|
+
jpeg: r.jpeg,
|
|
21101
|
+
deviceId: r.cameraId,
|
|
21102
|
+
sourceId: String(r.cameraId)
|
|
21103
|
+
}))
|
|
21104
|
+
}, this.deps.logger);
|
|
21105
|
+
return {
|
|
21106
|
+
mosaic: stillShelfMedia(jpeg, atMs),
|
|
21107
|
+
servable: landed,
|
|
21108
|
+
drawn: landed.length - droppedTiles
|
|
21109
|
+
};
|
|
21110
|
+
} catch (err) {
|
|
21111
|
+
this.deps.logger.warn("sensor mosaic could not be composed — shipping the first camera", {
|
|
21112
|
+
tags: { deviceId: sensorId },
|
|
21113
|
+
meta: {
|
|
21114
|
+
error: String(err),
|
|
21115
|
+
offered: landed.length,
|
|
21116
|
+
shipped: first.cameraId
|
|
21117
|
+
}
|
|
21118
|
+
});
|
|
21119
|
+
return {
|
|
21120
|
+
mosaic: stillShelfMedia(first.jpeg, atMs),
|
|
21121
|
+
servable: landed,
|
|
21122
|
+
drawn: 1
|
|
21123
|
+
};
|
|
21124
|
+
}
|
|
21125
|
+
}
|
|
21126
|
+
logTile(sensorId, r) {
|
|
21127
|
+
const extras = {
|
|
21128
|
+
tags: { deviceId: r.cameraId },
|
|
21129
|
+
meta: {
|
|
21130
|
+
sensorId,
|
|
21131
|
+
outcome: r.outcome,
|
|
21132
|
+
tookMs: r.tookMs,
|
|
21133
|
+
...r.reason !== void 0 ? { reason: r.reason } : {},
|
|
21134
|
+
...r.stateUnknown !== void 0 ? { stateUnknown: r.stateUnknown } : {}
|
|
21135
|
+
}
|
|
21136
|
+
};
|
|
21137
|
+
if (r.outcome === "excluded") this.deps.logger.info("linked camera excluded from the sensor notification", extras);
|
|
21138
|
+
else if (r.outcome === "landed" && r.stateUnknown !== void 0) this.deps.logger.info("linked camera photographed for the sensor notification — state unknown, tile kept", extras);
|
|
21139
|
+
else if (r.outcome === "landed") this.deps.logger.debug("linked camera photographed for the sensor notification", extras);
|
|
21140
|
+
else this.deps.logger.warn("linked camera gave no usable picture for the sensor notification", extras);
|
|
21141
|
+
}
|
|
21142
|
+
};
|
|
21143
|
+
/** Reject with {@link LinkedReadTimeout} if `promise` does not settle within `ms`. */
|
|
21144
|
+
function withTimeout$3(promise, ms, what) {
|
|
21145
|
+
return new Promise((resolve, reject) => {
|
|
21146
|
+
const timer = setTimeout(() => reject(new LinkedReadTimeout(what, ms)), ms);
|
|
21147
|
+
promise.then((value) => {
|
|
21148
|
+
clearTimeout(timer);
|
|
21149
|
+
resolve(value);
|
|
21150
|
+
}, (err) => {
|
|
21151
|
+
clearTimeout(timer);
|
|
21152
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
21153
|
+
});
|
|
21154
|
+
});
|
|
21155
|
+
}
|
|
21156
|
+
//#endregion
|
|
20112
21157
|
//#region src/notification-center/liveness-ledger.ts
|
|
20113
21158
|
/**
|
|
20114
21159
|
* @durable class=ledger owner=notification-center
|
|
@@ -22948,279 +23993,6 @@ var NcSnoozeStore = class {
|
|
|
22948
23993
|
return parsed.success ? parsed.data : null;
|
|
22949
23994
|
}
|
|
22950
23995
|
};
|
|
22951
|
-
/** JPEG quality for the downscaled full frame — matches the crop path. */
|
|
22952
|
-
var FULL_FRAME_QUALITY = 80;
|
|
22953
|
-
/**
|
|
22954
|
-
* Downscale an already-encoded JPEG full frame to FIT WITHIN
|
|
22955
|
-
* {@link FULL_FRAME_MAX_WIDTH}×{@link FULL_FRAME_MAX_HEIGHT}, preserving aspect
|
|
22956
|
-
* ratio (`fit: 'inside'`) and never enlarging a source already smaller than the
|
|
22957
|
-
* box. Re-encodes as JPEG. Used before persisting a synthetic sensor/control
|
|
22958
|
-
* track's whole-scene snapshot so a raw native-resolution frame (a 4K bedroom
|
|
22959
|
-
* at night) is never stored or served — the privacy fix moved to CAPTURE time.
|
|
22960
|
-
*/
|
|
22961
|
-
async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
|
|
22962
|
-
return sharp(Buffer.from(jpeg)).resize(maxWidth, maxHeight, {
|
|
22963
|
-
fit: "inside",
|
|
22964
|
-
withoutEnlargement: true
|
|
22965
|
-
}).jpeg({ quality: FULL_FRAME_QUALITY }).toBuffer();
|
|
22966
|
-
}
|
|
22967
|
-
//#endregion
|
|
22968
|
-
//#region src/notification-center/still-shelf.ts
|
|
22969
|
-
/**
|
|
22970
|
-
* The still shelf — the PHOTOGRAPH a notification carries when nothing it can
|
|
22971
|
-
* name owns a frame.
|
|
22972
|
-
*
|
|
22973
|
-
* ## Why a trigger needs one at all
|
|
22974
|
-
*
|
|
22975
|
-
* The attachment ladder resolves media by OWNER, and most triggers have one: an
|
|
22976
|
-
* object or package event owns its crops, a closed track owns its best shot, a
|
|
22977
|
-
* doorbell press owns the marker track the same press projected
|
|
22978
|
-
* (`sensor-marker-projector.ts`), an occupancy edge names one of the objects it
|
|
22979
|
-
* counted (`chooseOccupancyMediaOwner`). Two triggers own nothing, and for the
|
|
22980
|
-
* same reason in both cases — the subject is an ABSENCE:
|
|
22981
|
-
*
|
|
22982
|
-
* - an **audio** match: nothing was boxed, nothing was tracked, and —
|
|
22983
|
-
* deliberately — nothing is persisted at all. A confirmed window is a claim
|
|
22984
|
-
* about sound that has already stopped, and `event-intake.ts` states why
|
|
22985
|
-
* replaying it later would be wrong.
|
|
22986
|
-
* - an **occupancy** edge whose scope is EMPTY — "posto libero". The vehicle
|
|
22987
|
-
* whose departure IS the news has left, so `chooseOccupancyMediaOwner` names
|
|
22988
|
-
* nobody and the ladder logs `no still could be resolved … owners=[]`.
|
|
22989
|
-
*
|
|
22990
|
-
* So the only honest picture is a PHOTOGRAPH of the camera taken at the moment
|
|
22991
|
-
* of the trigger. Not of the sound, not of the object that left — of what the
|
|
22992
|
-
* camera can see now that it happened. For a freed parking space that is
|
|
22993
|
-
* exactly the answer the operator wants: the space, empty.
|
|
22994
|
-
*
|
|
22995
|
-
* **One shelf, not two.** The mechanism is identical down to the reuse window,
|
|
22996
|
-
* and the only thing that differs between the two triggers is the GATE deciding
|
|
22997
|
-
* that a photograph is owed at all — which belongs at the trigger site, where
|
|
22998
|
-
* the rules are, and not here. A twin module would be a second derivation of
|
|
22999
|
-
* one thing. The trigger rides along as {@link NcStillTrigger} only so the logs
|
|
23000
|
-
* can say which absence they are about.
|
|
23001
|
-
*
|
|
23002
|
-
* ## Three properties, and each one is a decision
|
|
23003
|
-
*
|
|
23004
|
-
* **It is not a record.** The bytes live here, in RAM, under an owner id and a
|
|
23005
|
-
* TTL that covers the outbox's whole retry horizon — and nowhere else. The
|
|
23006
|
-
* alternative was the doorbell's: materialise a synthetic marker track through
|
|
23007
|
-
* `SyntheticTrackMaterializer` and let the notification name it. That would put
|
|
23008
|
-
* a durable Track on the camera's timeline for every confirmed window and every
|
|
23009
|
-
* emptied zone, feeding the digest's `listTracks`, retention, and the audio-
|
|
23010
|
-
* marker feature's own operator ceilings (`audio-marker-projector.ts` exists
|
|
23011
|
-
* precisely to bound how many audio markers a camera may emit). A notification
|
|
23012
|
-
* must not manufacture timeline history as a side effect of wanting a picture.
|
|
23013
|
-
*
|
|
23014
|
-
* **The capture STARTS immediately and is never awaited.** The subject is
|
|
23015
|
-
* transient — a scream is over before a snapshot round-trip completes, and a
|
|
23016
|
-
* freed space is about to be taken by the next car — so the fetch is kicked off
|
|
23017
|
-
* at the trigger, before the rule evaluation runs, and the owner id is minted
|
|
23018
|
-
* synchronously so the outbox row can name it. The bytes land while the row
|
|
23019
|
-
* waits in the queue, and the dispatcher's existing bounded still-wait (or the
|
|
23020
|
-
* pause its own footage render already costs) picks them up. A camera that
|
|
23021
|
-
* never answers costs the picture and never the notification.
|
|
23022
|
-
*
|
|
23023
|
-
* **Two triggers seconds apart share ONE capture.** A barking dog confirms
|
|
23024
|
-
* repeatedly and a label-mode rule has no re-arm timer at all (D157) — the
|
|
23025
|
-
* rule's cooldown is its only brake, and the cooldown is applied AFTER this.
|
|
23026
|
-
* Without a reuse window this would photograph a camera at whatever rate the
|
|
23027
|
-
* sound happens to occur. Each trigger still gets its OWN owner id, so two
|
|
23028
|
-
* outbox rows are never mistaken for one subject; they merely point at the same
|
|
23029
|
-
* frame, which is the truth — the scene did not change in ten seconds. The
|
|
23030
|
-
* window is per CAMERA and trigger-agnostic for the same reason: a sound and an
|
|
23031
|
-
* emptied zone ten seconds apart are two claims about one scene.
|
|
23032
|
-
*
|
|
23033
|
-
* Nothing here is silent: a capture that lands and a camera that refuses each
|
|
23034
|
-
* emit one line carrying `tags: { deviceId }`, because "why did 617 get a photo
|
|
23035
|
-
* and 615 not" is the only form that question is ever asked in.
|
|
23036
|
-
*/
|
|
23037
|
-
/**
|
|
23038
|
-
* The owner-id namespace. It is what routes a lookup here instead of to the
|
|
23039
|
-
* media store, and it is the reason the dispatcher needs no audio or occupancy
|
|
23040
|
-
* branch — `getMediaForOwner` answers for all of them under one signature.
|
|
23041
|
-
*/
|
|
23042
|
-
var NC_STILL_SHELF_PREFIX = "nc-still:";
|
|
23043
|
-
/** True for an owner id this shelf minted. */
|
|
23044
|
-
function isStillShelfId(id) {
|
|
23045
|
-
return id.startsWith(NC_STILL_SHELF_PREFIX);
|
|
23046
|
-
}
|
|
23047
|
-
/**
|
|
23048
|
-
* How long the bytes are held.
|
|
23049
|
-
*
|
|
23050
|
-
* The outbox retries 8 times with a 5 s → 300 s backoff, which tops out around
|
|
23051
|
-
* ten minutes; fifteen covers that with room for a slow drain. Past it the row
|
|
23052
|
-
* ships text-only, which is the correct degradation for a photograph of a scene
|
|
23053
|
-
* that is a quarter of an hour stale anyway.
|
|
23054
|
-
*/
|
|
23055
|
-
var NC_STILL_SHELF_TTL_MS = 15 * 6e4;
|
|
23056
|
-
/** A hanging snapshot cap must not pin a capture slot forever. */
|
|
23057
|
-
var SNAPSHOT_TIMEOUT_MS = 8e3;
|
|
23058
|
-
/**
|
|
23059
|
-
* Hard bound on held captures. Reached only if every camera on the hub triggers
|
|
23060
|
-
* one inside a single TTL; the oldest is dropped first, which costs a
|
|
23061
|
-
* fifteen-minute-old picture nobody is waiting for.
|
|
23062
|
-
*/
|
|
23063
|
-
var MAX_CAPTURES = 64;
|
|
23064
|
-
var NcStillShelf = class {
|
|
23065
|
-
deps;
|
|
23066
|
-
/** captureId → the photograph. */
|
|
23067
|
-
captures = /* @__PURE__ */ new Map();
|
|
23068
|
-
/** ownerId → captureId. Several owners may name one capture (the reuse window). */
|
|
23069
|
-
owners = /* @__PURE__ */ new Map();
|
|
23070
|
-
/** deviceId → its newest capture, for the reuse window. */
|
|
23071
|
-
newest = /* @__PURE__ */ new Map();
|
|
23072
|
-
constructor(deps) {
|
|
23073
|
-
this.deps = deps;
|
|
23074
|
-
}
|
|
23075
|
-
/**
|
|
23076
|
-
* Photograph `deviceId` for a trigger at `atMs`, and return the owner id the
|
|
23077
|
-
* subject should name. SYNCHRONOUS by contract: the caller is on the trigger
|
|
23078
|
-
* path and the outbox row is built from what this returns.
|
|
23079
|
-
*/
|
|
23080
|
-
capture(deviceId, atMs, trigger) {
|
|
23081
|
-
const now = this.deps.now();
|
|
23082
|
-
this.prune(now);
|
|
23083
|
-
const ownerId = `${NC_STILL_SHELF_PREFIX}${randomUUID()}`;
|
|
23084
|
-
const recent = this.newest.get(deviceId);
|
|
23085
|
-
if (recent !== void 0 && now - recent.at < 1e4 && this.captures.has(recent.captureId)) {
|
|
23086
|
-
this.owners.set(ownerId, recent.captureId);
|
|
23087
|
-
return ownerId;
|
|
23088
|
-
}
|
|
23089
|
-
const captureId = randomUUID();
|
|
23090
|
-
this.captures.set(captureId, {
|
|
23091
|
-
deviceId,
|
|
23092
|
-
trigger,
|
|
23093
|
-
startedAt: now,
|
|
23094
|
-
expiresAt: now + NC_STILL_SHELF_TTL_MS,
|
|
23095
|
-
files: []
|
|
23096
|
-
});
|
|
23097
|
-
this.newest.set(deviceId, {
|
|
23098
|
-
captureId,
|
|
23099
|
-
at: now
|
|
23100
|
-
});
|
|
23101
|
-
this.owners.set(ownerId, captureId);
|
|
23102
|
-
this.fetch(captureId, deviceId, atMs, trigger);
|
|
23103
|
-
return ownerId;
|
|
23104
|
-
}
|
|
23105
|
-
/**
|
|
23106
|
-
* The media an owner id holds, or `undefined` when this shelf never minted
|
|
23107
|
-
* it. An EMPTY array is a different answer: the capture exists and has not
|
|
23108
|
-
* landed (or never will), which is exactly the case the dispatcher's bounded
|
|
23109
|
-
* wait and its `no still could be resolved` line are for.
|
|
23110
|
-
*/
|
|
23111
|
-
get(ownerId) {
|
|
23112
|
-
const captureId = this.owners.get(ownerId);
|
|
23113
|
-
if (captureId === void 0) return void 0;
|
|
23114
|
-
return this.captures.get(captureId)?.files ?? [];
|
|
23115
|
-
}
|
|
23116
|
-
/** Drop expired captures and the owners that named them. */
|
|
23117
|
-
prune(now) {
|
|
23118
|
-
for (const [captureId, held] of this.captures) {
|
|
23119
|
-
if (held.expiresAt > now) continue;
|
|
23120
|
-
this.captures.delete(captureId);
|
|
23121
|
-
if (this.newest.get(held.deviceId)?.captureId === captureId) this.newest.delete(held.deviceId);
|
|
23122
|
-
}
|
|
23123
|
-
while (this.captures.size > MAX_CAPTURES) {
|
|
23124
|
-
const oldest = this.captures.keys().next();
|
|
23125
|
-
if (oldest.done === true) break;
|
|
23126
|
-
const held = this.captures.get(oldest.value);
|
|
23127
|
-
this.captures.delete(oldest.value);
|
|
23128
|
-
if (held !== void 0 && this.newest.get(held.deviceId)?.captureId === oldest.value) this.newest.delete(held.deviceId);
|
|
23129
|
-
}
|
|
23130
|
-
for (const [ownerId, captureId] of this.owners) if (!this.captures.has(captureId)) this.owners.delete(ownerId);
|
|
23131
|
-
}
|
|
23132
|
-
/** Drop everything (shutdown). */
|
|
23133
|
-
clear() {
|
|
23134
|
-
this.captures.clear();
|
|
23135
|
-
this.owners.clear();
|
|
23136
|
-
this.newest.clear();
|
|
23137
|
-
}
|
|
23138
|
-
async fetch(captureId, deviceId, atMs, trigger) {
|
|
23139
|
-
try {
|
|
23140
|
-
const shot = await withTimeout$3(this.deps.getSnapshot(deviceId), SNAPSHOT_TIMEOUT_MS);
|
|
23141
|
-
if (shot === null || shot.base64.length === 0) {
|
|
23142
|
-
this.reportMiss(deviceId, trigger, "the camera returned no snapshot");
|
|
23143
|
-
return;
|
|
23144
|
-
}
|
|
23145
|
-
const raw = Buffer.from(shot.base64, "base64");
|
|
23146
|
-
let data = raw;
|
|
23147
|
-
try {
|
|
23148
|
-
data = await downscaleFullFrameJpeg(raw, 960, 540);
|
|
23149
|
-
} catch {}
|
|
23150
|
-
const held = this.captures.get(captureId);
|
|
23151
|
-
if (held === void 0) return;
|
|
23152
|
-
this.captures.set(captureId, {
|
|
23153
|
-
...held,
|
|
23154
|
-
files: stillShelfMedia(data, atMs)
|
|
23155
|
-
});
|
|
23156
|
-
this.deps.logger.info(`${trigger} still captured`, {
|
|
23157
|
-
tags: { deviceId },
|
|
23158
|
-
meta: {
|
|
23159
|
-
trigger,
|
|
23160
|
-
bytes: data.byteLength,
|
|
23161
|
-
tookMs: this.deps.now() - held.startedAt
|
|
23162
|
-
}
|
|
23163
|
-
});
|
|
23164
|
-
} catch (err) {
|
|
23165
|
-
this.reportMiss(deviceId, trigger, err instanceof Error ? err.message : String(err));
|
|
23166
|
-
}
|
|
23167
|
-
}
|
|
23168
|
-
/**
|
|
23169
|
-
* A branch that drops work says so. This one costs the operator the picture
|
|
23170
|
-
* on a notification they DID receive, so it is a warn and it carries the
|
|
23171
|
-
* camera — the only key the question is ever asked with.
|
|
23172
|
-
*/
|
|
23173
|
-
reportMiss(deviceId, trigger, reason) {
|
|
23174
|
-
this.deps.logger.warn(`the camera did not answer the ${trigger} still — this notification ships text-only`, {
|
|
23175
|
-
tags: { deviceId },
|
|
23176
|
-
meta: {
|
|
23177
|
-
trigger,
|
|
23178
|
-
reason
|
|
23179
|
-
}
|
|
23180
|
-
});
|
|
23181
|
-
}
|
|
23182
|
-
};
|
|
23183
|
-
/**
|
|
23184
|
-
* The photograph, in the three kinds the CLEAN-SCENE ladders ask for.
|
|
23185
|
-
*
|
|
23186
|
-
* `keyFrameSmall` leads because it is the first rung of both `attach: 'best'`
|
|
23187
|
-
* and `attach: 'keyFrame'` on a track owner; `keyFrame` and `fullFrame` answer
|
|
23188
|
-
* `frame: 'full'` and `frame: 'boxed'`'s honest degrade. There is deliberately
|
|
23189
|
-
* no `crop` / `thumbnail`: nothing was boxed, so `frame: 'cropped'` resolves
|
|
23190
|
-
* NOTHING and the notification ships text-only with the ordinary line saying
|
|
23191
|
-
* so. Inventing a centre crop would answer a different question from the one
|
|
23192
|
-
* the operator asked.
|
|
23193
|
-
*/
|
|
23194
|
-
function stillShelfMedia(data, timestamp) {
|
|
23195
|
-
const base64 = data.toString("base64");
|
|
23196
|
-
const file = (kind) => ({
|
|
23197
|
-
mediaId: nextUnstoredMediaId(),
|
|
23198
|
-
kind,
|
|
23199
|
-
base64,
|
|
23200
|
-
sizeBytes: data.byteLength,
|
|
23201
|
-
timestamp
|
|
23202
|
-
});
|
|
23203
|
-
return [
|
|
23204
|
-
file("keyFrameSmall"),
|
|
23205
|
-
file("keyFrame"),
|
|
23206
|
-
file("fullFrame")
|
|
23207
|
-
];
|
|
23208
|
-
}
|
|
23209
|
-
/** Reject if `promise` does not settle within `ms`. */
|
|
23210
|
-
function withTimeout$3(promise, ms) {
|
|
23211
|
-
return new Promise((resolve, reject) => {
|
|
23212
|
-
const timer = setTimeout(() => {
|
|
23213
|
-
reject(/* @__PURE__ */ new Error(`snapshot cap timed out after ${String(ms)}ms`));
|
|
23214
|
-
}, ms);
|
|
23215
|
-
promise.then((value) => {
|
|
23216
|
-
clearTimeout(timer);
|
|
23217
|
-
resolve(value);
|
|
23218
|
-
}, (err) => {
|
|
23219
|
-
clearTimeout(timer);
|
|
23220
|
-
reject(err instanceof Error ? err : new Error(String(err)));
|
|
23221
|
-
});
|
|
23222
|
-
});
|
|
23223
|
-
}
|
|
23224
23996
|
//#endregion
|
|
23225
23997
|
//#region src/notification-center/summary/summary-ai.ts
|
|
23226
23998
|
/**
|
|
@@ -23712,293 +24484,6 @@ function findNextWindowOpen(input) {
|
|
|
23712
24484
|
endMs: null
|
|
23713
24485
|
};
|
|
23714
24486
|
}
|
|
23715
|
-
/** The canvas behind a tile whose aspect does not fill its cell. */
|
|
23716
|
-
var MOSAIC_BACKGROUND = {
|
|
23717
|
-
r: 17,
|
|
23718
|
-
g: 17,
|
|
23719
|
-
b: 17
|
|
23720
|
-
};
|
|
23721
|
-
/** Tile aspect — cameras are 16:9 and a square cell would letterbox every one. */
|
|
23722
|
-
var TILE_ASPECT = 9 / 16;
|
|
23723
|
-
/** Height of the per-tile label strip, as a fraction of the tile height. */
|
|
23724
|
-
var LABEL_BAND_FRACTION = .14;
|
|
23725
|
-
var LABEL_MIN_PX = 11;
|
|
23726
|
-
/** Height of the mosaic's own title band, as a fraction of a tile's height. */
|
|
23727
|
-
var TITLE_BAND_FRACTION = .22;
|
|
23728
|
-
var TITLE_MIN_PX = 16;
|
|
23729
|
-
/**
|
|
23730
|
-
* Average glyph width as a fraction of the font size, for bold sans-serif.
|
|
23731
|
-
*
|
|
23732
|
-
* An ESTIMATE, and it has to be: librsvg lays the text out long after this
|
|
23733
|
-
* string is written, so there are no metrics to measure against. The same
|
|
23734
|
-
* number and the same reasoning as `preview-frame.ts#fitTitle` — deliberately
|
|
23735
|
-
* GENEROUS, because a label that shrank slightly more than it had to is
|
|
23736
|
-
* invisible and one that overflowed runs into the tile beside it. The first
|
|
23737
|
-
* live mosaic did exactly that: `Videocamera cortile · 07:49 · animal` was cut
|
|
23738
|
-
* to `…· anima` at the tile edge, mid-word.
|
|
23739
|
-
*/
|
|
23740
|
-
var GLYPH_ADVANCE = .56;
|
|
23741
|
-
/** Fraction of the band a label may occupy, leaving the padding either side. */
|
|
23742
|
-
var LABEL_USABLE_FRACTION = .94;
|
|
23743
|
-
/** `<`, `>`, `&`, `'`, `"` in a camera name or an operator caption would each
|
|
23744
|
-
* turn the overlay into a document librsvg refuses. */
|
|
23745
|
-
function escapeXml$1(s) {
|
|
23746
|
-
return s.replace(/[<>&'"]/g, (ch) => {
|
|
23747
|
-
switch (ch) {
|
|
23748
|
-
case "<": return "<";
|
|
23749
|
-
case ">": return ">";
|
|
23750
|
-
case "&": return "&";
|
|
23751
|
-
case "'": return "'";
|
|
23752
|
-
default: return """;
|
|
23753
|
-
}
|
|
23754
|
-
});
|
|
23755
|
-
}
|
|
23756
|
-
/**
|
|
23757
|
-
* The single decision about drawn text — and the home of the DEFAULT.
|
|
23758
|
-
*
|
|
23759
|
-
* `undefined` means `none`, not `all`: a rule stored before this option existed
|
|
23760
|
-
* has no `captions` key, and the operator's request is that such a rule deliver
|
|
23761
|
-
* pictures. The schema materialises the same default on read
|
|
23762
|
-
* (`NcSummaryRuleInputSchema`), so this arm only fires for a caller that never
|
|
23763
|
-
* had a rule — the test bridge and the spec. Both agreeing is the point.
|
|
23764
|
-
*/
|
|
23765
|
-
function mosaicTextPlan(captions) {
|
|
23766
|
-
if (captions === "all") return {
|
|
23767
|
-
tileLabels: true,
|
|
23768
|
-
titleBand: true
|
|
23769
|
-
};
|
|
23770
|
-
if (captions === "tiles") return {
|
|
23771
|
-
tileLabels: true,
|
|
23772
|
-
titleBand: false
|
|
23773
|
-
};
|
|
23774
|
-
return {
|
|
23775
|
-
tileLabels: false,
|
|
23776
|
-
titleBand: false
|
|
23777
|
-
};
|
|
23778
|
-
}
|
|
23779
|
-
/** Grid shape for `n` tiles: as square as possible, rows never exceeding cols
|
|
23780
|
-
* by more than one — a 3×3 for nine, a 3×2 for five, never a 1×9 strip.
|
|
23781
|
-
*
|
|
23782
|
-
* `rows` is the load-bearing half — it is how many rows {@link mosaicRowCounts}
|
|
23783
|
-
* distributes into. `cols` describes the WIDEST row, and is no longer a cell
|
|
23784
|
-
* count: see {@link mosaicRowCounts} for why a `cols × rows` grid was the bug. */
|
|
23785
|
-
function mosaicGrid(n) {
|
|
23786
|
-
const count = Math.max(1, Math.floor(n));
|
|
23787
|
-
const cols = Math.ceil(Math.sqrt(count));
|
|
23788
|
-
return {
|
|
23789
|
-
cols,
|
|
23790
|
-
rows: Math.ceil(count / cols)
|
|
23791
|
-
};
|
|
23792
|
-
}
|
|
23793
|
-
/**
|
|
23794
|
-
* How many tiles each ROW carries, top to bottom — and the reason the mosaic
|
|
23795
|
-
* can no longer have a hole in it.
|
|
23796
|
-
*
|
|
23797
|
-
* The layout used to be a `cols × rows` GRID, and a grid holds more cells than
|
|
23798
|
-
* tiles for every count that is not a perfect fit: 3 into a 2×2, 5 into a 3×2,
|
|
23799
|
-
* 7 and 8 into a 3×3. The composite only ever wrote the tiles, so the spare
|
|
23800
|
-
* cells stayed the canvas — `MOSAIC_BACKGROUND`, rgb 17 — and the operator's
|
|
23801
|
-
* three-tile digest was a quarter black. Measured on the shipped renderer:
|
|
23802
|
-
* 24.8% of a three-tile mosaic was background. His words: *"se ci sono 3
|
|
23803
|
-
* elementi non dovrebbe renderizzare buchi neri"*.
|
|
23804
|
-
*
|
|
23805
|
-
* Counting ROWS instead removes the failure by construction rather than by
|
|
23806
|
-
* patching it. Every row spans the full width and divides it among the tiles it
|
|
23807
|
-
* actually has, so a row is full whatever the remainder and there is no such
|
|
23808
|
-
* thing as a spare cell. The shapes fall out of it, and they are the ones asked
|
|
23809
|
-
* for: 1 ⇒ full frame, 2 ⇒ side by side, 3 ⇒ one big over two small, 4 ⇒ 2×2.
|
|
23810
|
-
*
|
|
23811
|
-
* **The fewer-tiled row comes FIRST.** Tiles arrive in the selection's order,
|
|
23812
|
-
* so row 0 is the head of the ranking; putting the remainder at the top would
|
|
23813
|
-
* make the WORST tile the big one.
|
|
23814
|
-
*
|
|
23815
|
-
* Every cell keeps {@link TILE_ASPECT}, so rows differ in height — which is
|
|
23816
|
-
* also what stops the letterbox: a full-width hero tile is 16:9 exactly, not a
|
|
23817
|
-
* 16:9 picture centred in a double-height cell with bars over and under it.
|
|
23818
|
-
*/
|
|
23819
|
-
function mosaicRowCounts(n) {
|
|
23820
|
-
const count = Math.max(1, Math.floor(n));
|
|
23821
|
-
const { rows } = mosaicGrid(count);
|
|
23822
|
-
const base = Math.floor(count / rows);
|
|
23823
|
-
const remainder = count % rows;
|
|
23824
|
-
const counts = [];
|
|
23825
|
-
for (let row = 0; row < rows; row += 1) counts.push(row < rows - remainder ? base : base + 1);
|
|
23826
|
-
return counts;
|
|
23827
|
-
}
|
|
23828
|
-
/**
|
|
23829
|
-
* Where every tile lands. Pure — no bytes, no sharp, no clock.
|
|
23830
|
-
*
|
|
23831
|
-
* Exported because the rectangles are DATA the artefact should carry: a viewer
|
|
23832
|
-
* that wants a tappable mosaic reads these rather than re-deriving a layout
|
|
23833
|
-
* that could drift from the one actually rendered.
|
|
23834
|
-
*
|
|
23835
|
-
* The rectangles COVER the picture area exactly — that is the property the
|
|
23836
|
-
* spec asserts, and it is the one the old grid broke.
|
|
23837
|
-
*/
|
|
23838
|
-
function mosaicLayout(input) {
|
|
23839
|
-
const rowCounts = mosaicRowCounts(input.tileCount);
|
|
23840
|
-
const width = Math.max(1, Math.floor(input.maxWidth ?? 1920));
|
|
23841
|
-
const firstRowCount = rowCounts[0] ?? 1;
|
|
23842
|
-
const tileWidth = Math.floor(width / firstRowCount);
|
|
23843
|
-
const tileHeight = Math.round(tileWidth * TILE_ASPECT);
|
|
23844
|
-
const titleHeight = input.withTitle === true ? Math.round(tileHeight * TITLE_BAND_FRACTION) : 0;
|
|
23845
|
-
const tiles = [];
|
|
23846
|
-
let y = titleHeight;
|
|
23847
|
-
let index = 0;
|
|
23848
|
-
for (const count of rowCounts) {
|
|
23849
|
-
const nominalWidth = Math.floor(width / count);
|
|
23850
|
-
const rowHeight = Math.round(nominalWidth * TILE_ASPECT);
|
|
23851
|
-
let x = 0;
|
|
23852
|
-
for (let inRow = 0; inRow < count; inRow += 1) {
|
|
23853
|
-
const w = inRow === count - 1 ? width - x : nominalWidth;
|
|
23854
|
-
tiles.push({
|
|
23855
|
-
index,
|
|
23856
|
-
x,
|
|
23857
|
-
y,
|
|
23858
|
-
w,
|
|
23859
|
-
h: rowHeight
|
|
23860
|
-
});
|
|
23861
|
-
x += w;
|
|
23862
|
-
index += 1;
|
|
23863
|
-
}
|
|
23864
|
-
y += rowHeight;
|
|
23865
|
-
}
|
|
23866
|
-
return {
|
|
23867
|
-
width,
|
|
23868
|
-
height: y,
|
|
23869
|
-
cols: Math.max(...rowCounts),
|
|
23870
|
-
rows: rowCounts.length,
|
|
23871
|
-
rowCounts,
|
|
23872
|
-
tileWidth,
|
|
23873
|
-
tileHeight,
|
|
23874
|
-
titleHeight,
|
|
23875
|
-
tiles
|
|
23876
|
-
};
|
|
23877
|
-
}
|
|
23878
|
-
/**
|
|
23879
|
-
* Shrink, then ELIDE, until the line fits `usablePx`.
|
|
23880
|
-
*
|
|
23881
|
-
* Shrinking is tried first because a slightly smaller label still says
|
|
23882
|
-
* everything; only past `minPx` is the tail cut, and then with an ellipsis so
|
|
23883
|
-
* the reader can SEE that something was cut. The failure this replaces was
|
|
23884
|
-
* silent: SVG simply clips at the viewport, so an overlong label lost its last
|
|
23885
|
-
* word mid-glyph and looked like a rendering bug rather than a truncation.
|
|
23886
|
-
*
|
|
23887
|
-
* Pure and exported so the arithmetic is testable without rendering a pixel.
|
|
23888
|
-
*/
|
|
23889
|
-
function fitLine(text, usablePx, basePx, minPx) {
|
|
23890
|
-
const flat = text.trim().replace(/\s+/g, " ");
|
|
23891
|
-
const fits = (s, fontPx) => s.length * fontPx * GLYPH_ADVANCE <= usablePx;
|
|
23892
|
-
let fontPx = Math.max(minPx, Math.round(basePx));
|
|
23893
|
-
while (fontPx > minPx && !fits(flat, fontPx)) fontPx = Math.max(minPx, fontPx - 1);
|
|
23894
|
-
if (fits(flat, fontPx)) return {
|
|
23895
|
-
text: flat,
|
|
23896
|
-
fontPx
|
|
23897
|
-
};
|
|
23898
|
-
const budget = Math.max(1, Math.floor(usablePx / (fontPx * GLYPH_ADVANCE)));
|
|
23899
|
-
return {
|
|
23900
|
-
text: `${flat.slice(0, Math.max(1, budget - 1)).trimEnd()}…`,
|
|
23901
|
-
fontPx
|
|
23902
|
-
};
|
|
23903
|
-
}
|
|
23904
|
-
/** The label strip drawn across the bottom of one tile. */
|
|
23905
|
-
function tileLabelSvg(width, height, label) {
|
|
23906
|
-
const bandHeight = Math.max(18, Math.round(height * LABEL_BAND_FRACTION));
|
|
23907
|
-
const basePx = Math.round(bandHeight * .62);
|
|
23908
|
-
const padding = Math.round(basePx * .6);
|
|
23909
|
-
const fitted = fitLine(label, width * LABEL_USABLE_FRACTION - padding, basePx, LABEL_MIN_PX);
|
|
23910
|
-
const baseline = height - Math.round((bandHeight - fitted.fontPx) / 2) - Math.round(fitted.fontPx * .18);
|
|
23911
|
-
return Buffer.from(`<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
|
|
23912
|
-
<rect x="0" y="${height - bandHeight}" width="${width}" height="${bandHeight}" fill="black" fill-opacity="0.55"/>
|
|
23913
|
-
<text x="${padding}" y="${baseline}" font-size="${fitted.fontPx}" font-family="sans-serif" font-weight="bold" fill="white">${escapeXml$1(fitted.text)}</text>
|
|
23914
|
-
</svg>`);
|
|
23915
|
-
}
|
|
23916
|
-
/** The mosaic's own title band. */
|
|
23917
|
-
function titleSvg(width, height, caption) {
|
|
23918
|
-
const fitted = fitLine(caption, width * LABEL_USABLE_FRACTION, Math.round(height * .5), TITLE_MIN_PX);
|
|
23919
|
-
const baseline = Math.round(height / 2 + fitted.fontPx * .36);
|
|
23920
|
-
return Buffer.from(`<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
|
|
23921
|
-
<rect x="0" y="0" width="${width}" height="${height}" fill="#111111"/>
|
|
23922
|
-
<text x="${Math.round(width / 2)}" y="${baseline}" font-size="${fitted.fontPx}" font-family="sans-serif" font-weight="bold" text-anchor="middle" fill="white">${escapeXml$1(fitted.text)}</text>
|
|
23923
|
-
</svg>`);
|
|
23924
|
-
}
|
|
23925
|
-
/**
|
|
23926
|
-
* Compose the mosaic.
|
|
23927
|
-
*
|
|
23928
|
-
* Every tile is fitted with `contain` onto the cell background rather than
|
|
23929
|
-
* cropped: a `cover` fit would silently cut the subject out of a portrait crop,
|
|
23930
|
-
* and a tile that does not show what it is about is worse than a letterboxed
|
|
23931
|
-
* one.
|
|
23932
|
-
*
|
|
23933
|
-
* A tile sharp cannot read is DROPPED and counted, never composited as a blank
|
|
23934
|
-
* cell — a black square in a digest reads as "the camera saw nothing", which is
|
|
23935
|
-
* the opposite of what happened. The layout is computed from the SURVIVORS, and
|
|
23936
|
-
* because {@link mosaicRowCounts} counts rows rather than grid cells there is no
|
|
23937
|
-
* remainder to leave empty either.
|
|
23938
|
-
*
|
|
23939
|
-
* @throws when every tile was dropped, and when sharp cannot produce the final
|
|
23940
|
-
* canvas at all; the caller treats both as "no mosaic" and still
|
|
23941
|
-
* delivers the counts.
|
|
23942
|
-
*/
|
|
23943
|
-
async function renderMosaic(request, logger) {
|
|
23944
|
-
const plan = mosaicTextPlan(request.captions);
|
|
23945
|
-
const usable = [];
|
|
23946
|
-
for (const tile of request.tiles) try {
|
|
23947
|
-
await sharp(tile.jpeg).metadata();
|
|
23948
|
-
usable.push(tile);
|
|
23949
|
-
} catch (err) {
|
|
23950
|
-
logger.warn("summary mosaic: a tile could not be decoded and was dropped", {
|
|
23951
|
-
tags: { deviceId: tile.deviceId },
|
|
23952
|
-
meta: {
|
|
23953
|
-
trackId: tile.trackId,
|
|
23954
|
-
error: String(err)
|
|
23955
|
-
}
|
|
23956
|
-
});
|
|
23957
|
-
}
|
|
23958
|
-
const droppedTiles = request.tiles.length - usable.length;
|
|
23959
|
-
if (usable.length === 0) throw new Error(`summary mosaic: no tile could be decoded (${request.tiles.length} offered)`);
|
|
23960
|
-
const caption = plan.titleBand ? request.caption?.trim() ?? "" : "";
|
|
23961
|
-
const layout = mosaicLayout({
|
|
23962
|
-
tileCount: usable.length,
|
|
23963
|
-
...request.maxWidth !== void 0 ? { maxWidth: request.maxWidth } : {},
|
|
23964
|
-
withTitle: caption.length > 0
|
|
23965
|
-
});
|
|
23966
|
-
const composites = await Promise.all(usable.map(async (tile, index) => {
|
|
23967
|
-
const rect = layout.tiles[index];
|
|
23968
|
-
if (rect === void 0) throw new Error("summary mosaic: layout lost a tile");
|
|
23969
|
-
const label = plan.tileLabels ? tile.label?.trim() ?? "" : "";
|
|
23970
|
-
const fitted = sharp(tile.jpeg).resize({
|
|
23971
|
-
width: rect.w,
|
|
23972
|
-
height: rect.h,
|
|
23973
|
-
fit: "contain",
|
|
23974
|
-
background: MOSAIC_BACKGROUND
|
|
23975
|
-
});
|
|
23976
|
-
return {
|
|
23977
|
-
input: await (label.length === 0 ? fitted : fitted.composite([{
|
|
23978
|
-
input: tileLabelSvg(rect.w, rect.h, label),
|
|
23979
|
-
top: 0,
|
|
23980
|
-
left: 0
|
|
23981
|
-
}])).jpeg({ quality: 82 }).toBuffer(),
|
|
23982
|
-
top: rect.y,
|
|
23983
|
-
left: rect.x
|
|
23984
|
-
};
|
|
23985
|
-
}));
|
|
23986
|
-
if (caption.length > 0 && layout.titleHeight > 0) composites.unshift({
|
|
23987
|
-
input: titleSvg(layout.width, layout.titleHeight, caption),
|
|
23988
|
-
top: 0,
|
|
23989
|
-
left: 0
|
|
23990
|
-
});
|
|
23991
|
-
return {
|
|
23992
|
-
jpeg: await sharp({ create: {
|
|
23993
|
-
width: layout.width,
|
|
23994
|
-
height: layout.height,
|
|
23995
|
-
channels: 3,
|
|
23996
|
-
background: MOSAIC_BACKGROUND
|
|
23997
|
-
} }).composite(composites).jpeg({ quality: 82 }).toBuffer(),
|
|
23998
|
-
layout,
|
|
23999
|
-
droppedTiles
|
|
24000
|
-
};
|
|
24001
|
-
}
|
|
24002
24487
|
//#endregion
|
|
24003
24488
|
//#region src/notification-center/summary/summary-ai-subject-view.ts
|
|
24004
24489
|
/**
|
|
@@ -25322,11 +25807,12 @@ var NcSummaryProducer = class {
|
|
|
25322
25807
|
try {
|
|
25323
25808
|
const plan = mosaicTextPlan(rule.captions);
|
|
25324
25809
|
rendered = await renderMosaic({
|
|
25810
|
+
purpose: "summary",
|
|
25325
25811
|
tiles: tiles.map(({ candidate, jpeg }) => ({
|
|
25326
25812
|
jpeg,
|
|
25327
25813
|
...plan.tileLabels ? { label: `${names.get(candidate.deviceId) ?? candidate.deviceId} · ${clockOf$1(candidate.firstSeen)} · ${candidate.className}` } : {},
|
|
25328
25814
|
deviceId: candidate.deviceId,
|
|
25329
|
-
|
|
25815
|
+
sourceId: candidate.trackId
|
|
25330
25816
|
})),
|
|
25331
25817
|
captions: rule.captions,
|
|
25332
25818
|
...plan.titleBand ? { caption: summaryCaptionText({
|
|
@@ -27689,6 +28175,12 @@ var NotificationCenter = class NotificationCenter {
|
|
|
27689
28175
|
*/
|
|
27690
28176
|
stills;
|
|
27691
28177
|
/**
|
|
28178
|
+
* The photographs a SENSOR notification carries of every camera linking the
|
|
28179
|
+
* sensor (`linked-stills.ts`, ADR 0632). Null when this wiring has no
|
|
28180
|
+
* snapshot cap — such a notification then ships text-only.
|
|
28181
|
+
*/
|
|
28182
|
+
linkedStills;
|
|
28183
|
+
/**
|
|
27692
28184
|
* Rate limit for the "matched NO rule" report — see `reportNoMatch`. Keyed by
|
|
27693
28185
|
* {@link noMatchReportKey} (device + subject kind + class set), NOT by device:
|
|
27694
28186
|
* a person's verdict must never consume the window a cat's verdict needed on
|
|
@@ -27699,6 +28191,10 @@ var NotificationCenter = class NotificationCenter {
|
|
|
27699
28191
|
/** Same rate-limit pair, same key, for the per-camera mute drop — see `reportMutedDrop`. */
|
|
27700
28192
|
lastMutedReportAt = /* @__PURE__ */ new Map();
|
|
27701
28193
|
mutedSuppressed = /* @__PURE__ */ new Map();
|
|
28194
|
+
/** Same pair, keyed by CAMERA, for a muted camera left out of a linked set —
|
|
28195
|
+
* see `reportLinkedCameraMuted`. */
|
|
28196
|
+
lastLinkedMutedReportAt = /* @__PURE__ */ new Map();
|
|
28197
|
+
linkedMutedSuppressed = /* @__PURE__ */ new Map();
|
|
27702
28198
|
/**
|
|
27703
28199
|
* RAM-only same-edge dedup for the families the durable ledger does NOT
|
|
27704
28200
|
* govern — stream health and the operator's disable switch. Both are already
|
|
@@ -28349,6 +28845,14 @@ var NotificationCenter = class NotificationCenter {
|
|
|
28349
28845
|
logger: this.logger.child("still-shelf"),
|
|
28350
28846
|
now: this.now
|
|
28351
28847
|
}) : null;
|
|
28848
|
+
this.linkedStills = getSnapshot !== void 0 ? new NcLinkedStillShelf({
|
|
28849
|
+
getSnapshot,
|
|
28850
|
+
...deps.getSnapshotState !== void 0 ? { getSnapshotState: deps.getSnapshotState } : {},
|
|
28851
|
+
...deps.getFrameCapturedAt !== void 0 ? { getFrameCapturedAt: deps.getFrameCapturedAt } : {},
|
|
28852
|
+
isOnline: (id) => this.deviceDirectory.get(id)?.online,
|
|
28853
|
+
logger: this.logger.child("linked-stills"),
|
|
28854
|
+
now: this.now
|
|
28855
|
+
}) : null;
|
|
28352
28856
|
this.rules = new NcRuleStore({
|
|
28353
28857
|
store: deps.store,
|
|
28354
28858
|
logger: this.logger.child("rules"),
|
|
@@ -28466,6 +28970,8 @@ var NotificationCenter = class NotificationCenter {
|
|
|
28466
28970
|
if (held !== void 0) return held.files;
|
|
28467
28971
|
const shelved = isStillShelfId(ownerId) ? this.stills?.get(ownerId) : void 0;
|
|
28468
28972
|
if (shelved !== void 0) return shelved;
|
|
28973
|
+
const linked = isLinkedStillId(ownerId) ? this.linkedStills?.get(ownerId) : void 0;
|
|
28974
|
+
if (linked !== void 0) return linked;
|
|
28469
28975
|
return deps.dispatcher.getMediaForOwner(ownerKind, ownerId);
|
|
28470
28976
|
},
|
|
28471
28977
|
...deps.now !== void 0 ? { now: deps.now } : {},
|
|
@@ -28864,6 +29370,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
28864
29370
|
this.timelapseScheduler?.stop();
|
|
28865
29371
|
this.summaryProducer?.stop();
|
|
28866
29372
|
this.stills?.clear();
|
|
29373
|
+
this.linkedStills?.clear();
|
|
28867
29374
|
this.addonUpdates.dispose();
|
|
28868
29375
|
this.livenessHoldoff.dispose();
|
|
28869
29376
|
this.evaluationActive = false;
|
|
@@ -29192,6 +29699,110 @@ var NotificationCenter = class NotificationCenter {
|
|
|
29192
29699
|
this.consumeEvent(incomingFromUnownedSensorEvent(event, linkedCameraIds));
|
|
29193
29700
|
}
|
|
29194
29701
|
/**
|
|
29702
|
+
* Start photographing every camera that links `sensorId`, and return the
|
|
29703
|
+
* owners the sensor-level evaluation names — or `undefined` when there is no
|
|
29704
|
+
* set (≤1 linked camera: today's marker is the picture), nothing to show
|
|
29705
|
+
* (every linked camera muted), the event will be dropped as muted (the
|
|
29706
|
+
* SENSOR is muted, or there were row cameras and every one is muted — see
|
|
29707
|
+
* the evaluation's mute gate), or no enabled device-event rule that could
|
|
29708
|
+
* fire on this sensor KIND wants a picture (the gate is the RULE's media
|
|
29709
|
+
* policy, scope and kind conditions, read from the in-memory mirror — D49,
|
|
29710
|
+
* as `audioStillOwner`; an occupancy rule, which rides the same delivery,
|
|
29711
|
+
* never fires on a sensor). Coarse on purpose, in one direction only: a
|
|
29712
|
+
* capture for a rule whose other conditions then fail is one wasted
|
|
29713
|
+
* snapshot, a missing capture is a notification without its picture. The
|
|
29714
|
+
* kind check is what keeps a chatty multi-linked motion sensor from
|
|
29715
|
+
* composing a mosaic on every edge for a doorbell rule.
|
|
29716
|
+
*
|
|
29717
|
+
* A MUTED camera (D62) is never dialled: its tile is left out, said on that
|
|
29718
|
+
* camera (once per window), and the primary is the first linked camera that
|
|
29719
|
+
* is not muted — the same derivation {@link onSensorEvent} makes.
|
|
29720
|
+
*
|
|
29721
|
+
* `cameraIds` is every linking camera, UNFILTERED; `rowCameraIds` the ones
|
|
29722
|
+
* that take a timeline row for the change — with the sensor, the device
|
|
29723
|
+
* scope the evaluation will match on (`matchesDeviceScope`).
|
|
29724
|
+
*/
|
|
29725
|
+
beginLinkedStills(request) {
|
|
29726
|
+
const { sensorId, sensorKind, cameraIds, atMs, rowCameraIds } = request;
|
|
29727
|
+
const shelf = this.linkedStills;
|
|
29728
|
+
if (shelf === null || !this.evaluationActive || cameraIds.length < 2) return void 0;
|
|
29729
|
+
if (this.deviceMutes.isMuted(sensorId)) return void 0;
|
|
29730
|
+
const rowShown = this.unmutedCameras(rowCameraIds);
|
|
29731
|
+
if (rowCameraIds.length > 0 && rowShown.length === 0) return void 0;
|
|
29732
|
+
const shown = this.unmutedCameras(cameraIds);
|
|
29733
|
+
const involved = [sensorId, ...rowShown];
|
|
29734
|
+
if (!this.rules.listEnabled("device-event").some((rule) => {
|
|
29735
|
+
if (rule.media.attach === "none") return false;
|
|
29736
|
+
if (!deviceEventRuleMayFireOnKind(rule, sensorKind)) return false;
|
|
29737
|
+
const devices = rule.conditions.devices;
|
|
29738
|
+
return devices === void 0 || devices.length === 0 || devices.some((id) => involved.includes(id));
|
|
29739
|
+
})) return void 0;
|
|
29740
|
+
for (const cameraId of cameraIds) if (!shown.includes(cameraId)) this.reportLinkedCameraMuted(cameraId, sensorId);
|
|
29741
|
+
return shown.length > 0 ? shelf.captureSet(sensorId, shown, atMs) : void 0;
|
|
29742
|
+
}
|
|
29743
|
+
/**
|
|
29744
|
+
* A muted camera left out of a linked set — one INFO line per camera per
|
|
29745
|
+
* window, the suppressed count folded into the next one. A chatty contact
|
|
29746
|
+
* linked to a muted camera must not write a line on every change.
|
|
29747
|
+
*/
|
|
29748
|
+
reportLinkedCameraMuted(cameraId, sensorId) {
|
|
29749
|
+
const now = this.now();
|
|
29750
|
+
const last = this.lastLinkedMutedReportAt.get(cameraId);
|
|
29751
|
+
const suppressed = this.linkedMutedSuppressed.get(cameraId) ?? 0;
|
|
29752
|
+
if (last !== void 0 && now - last < NO_MATCH_REPORT_INTERVAL_MS) {
|
|
29753
|
+
this.linkedMutedSuppressed.set(cameraId, suppressed + 1);
|
|
29754
|
+
return;
|
|
29755
|
+
}
|
|
29756
|
+
this.lastLinkedMutedReportAt.set(cameraId, now);
|
|
29757
|
+
this.linkedMutedSuppressed.set(cameraId, 0);
|
|
29758
|
+
this.logger.info("linked camera muted — left out of the sensor notification", {
|
|
29759
|
+
tags: { deviceId: cameraId },
|
|
29760
|
+
meta: {
|
|
29761
|
+
sensorId,
|
|
29762
|
+
reason: "muted",
|
|
29763
|
+
...suppressed > 0 ? { alsoLeftOutSinceLastReport: suppressed } : {}
|
|
29764
|
+
}
|
|
29765
|
+
});
|
|
29766
|
+
}
|
|
29767
|
+
/**
|
|
29768
|
+
* ONE evaluation for one sensor state change (ADR 0632) — replaces the
|
|
29769
|
+
* per-camera fan-out when the sensor is linked to more than one camera.
|
|
29770
|
+
*
|
|
29771
|
+
* The linked cameras widen the MEDIA only — which cameras' stills ship —
|
|
29772
|
+
* never which rules fire. Matching is on the SENSOR and the cameras that
|
|
29773
|
+
* took a timeline row (`rowCameraIds`, not muted) — D630 §6, whatever the
|
|
29774
|
+
* number of linked cameras. The primary is only the camera the notification
|
|
29775
|
+
* leads with.
|
|
29776
|
+
*
|
|
29777
|
+
* The primary is re-derived here as the first linked camera that is NOT
|
|
29778
|
+
* muted (D62): a camera's mute drops only its own tile, and its marker is
|
|
29779
|
+
* then not the picture. Every linked camera muted ⇒ the sensor is its own
|
|
29780
|
+
* device and the notification is text-only. The MUTE of the event itself is
|
|
29781
|
+
* judged on the sensor ({@link NcRuleSubject.linkedSensorId}). A
|
|
29782
|
+
* `rule-device` cooldown is keyed by the primary. Same lossy boundary as
|
|
29783
|
+
* {@link onSensorEventPersisted}.
|
|
29784
|
+
*/
|
|
29785
|
+
onSensorEvent(input) {
|
|
29786
|
+
this.observeOpeningEvent(input.event);
|
|
29787
|
+
const shown = this.unmutedCameras(input.linkedCameraIds);
|
|
29788
|
+
const muted = input.linkedCameraIds.filter((id) => !shown.includes(id));
|
|
29789
|
+
const primary = shown[0] ?? input.event.sourceDeviceId;
|
|
29790
|
+
const marker = primary === input.event.deviceId ? input.markerTrackId : void 0;
|
|
29791
|
+
this.consumeEvent(incomingFromSensorEvent({
|
|
29792
|
+
...input.event,
|
|
29793
|
+
deviceId: primary
|
|
29794
|
+
}, marker, {
|
|
29795
|
+
linkedCameraIds: shown,
|
|
29796
|
+
rowCameraIds: this.unmutedCameras(input.rowCameraIds),
|
|
29797
|
+
mutedRowCameraIds: input.rowCameraIds.filter((id) => this.deviceMutes.isMuted(id)),
|
|
29798
|
+
...input.linkedStills !== void 0 ? { linkedStills: input.linkedStills } : {}
|
|
29799
|
+
}, muted));
|
|
29800
|
+
}
|
|
29801
|
+
/** The linked cameras a sensor notification may show — not muted (D62), in order. */
|
|
29802
|
+
unmutedCameras(cameraIds) {
|
|
29803
|
+
return cameraIds.filter((id) => !this.deviceMutes.isMuted(id));
|
|
29804
|
+
}
|
|
29805
|
+
/**
|
|
29195
29806
|
* The opening reading a sensor row carries — a contact, a cover or a lock,
|
|
29196
29807
|
* read with D630's table (`alarmOpeningFromSensorEvent`) — handed to the
|
|
29197
29808
|
* panel. A row that is not an opening, or whose word is unknown, hands over
|
|
@@ -30318,8 +30929,9 @@ var NotificationCenter = class NotificationCenter {
|
|
|
30318
30929
|
outcomes,
|
|
30319
30930
|
...extra
|
|
30320
30931
|
});
|
|
30321
|
-
|
|
30322
|
-
|
|
30932
|
+
const muteDeviceId = this.mutedAuthority(subject);
|
|
30933
|
+
if (muteDeviceId !== void 0 && controls.bypassMute !== true) {
|
|
30934
|
+
this.reportMutedDrop(subject, muteDeviceId, kind, this.now());
|
|
30323
30935
|
return report({ droppedBy: "muted" });
|
|
30324
30936
|
}
|
|
30325
30937
|
const delivery = deliveryForKind(kind);
|
|
@@ -30490,7 +31102,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
30490
31102
|
buttons: unresolvable.join(", ")
|
|
30491
31103
|
}
|
|
30492
31104
|
});
|
|
30493
|
-
if (!noOwnerReported && kind === "device-event" && subject.trackId === void 0 && rule.media.attach !== "none") {
|
|
31105
|
+
if (!noOwnerReported && kind === "device-event" && subject.trackId === void 0 && subject.linkedStills === void 0 && rule.media.attach !== "none") {
|
|
30494
31106
|
noOwnerReported = true;
|
|
30495
31107
|
const linked = subject.linkedCameraIds;
|
|
30496
31108
|
const meta = {
|
|
@@ -30499,7 +31111,19 @@ var NotificationCenter = class NotificationCenter {
|
|
|
30499
31111
|
ruleId: rule.id,
|
|
30500
31112
|
rule: rule.name
|
|
30501
31113
|
};
|
|
30502
|
-
if (linked !== void 0
|
|
31114
|
+
if (linked !== void 0 && linked.length === 0 && isLinkedSetSubject(subject)) this.logger.info("every linked camera is muted — this sensor notification ships text-only", {
|
|
31115
|
+
tags: { deviceId: subject.linkedSensorId ?? subject.deviceId },
|
|
31116
|
+
meta
|
|
31117
|
+
});
|
|
31118
|
+
else if (linked !== void 0 && isLinkedSetSubject(subject)) this.logger.warn("sensor event has no linked-camera pictures and no marker — this notification ships text-only", {
|
|
31119
|
+
tags: { deviceId: subject.sourceDeviceId ?? subject.deviceId },
|
|
31120
|
+
meta: {
|
|
31121
|
+
...meta,
|
|
31122
|
+
linkedCameraIds: [...linked],
|
|
31123
|
+
snapshotWired: this.linkedStills !== null
|
|
31124
|
+
}
|
|
31125
|
+
});
|
|
31126
|
+
else if (linked !== void 0) this.logger.debug(linked.length === 0 ? "device event from a sensor linked to no camera — this notification ships text-only" : `device event from a sensor linked to ${linked.length} camera(s), none opted into tracked events — this notification ships text-only`, {
|
|
30503
31127
|
tags: { deviceId: subject.deviceId },
|
|
30504
31128
|
meta: {
|
|
30505
31129
|
...meta,
|
|
@@ -30756,14 +31380,26 @@ var NotificationCenter = class NotificationCenter {
|
|
|
30756
31380
|
});
|
|
30757
31381
|
}
|
|
30758
31382
|
/**
|
|
31383
|
+
* The muted device that drops this subject, or `undefined` when none does.
|
|
31384
|
+
* A per-camera subject: its camera. A linked set: the sensor; else, when
|
|
31385
|
+
* the change took rows and every row camera is muted, the sensor too — the
|
|
31386
|
+
* line names the muted row cameras.
|
|
31387
|
+
*/
|
|
31388
|
+
mutedAuthority(subject) {
|
|
31389
|
+
const sensorId = subject.linkedSensorId;
|
|
31390
|
+
if (sensorId === void 0) return this.deviceMutes.isMuted(subject.deviceId) ? subject.deviceId : void 0;
|
|
31391
|
+
if (this.deviceMutes.isMuted(sensorId)) return sensorId;
|
|
31392
|
+
return (subject.rowCameraIds ?? []).length === 0 && (subject.mutedRowCameraIds ?? []).length > 0 ? sensorId : void 0;
|
|
31393
|
+
}
|
|
31394
|
+
/**
|
|
30759
31395
|
* One INFO line when a MUTED camera's event is dropped, bounded to one per
|
|
30760
31396
|
* camera per window with the suppressed count folded into the next report —
|
|
30761
31397
|
* identical shape to `reportNoMatch`, because the question an operator asks
|
|
30762
31398
|
* is identical: "why did this camera not notify me?".
|
|
30763
31399
|
*/
|
|
30764
|
-
reportMutedDrop(subject, kind, now) {
|
|
31400
|
+
reportMutedDrop(subject, mutedDeviceId, kind, now) {
|
|
30765
31401
|
const bucket = noMatchReportKey({
|
|
30766
|
-
deviceId:
|
|
31402
|
+
deviceId: mutedDeviceId,
|
|
30767
31403
|
kind,
|
|
30768
31404
|
classNames: subject.classNames
|
|
30769
31405
|
});
|
|
@@ -30775,10 +31411,12 @@ var NotificationCenter = class NotificationCenter {
|
|
|
30775
31411
|
}
|
|
30776
31412
|
this.lastMutedReportAt.set(bucket, now);
|
|
30777
31413
|
this.mutedSuppressed.set(bucket, 0);
|
|
30778
|
-
|
|
30779
|
-
|
|
31414
|
+
const rowsMuted = subject.linkedSensorId !== void 0 && !this.deviceMutes.isMuted(subject.linkedSensorId);
|
|
31415
|
+
this.logger.info(subject.linkedSensorId === void 0 ? "notifications muted for this camera — event dropped before rule evaluation" : rowsMuted ? "every camera that took its row is muted — sensor event dropped before rule evaluation" : "notifications muted for this sensor — event dropped before rule evaluation", {
|
|
31416
|
+
tags: { deviceId: mutedDeviceId },
|
|
30780
31417
|
meta: {
|
|
30781
31418
|
kind,
|
|
31419
|
+
...rowsMuted ? { mutedRowCameraIds: [...subject.mutedRowCameraIds ?? []] } : {},
|
|
30782
31420
|
classes: subject.classNames,
|
|
30783
31421
|
eventId: subject.recordId,
|
|
30784
31422
|
...subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
|
|
@@ -30829,6 +31467,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
30829
31467
|
const confirm = frozenConfirmPolicy(rule, isSystemEvent);
|
|
30830
31468
|
const cropZoneIds = rule.conditions.zones !== void 0 ? rule.conditions.zones.ids : rule.conditions.occupancy?.zoneId !== void 0 ? [rule.conditions.occupancy.zoneId] : void 0;
|
|
30831
31469
|
const allTargets = [...rule.targets, ...userTargets.filter((t) => !direct.has(t.targetId))];
|
|
31470
|
+
const linkedOwners = !noMedia && subject.linkedStills !== void 0 ? linkedMediaOwners(subject.linkedStills, linkedCameraMode(mediaRule.media)) : void 0;
|
|
30832
31471
|
return (upgrade !== void 0 ? allTargets.filter((t) => upgrade.targetIds.includes(t.targetId)) : allTargets).filter((target) => !rule.disabledTargetIds.includes(target.targetId)).map((target) => {
|
|
30833
31472
|
const base = {
|
|
30834
31473
|
ruleName: rule.name,
|
|
@@ -30859,7 +31498,12 @@ var NotificationCenter = class NotificationCenter {
|
|
|
30859
31498
|
...target.params !== void 0 ? { params: target.params } : {},
|
|
30860
31499
|
subject: {
|
|
30861
31500
|
deviceId: subject.deviceId,
|
|
30862
|
-
...alarmSource?.trackId !== void 0 ? { trackId: alarmSource.trackId } :
|
|
31501
|
+
...alarmSource?.trackId !== void 0 ? { trackId: alarmSource.trackId } : linkedOwners !== void 0 ? {
|
|
31502
|
+
trackId: linkedOwners.trackId,
|
|
31503
|
+
...linkedOwners.extraStills.length > 0 ? { extraStills: linkedOwners.extraStills.map((x) => ({ ...x })) } : {},
|
|
31504
|
+
analysisTiles: linkedOwners.analysisTiles.map((x) => ({ ...x }))
|
|
31505
|
+
} : subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
|
|
31506
|
+
...subject.linkedCameraIds !== void 0 && subject.linkedCameraIds.length > 1 && isLinkedSetSubject(subject) ? { linkedCameraIds: [...subject.linkedCameraIds] } : {},
|
|
30863
31507
|
...alarmSource?.eventId !== void 0 ? { eventId: alarmSource.eventId } : hasEventMedia ? { eventId: subject.recordId } : {},
|
|
30864
31508
|
className: subject.classNames[0] ?? subject.sensorKind ?? "event",
|
|
30865
31509
|
...subject.label !== void 0 ? { label: subject.label } : {},
|
|
@@ -72184,6 +72828,20 @@ function hasWrappedDetection(entries) {
|
|
|
72184
72828
|
return entries.some((e) => e.capName === "detection-pipeline" && e.kind === "wrapped");
|
|
72185
72829
|
}
|
|
72186
72830
|
//#endregion
|
|
72831
|
+
//#region src/pipeline-analytics/runtime/linked-snapshot-reads.ts
|
|
72832
|
+
function buildLinkedSnapshotReads(snapshot) {
|
|
72833
|
+
return {
|
|
72834
|
+
getSnapshotState: async (deviceId) => {
|
|
72835
|
+
return { reason: (await snapshot.getDebugState.query({ deviceId })).state.reason };
|
|
72836
|
+
},
|
|
72837
|
+
getFrameCapturedAt: async (deviceIds) => {
|
|
72838
|
+
if (deviceIds.length === 0) return /* @__PURE__ */ new Map();
|
|
72839
|
+
const rows = await snapshot.getSnapshotOverview.query({ deviceIds: [...deviceIds] });
|
|
72840
|
+
return new Map(rows.map((r) => [r.deviceId, r.lastCapturedAt]));
|
|
72841
|
+
}
|
|
72842
|
+
};
|
|
72843
|
+
}
|
|
72844
|
+
//#endregion
|
|
72187
72845
|
//#region src/pipeline-analytics/runtime/native-crop-dedupe.ts
|
|
72188
72846
|
var NativeCropDedupe = class {
|
|
72189
72847
|
config;
|
|
@@ -73792,6 +74450,59 @@ async function resolveSensorEventCameras(cache, data) {
|
|
|
73792
74450
|
};
|
|
73793
74451
|
}
|
|
73794
74452
|
/**
|
|
74453
|
+
* The cameras a NOTIFICATION about this sensor shows — the emitting device
|
|
74454
|
+
* itself when it is a camera that may self-emit this cap, then every camera
|
|
74455
|
+
* that links it, opt-in or not (ADR 0632). The order is the mosaic's tile order
|
|
74456
|
+
* and names the PRIMARY camera (the first). Deliberately a sibling of
|
|
74457
|
+
* resolveSensorEventCameras rather than a flag on it: that one decides which
|
|
74458
|
+
* timeline rows exist and must keep honouring the opt-in.
|
|
74459
|
+
*
|
|
74460
|
+
* `[]` is NOT knowledge. It answers both "no camera links this sensor" and
|
|
74461
|
+
* "the camera index has not answered yet" (cold start, no camera list read —
|
|
74462
|
+
* this function does not consult `answered()`). A caller must read `[]` (and a
|
|
74463
|
+
* single camera) as "no mosaic this time, fall back to the plain path", never
|
|
74464
|
+
* as a durable "linked to nothing" that drops or deletes work (D49).
|
|
74465
|
+
*/
|
|
74466
|
+
async function resolveLinkedMediaCameras(cache, data) {
|
|
74467
|
+
const linked = await cache.allCamerasFor(data.deviceId);
|
|
74468
|
+
return [...await cache.isCamera(data.deviceId) && selfCameraMayEmit(data.capName) ? [data.deviceId] : [], ...linked.filter((id) => id !== data.deviceId)];
|
|
74469
|
+
}
|
|
74470
|
+
var NO_SENSOR_NOTIFICATION_CAMERAS = {
|
|
74471
|
+
mediaCameraIds: [],
|
|
74472
|
+
rowCameraIds: []
|
|
74473
|
+
};
|
|
74474
|
+
/**
|
|
74475
|
+
* Which cameras a notification about this change shows, known BEFORE the
|
|
74476
|
+
* ingest runs, so the capture can start at the trigger.
|
|
74477
|
+
*
|
|
74478
|
+
* Only a change the ingest EVALUATES has any: a mapped cap that takes rows
|
|
74479
|
+
* (`dropReason` null) or an unowned sensor (`no-camera`, D630 §6). A camera's
|
|
74480
|
+
* own `motion` no camera opted in (`camera-native-cap`) and a cold index
|
|
74481
|
+
* (`camera-index-unknown`) are drops today, and a camera merely LINKING the
|
|
74482
|
+
* device must not turn a drop into a notification. Asks the SAME resolution
|
|
74483
|
+
* the ingest asks (`resolveSensorEventCameras`), off the same cache.
|
|
74484
|
+
*/
|
|
74485
|
+
async function resolveSensorNotificationCameras(cache, data) {
|
|
74486
|
+
if (EVENT_KIND_BY_CAP[data.capName] === void 0) return NO_SENSOR_NOTIFICATION_CAMERAS;
|
|
74487
|
+
const rows = await resolveSensorEventCameras(cache, data);
|
|
74488
|
+
if (rows.dropReason !== null && rows.dropReason !== "no-camera") return NO_SENSOR_NOTIFICATION_CAMERAS;
|
|
74489
|
+
return {
|
|
74490
|
+
mediaCameraIds: await resolveLinkedMediaCameras(cache, data),
|
|
74491
|
+
rowCameraIds: rows.cameraIds
|
|
74492
|
+
};
|
|
74493
|
+
}
|
|
74494
|
+
/**
|
|
74495
|
+
* A sensor slice as a row's `value`: the object it is, or `null` for anything
|
|
74496
|
+
* else (absent, a primitive, an array) — one narrowing for every sensor event
|
|
74497
|
+
* built from a `DeviceStateChanged`.
|
|
74498
|
+
*/
|
|
74499
|
+
function sensorSliceRecord(slice) {
|
|
74500
|
+
return isSliceRecord(slice) ? slice : null;
|
|
74501
|
+
}
|
|
74502
|
+
function isSliceRecord(slice) {
|
|
74503
|
+
return typeof slice === "object" && slice !== null && !Array.isArray(slice);
|
|
74504
|
+
}
|
|
74505
|
+
/**
|
|
73795
74506
|
* One `DeviceStateChanged` → N history rows, one per camera the event belongs
|
|
73796
74507
|
* to: the emitting device's LINKED cameras, plus the emitting device ITSELF
|
|
73797
74508
|
* when it is a camera carrying the sensor cap (a doorbell wrapper bound on
|
|
@@ -73807,8 +74518,7 @@ async function ingestSensorStateChange(deps, data, timestamp) {
|
|
|
73807
74518
|
const descriptor = EVENT_KIND_BY_CAP[data.capName];
|
|
73808
74519
|
if (descriptor === void 0) return 0;
|
|
73809
74520
|
const { cameraIds, dropReason, linkedCameraIds } = await resolveSensorEventCameras(deps.cache, data);
|
|
73810
|
-
const
|
|
73811
|
-
const value = slice !== null && slice !== void 0 && typeof slice === "object" && !Array.isArray(slice) ? slice : null;
|
|
74521
|
+
const value = sensorSliceRecord(data.slice);
|
|
73812
74522
|
const makeId = deps.makeId ?? (() => `pa-sensor-${randomUUID()}`);
|
|
73813
74523
|
if (dropReason === "no-camera" && deps.onUnowned !== void 0) {
|
|
73814
74524
|
const ev = {
|
|
@@ -74787,8 +75497,14 @@ var MotionEventSnapshotCapturer = class {
|
|
|
74787
75497
|
/**
|
|
74788
75498
|
* Project one `DeviceStateChanged` of a mapped sensor/control cap into a
|
|
74789
75499
|
* synthetic track per camera the event belongs to. Returns the number of
|
|
74790
|
-
* markers that landed. Best-effort (telemetry, D8)
|
|
74791
|
-
*
|
|
75500
|
+
* markers that landed. Best-effort (telemetry, D8).
|
|
75501
|
+
*
|
|
75502
|
+
* Every camera is materialized CONCURRENTLY — the notification about this
|
|
75503
|
+
* event waits for the markers, so N cameras cost one snapshot bound, not N —
|
|
75504
|
+
* and reported afterwards in camera order. A materializer that throws for one
|
|
75505
|
+
* camera no longer propagates to the caller (where it was logged against the
|
|
75506
|
+
* SENSOR): it is reported as `not-materialized` on ITS camera, carrying the
|
|
75507
|
+
* rejection's message in `error`, and the other cameras still land.
|
|
74792
75508
|
*/
|
|
74793
75509
|
async function projectSensorMarkers(deps, data, timestamp) {
|
|
74794
75510
|
const descriptor = EVENT_KIND_BY_CAP[data.capName];
|
|
@@ -74814,26 +75530,30 @@ async function projectSensorMarkers(deps, data, timestamp) {
|
|
|
74814
75530
|
return 0;
|
|
74815
75531
|
}
|
|
74816
75532
|
const producingDeviceName = await deps.producingDeviceName(data.deviceId);
|
|
75533
|
+
const settled = await Promise.allSettled(cameraIds.map((cameraId) => deps.markers.materialize({
|
|
75534
|
+
cameraId,
|
|
75535
|
+
sourceDeviceId: data.deviceId,
|
|
75536
|
+
source: "sensor",
|
|
75537
|
+
kind: descriptor.kind,
|
|
75538
|
+
timestamp,
|
|
75539
|
+
snapshotTimeoutMs: deps.snapshotTimeoutMs ?? 2500,
|
|
75540
|
+
...producingDeviceName !== void 0 ? { producingDeviceName } : {}
|
|
75541
|
+
})));
|
|
74817
75542
|
let landed = 0;
|
|
74818
|
-
|
|
74819
|
-
const
|
|
74820
|
-
|
|
74821
|
-
|
|
74822
|
-
source: "sensor",
|
|
74823
|
-
kind: descriptor.kind,
|
|
74824
|
-
timestamp,
|
|
74825
|
-
snapshotTimeoutMs: deps.snapshotTimeoutMs ?? 2500,
|
|
74826
|
-
...producingDeviceName !== void 0 ? { producingDeviceName } : {}
|
|
74827
|
-
});
|
|
75543
|
+
settled.forEach((outcome, i) => {
|
|
75544
|
+
const cameraId = cameraIds[i];
|
|
75545
|
+
if (cameraId === void 0) return;
|
|
75546
|
+
const track = outcome.status === "fulfilled" ? outcome.value : null;
|
|
74828
75547
|
if (track === null) {
|
|
74829
75548
|
deps.onSkipped({
|
|
74830
75549
|
cameraId,
|
|
74831
75550
|
sourceDeviceId: data.deviceId,
|
|
74832
75551
|
capName: data.capName,
|
|
74833
75552
|
kind: descriptor.kind,
|
|
74834
|
-
reason: "not-materialized"
|
|
75553
|
+
reason: "not-materialized",
|
|
75554
|
+
...outcome.status === "rejected" ? { error: errMsg$1(outcome.reason) } : {}
|
|
74835
75555
|
});
|
|
74836
|
-
|
|
75556
|
+
return;
|
|
74837
75557
|
}
|
|
74838
75558
|
landed++;
|
|
74839
75559
|
deps.onProjected({
|
|
@@ -74845,7 +75565,7 @@ async function projectSensorMarkers(deps, data, timestamp) {
|
|
|
74845
75565
|
selfCamera: cameraId === data.deviceId,
|
|
74846
75566
|
hasMedia: track.snapshots.length > 0
|
|
74847
75567
|
});
|
|
74848
|
-
}
|
|
75568
|
+
});
|
|
74849
75569
|
return landed;
|
|
74850
75570
|
}
|
|
74851
75571
|
//#endregion
|
|
@@ -79994,6 +80714,14 @@ var RETRAIN_EXPORT_MAX = 2e4;
|
|
|
79994
80714
|
* surface to turn the refinement pipeline on/off for a camera.
|
|
79995
80715
|
*/
|
|
79996
80716
|
var TTL_SWEEP_INTERVAL_MS = 5e3;
|
|
80717
|
+
/**
|
|
80718
|
+
* How long shutdown waits for the sensor handlers already running. Just above
|
|
80719
|
+
* the marker's bounded snapshot fetch (`SENSOR_MARKER_SNAPSHOT_TIMEOUT_MS`,
|
|
80720
|
+
* 2.5 s), the one long await a handler makes, and BELOW the kernel's SIGKILL
|
|
80721
|
+
* fallback (process-service.ts: 3 s after SIGTERM on the stop-all path, 5 s on
|
|
80722
|
+
* the others) — a longer drain would be killed mid-way and log nothing.
|
|
80723
|
+
*/
|
|
80724
|
+
var SENSOR_HANDLER_DRAIN_MS = 2700;
|
|
79997
80725
|
/** Throttle for the group-shot gate's window line (D470). */
|
|
79998
80726
|
var GROUP_SHOT_METRIC_INTERVAL_MS = 3e4;
|
|
79999
80727
|
/**
|
|
@@ -81064,6 +81792,14 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
81064
81792
|
getNativeKeyFrameRgb = null;
|
|
81065
81793
|
shuttingDown = false;
|
|
81066
81794
|
/**
|
|
81795
|
+
* Sensor handlers the bus dispatched under `void` that have not finished.
|
|
81796
|
+
* `onShutdown` DRAINS them before it tears anything down: a handler awaits a
|
|
81797
|
+
* snapshot for its marker before its last context read, and one left running
|
|
81798
|
+
* across the teardown threw "ctx accessed before initialize()" as an
|
|
81799
|
+
* unhandled rejection with no device on it.
|
|
81800
|
+
*/
|
|
81801
|
+
sensorHandlersInFlight = /* @__PURE__ */ new Set();
|
|
81802
|
+
/**
|
|
81067
81803
|
* Per-device delivery-lag windows, folded at `handleInferenceResult` entry.
|
|
81068
81804
|
* The instrument that tells a late DELIVERY (starved) apart from a slow
|
|
81069
81805
|
* `processFrame` (waiting) — see `frame-lag-observer.ts`. Costs one map read
|
|
@@ -81610,7 +82346,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
81610
82346
|
onMotionAnalysis: (deviceId, data, timestampMs) => this.handleMotionAnalysis(deviceId, data, timestampMs),
|
|
81611
82347
|
onOnboardMotion: (deviceId, data, timestampMs) => this.handleOnboardMotion(deviceId, data, timestampMs),
|
|
81612
82348
|
onBatterySample: (deviceId, percentage, at) => this.lts?.note("battery", deviceId, percentage, at),
|
|
81613
|
-
onSensorStateChanged: (data, timestampMs) => this.
|
|
82349
|
+
onSensorStateChanged: (data, timestampMs) => this.dispatchSensorStateChanged(data, timestampMs),
|
|
81614
82350
|
onBindingsChanged: (data) => this.handleBindingsChangedEvent(data),
|
|
81615
82351
|
onDeviceUnregistered: (deviceId) => this.handleDeviceUnregisteredEvent(deviceId),
|
|
81616
82352
|
onClusterTopology: (nodes) => this.nodeLiveness.applySnapshot(nodes, Date.now()),
|
|
@@ -82300,10 +83036,13 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
82300
83036
|
snapshot: { getSnapshot: (i) => api.snapshot.getSnapshot.query(i) },
|
|
82301
83037
|
media: mediaStore,
|
|
82302
83038
|
tracks: trackStore,
|
|
82303
|
-
onError: (scope, err) => logger.warn("synthetic track error", {
|
|
82304
|
-
|
|
82305
|
-
|
|
82306
|
-
|
|
83039
|
+
onError: (scope, err, deviceId) => logger.warn("synthetic track error", {
|
|
83040
|
+
...deviceId !== void 0 ? { tags: { deviceId } } : {},
|
|
83041
|
+
meta: {
|
|
83042
|
+
scope,
|
|
83043
|
+
error: errMsg$1(err)
|
|
83044
|
+
}
|
|
83045
|
+
})
|
|
82307
83046
|
});
|
|
82308
83047
|
this.syntheticTracks = syntheticTracks;
|
|
82309
83048
|
this.motionEventSnapshots = new MotionEventSnapshotCapturer({
|
|
@@ -82610,6 +83349,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
82610
83349
|
},
|
|
82611
83350
|
isDeviceDeleted: async (deviceId) => await api.deviceManager.getDevice.query({ deviceId }) === null,
|
|
82612
83351
|
getSnapshot: (deviceId) => api.snapshot.getSnapshot.query({ deviceId }),
|
|
83352
|
+
...buildLinkedSnapshotReads(api.snapshot),
|
|
82613
83353
|
identityIdsByName: async () => {
|
|
82614
83354
|
const byName = /* @__PURE__ */ new Map();
|
|
82615
83355
|
const identities = await stores.identityStore.listIdentities();
|
|
@@ -84145,7 +84885,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
84145
84885
|
this.alarmPanelConvergence = null;
|
|
84146
84886
|
for (const unsub of this.subscriptionUnsubs) unsub();
|
|
84147
84887
|
this.subscriptionUnsubs = [];
|
|
84148
|
-
await this.motionEpisodes?.closeAll("addon-shutdown");
|
|
84888
|
+
await Promise.all([this.drainSensorHandlers(), this.motionEpisodes?.closeAll("addon-shutdown")]);
|
|
84149
84889
|
await this.embeddingDispatcher?.stop();
|
|
84150
84890
|
this.embeddingDispatcher = null;
|
|
84151
84891
|
await this.notificationCenter?.stop();
|
|
@@ -88455,11 +89195,118 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
88455
89195
|
return this.queryFacade.getSensorEvents(input);
|
|
88456
89196
|
}
|
|
88457
89197
|
/**
|
|
89198
|
+
* Start the linked set's capture (ADR 0632) with the UNFILTERED camera list:
|
|
89199
|
+
* the centre applies the mute itself and says so per camera.
|
|
89200
|
+
*/
|
|
89201
|
+
startLinkedStills(data, timestamp, cameras) {
|
|
89202
|
+
const sensorKind = EVENT_KIND_BY_CAP[data.capName]?.kind;
|
|
89203
|
+
if (sensorKind === void 0) return void 0;
|
|
89204
|
+
try {
|
|
89205
|
+
return this.notificationCenter?.beginLinkedStills({
|
|
89206
|
+
sensorId: data.deviceId,
|
|
89207
|
+
sensorKind,
|
|
89208
|
+
cameraIds: cameras.mediaCameraIds,
|
|
89209
|
+
rowCameraIds: cameras.rowCameraIds,
|
|
89210
|
+
atMs: timestamp
|
|
89211
|
+
});
|
|
89212
|
+
} catch (err) {
|
|
89213
|
+
this.ctx.logger.warn("linked-camera capture could not start — the notification ships without it", {
|
|
89214
|
+
tags: { deviceId: data.deviceId },
|
|
89215
|
+
meta: {
|
|
89216
|
+
capName: data.capName,
|
|
89217
|
+
cameraIds: [...cameras.mediaCameraIds],
|
|
89218
|
+
error: errMsg$1(err)
|
|
89219
|
+
}
|
|
89220
|
+
});
|
|
89221
|
+
return;
|
|
89222
|
+
}
|
|
89223
|
+
}
|
|
89224
|
+
/**
|
|
89225
|
+
* The ONE sensor-level evaluation of a linked set (ADR 0632). Its record is
|
|
89226
|
+
* the primary camera's row when that camera has one, else another camera's
|
|
89227
|
+
* row, else a camera-attributed event that is NOT a timeline row (no linking
|
|
89228
|
+
* camera opted the sensor in). The cameras that took a row travel as
|
|
89229
|
+
* `rowCameraIds`: with the sensor, what a camera-scoped rule matches on
|
|
89230
|
+
* (D630 §6).
|
|
89231
|
+
*/
|
|
89232
|
+
handOffLinkedSensorEvent(input) {
|
|
89233
|
+
const { data, timestamp, persisted } = input;
|
|
89234
|
+
const primary = input.mediaCameraIds[0];
|
|
89235
|
+
if (primary === void 0) return;
|
|
89236
|
+
const event = persisted.find((ev) => ev.deviceId === primary) ?? persisted[0] ?? {
|
|
89237
|
+
id: `pa-sensor-${randomUUID()}`,
|
|
89238
|
+
deviceId: primary,
|
|
89239
|
+
sourceDeviceId: data.deviceId,
|
|
89240
|
+
kind: EVENT_KIND_BY_CAP[data.capName]?.kind ?? data.capName,
|
|
89241
|
+
value: sensorSliceRecord(data.slice),
|
|
89242
|
+
timestamp
|
|
89243
|
+
};
|
|
89244
|
+
const markerTrackId = input.markerByCamera.get(primary)?.trackId;
|
|
89245
|
+
try {
|
|
89246
|
+
this.notificationCenter?.onSensorEvent({
|
|
89247
|
+
event: {
|
|
89248
|
+
...event,
|
|
89249
|
+
deviceId: primary
|
|
89250
|
+
},
|
|
89251
|
+
linkedCameraIds: input.mediaCameraIds,
|
|
89252
|
+
rowCameraIds: persisted.map((ev) => ev.deviceId),
|
|
89253
|
+
...markerTrackId !== void 0 ? { markerTrackId } : {},
|
|
89254
|
+
...input.linkedStills !== void 0 ? { linkedStills: input.linkedStills } : {}
|
|
89255
|
+
});
|
|
89256
|
+
} catch (err) {
|
|
89257
|
+
this.ctx.logger.warn("sensor event hand-off to the notification centre failed", {
|
|
89258
|
+
tags: { deviceId: data.deviceId },
|
|
89259
|
+
meta: {
|
|
89260
|
+
kind: event.kind,
|
|
89261
|
+
linkedCameraIds: [...input.mediaCameraIds],
|
|
89262
|
+
error: errMsg$1(err)
|
|
89263
|
+
}
|
|
89264
|
+
});
|
|
89265
|
+
}
|
|
89266
|
+
}
|
|
89267
|
+
/**
|
|
89268
|
+
* The bus's door into {@link handleSensorStateChanged}: tracked, so shutdown
|
|
89269
|
+
* can drain it, and caught, so a throw is a tagged line instead of an
|
|
89270
|
+
* unhandled rejection. Refused once shutdown began.
|
|
89271
|
+
*/
|
|
89272
|
+
dispatchSensorStateChanged(data, timestampMs) {
|
|
89273
|
+
if (this.shuttingDown) return Promise.resolve();
|
|
89274
|
+
const run = this.handleSensorStateChanged(data, timestampMs).catch((err) => {
|
|
89275
|
+
this.ctxIfReady?.logger.warn("sensor state change handler failed", {
|
|
89276
|
+
tags: { deviceId: data.deviceId },
|
|
89277
|
+
meta: {
|
|
89278
|
+
capName: data.capName,
|
|
89279
|
+
error: errMsg$1(err)
|
|
89280
|
+
}
|
|
89281
|
+
});
|
|
89282
|
+
});
|
|
89283
|
+
this.sensorHandlersInFlight.add(run);
|
|
89284
|
+
run.finally(() => this.sensorHandlersInFlight.delete(run));
|
|
89285
|
+
return run;
|
|
89286
|
+
}
|
|
89287
|
+
/** Wait for the in-flight sensor handlers, bounded — see {@link sensorHandlersInFlight}. */
|
|
89288
|
+
async drainSensorHandlers() {
|
|
89289
|
+
const inFlight = [...this.sensorHandlersInFlight];
|
|
89290
|
+
if (inFlight.length === 0) return;
|
|
89291
|
+
let timer;
|
|
89292
|
+
const bound = new Promise((resolve) => {
|
|
89293
|
+
timer = setTimeout(() => resolve("bound"), SENSOR_HANDLER_DRAIN_MS);
|
|
89294
|
+
});
|
|
89295
|
+
const outcome = await Promise.race([Promise.all(inFlight).then(() => "drained"), bound]);
|
|
89296
|
+
clearTimeout(timer);
|
|
89297
|
+
if (outcome === "bound") this.ctx.logger.warn("shutdown: sensor handlers still running past the drain bound", { meta: {
|
|
89298
|
+
inFlight: this.sensorHandlersInFlight.size,
|
|
89299
|
+
boundMs: SENSOR_HANDLER_DRAIN_MS
|
|
89300
|
+
} });
|
|
89301
|
+
}
|
|
89302
|
+
/**
|
|
88458
89303
|
* Sensor-event ingest handler — `DeviceStateChanged` of a device exposing a
|
|
88459
89304
|
* mapped sensor cap. Resolves the camera set through the TTL cache (the
|
|
88460
89305
|
* device's LINKED cameras, plus the device itself when it is a camera
|
|
88461
89306
|
* carrying the cap) and inserts ONE row per camera. Best-effort
|
|
88462
|
-
* (telemetry-lossy).
|
|
89307
|
+
* (telemetry-lossy). The NOTIFICATION is not per row when more than one
|
|
89308
|
+
* camera links the sensor (ADR 0632): the linked cameras are photographed at
|
|
89309
|
+
* the trigger and the change is evaluated ONCE, at the sensor level.
|
|
88463
89310
|
*
|
|
88464
89311
|
* D8/D11 boundary, stated plainly: the ride here is the LOSSY
|
|
88465
89312
|
* `DeviceStateChanged` bus, and there is no reconcile that replays a missed
|
|
@@ -88480,6 +89327,18 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
88480
89327
|
const cache = this.linkedCamerasCache;
|
|
88481
89328
|
if (store === null || cache === null) return;
|
|
88482
89329
|
if (!this.sensorSliceCarriedATransition(data)) return;
|
|
89330
|
+
const cameras = await resolveSensorNotificationCameras(cache, data).catch((err) => {
|
|
89331
|
+
this.ctx.logger.debug("linked-camera resolution failed — evaluated per row", {
|
|
89332
|
+
tags: { deviceId: data.deviceId },
|
|
89333
|
+
meta: {
|
|
89334
|
+
capName: data.capName,
|
|
89335
|
+
error: errMsg$1(err)
|
|
89336
|
+
}
|
|
89337
|
+
});
|
|
89338
|
+
return NO_SENSOR_NOTIFICATION_CAMERAS;
|
|
89339
|
+
});
|
|
89340
|
+
const linkedSet = cameras.mediaCameraIds.length > 1;
|
|
89341
|
+
const linkedStills = linkedSet ? this.startLinkedStills(data, timestamp, cameras) : void 0;
|
|
88483
89342
|
const persisted = [];
|
|
88484
89343
|
const unowned = [];
|
|
88485
89344
|
try {
|
|
@@ -88524,18 +89383,33 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
88524
89383
|
}
|
|
88525
89384
|
});
|
|
88526
89385
|
},
|
|
88527
|
-
onSkipped: (r) =>
|
|
88528
|
-
|
|
88529
|
-
|
|
88530
|
-
|
|
88531
|
-
|
|
88532
|
-
|
|
88533
|
-
|
|
89386
|
+
onSkipped: (r) => {
|
|
89387
|
+
if (r.error !== void 0) {
|
|
89388
|
+
this.ctx.logger.warn("synthetic sensor-track materialize rejected", {
|
|
89389
|
+
tags: { deviceId: r.cameraId ?? r.sourceDeviceId },
|
|
89390
|
+
meta: {
|
|
89391
|
+
capName: r.capName,
|
|
89392
|
+
kind: r.kind,
|
|
89393
|
+
reason: r.reason,
|
|
89394
|
+
sourceDeviceId: r.sourceDeviceId,
|
|
89395
|
+
error: r.error
|
|
89396
|
+
}
|
|
89397
|
+
});
|
|
89398
|
+
return;
|
|
88534
89399
|
}
|
|
88535
|
-
|
|
89400
|
+
this.ctx.logger.debug("synthetic sensor-track NOT projected", {
|
|
89401
|
+
tags: { deviceId: r.cameraId ?? r.sourceDeviceId },
|
|
89402
|
+
meta: {
|
|
89403
|
+
capName: r.capName,
|
|
89404
|
+
kind: r.kind,
|
|
89405
|
+
reason: r.reason,
|
|
89406
|
+
sourceDeviceId: r.sourceDeviceId
|
|
89407
|
+
}
|
|
89408
|
+
});
|
|
89409
|
+
}
|
|
88536
89410
|
}, data, timestamp);
|
|
88537
89411
|
} catch (err) {
|
|
88538
|
-
this.ctx.logger.warn("synthetic sensor-track
|
|
89412
|
+
this.ctx.logger.warn("synthetic sensor-track camera resolution / producing-device name read failed", {
|
|
88539
89413
|
tags: { deviceId: data.deviceId },
|
|
88540
89414
|
meta: {
|
|
88541
89415
|
capName: data.capName,
|
|
@@ -88543,6 +89417,17 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
88543
89417
|
}
|
|
88544
89418
|
});
|
|
88545
89419
|
}
|
|
89420
|
+
if (linkedSet) {
|
|
89421
|
+
this.handOffLinkedSensorEvent({
|
|
89422
|
+
data,
|
|
89423
|
+
timestamp,
|
|
89424
|
+
mediaCameraIds: cameras.mediaCameraIds,
|
|
89425
|
+
persisted,
|
|
89426
|
+
markerByCamera,
|
|
89427
|
+
...linkedStills !== void 0 ? { linkedStills } : {}
|
|
89428
|
+
});
|
|
89429
|
+
return;
|
|
89430
|
+
}
|
|
88546
89431
|
for (const ev of persisted) try {
|
|
88547
89432
|
this.notificationCenter?.onSensorEventPersisted(ev, markerByCamera.get(ev.deviceId)?.trackId);
|
|
88548
89433
|
} catch (err) {
|