@camstack/addon-post-analysis 1.2.67 → 1.2.68
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-9EkXtpfJ.js → dist-Dl1kEFqi.js} +85 -26
- package/dist/{dist-DJC4JrMD.mjs → dist-rpHe5qpB.mjs} +68 -27
- 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-ZFLOdLjS.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-DgDC53Dh.mjs} +3 -3
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-B57Jy4_o.mjs +26 -0
- package/dist/pipeline-analytics/{hostInit-b11Ff_-Y.mjs → hostInit-GicJZVng.mjs} +3 -3
- package/dist/pipeline-analytics/index.js +2513 -250
- package/dist/pipeline-analytics/index.mjs +2505 -242
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CocFIK-G.mjs +0 -26
|
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
|
|
|
2
2
|
__esModule: { value: true },
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
|
-
const require_dist = require("../dist-
|
|
5
|
+
const require_dist = require("../dist-Dl1kEFqi.js");
|
|
6
6
|
let node_fs = require("node:fs");
|
|
7
7
|
node_fs = require_dist.__toESM(node_fs, 1);
|
|
8
8
|
let node_path = require("node:path");
|
|
@@ -12,9 +12,9 @@ let node_crypto = require("node:crypto");
|
|
|
12
12
|
let node_child_process = require("node:child_process");
|
|
13
13
|
let node_os = require("node:os");
|
|
14
14
|
node_os = require_dist.__toESM(node_os);
|
|
15
|
-
let node_buffer = require("node:buffer");
|
|
16
15
|
let sharp = require("sharp");
|
|
17
16
|
sharp = require_dist.__toESM(sharp);
|
|
17
|
+
let node_buffer = require("node:buffer");
|
|
18
18
|
//#region src/notification-center/action-token.ts
|
|
19
19
|
/**
|
|
20
20
|
* The authority behind a notification button.
|
|
@@ -1759,8 +1759,8 @@ function defaultPrompt(input) {
|
|
|
1759
1759
|
}
|
|
1760
1760
|
/** Longest-edge downscale via sharp — the production `downscale` dep. */
|
|
1761
1761
|
async function downscaleJpeg(bytes, maxPx) {
|
|
1762
|
-
const { default: sharp$
|
|
1763
|
-
const out = await sharp$
|
|
1762
|
+
const { default: sharp$12 } = await import("sharp");
|
|
1763
|
+
const out = await sharp$12(Buffer.from(bytes)).resize({
|
|
1764
1764
|
width: maxPx,
|
|
1765
1765
|
height: maxPx,
|
|
1766
1766
|
fit: "inside",
|
|
@@ -2718,25 +2718,18 @@ function scopeZoneIdsToDevice(ids, deviceId, owner) {
|
|
|
2718
2718
|
/**
|
|
2719
2719
|
* The rule-level entry point: scope this rule's zones to this camera.
|
|
2720
2720
|
*
|
|
2721
|
-
* ──
|
|
2721
|
+
* ── One semantic for scoped AND unscoped rules (operator, 2026-08-13) ───────
|
|
2722
2722
|
*
|
|
2723
|
-
*
|
|
2724
|
-
*
|
|
2725
|
-
*
|
|
2726
|
-
*
|
|
2727
|
-
*
|
|
2728
|
-
*
|
|
2729
|
-
*
|
|
2730
|
-
*
|
|
2731
|
-
*
|
|
2732
|
-
* The rule editor takes zone ids as free text (`zoneIdList` / `zoneSelection`
|
|
2733
|
-
* tag inputs, no per-camera picker), so an all-devices rule carrying one
|
|
2734
|
-
* camera's zone ids is fully authorable and exists in the wild. Its zones stay
|
|
2735
|
-
* flat: the rule remains, as today, effectively about the camera that owns
|
|
2736
|
-
* those zones. Naming the cameras is the operator's way of asking for more.
|
|
2723
|
+
* "No zones set on a camera means everywhere." A zone in the list narrows ONLY
|
|
2724
|
+
* the camera that owns it; every other camera the rule reaches — named or
|
|
2725
|
+
* reached because the rule names no camera at all — matches whole-frame. The
|
|
2726
|
+
* first cut kept unscoped rules flat to avoid widening "notify me when someone
|
|
2727
|
+
* is in the driveway" into an all-camera rule; the operator overruled it: an
|
|
2728
|
+
* unscoped rule already IS an all-camera rule, and the zone is a per-camera
|
|
2729
|
+
* narrowing, not a rule-wide one. Scoping a rule to cameras remains the way to
|
|
2730
|
+
* limit where it fires.
|
|
2737
2731
|
*/
|
|
2738
2732
|
function scopeZoneIdsForRule(input, owner) {
|
|
2739
|
-
if (input.deviceScope === void 0 || input.deviceScope.length === 0) return UNCHANGED;
|
|
2740
2733
|
return scopeZoneIdsToDevice(input.ids, input.deviceId, owner);
|
|
2741
2734
|
}
|
|
2742
2735
|
//#endregion
|
|
@@ -3522,7 +3515,6 @@ var en_default = {
|
|
|
3522
3515
|
"other": "{{events}} stretches filmed at a dense cadence"
|
|
3523
3516
|
},
|
|
3524
3517
|
"timelapse.body.detections": "{{detectionSummary}}",
|
|
3525
|
-
"timelapse.body.coverageWarning": "WARNING: recording available for only {{coverage}}% of the window",
|
|
3526
3518
|
"snooze.digest.title": {
|
|
3527
3519
|
"one": "Snooze ended — {{total}} notification hidden",
|
|
3528
3520
|
"other": "Snooze ended — {{total}} notifications hidden"
|
|
@@ -3554,7 +3546,20 @@ var en_default = {
|
|
|
3554
3546
|
"system.alarm-disarmed.body": "The alarm was disarmed",
|
|
3555
3547
|
"system.alarm-triggered.title": "ALARM",
|
|
3556
3548
|
"system.alarm-triggered.body": "The alarm was triggered",
|
|
3557
|
-
"system.alarm-triggered.body.rule": "The alarm was triggered by {{rule}} on {{deviceName}}"
|
|
3549
|
+
"system.alarm-triggered.body.rule": "The alarm was triggered by {{rule}} on {{deviceName}}",
|
|
3550
|
+
"summary.title": "Summary — {{rule}}",
|
|
3551
|
+
"summary.body.window": "{{from}}→{{to}}",
|
|
3552
|
+
"summary.body.events": {
|
|
3553
|
+
"one": "{{events}} event",
|
|
3554
|
+
"other": "{{events}} events"
|
|
3555
|
+
},
|
|
3556
|
+
"summary.body.cameras": {
|
|
3557
|
+
"one": "{{cameras}} camera",
|
|
3558
|
+
"other": "{{cameras}} cameras"
|
|
3559
|
+
},
|
|
3560
|
+
"summary.body.detections": "{{detectionSummary}}",
|
|
3561
|
+
"summary.body.truncated": "showing the {{shown}} most relevant of {{total}}",
|
|
3562
|
+
"summary.body.empty": "nothing was seen in this window"
|
|
3558
3563
|
};
|
|
3559
3564
|
//#endregion
|
|
3560
3565
|
//#region src/notification-center/texts/it.json
|
|
@@ -3651,7 +3656,6 @@ var it_default = {
|
|
|
3651
3656
|
"other": "{{events}} intervalli ripresi a cadenza fitta"
|
|
3652
3657
|
},
|
|
3653
3658
|
"timelapse.body.detections": "{{detectionSummary}}",
|
|
3654
|
-
"timelapse.body.coverageWarning": "ATTENZIONE: registrazione disponibile solo per il {{coverage}}% della finestra",
|
|
3655
3659
|
"snooze.digest.title": {
|
|
3656
3660
|
"one": "Pausa terminata — {{total}} notifica nascosta",
|
|
3657
3661
|
"other": "Pausa terminata — {{total}} notifiche nascoste"
|
|
@@ -3683,7 +3687,20 @@ var it_default = {
|
|
|
3683
3687
|
"system.alarm-disarmed.body": "L'allarme è stato disattivato",
|
|
3684
3688
|
"system.alarm-triggered.title": "ALLARME",
|
|
3685
3689
|
"system.alarm-triggered.body": "L'allarme è scattato",
|
|
3686
|
-
"system.alarm-triggered.body.rule": "L'allarme è scattato per {{rule}} su {{deviceName}}"
|
|
3690
|
+
"system.alarm-triggered.body.rule": "L'allarme è scattato per {{rule}} su {{deviceName}}",
|
|
3691
|
+
"summary.title": "Riepilogo — {{rule}}",
|
|
3692
|
+
"summary.body.window": "{{from}}→{{to}}",
|
|
3693
|
+
"summary.body.events": {
|
|
3694
|
+
"one": "{{events}} evento",
|
|
3695
|
+
"other": "{{events}} eventi"
|
|
3696
|
+
},
|
|
3697
|
+
"summary.body.cameras": {
|
|
3698
|
+
"one": "{{cameras}} camera",
|
|
3699
|
+
"other": "{{cameras}} camere"
|
|
3700
|
+
},
|
|
3701
|
+
"summary.body.detections": "{{detectionSummary}}",
|
|
3702
|
+
"summary.body.truncated": "mostrati i {{shown}} più rilevanti su {{total}}",
|
|
3703
|
+
"summary.body.empty": "nessun evento in questa finestra"
|
|
3687
3704
|
};
|
|
3688
3705
|
//#endregion
|
|
3689
3706
|
//#region src/notification-center/text-catalog.ts
|
|
@@ -4609,8 +4626,8 @@ var NcDispatcher = class {
|
|
|
4609
4626
|
try {
|
|
4610
4627
|
const points = (await this.deps.getZonePolygons?.(deviceId, zoneIds) ?? []).flat();
|
|
4611
4628
|
if (points.length === 0) return null;
|
|
4612
|
-
const { default: sharp$
|
|
4613
|
-
const img = sharp$
|
|
4629
|
+
const { default: sharp$11 } = await import("sharp");
|
|
4630
|
+
const img = sharp$11(Buffer.from(jpeg));
|
|
4614
4631
|
const meta = await img.metadata();
|
|
4615
4632
|
const W = meta.width ?? 0;
|
|
4616
4633
|
const H = meta.height ?? 0;
|
|
@@ -5758,7 +5775,7 @@ var NC_OCCUPANCY_INDEXES = [{
|
|
|
5758
5775
|
}];
|
|
5759
5776
|
/** Query cap — a per-(device, zone, class) key set is small; this is a
|
|
5760
5777
|
* generous ceiling that still bounds a pathological read. */
|
|
5761
|
-
var LOAD_LIMIT$
|
|
5778
|
+
var LOAD_LIMIT$2 = 1e5;
|
|
5762
5779
|
var OccupancyStore = class {
|
|
5763
5780
|
cache = /* @__PURE__ */ new Map();
|
|
5764
5781
|
store;
|
|
@@ -5785,7 +5802,7 @@ var OccupancyStore = class {
|
|
|
5785
5802
|
try {
|
|
5786
5803
|
const records = await this.store.query.query({
|
|
5787
5804
|
collection: NC_OCCUPANCY_COLLECTION,
|
|
5788
|
-
filter: { limit: LOAD_LIMIT$
|
|
5805
|
+
filter: { limit: LOAD_LIMIT$2 }
|
|
5789
5806
|
});
|
|
5790
5807
|
this.cache.clear();
|
|
5791
5808
|
let skipped = 0;
|
|
@@ -6376,6 +6393,18 @@ function entryToRow(entry) {
|
|
|
6376
6393
|
payload: entry.payload
|
|
6377
6394
|
};
|
|
6378
6395
|
}
|
|
6396
|
+
/**
|
|
6397
|
+
* Every kind a persisted row may carry, for the READ path.
|
|
6398
|
+
*
|
|
6399
|
+
* This must list the WHOLE union. A kind missing here makes `rowToEntry` answer
|
|
6400
|
+
* `null`, and a `null` row is invisible three ways at once: it is not
|
|
6401
|
+
* re-hydrated as pending at boot (so a row still queued when the process
|
|
6402
|
+
* restarted is dropped on the floor), it never appears in `queryHistory`, and
|
|
6403
|
+
* it never appears in the recent-rows scan. `'timelapse'` was missing from this
|
|
6404
|
+
* set from the day it shipped — the live deliveries hid it, because an enqueue
|
|
6405
|
+
* hands the entry straight to the drain from memory and only a RESTART goes
|
|
6406
|
+
* through this function.
|
|
6407
|
+
*/
|
|
6379
6408
|
var OUTBOX_RECORD_KINDS = new Set([
|
|
6380
6409
|
"object-event",
|
|
6381
6410
|
"track-end",
|
|
@@ -6383,7 +6412,9 @@ var OUTBOX_RECORD_KINDS = new Set([
|
|
|
6383
6412
|
"package-event",
|
|
6384
6413
|
"audio-event",
|
|
6385
6414
|
"audio-window",
|
|
6386
|
-
"system-event"
|
|
6415
|
+
"system-event",
|
|
6416
|
+
"timelapse",
|
|
6417
|
+
"ai-summary"
|
|
6387
6418
|
]);
|
|
6388
6419
|
function isOutboxRecordKind(x) {
|
|
6389
6420
|
return typeof x === "string" && OUTBOX_RECORD_KINDS.has(x);
|
|
@@ -7478,8 +7509,8 @@ async function deriveSyntheticMedia(jpeg, bbox, timestamp) {
|
|
|
7478
7509
|
];
|
|
7479
7510
|
if (bbox === void 0) return files;
|
|
7480
7511
|
try {
|
|
7481
|
-
const { default: sharp$
|
|
7482
|
-
const meta = await sharp$
|
|
7512
|
+
const { default: sharp$10 } = await import("sharp");
|
|
7513
|
+
const meta = await sharp$10(Buffer.from(jpeg)).metadata();
|
|
7483
7514
|
const W = meta.width ?? 0;
|
|
7484
7515
|
const H = meta.height ?? 0;
|
|
7485
7516
|
if (W === 0 || H === 0) return files;
|
|
@@ -7487,7 +7518,7 @@ async function deriveSyntheticMedia(jpeg, bbox, timestamp) {
|
|
|
7487
7518
|
const top = Math.max(0, Math.min(H - 1, Math.floor(bbox.y * H)));
|
|
7488
7519
|
const width = Math.max(1, Math.min(W - left, Math.ceil(bbox.w * W)));
|
|
7489
7520
|
const height = Math.max(1, Math.min(H - top, Math.ceil(bbox.h * H)));
|
|
7490
|
-
const cropBuf = await sharp$
|
|
7521
|
+
const cropBuf = await sharp$10(Buffer.from(jpeg)).extract({
|
|
7491
7522
|
left,
|
|
7492
7523
|
top,
|
|
7493
7524
|
width,
|
|
@@ -7509,7 +7540,7 @@ async function deriveSyntheticMedia(jpeg, bbox, timestamp) {
|
|
|
7509
7540
|
});
|
|
7510
7541
|
const stroke = Math.max(2, Math.round(Math.min(W, H) / 240));
|
|
7511
7542
|
const overlay = Buffer.from(`<svg width="${W}" height="${H}"><rect x="${left}" y="${top}" width="${width}" height="${height}" fill="none" stroke="#22d3ee" stroke-width="${stroke}"/></svg>`);
|
|
7512
|
-
const boxedBuf = await sharp$
|
|
7543
|
+
const boxedBuf = await sharp$10(Buffer.from(jpeg)).composite([{
|
|
7513
7544
|
input: overlay,
|
|
7514
7545
|
top: 0,
|
|
7515
7546
|
left: 0
|
|
@@ -7992,10 +8023,18 @@ function clampToFloor(everyMs) {
|
|
|
7992
8023
|
*
|
|
7993
8024
|
* Pure — no clock, no I/O, no store. The scheduler hands over the tracks the
|
|
7994
8025
|
* window actually contained (`pipelineAnalytics.listTracks`, which carry
|
|
7995
|
-
* `firstSeen`/`lastSeen`) and gets back ranges expressed as SECONDS
|
|
7996
|
-
* WINDOW START, which is exactly the coordinate system
|
|
8026
|
+
* `firstSeen`/`lastSeen`) and gets back ranges expressed as WALL-CLOCK SECONDS
|
|
8027
|
+
* FROM THE WINDOW START, which is exactly the coordinate system
|
|
7997
8028
|
* `ExportTimelapseSchema.dense.ranges` speaks.
|
|
7998
8029
|
*
|
|
8030
|
+
* WALL clock is the load-bearing word. It is NOT ffmpeg's `t`: `t` runs over
|
|
8031
|
+
* the concatenation of the segments that exist, so every recording gap is
|
|
8032
|
+
* missing from it. The recorder translates one into the other at render time
|
|
8033
|
+
* (`export-dense-map.ts` in addon-pipeline), because only the side that builds
|
|
8034
|
+
* the source playlist knows where the gaps are. Handing `t` seconds from here
|
|
8035
|
+
* is impossible — a track knows when it happened, not how much footage
|
|
8036
|
+
* preceded it.
|
|
8037
|
+
*
|
|
7999
8038
|
* Why post-hoc and not a live sampler: the footage already exists, the tracks
|
|
8000
8039
|
* already exist, and the recorder already knows how to render a range. A live
|
|
8001
8040
|
* sampler would hold a frame ring open for eight hours per camera to
|
|
@@ -8144,27 +8183,6 @@ function detectionTemplateVars(texts, counts) {
|
|
|
8144
8183
|
* short video gets skipped too.
|
|
8145
8184
|
*/
|
|
8146
8185
|
var COVERAGE_COMPLETE_SLACK_MS = 3e4;
|
|
8147
|
-
/**
|
|
8148
|
-
* Is this coverage worth a sentence in the notification body?
|
|
8149
|
-
*
|
|
8150
|
-
* TWO conditions, and the second one is the whole point: the shortfall must be
|
|
8151
|
-
* past the slack AND the figure the sentence would PRINT must itself be short.
|
|
8152
|
-
*
|
|
8153
|
-
* They came apart live on 2026-08-12. A 10 h window on device 615 was covered
|
|
8154
|
-
* to 35,858,961 / 36,000,000 ms — 2m21s missing, so `complete` was false — and
|
|
8155
|
-
* `percent` was `Math.round(99.608) = 100`. The body read *"ATTENZIONE:
|
|
8156
|
-
* registrazione disponibile solo per il 100% della finestra"*: a warning about
|
|
8157
|
-
* a complete window, which is worse than no warning at all. The operator
|
|
8158
|
-
* learns to skip that line, and the night the video really IS a quarter of the
|
|
8159
|
-
* window gets skipped with it.
|
|
8160
|
-
*
|
|
8161
|
-
* So the gate is tied to the number the reader sees. Reporting is the only
|
|
8162
|
-
* consumer of this predicate — {@link FootageCoverage.complete} still states
|
|
8163
|
-
* the physical fact for anything that needs it.
|
|
8164
|
-
*/
|
|
8165
|
-
function coverageWorthReporting(coverage) {
|
|
8166
|
-
return !coverage.complete && coverage.percent < 100;
|
|
8167
|
-
}
|
|
8168
8186
|
/** Merge + clamp + sum. */
|
|
8169
8187
|
function footageCoverage(ranges, windowStartMs, windowEndMs) {
|
|
8170
8188
|
const windowMs = Math.max(0, windowEndMs - windowStartMs);
|
|
@@ -8236,7 +8254,7 @@ function pickProfile(ranges, window) {
|
|
|
8236
8254
|
return best;
|
|
8237
8255
|
}
|
|
8238
8256
|
/** `HH:MM`, for the export title an operator sees in the exports list. */
|
|
8239
|
-
function clockOf$
|
|
8257
|
+
function clockOf$3(atMs) {
|
|
8240
8258
|
const d = new Date(atMs);
|
|
8241
8259
|
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
8242
8260
|
}
|
|
@@ -8329,7 +8347,7 @@ var TimelapseRenderer = class {
|
|
|
8329
8347
|
includeAudio: false,
|
|
8330
8348
|
maxLifeMs: TIMELAPSE_EXPORT_LIFE_MS,
|
|
8331
8349
|
deleteAfterDownload: false,
|
|
8332
|
-
title: `${request.ruleName} ${clockOf$
|
|
8350
|
+
title: `${request.ruleName} ${clockOf$3(window.startMs)}→${clockOf$3(window.endMs)}`,
|
|
8333
8351
|
timelapse: {
|
|
8334
8352
|
everyMs,
|
|
8335
8353
|
outputFps: request.framerate,
|
|
@@ -8447,7 +8465,7 @@ var TimelapseRenderer = class {
|
|
|
8447
8465
|
//#region src/notification-center/timelapse/timelapse-delivery.ts
|
|
8448
8466
|
/** What a caller that counted nothing looks like — so `{{detections}}` renders
|
|
8449
8467
|
* `0` and `{{detectionSummary}}` renders empty, never the literal braces. */
|
|
8450
|
-
var NO_DETECTIONS = {
|
|
8468
|
+
var NO_DETECTIONS$1 = {
|
|
8451
8469
|
total: 0,
|
|
8452
8470
|
byClass: {},
|
|
8453
8471
|
classes: []
|
|
@@ -8459,41 +8477,40 @@ var NO_DETECTIONS = {
|
|
|
8459
8477
|
* prose. A translator has nothing to say about it, and every key the guard has
|
|
8460
8478
|
* to check is a key somebody can get wrong.
|
|
8461
8479
|
*/
|
|
8462
|
-
var BODY_SEPARATOR = " · ";
|
|
8480
|
+
var BODY_SEPARATOR$1 = " · ";
|
|
8463
8481
|
/** `HH:MM` in the host timezone — the two numbers a body actually needs. */
|
|
8464
|
-
function clockOf(atMs) {
|
|
8482
|
+
function clockOf$2(atMs) {
|
|
8465
8483
|
const d = new Date(atMs);
|
|
8466
8484
|
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
8467
8485
|
}
|
|
8468
8486
|
/** Template vocabulary. Deliberately the same NAMES the rule templates use
|
|
8469
8487
|
* (`camera`, `rule`, `time`) plus the timelapse-only ones. */
|
|
8470
|
-
function templateVars(input) {
|
|
8488
|
+
function templateVars$1(input) {
|
|
8471
8489
|
return {
|
|
8472
8490
|
camera: input.deviceName ?? `camera ${input.deviceId}`,
|
|
8473
8491
|
rule: input.ruleName,
|
|
8474
|
-
from: clockOf(input.window.startMs),
|
|
8475
|
-
to: clockOf(input.window.endMs),
|
|
8476
|
-
time: clockOf(input.generatedAt),
|
|
8492
|
+
from: clockOf$2(input.window.startMs),
|
|
8493
|
+
to: clockOf$2(input.window.endMs),
|
|
8494
|
+
time: clockOf$2(input.generatedAt),
|
|
8477
8495
|
coverage: String(input.coverage.percent),
|
|
8478
8496
|
events: String(input.denseRanges),
|
|
8479
|
-
...detectionTemplateVars(input.texts, input.detections ?? NO_DETECTIONS)
|
|
8497
|
+
...detectionTemplateVars(input.texts, input.detections ?? NO_DETECTIONS$1)
|
|
8480
8498
|
};
|
|
8481
8499
|
}
|
|
8482
8500
|
/**
|
|
8483
8501
|
* The body an operator reads when he did not author one.
|
|
8484
8502
|
*
|
|
8485
|
-
*
|
|
8486
|
-
*
|
|
8487
|
-
*
|
|
8488
|
-
*
|
|
8489
|
-
*
|
|
8490
|
-
*
|
|
8491
|
-
*
|
|
8492
|
-
*
|
|
8493
|
-
*
|
|
8494
|
-
|
|
8495
|
-
|
|
8496
|
-
function derivedBody(input, vars) {
|
|
8503
|
+
* **No coverage sentence.** It used to end with one whenever the window was
|
|
8504
|
+
* short, and it was removed on the operator's instruction: the clause fired on
|
|
8505
|
+
* every night the recorder had stumbled at all — including once at a printed
|
|
8506
|
+
* *100%* (2026-08-12, device 615: 141 s missing out of 10 h, so the window was
|
|
8507
|
+
* incomplete while the rounded figure was 100) — and a line that cries wolf is
|
|
8508
|
+
* a line the reader skips, which costs the clauses beside it too. The figure
|
|
8509
|
+
* is not lost: it rides the `timelapse produced` log line as `coveragePct`,
|
|
8510
|
+
* which is where a short video is diagnosed, and `{{coverage}}` still resolves
|
|
8511
|
+
* for an operator who puts it in a body HE wrote.
|
|
8512
|
+
*/
|
|
8513
|
+
function derivedBody$1(input, vars) {
|
|
8497
8514
|
const texts = input.texts;
|
|
8498
8515
|
const parts = [texts.text({
|
|
8499
8516
|
key: "timelapse.body.window",
|
|
@@ -8508,18 +8525,14 @@ function derivedBody(input, vars) {
|
|
|
8508
8525
|
count: input.denseRanges,
|
|
8509
8526
|
vars
|
|
8510
8527
|
}));
|
|
8511
|
-
if ((input.detections ?? NO_DETECTIONS).total > 0) parts.push(texts.text({
|
|
8528
|
+
if ((input.detections ?? NO_DETECTIONS$1).total > 0) parts.push(texts.text({
|
|
8512
8529
|
key: "timelapse.body.detections",
|
|
8513
8530
|
vars
|
|
8514
8531
|
}));
|
|
8515
|
-
|
|
8516
|
-
key: "timelapse.body.coverageWarning",
|
|
8517
|
-
vars
|
|
8518
|
-
}));
|
|
8519
|
-
return parts.filter((part) => part.length > 0).join(BODY_SEPARATOR);
|
|
8532
|
+
return parts.filter((part) => part.length > 0).join(BODY_SEPARATOR$1);
|
|
8520
8533
|
}
|
|
8521
8534
|
/** Per-target params with the derived `clickUrl` filled in where absent. */
|
|
8522
|
-
function paramsFor(target, clickUrl) {
|
|
8535
|
+
function paramsFor$1(target, clickUrl) {
|
|
8523
8536
|
if (clickUrl === void 0) return target.params;
|
|
8524
8537
|
if (target.params === void 0) return { clickUrl };
|
|
8525
8538
|
if (typeof target.params["clickUrl"] === "string") return target.params;
|
|
@@ -8543,8 +8556,8 @@ function timelapsePreviewText(input) {
|
|
|
8543
8556
|
camera: input.deviceName ?? `camera ${input.deviceId}`,
|
|
8544
8557
|
...input.ruleName !== void 0 ? { rule: input.ruleName } : {},
|
|
8545
8558
|
...input.window !== void 0 ? {
|
|
8546
|
-
from: clockOf(input.window.startMs),
|
|
8547
|
-
to: clockOf(input.window.endMs)
|
|
8559
|
+
from: clockOf$2(input.window.startMs),
|
|
8560
|
+
to: clockOf$2(input.window.endMs)
|
|
8548
8561
|
} : {}
|
|
8549
8562
|
};
|
|
8550
8563
|
return renderTemplate(input.text ?? "Timelapse {{camera}}", vars) ?? "";
|
|
@@ -8559,13 +8572,13 @@ function timelapseRecordId(deviceId, windowEndMs) {
|
|
|
8559
8572
|
* @returns rows ready for `NcOutbox.enqueue` — never sent from here.
|
|
8560
8573
|
*/
|
|
8561
8574
|
function buildTimelapseOutboxInputs(input) {
|
|
8562
|
-
const vars = templateVars(input);
|
|
8575
|
+
const vars = templateVars$1(input);
|
|
8563
8576
|
const optedOut = new Set(input.disabledTargetIds ?? []);
|
|
8564
8577
|
const title = renderTemplate(input.template?.title, vars) ?? input.texts.text({
|
|
8565
8578
|
key: "timelapse.title",
|
|
8566
8579
|
vars
|
|
8567
8580
|
});
|
|
8568
|
-
const body = renderTemplate(input.template?.body, vars) ?? derivedBody(input, vars);
|
|
8581
|
+
const body = renderTemplate(input.template?.body, vars) ?? derivedBody$1(input, vars);
|
|
8569
8582
|
const recordId = timelapseRecordId(input.deviceId, input.window.endMs);
|
|
8570
8583
|
const artifacts = [...input.preview !== void 0 ? [{
|
|
8571
8584
|
mediaType: "image",
|
|
@@ -8585,7 +8598,7 @@ function buildTimelapseOutboxInputs(input) {
|
|
|
8585
8598
|
},
|
|
8586
8599
|
media: "none",
|
|
8587
8600
|
...artifacts.length > 0 ? { artifacts } : {},
|
|
8588
|
-
...paramsFor(target, input.clickUrl) !== void 0 ? { params: paramsFor(target, input.clickUrl) } : {},
|
|
8601
|
+
...paramsFor$1(target, input.clickUrl) !== void 0 ? { params: paramsFor$1(target, input.clickUrl) } : {},
|
|
8589
8602
|
subject: {
|
|
8590
8603
|
deviceId: input.deviceId,
|
|
8591
8604
|
className: "timelapse",
|
|
@@ -8912,7 +8925,1784 @@ var NC_TIMELAPSE_RULES_COLUMNS = [
|
|
|
8912
8925
|
notNull: true
|
|
8913
8926
|
},
|
|
8914
8927
|
(
|
|
8915
|
-
/** The FULL rule object (Zod-validated on read) — scalars above are
|
|
8928
|
+
/** The FULL rule object (Zod-validated on read) — scalars above are
|
|
8929
|
+
* indexed projections only. */
|
|
8930
|
+
{
|
|
8931
|
+
name: "rule",
|
|
8932
|
+
type: "JSON",
|
|
8933
|
+
notNull: true
|
|
8934
|
+
})
|
|
8935
|
+
];
|
|
8936
|
+
var NC_TIMELAPSE_RULES_INDEXES = [{
|
|
8937
|
+
name: "idx_nc_timelapse_rules_enabled",
|
|
8938
|
+
columns: ["enabled"]
|
|
8939
|
+
}];
|
|
8940
|
+
/** Query cap — the rule set is operator-authored and tiny; a generous ceiling. */
|
|
8941
|
+
var LOAD_LIMIT$1 = 1e4;
|
|
8942
|
+
/**
|
|
8943
|
+
* Resolve the three-way `template` patch signal onto a merged rule, immutably:
|
|
8944
|
+
* `undefined` (key absent) leaves it as-is, `null` DROPS the key, an object
|
|
8945
|
+
* replaces it. Keeping `null` out of the persisted rule is what lets
|
|
8946
|
+
* `TimelapseRuleSchema` stay a plain `.optional()`.
|
|
8947
|
+
*/
|
|
8948
|
+
function applyTemplatePatch$1(merged, template) {
|
|
8949
|
+
if (template === void 0) return merged;
|
|
8950
|
+
if (template !== null) return {
|
|
8951
|
+
...merged,
|
|
8952
|
+
template
|
|
8953
|
+
};
|
|
8954
|
+
const { template: _cleared, ...withoutTemplate } = merged;
|
|
8955
|
+
return withoutTemplate;
|
|
8956
|
+
}
|
|
8957
|
+
/** Narrowing guard — the persisted `rule` column is `unknown` until parsed. */
|
|
8958
|
+
function isRecord$1(value) {
|
|
8959
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
8960
|
+
}
|
|
8961
|
+
var TimelapseStore = class {
|
|
8962
|
+
byId = /* @__PURE__ */ new Map();
|
|
8963
|
+
/**
|
|
8964
|
+
* Birth stamps synthesised for LEGACY rows (see {@link withCreatedAt}), kept
|
|
8965
|
+
* so a repeated `load()` re-uses the FIRST one. Without it the synthesised
|
|
8966
|
+
* `createdAt` would advance to "now" on every refresh, and the scheduler's
|
|
8967
|
+
* eligibility floor — which is `max(stamp, createdAt)` — would creep past
|
|
8968
|
+
* every window such a rule was ever offered, silently producing nothing.
|
|
8969
|
+
*/
|
|
8970
|
+
synthesisedCreatedAt = /* @__PURE__ */ new Map();
|
|
8971
|
+
store;
|
|
8972
|
+
logger;
|
|
8973
|
+
now;
|
|
8974
|
+
newId;
|
|
8975
|
+
constructor(deps) {
|
|
8976
|
+
this.store = deps.store;
|
|
8977
|
+
this.logger = deps.logger;
|
|
8978
|
+
this.now = deps.now ?? (() => Date.now());
|
|
8979
|
+
this.newId = deps.newId ?? (() => (0, node_crypto.randomUUID)());
|
|
8980
|
+
}
|
|
8981
|
+
static async declare(store) {
|
|
8982
|
+
await store.declareCollection.mutate({
|
|
8983
|
+
collection: NC_TIMELAPSE_RULES_COLLECTION,
|
|
8984
|
+
columns: [...NC_TIMELAPSE_RULES_COLUMNS],
|
|
8985
|
+
indexes: [...NC_TIMELAPSE_RULES_INDEXES]
|
|
8986
|
+
});
|
|
8987
|
+
}
|
|
8988
|
+
/**
|
|
8989
|
+
* (Re)hydrate the FULL rule set from the store — called at boot and on the
|
|
8990
|
+
* periodic refresh tick. Replaces the cache wholesale; a row whose JSON no
|
|
8991
|
+
* longer validates is skipped with a warning (a degraded rule must never
|
|
8992
|
+
* crash the scheduler).
|
|
8993
|
+
*/
|
|
8994
|
+
async load() {
|
|
8995
|
+
try {
|
|
8996
|
+
const rows = await this.store.query.query({
|
|
8997
|
+
collection: NC_TIMELAPSE_RULES_COLLECTION,
|
|
8998
|
+
filter: { limit: LOAD_LIMIT$1 }
|
|
8999
|
+
});
|
|
9000
|
+
this.byId.clear();
|
|
9001
|
+
let skipped = 0;
|
|
9002
|
+
let synthesised = 0;
|
|
9003
|
+
for (const row of rows) {
|
|
9004
|
+
const raw = this.withCreatedAt(row.id, row.data["rule"]);
|
|
9005
|
+
if (raw !== row.data["rule"]) synthesised += 1;
|
|
9006
|
+
const parsed = require_dist.TimelapseRuleSchema.safeParse(raw);
|
|
9007
|
+
if (!parsed.success) {
|
|
9008
|
+
skipped += 1;
|
|
9009
|
+
continue;
|
|
9010
|
+
}
|
|
9011
|
+
this.byId.set(parsed.data.id, parsed.data);
|
|
9012
|
+
}
|
|
9013
|
+
this.logger.debug("timelapse rules loaded", { meta: {
|
|
9014
|
+
rules: this.byId.size,
|
|
9015
|
+
...skipped > 0 ? { skippedInvalid: skipped } : {},
|
|
9016
|
+
...synthesised > 0 ? { synthesisedCreatedAt: synthesised } : {}
|
|
9017
|
+
} });
|
|
9018
|
+
} catch (err) {
|
|
9019
|
+
this.logger.warn("timelapse rules load failed", { meta: { error: String(err) } });
|
|
9020
|
+
}
|
|
9021
|
+
}
|
|
9022
|
+
/**
|
|
9023
|
+
* Give a persisted blob a `createdAt` if it has none, WITHOUT persisting
|
|
9024
|
+
* anything: the rule reads as born at this load.
|
|
9025
|
+
*
|
|
9026
|
+
* `createdAt` has always been required by `TimelapseRuleSchema`, so a row
|
|
9027
|
+
* without one is a pre-schema artefact — and before this it was dropped
|
|
9028
|
+
* outright at load (a silently vanished rule). Reading it as "born now" is
|
|
9029
|
+
* the conservative choice in BOTH directions that matter: the rule survives,
|
|
9030
|
+
* and the scheduler's eligibility floor stops it back-filling a window that
|
|
9031
|
+
* closed years before anyone looked at it. The cost is one skipped window if
|
|
9032
|
+
* a legacy rule is loaded between a close and its settle — a bounded miss,
|
|
9033
|
+
* against an unbounded retry of a window no footage can satisfy.
|
|
9034
|
+
*
|
|
9035
|
+
* The synthesised value is remembered per rule id so a later refresh does not
|
|
9036
|
+
* move the floor, and the first `update`/`markGenerated` writes it through —
|
|
9037
|
+
* after which the row is no longer legacy.
|
|
9038
|
+
*/
|
|
9039
|
+
withCreatedAt(rowId, raw) {
|
|
9040
|
+
if (!isRecord$1(raw)) return raw;
|
|
9041
|
+
if (typeof raw["createdAt"] === "number") return raw;
|
|
9042
|
+
const stamped = this.synthesisedCreatedAt.get(rowId) ?? this.now();
|
|
9043
|
+
this.synthesisedCreatedAt.set(rowId, stamped);
|
|
9044
|
+
return {
|
|
9045
|
+
...raw,
|
|
9046
|
+
createdAt: stamped
|
|
9047
|
+
};
|
|
9048
|
+
}
|
|
9049
|
+
/** Every rule, newest-first (admin path). */
|
|
9050
|
+
list() {
|
|
9051
|
+
return [...this.byId.values()].sort((a, b) => b.updatedAt - a.updatedAt);
|
|
9052
|
+
}
|
|
9053
|
+
/** The scheduler's read: only rules that should be ticked. */
|
|
9054
|
+
listEnabled() {
|
|
9055
|
+
return this.list().filter((r) => r.enabled);
|
|
9056
|
+
}
|
|
9057
|
+
get(ruleId) {
|
|
9058
|
+
return this.byId.get(ruleId) ?? null;
|
|
9059
|
+
}
|
|
9060
|
+
/**
|
|
9061
|
+
* Rules visible to `userId`: their OWN personal rules (`ownerUserId ===
|
|
9062
|
+
* userId`) plus every admin/global rule (`ownerUserId` absent). Never another
|
|
9063
|
+
* user's personal rows. Newest-first (inherits {@link list}).
|
|
9064
|
+
*
|
|
9065
|
+
* The caller identity is server-derived; an absent/undefined caller must be
|
|
9066
|
+
* resolved to a fail-closed value by the bridge action BEFORE calling here —
|
|
9067
|
+
* this store never treats a missing caller as admin/global.
|
|
9068
|
+
*/
|
|
9069
|
+
listForOwner(userId) {
|
|
9070
|
+
return this.list().filter((r) => r.ownerUserId === void 0 || r.ownerUserId === userId);
|
|
9071
|
+
}
|
|
9072
|
+
/**
|
|
9073
|
+
* Mutation gate for a NON-admin caller: true only for a PERSONAL rule this
|
|
9074
|
+
* user owns. A global rule (no `ownerUserId`) returns false — global rules
|
|
9075
|
+
* are admin-only, and the bridge action grants admins the mutation without
|
|
9076
|
+
* consulting this check. An unknown rule id is false (fail-closed).
|
|
9077
|
+
*/
|
|
9078
|
+
isOwnedBy(ruleId, userId) {
|
|
9079
|
+
const rule = this.byId.get(ruleId);
|
|
9080
|
+
return rule?.ownerUserId !== void 0 && rule.ownerUserId === userId;
|
|
9081
|
+
}
|
|
9082
|
+
/**
|
|
9083
|
+
* Create a rule. `createdBy` is the SERVER-injected caller userId;
|
|
9084
|
+
* `ownerUserId` is the server-derived owner (omit for an admin/global rule).
|
|
9085
|
+
* Neither is ever read from `input`.
|
|
9086
|
+
*
|
|
9087
|
+
* The input is re-parsed through {@link TimelapseRuleInputSchema} BEFORE the
|
|
9088
|
+
* spread — that schema carries no ownership/provenance keys, so it strips any
|
|
9089
|
+
* that rode in on the blob. Without it, an `ownerUserId` on `input` would
|
|
9090
|
+
* survive whenever the `ownerUserId` ARGUMENT is omitted (the admin/global
|
|
9091
|
+
* path): a `TimelapseRule` is structurally assignable to `TimelapseRuleInput`,
|
|
9092
|
+
* so a future "duplicate rule" action (`create(existingRule, caller)`) would
|
|
9093
|
+
* compile cleanly and silently clone the ORIGINAL owner.
|
|
9094
|
+
*/
|
|
9095
|
+
async create(input, createdBy, ownerUserId) {
|
|
9096
|
+
const now = this.now();
|
|
9097
|
+
const rule = require_dist.TimelapseRuleSchema.parse({
|
|
9098
|
+
...require_dist.TimelapseRuleInputSchema.parse(input),
|
|
9099
|
+
id: this.newId(),
|
|
9100
|
+
...ownerUserId !== void 0 ? { ownerUserId } : {},
|
|
9101
|
+
createdBy,
|
|
9102
|
+
createdAt: now,
|
|
9103
|
+
updatedAt: now
|
|
9104
|
+
});
|
|
9105
|
+
await this.persist(rule);
|
|
9106
|
+
this.byId.set(rule.id, rule);
|
|
9107
|
+
return rule;
|
|
9108
|
+
}
|
|
9109
|
+
/**
|
|
9110
|
+
* Apply a partial patch. Immutable: returns the NEW rule object. Identity,
|
|
9111
|
+
* ownership and generation state are re-pinned from the existing rule AFTER
|
|
9112
|
+
* the spread — the patch schema carries none of them, and this makes a
|
|
9113
|
+
* hand-built (unparsed) patch object equally unable to re-own a rule.
|
|
9114
|
+
*
|
|
9115
|
+
* `template` is the one clearable field: an absent key leaves it unchanged,
|
|
9116
|
+
* an explicit `null` CLEARS it (the persisted rule loses the key — `null`
|
|
9117
|
+
* never reaches {@link TimelapseRuleSchema}). See the patch schema's wire
|
|
9118
|
+
* note.
|
|
9119
|
+
*/
|
|
9120
|
+
async update(ruleId, patch) {
|
|
9121
|
+
const existing = this.byId.get(ruleId);
|
|
9122
|
+
if (!existing) throw new Error(`timelapse rule not found: ${ruleId}`);
|
|
9123
|
+
const { template, ...rest } = patch;
|
|
9124
|
+
const merged = {
|
|
9125
|
+
...existing,
|
|
9126
|
+
...rest,
|
|
9127
|
+
id: existing.id,
|
|
9128
|
+
ownerUserId: existing.ownerUserId,
|
|
9129
|
+
lastGeneratedAt: existing.lastGeneratedAt,
|
|
9130
|
+
generatedByDevice: existing.generatedByDevice,
|
|
9131
|
+
createdBy: existing.createdBy,
|
|
9132
|
+
createdAt: existing.createdAt,
|
|
9133
|
+
updatedAt: this.now()
|
|
9134
|
+
};
|
|
9135
|
+
return this.write(applyTemplatePatch$1(merged, template));
|
|
9136
|
+
}
|
|
9137
|
+
async setEnabled(ruleId, enabled) {
|
|
9138
|
+
return this.update(ruleId, { enabled });
|
|
9139
|
+
}
|
|
9140
|
+
/**
|
|
9141
|
+
* Record a successful generation for ONE camera of the rule. `at` is the
|
|
9142
|
+
* generation epoch-ms — the durable state behind the re-generation guard.
|
|
9143
|
+
* Does NOT bump `updatedAt` (a generation is not an edit of the rule).
|
|
9144
|
+
*
|
|
9145
|
+
* PER-DEVICE, and that is the whole point: a rule covering three cameras
|
|
9146
|
+
* renders three videos, and stamping the rule when the first one succeeds
|
|
9147
|
+
* tells the other two they are already done. Their night is then gone for
|
|
9148
|
+
* good — a closed window does not come back. Call this ONLY after the export
|
|
9149
|
+
* reached `ready` and the notification was enqueued; a failed render must
|
|
9150
|
+
* leave the pair unstamped so the next tick can retry it.
|
|
9151
|
+
*
|
|
9152
|
+
* The rule-wide `lastGeneratedAt` is kept as the NEWEST of the stamps (what
|
|
9153
|
+
* a UI shows) and never regresses when an older device generation lands.
|
|
9154
|
+
*
|
|
9155
|
+
* A rule that still carries only the LEGACY rule-wide stamp has its map
|
|
9156
|
+
* SEEDED from it for every camera the rule covers, before the new stamp is
|
|
9157
|
+
* applied — writing `{ [deviceId]: at }` alone would drop the other cameras
|
|
9158
|
+
* to "never generated" and re-render them once.
|
|
9159
|
+
*/
|
|
9160
|
+
async markGenerated(ruleId, deviceId, at) {
|
|
9161
|
+
const existing = this.byId.get(ruleId);
|
|
9162
|
+
if (!existing) throw new Error(`timelapse rule not found: ${ruleId}`);
|
|
9163
|
+
const seeded = {};
|
|
9164
|
+
if (existing.generatedByDevice === void 0 && existing.lastGeneratedAt !== void 0) for (const id of existing.deviceIds) seeded[String(id)] = existing.lastGeneratedAt;
|
|
9165
|
+
const generatedByDevice = {
|
|
9166
|
+
...seeded,
|
|
9167
|
+
...existing.generatedByDevice,
|
|
9168
|
+
[String(deviceId)]: at
|
|
9169
|
+
};
|
|
9170
|
+
return this.write({
|
|
9171
|
+
...existing,
|
|
9172
|
+
generatedByDevice,
|
|
9173
|
+
lastGeneratedAt: Math.max(existing.lastGeneratedAt ?? 0, at)
|
|
9174
|
+
});
|
|
9175
|
+
}
|
|
9176
|
+
/** Idempotent delete — unknown ids are a no-op. */
|
|
9177
|
+
async delete(ruleId) {
|
|
9178
|
+
this.byId.delete(ruleId);
|
|
9179
|
+
try {
|
|
9180
|
+
await this.store.delete.mutate({
|
|
9181
|
+
collection: NC_TIMELAPSE_RULES_COLLECTION,
|
|
9182
|
+
key: ruleId
|
|
9183
|
+
});
|
|
9184
|
+
} catch (err) {
|
|
9185
|
+
this.logger.warn("timelapse rule delete failed", { meta: {
|
|
9186
|
+
ruleId,
|
|
9187
|
+
error: String(err)
|
|
9188
|
+
} });
|
|
9189
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
9190
|
+
}
|
|
9191
|
+
}
|
|
9192
|
+
/**
|
|
9193
|
+
* Re-validate a merged candidate, persist it, then cache it. Re-validating
|
|
9194
|
+
* means a patch can never persist a rule that would be skipped at the next
|
|
9195
|
+
* `load()`; provenance/ownership survive because they are spread from the
|
|
9196
|
+
* existing rule and never present on a patch.
|
|
9197
|
+
*/
|
|
9198
|
+
async write(candidate) {
|
|
9199
|
+
const rule = require_dist.TimelapseRuleSchema.parse(candidate);
|
|
9200
|
+
await this.persist(rule);
|
|
9201
|
+
this.byId.set(rule.id, rule);
|
|
9202
|
+
return rule;
|
|
9203
|
+
}
|
|
9204
|
+
async persist(rule) {
|
|
9205
|
+
await this.store.set.mutate({
|
|
9206
|
+
collection: NC_TIMELAPSE_RULES_COLLECTION,
|
|
9207
|
+
key: rule.id,
|
|
9208
|
+
value: {
|
|
9209
|
+
name: rule.name,
|
|
9210
|
+
enabled: rule.enabled,
|
|
9211
|
+
updatedAt: rule.updatedAt,
|
|
9212
|
+
rule
|
|
9213
|
+
}
|
|
9214
|
+
});
|
|
9215
|
+
}
|
|
9216
|
+
};
|
|
9217
|
+
var NcSummaryCollector = class {
|
|
9218
|
+
openTracks = /* @__PURE__ */ new Map();
|
|
9219
|
+
/** First+last motion per camera SINCE that camera's last window close. The
|
|
9220
|
+
* union across a rule's cameras is what opens and keeps a window alive. */
|
|
9221
|
+
motionByDevice = /* @__PURE__ */ new Map();
|
|
9222
|
+
/** Per-rule motion window. Absent = no window open for that rule. */
|
|
9223
|
+
motionByRule = /* @__PURE__ */ new Map();
|
|
9224
|
+
/**
|
|
9225
|
+
* Motion was observed on a camera. Called once per persisted object event —
|
|
9226
|
+
* the cheapest true signal of "something is happening" this process has.
|
|
9227
|
+
*
|
|
9228
|
+
* `trackId` is optional because the slim event projection omits it; an event
|
|
9229
|
+
* without one still counts as motion, it simply contributes no barrier entry.
|
|
9230
|
+
*/
|
|
9231
|
+
observeMotion(input) {
|
|
9232
|
+
const previous = this.motionByDevice.get(input.deviceId);
|
|
9233
|
+
this.motionByDevice.set(input.deviceId, previous === void 0 ? {
|
|
9234
|
+
firstMotionMs: input.atMs,
|
|
9235
|
+
lastMotionMs: input.atMs
|
|
9236
|
+
} : {
|
|
9237
|
+
firstMotionMs: Math.min(previous.firstMotionMs, input.atMs),
|
|
9238
|
+
lastMotionMs: Math.max(previous.lastMotionMs, input.atMs)
|
|
9239
|
+
});
|
|
9240
|
+
if (input.trackId === void 0) return;
|
|
9241
|
+
if (this.openTracks.has(input.trackId)) return;
|
|
9242
|
+
if (this.openTracks.size >= 5e3) this.evictOldest();
|
|
9243
|
+
this.openTracks.set(input.trackId, {
|
|
9244
|
+
deviceId: input.deviceId,
|
|
9245
|
+
openedAtMs: input.atMs
|
|
9246
|
+
});
|
|
9247
|
+
}
|
|
9248
|
+
/** A track closed — the tracker's own single close point. */
|
|
9249
|
+
observeTrackClosed(trackId) {
|
|
9250
|
+
this.openTracks.delete(trackId);
|
|
9251
|
+
}
|
|
9252
|
+
/**
|
|
9253
|
+
* The span of motion across these cameras since their last window close, or
|
|
9254
|
+
* null when none of them has seen any.
|
|
9255
|
+
*/
|
|
9256
|
+
motionAcross(deviceIds) {
|
|
9257
|
+
let first = Number.MAX_SAFE_INTEGER;
|
|
9258
|
+
let last = 0;
|
|
9259
|
+
for (const deviceId of deviceIds) {
|
|
9260
|
+
const seen = this.motionByDevice.get(deviceId);
|
|
9261
|
+
if (seen === void 0) continue;
|
|
9262
|
+
if (seen.firstMotionMs < first) first = seen.firstMotionMs;
|
|
9263
|
+
if (seen.lastMotionMs > last) last = seen.lastMotionMs;
|
|
9264
|
+
}
|
|
9265
|
+
return last === 0 ? null : {
|
|
9266
|
+
firstMotionMs: first,
|
|
9267
|
+
lastMotionMs: last
|
|
9268
|
+
};
|
|
9269
|
+
}
|
|
9270
|
+
/**
|
|
9271
|
+
* How many of this rule's cameras still have a track open that STARTED inside
|
|
9272
|
+
* the window — the barrier's count.
|
|
9273
|
+
*
|
|
9274
|
+
* The `openedAtMs >= sinceMs` test is what stops a track that began hours
|
|
9275
|
+
* before the window from holding it: that track is not part of this digest,
|
|
9276
|
+
* and waiting for it would make a rule with one resident object undeliverable.
|
|
9277
|
+
*/
|
|
9278
|
+
openTrackCount(deviceIds, sinceMs) {
|
|
9279
|
+
const wanted = new Set(deviceIds);
|
|
9280
|
+
let count = 0;
|
|
9281
|
+
for (const open of this.openTracks.values()) if (wanted.has(open.deviceId) && open.openedAtMs >= sinceMs) count += 1;
|
|
9282
|
+
return count;
|
|
9283
|
+
}
|
|
9284
|
+
/** The ids behind {@link openTrackCount} — for the log line when the barrier
|
|
9285
|
+
* is broken, because "2 tracks" is not diagnosable and two ids are. */
|
|
9286
|
+
openTrackIds(deviceIds, sinceMs) {
|
|
9287
|
+
const wanted = new Set(deviceIds);
|
|
9288
|
+
const out = [];
|
|
9289
|
+
for (const [trackId, open] of this.openTracks) if (wanted.has(open.deviceId) && open.openedAtMs >= sinceMs) out.push(trackId);
|
|
9290
|
+
return out;
|
|
9291
|
+
}
|
|
9292
|
+
/** The rule's currently open motion window, or null. */
|
|
9293
|
+
motionWindow(ruleId) {
|
|
9294
|
+
return this.motionByRule.get(ruleId) ?? null;
|
|
9295
|
+
}
|
|
9296
|
+
/**
|
|
9297
|
+
* Bring a rule's motion window up to date against the observed motion.
|
|
9298
|
+
*
|
|
9299
|
+
* Opening and extending are the SAME operation seen from two states, so they
|
|
9300
|
+
* are one function: a rule with no window that sees motion opens one at that
|
|
9301
|
+
* instant; a rule with a window that sees newer motion extends it. Motion
|
|
9302
|
+
* older than the window's own `lastMotionMs` changes nothing, which makes
|
|
9303
|
+
* this idempotent and safe to call on every tick.
|
|
9304
|
+
*/
|
|
9305
|
+
refreshMotionWindow(ruleId, deviceIds) {
|
|
9306
|
+
const motion = this.motionAcross(deviceIds);
|
|
9307
|
+
if (motion === null) return this.motionByRule.get(ruleId) ?? null;
|
|
9308
|
+
const existing = this.motionByRule.get(ruleId);
|
|
9309
|
+
if (existing === void 0) {
|
|
9310
|
+
const opened = {
|
|
9311
|
+
openedAtMs: motion.firstMotionMs,
|
|
9312
|
+
lastMotionMs: motion.lastMotionMs
|
|
9313
|
+
};
|
|
9314
|
+
this.motionByRule.set(ruleId, opened);
|
|
9315
|
+
return opened;
|
|
9316
|
+
}
|
|
9317
|
+
if (motion.lastMotionMs <= existing.lastMotionMs) return existing;
|
|
9318
|
+
const extended = {
|
|
9319
|
+
...existing,
|
|
9320
|
+
lastMotionMs: motion.lastMotionMs
|
|
9321
|
+
};
|
|
9322
|
+
this.motionByRule.set(ruleId, extended);
|
|
9323
|
+
return extended;
|
|
9324
|
+
}
|
|
9325
|
+
/**
|
|
9326
|
+
* Forget a rule's window — called once it has been produced (or abandoned).
|
|
9327
|
+
*
|
|
9328
|
+
* A camera whose last motion is at or before the close is cleared outright:
|
|
9329
|
+
* it is quiet, and the next window should wait for it to move again. A camera
|
|
9330
|
+
* still busy keeps its mark, and its `firstMotionMs` is CLAMPED to the close
|
|
9331
|
+
* — which is what makes back-to-back windows on a permanently busy camera
|
|
9332
|
+
* contiguous instead of overlapping. Without the clamp the next window would
|
|
9333
|
+
* re-open at the original first motion and re-deliver the same passage.
|
|
9334
|
+
*/
|
|
9335
|
+
closeMotionWindow(ruleId, atMs) {
|
|
9336
|
+
this.motionByRule.delete(ruleId);
|
|
9337
|
+
for (const [deviceId, seen] of this.motionByDevice) {
|
|
9338
|
+
if (seen.lastMotionMs <= atMs) {
|
|
9339
|
+
this.motionByDevice.delete(deviceId);
|
|
9340
|
+
continue;
|
|
9341
|
+
}
|
|
9342
|
+
if (seen.firstMotionMs < atMs) this.motionByDevice.set(deviceId, {
|
|
9343
|
+
...seen,
|
|
9344
|
+
firstMotionMs: atMs
|
|
9345
|
+
});
|
|
9346
|
+
}
|
|
9347
|
+
}
|
|
9348
|
+
/** Drop rules that no longer exist, so a deleted rule cannot leak a window. */
|
|
9349
|
+
retainRules(ruleIds) {
|
|
9350
|
+
const alive = new Set(ruleIds);
|
|
9351
|
+
for (const ruleId of Array.from(this.motionByRule.keys())) if (!alive.has(ruleId)) this.motionByRule.delete(ruleId);
|
|
9352
|
+
}
|
|
9353
|
+
/**
|
|
9354
|
+
* Drop tracks that were never closed. @returns how many were abandoned — a
|
|
9355
|
+
* number that belongs on a log line, because a non-zero one means the close
|
|
9356
|
+
* hook is missing closes and every barrier is silently degraded.
|
|
9357
|
+
*/
|
|
9358
|
+
sweep(nowMs) {
|
|
9359
|
+
let dropped = 0;
|
|
9360
|
+
for (const [trackId, open] of this.openTracks) if (nowMs - open.openedAtMs > 216e5) {
|
|
9361
|
+
this.openTracks.delete(trackId);
|
|
9362
|
+
dropped += 1;
|
|
9363
|
+
}
|
|
9364
|
+
return dropped;
|
|
9365
|
+
}
|
|
9366
|
+
/** Observability — the producer puts this on its tick line. */
|
|
9367
|
+
stats() {
|
|
9368
|
+
return {
|
|
9369
|
+
openTracks: this.openTracks.size,
|
|
9370
|
+
cameras: this.motionByDevice.size,
|
|
9371
|
+
windows: this.motionByRule.size
|
|
9372
|
+
};
|
|
9373
|
+
}
|
|
9374
|
+
evictOldest() {
|
|
9375
|
+
let oldestId = null;
|
|
9376
|
+
let oldestAt = Number.MAX_SAFE_INTEGER;
|
|
9377
|
+
for (const [trackId, open] of this.openTracks) if (open.openedAtMs < oldestAt) {
|
|
9378
|
+
oldestAt = open.openedAtMs;
|
|
9379
|
+
oldestId = trackId;
|
|
9380
|
+
}
|
|
9381
|
+
if (oldestId !== null) this.openTracks.delete(oldestId);
|
|
9382
|
+
}
|
|
9383
|
+
};
|
|
9384
|
+
/** The canvas behind a tile whose aspect does not fill its cell. */
|
|
9385
|
+
var MOSAIC_BACKGROUND = {
|
|
9386
|
+
r: 17,
|
|
9387
|
+
g: 17,
|
|
9388
|
+
b: 17
|
|
9389
|
+
};
|
|
9390
|
+
/** Tile aspect — cameras are 16:9 and a square cell would letterbox every one. */
|
|
9391
|
+
var TILE_ASPECT = 9 / 16;
|
|
9392
|
+
/** Height of the per-tile label strip, as a fraction of the tile height. */
|
|
9393
|
+
var LABEL_BAND_FRACTION = .14;
|
|
9394
|
+
var LABEL_MIN_PX = 11;
|
|
9395
|
+
/** Height of the mosaic's own title band, as a fraction of a tile's height. */
|
|
9396
|
+
var TITLE_BAND_FRACTION = .22;
|
|
9397
|
+
var TITLE_MIN_PX = 16;
|
|
9398
|
+
/**
|
|
9399
|
+
* Average glyph width as a fraction of the font size, for bold sans-serif.
|
|
9400
|
+
*
|
|
9401
|
+
* An ESTIMATE, and it has to be: librsvg lays the text out long after this
|
|
9402
|
+
* string is written, so there are no metrics to measure against. The same
|
|
9403
|
+
* number and the same reasoning as `preview-frame.ts#fitTitle` — deliberately
|
|
9404
|
+
* GENEROUS, because a label that shrank slightly more than it had to is
|
|
9405
|
+
* invisible and one that overflowed runs into the tile beside it. The first
|
|
9406
|
+
* live mosaic did exactly that: `Videocamera cortile · 07:49 · animal` was cut
|
|
9407
|
+
* to `…· anima` at the tile edge, mid-word.
|
|
9408
|
+
*/
|
|
9409
|
+
var GLYPH_ADVANCE = .56;
|
|
9410
|
+
/** Fraction of the band a label may occupy, leaving the padding either side. */
|
|
9411
|
+
var LABEL_USABLE_FRACTION = .94;
|
|
9412
|
+
/** `<`, `>`, `&`, `'`, `"` in a camera name or an operator caption would each
|
|
9413
|
+
* turn the overlay into a document librsvg refuses. */
|
|
9414
|
+
function escapeXml$2(s) {
|
|
9415
|
+
return s.replace(/[<>&'"]/g, (ch) => {
|
|
9416
|
+
switch (ch) {
|
|
9417
|
+
case "<": return "<";
|
|
9418
|
+
case ">": return ">";
|
|
9419
|
+
case "&": return "&";
|
|
9420
|
+
case "'": return "'";
|
|
9421
|
+
default: return """;
|
|
9422
|
+
}
|
|
9423
|
+
});
|
|
9424
|
+
}
|
|
9425
|
+
/** Grid shape for `n` tiles: as square as possible, rows never exceeding cols
|
|
9426
|
+
* by more than one — a 3×3 for nine, a 3×2 for five, never a 1×9 strip. */
|
|
9427
|
+
function mosaicGrid(n) {
|
|
9428
|
+
const count = Math.max(1, Math.floor(n));
|
|
9429
|
+
const cols = Math.ceil(Math.sqrt(count));
|
|
9430
|
+
return {
|
|
9431
|
+
cols,
|
|
9432
|
+
rows: Math.ceil(count / cols)
|
|
9433
|
+
};
|
|
9434
|
+
}
|
|
9435
|
+
/**
|
|
9436
|
+
* Where every tile lands. Pure — no bytes, no sharp, no clock.
|
|
9437
|
+
*
|
|
9438
|
+
* Exported because the rectangles are DATA the artefact should carry: a viewer
|
|
9439
|
+
* that wants a tappable mosaic reads these rather than re-deriving a layout
|
|
9440
|
+
* that could drift from the one actually rendered.
|
|
9441
|
+
*/
|
|
9442
|
+
function mosaicLayout(input) {
|
|
9443
|
+
const { cols, rows } = mosaicGrid(input.tileCount);
|
|
9444
|
+
const maxWidth = input.maxWidth ?? 1920;
|
|
9445
|
+
const tileWidth = Math.max(160, Math.floor(maxWidth / cols));
|
|
9446
|
+
const tileHeight = Math.round(tileWidth * TILE_ASPECT);
|
|
9447
|
+
const titleHeight = input.withTitle === true ? Math.round(tileHeight * TITLE_BAND_FRACTION) : 0;
|
|
9448
|
+
const tiles = [];
|
|
9449
|
+
for (let index = 0; index < Math.max(1, input.tileCount); index += 1) tiles.push({
|
|
9450
|
+
index,
|
|
9451
|
+
x: index % cols * tileWidth,
|
|
9452
|
+
y: titleHeight + Math.floor(index / cols) * tileHeight,
|
|
9453
|
+
w: tileWidth,
|
|
9454
|
+
h: tileHeight
|
|
9455
|
+
});
|
|
9456
|
+
return {
|
|
9457
|
+
width: cols * tileWidth,
|
|
9458
|
+
height: titleHeight + rows * tileHeight,
|
|
9459
|
+
cols,
|
|
9460
|
+
rows,
|
|
9461
|
+
tileWidth,
|
|
9462
|
+
tileHeight,
|
|
9463
|
+
titleHeight,
|
|
9464
|
+
tiles
|
|
9465
|
+
};
|
|
9466
|
+
}
|
|
9467
|
+
/**
|
|
9468
|
+
* Shrink, then ELIDE, until the line fits `usablePx`.
|
|
9469
|
+
*
|
|
9470
|
+
* Shrinking is tried first because a slightly smaller label still says
|
|
9471
|
+
* everything; only past `minPx` is the tail cut, and then with an ellipsis so
|
|
9472
|
+
* the reader can SEE that something was cut. The failure this replaces was
|
|
9473
|
+
* silent: SVG simply clips at the viewport, so an overlong label lost its last
|
|
9474
|
+
* word mid-glyph and looked like a rendering bug rather than a truncation.
|
|
9475
|
+
*
|
|
9476
|
+
* Pure and exported so the arithmetic is testable without rendering a pixel.
|
|
9477
|
+
*/
|
|
9478
|
+
function fitLine(text, usablePx, basePx, minPx) {
|
|
9479
|
+
const flat = text.trim().replace(/\s+/g, " ");
|
|
9480
|
+
const fits = (s, fontPx) => s.length * fontPx * GLYPH_ADVANCE <= usablePx;
|
|
9481
|
+
let fontPx = Math.max(minPx, Math.round(basePx));
|
|
9482
|
+
while (fontPx > minPx && !fits(flat, fontPx)) fontPx = Math.max(minPx, fontPx - 1);
|
|
9483
|
+
if (fits(flat, fontPx)) return {
|
|
9484
|
+
text: flat,
|
|
9485
|
+
fontPx
|
|
9486
|
+
};
|
|
9487
|
+
const budget = Math.max(1, Math.floor(usablePx / (fontPx * GLYPH_ADVANCE)));
|
|
9488
|
+
return {
|
|
9489
|
+
text: `${flat.slice(0, Math.max(1, budget - 1)).trimEnd()}…`,
|
|
9490
|
+
fontPx
|
|
9491
|
+
};
|
|
9492
|
+
}
|
|
9493
|
+
/** The label strip drawn across the bottom of one tile. */
|
|
9494
|
+
function tileLabelSvg(width, height, label) {
|
|
9495
|
+
const bandHeight = Math.max(18, Math.round(height * LABEL_BAND_FRACTION));
|
|
9496
|
+
const basePx = Math.round(bandHeight * .62);
|
|
9497
|
+
const padding = Math.round(basePx * .6);
|
|
9498
|
+
const fitted = fitLine(label, width * LABEL_USABLE_FRACTION - padding, basePx, LABEL_MIN_PX);
|
|
9499
|
+
const baseline = height - Math.round((bandHeight - fitted.fontPx) / 2) - Math.round(fitted.fontPx * .18);
|
|
9500
|
+
return Buffer.from(`<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
|
|
9501
|
+
<rect x="0" y="${height - bandHeight}" width="${width}" height="${bandHeight}" fill="black" fill-opacity="0.55"/>
|
|
9502
|
+
<text x="${padding}" y="${baseline}" font-size="${fitted.fontPx}" font-family="sans-serif" font-weight="bold" fill="white">${escapeXml$2(fitted.text)}</text>
|
|
9503
|
+
</svg>`);
|
|
9504
|
+
}
|
|
9505
|
+
/** The mosaic's own title band. */
|
|
9506
|
+
function titleSvg(width, height, caption) {
|
|
9507
|
+
const fitted = fitLine(caption, width * LABEL_USABLE_FRACTION, Math.round(height * .5), TITLE_MIN_PX);
|
|
9508
|
+
const baseline = Math.round(height / 2 + fitted.fontPx * .36);
|
|
9509
|
+
return Buffer.from(`<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
|
|
9510
|
+
<rect x="0" y="0" width="${width}" height="${height}" fill="#111111"/>
|
|
9511
|
+
<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$2(fitted.text)}</text>
|
|
9512
|
+
</svg>`);
|
|
9513
|
+
}
|
|
9514
|
+
/**
|
|
9515
|
+
* Compose the mosaic.
|
|
9516
|
+
*
|
|
9517
|
+
* Every tile is fitted with `contain` onto the cell background rather than
|
|
9518
|
+
* cropped: a `cover` fit would silently cut the subject out of a portrait crop,
|
|
9519
|
+
* and a tile that does not show what it is about is worse than a letterboxed
|
|
9520
|
+
* one.
|
|
9521
|
+
*
|
|
9522
|
+
* A tile sharp cannot read is DROPPED and counted, never composited as a blank
|
|
9523
|
+
* cell — a black square in a digest reads as "the camera saw nothing", which is
|
|
9524
|
+
* the opposite of what happened. The grid is recomputed from the survivors so
|
|
9525
|
+
* the mosaic has no hole.
|
|
9526
|
+
*
|
|
9527
|
+
* @throws only if sharp cannot produce the final canvas at all; the caller
|
|
9528
|
+
* treats that as "no mosaic" and still delivers the counts.
|
|
9529
|
+
*/
|
|
9530
|
+
async function renderMosaic(request, logger) {
|
|
9531
|
+
const decoded = [];
|
|
9532
|
+
const probeLayout = mosaicLayout({
|
|
9533
|
+
tileCount: Math.max(1, request.tiles.length),
|
|
9534
|
+
...request.maxWidth !== void 0 ? { maxWidth: request.maxWidth } : {}
|
|
9535
|
+
});
|
|
9536
|
+
for (const tile of request.tiles) try {
|
|
9537
|
+
const resized = await (0, sharp.default)(tile.jpeg).resize({
|
|
9538
|
+
width: probeLayout.tileWidth,
|
|
9539
|
+
height: probeLayout.tileHeight,
|
|
9540
|
+
fit: "contain",
|
|
9541
|
+
background: MOSAIC_BACKGROUND
|
|
9542
|
+
}).composite([{
|
|
9543
|
+
input: tileLabelSvg(probeLayout.tileWidth, probeLayout.tileHeight, tile.label),
|
|
9544
|
+
top: 0,
|
|
9545
|
+
left: 0
|
|
9546
|
+
}]).jpeg({ quality: 82 }).toBuffer();
|
|
9547
|
+
decoded.push({
|
|
9548
|
+
tile,
|
|
9549
|
+
resized
|
|
9550
|
+
});
|
|
9551
|
+
} catch (err) {
|
|
9552
|
+
logger.warn("summary mosaic: a tile could not be decoded and was dropped", {
|
|
9553
|
+
tags: { deviceId: tile.deviceId },
|
|
9554
|
+
meta: {
|
|
9555
|
+
trackId: tile.trackId,
|
|
9556
|
+
error: String(err)
|
|
9557
|
+
}
|
|
9558
|
+
});
|
|
9559
|
+
}
|
|
9560
|
+
const droppedTiles = request.tiles.length - decoded.length;
|
|
9561
|
+
const caption = request.caption?.trim() ?? "";
|
|
9562
|
+
const layout = mosaicLayout({
|
|
9563
|
+
tileCount: Math.max(1, decoded.length),
|
|
9564
|
+
...request.maxWidth !== void 0 ? { maxWidth: request.maxWidth } : {},
|
|
9565
|
+
withTitle: caption.length > 0
|
|
9566
|
+
});
|
|
9567
|
+
const composites = await Promise.all(decoded.map(async ({ resized }, index) => ({
|
|
9568
|
+
input: layout.tileWidth === probeLayout.tileWidth && layout.tileHeight === probeLayout.tileHeight ? resized : await (0, sharp.default)(resized).resize({
|
|
9569
|
+
width: layout.tileWidth,
|
|
9570
|
+
height: layout.tileHeight,
|
|
9571
|
+
fit: "contain",
|
|
9572
|
+
background: MOSAIC_BACKGROUND
|
|
9573
|
+
}).jpeg({ quality: 82 }).toBuffer(),
|
|
9574
|
+
top: layout.tiles[index]?.y ?? 0,
|
|
9575
|
+
left: layout.tiles[index]?.x ?? 0
|
|
9576
|
+
})));
|
|
9577
|
+
if (caption.length > 0 && layout.titleHeight > 0) composites.unshift({
|
|
9578
|
+
input: titleSvg(layout.width, layout.titleHeight, caption),
|
|
9579
|
+
top: 0,
|
|
9580
|
+
left: 0
|
|
9581
|
+
});
|
|
9582
|
+
return {
|
|
9583
|
+
jpeg: await (0, sharp.default)({ create: {
|
|
9584
|
+
width: layout.width,
|
|
9585
|
+
height: layout.height,
|
|
9586
|
+
channels: 3,
|
|
9587
|
+
background: MOSAIC_BACKGROUND
|
|
9588
|
+
} }).composite(composites).jpeg({ quality: 82 }).toBuffer(),
|
|
9589
|
+
layout,
|
|
9590
|
+
droppedTiles
|
|
9591
|
+
};
|
|
9592
|
+
}
|
|
9593
|
+
//#endregion
|
|
9594
|
+
//#region src/notification-center/summary/summary-delivery.ts
|
|
9595
|
+
var NO_DETECTIONS = {
|
|
9596
|
+
total: 0,
|
|
9597
|
+
byClass: {},
|
|
9598
|
+
classes: []
|
|
9599
|
+
};
|
|
9600
|
+
/** Punctuation, not prose — a module constant rather than a catalog key,
|
|
9601
|
+
* because a translator has nothing to say about it and every key the guard
|
|
9602
|
+
* has to check is a key somebody can get wrong. */
|
|
9603
|
+
var BODY_SEPARATOR = " · ";
|
|
9604
|
+
/** `HH:MM` in the host timezone. */
|
|
9605
|
+
function clockOf$1(atMs) {
|
|
9606
|
+
const d = new Date(atMs);
|
|
9607
|
+
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
9608
|
+
}
|
|
9609
|
+
/** Template vocabulary. Deliberately the SAME names the detection and timelapse
|
|
9610
|
+
* bodies use (`rule`, `from`, `to`, `time`) plus the digest-only ones. */
|
|
9611
|
+
function templateVars(input) {
|
|
9612
|
+
return {
|
|
9613
|
+
rule: input.ruleName,
|
|
9614
|
+
from: clockOf$1(input.window.startMs),
|
|
9615
|
+
to: clockOf$1(input.window.endMs),
|
|
9616
|
+
time: clockOf$1(input.generatedAt),
|
|
9617
|
+
events: String(input.tileCount),
|
|
9618
|
+
cameras: String(input.cameraCount),
|
|
9619
|
+
matched: String(input.matchedCount),
|
|
9620
|
+
shown: String(input.tileCount),
|
|
9621
|
+
total: String(input.matchedCount),
|
|
9622
|
+
...detectionTemplateVars(input.texts, input.detections ?? NO_DETECTIONS)
|
|
9623
|
+
};
|
|
9624
|
+
}
|
|
9625
|
+
/**
|
|
9626
|
+
* The body an operator reads when he did not write one.
|
|
9627
|
+
*
|
|
9628
|
+
* Every clause is a catalog key with real CLDR plurals rather than a template
|
|
9629
|
+
* string, for the reason the timelapse learned: a hand-rolled `n === 1` shipped
|
|
9630
|
+
* "1 intervalli ripresi" to a live phone.
|
|
9631
|
+
*/
|
|
9632
|
+
function derivedBody(input, vars) {
|
|
9633
|
+
const texts = input.texts;
|
|
9634
|
+
const parts = [texts.text({
|
|
9635
|
+
key: "summary.body.window",
|
|
9636
|
+
vars
|
|
9637
|
+
})];
|
|
9638
|
+
if (input.tileCount === 0) {
|
|
9639
|
+
parts.push(texts.text({
|
|
9640
|
+
key: "summary.body.empty",
|
|
9641
|
+
vars
|
|
9642
|
+
}));
|
|
9643
|
+
return parts.filter((p) => p.length > 0).join(BODY_SEPARATOR);
|
|
9644
|
+
}
|
|
9645
|
+
parts.push(texts.text({
|
|
9646
|
+
key: "summary.body.events",
|
|
9647
|
+
count: input.tileCount,
|
|
9648
|
+
vars
|
|
9649
|
+
}));
|
|
9650
|
+
parts.push(texts.text({
|
|
9651
|
+
key: "summary.body.cameras",
|
|
9652
|
+
count: input.cameraCount,
|
|
9653
|
+
vars
|
|
9654
|
+
}));
|
|
9655
|
+
if ((input.detections ?? NO_DETECTIONS).total > 0) parts.push(texts.text({
|
|
9656
|
+
key: "summary.body.detections",
|
|
9657
|
+
vars
|
|
9658
|
+
}));
|
|
9659
|
+
if (input.matchedCount > input.tileCount) parts.push(texts.text({
|
|
9660
|
+
key: "summary.body.truncated",
|
|
9661
|
+
vars
|
|
9662
|
+
}));
|
|
9663
|
+
return parts.filter((p) => p.length > 0).join(BODY_SEPARATOR);
|
|
9664
|
+
}
|
|
9665
|
+
/** Per-target params with the derived `clickUrl` filled in where absent. A
|
|
9666
|
+
* value the operator set on the target WINS — he chose it. */
|
|
9667
|
+
function paramsFor(target, clickUrl) {
|
|
9668
|
+
if (clickUrl === void 0) return target.params;
|
|
9669
|
+
if (target.params === void 0) return { clickUrl };
|
|
9670
|
+
if (typeof target.params["clickUrl"] === "string") return target.params;
|
|
9671
|
+
return {
|
|
9672
|
+
...target.params,
|
|
9673
|
+
clickUrl
|
|
9674
|
+
};
|
|
9675
|
+
}
|
|
9676
|
+
/** Stable, retry-safe outbox record id for one closed window of one rule. */
|
|
9677
|
+
function summaryRecordId(ruleId, windowEndMs) {
|
|
9678
|
+
return `summary:${ruleId}:${windowEndMs}`;
|
|
9679
|
+
}
|
|
9680
|
+
/** The caption burned onto the mosaic's title band, with the same `{{var}}`
|
|
9681
|
+
* vocabulary the title and body use. `''` is a real answer ("no caption"). */
|
|
9682
|
+
function summaryCaptionText(input) {
|
|
9683
|
+
if (input.captionText === void 0) return `${input.ruleName} — ${clockOf$1(input.window.startMs)}→${clockOf$1(input.window.endMs)}`;
|
|
9684
|
+
return renderTemplate(input.captionText, {
|
|
9685
|
+
rule: input.ruleName,
|
|
9686
|
+
from: clockOf$1(input.window.startMs),
|
|
9687
|
+
to: clockOf$1(input.window.endMs)
|
|
9688
|
+
}) ?? "";
|
|
9689
|
+
}
|
|
9690
|
+
/**
|
|
9691
|
+
* One outbox row per (still-enabled) target.
|
|
9692
|
+
*
|
|
9693
|
+
* @returns rows ready for `NcOutbox.enqueue` — never sent from here.
|
|
9694
|
+
*/
|
|
9695
|
+
function buildSummaryOutboxInputs(input) {
|
|
9696
|
+
const vars = templateVars(input);
|
|
9697
|
+
const optedOut = new Set(input.disabledTargetIds ?? []);
|
|
9698
|
+
const title = renderTemplate(input.template?.title, vars) ?? input.texts.text({
|
|
9699
|
+
key: "summary.title",
|
|
9700
|
+
vars
|
|
9701
|
+
});
|
|
9702
|
+
const body = renderTemplate(input.template?.body, vars) ?? derivedBody(input, vars);
|
|
9703
|
+
const recordId = summaryRecordId(input.ruleId, input.window.endMs);
|
|
9704
|
+
const artifacts = input.mosaic !== void 0 ? [{
|
|
9705
|
+
mediaType: "image",
|
|
9706
|
+
...input.mosaic
|
|
9707
|
+
}] : [];
|
|
9708
|
+
return input.targets.filter((t) => !optedOut.has(t.targetId)).map((target) => {
|
|
9709
|
+
const payload = {
|
|
9710
|
+
ruleName: input.ruleName,
|
|
9711
|
+
delivery: "ai-summary",
|
|
9712
|
+
priority: input.priority,
|
|
9713
|
+
template: {
|
|
9714
|
+
title,
|
|
9715
|
+
body
|
|
9716
|
+
},
|
|
9717
|
+
media: "none",
|
|
9718
|
+
...artifacts.length > 0 ? { artifacts } : {},
|
|
9719
|
+
...paramsFor(target, input.clickUrl) !== void 0 ? { params: paramsFor(target, input.clickUrl) } : {},
|
|
9720
|
+
subject: {
|
|
9721
|
+
deviceId: input.primaryDeviceId,
|
|
9722
|
+
className: "summary",
|
|
9723
|
+
zones: [],
|
|
9724
|
+
timestamp: input.window.endMs
|
|
9725
|
+
}
|
|
9726
|
+
};
|
|
9727
|
+
return {
|
|
9728
|
+
ruleId: input.ruleId,
|
|
9729
|
+
targetId: target.targetId,
|
|
9730
|
+
deviceId: input.primaryDeviceId,
|
|
9731
|
+
recordKind: "ai-summary",
|
|
9732
|
+
recordId,
|
|
9733
|
+
payload
|
|
9734
|
+
};
|
|
9735
|
+
});
|
|
9736
|
+
}
|
|
9737
|
+
//#endregion
|
|
9738
|
+
//#region src/notification-center/summary/summary-selection.ts
|
|
9739
|
+
/**
|
|
9740
|
+
* Which tracks become tiles — filter, rank, and the per-camera quota.
|
|
9741
|
+
*
|
|
9742
|
+
* Pure. No clock, no store, no I/O: a window's whole selection is a function of
|
|
9743
|
+
* the candidate list and the rule, which is what makes the quota provable
|
|
9744
|
+
* rather than plausible.
|
|
9745
|
+
*
|
|
9746
|
+
* ## The quota, and what it is protecting
|
|
9747
|
+
*
|
|
9748
|
+
* A multi-camera digest in which two of six cameras never appear is not a
|
|
9749
|
+
* multi-camera digest — it is a busy camera's digest with five names on it. The
|
|
9750
|
+
* driveway that sees forty passes a night would take every tile of a 3×3 grid
|
|
9751
|
+
* by pure ranking, every single night, and the operator would conclude the
|
|
9752
|
+
* other cameras were broken.
|
|
9753
|
+
*
|
|
9754
|
+
* So the first pass gives EVERY contributing camera its single best track,
|
|
9755
|
+
* cheapest-first in the rule's own rank order, and only the remaining slots go
|
|
9756
|
+
* to the global ranking. When there are more contributing cameras than tiles
|
|
9757
|
+
* the quota degrades honestly: the best cameras get their one tile, the rest
|
|
9758
|
+
* are counted as dropped and the count rides the log line — never silently.
|
|
9759
|
+
*
|
|
9760
|
+
* ## Zones are scoped PER CAMERA
|
|
9761
|
+
*
|
|
9762
|
+
* `filters.zones` is a flat id list on a rule that spans cameras, and zone ids
|
|
9763
|
+
* are unique per camera. Evaluated flatly, picking a zone on camera A mutes
|
|
9764
|
+
* camera B forever: B's tracks can never visit A's polygon. `zone-ownership.ts`
|
|
9765
|
+
* is the one place that turns "the rule's ids" into "the ids that apply to THIS
|
|
9766
|
+
* camera", and it is reused here rather than re-derived — including its
|
|
9767
|
+
* fail-closed posture, where an ownership lookup that could not answer leaves
|
|
9768
|
+
* the flat behaviour untouched instead of widening the rule.
|
|
9769
|
+
*/
|
|
9770
|
+
function classesOf(candidate) {
|
|
9771
|
+
const extra = candidate.classes ?? [];
|
|
9772
|
+
return extra.includes(candidate.className) ? extra : [candidate.className, ...extra];
|
|
9773
|
+
}
|
|
9774
|
+
function dwellSeconds(candidate) {
|
|
9775
|
+
return Math.max(0, candidate.lastSeen - candidate.firstSeen) / 1e3;
|
|
9776
|
+
}
|
|
9777
|
+
/**
|
|
9778
|
+
* Apply the rule's filters. Counting every drop reason is not decoration: the
|
|
9779
|
+
* question an operator asks about a digest is always "why is it empty", and a
|
|
9780
|
+
* branch that discards work silently reads as "it never happened".
|
|
9781
|
+
*/
|
|
9782
|
+
function filterSummaryCandidates(candidates, filters, zoneOwner) {
|
|
9783
|
+
if (filters === void 0) return {
|
|
9784
|
+
kept: [...candidates],
|
|
9785
|
+
droppedByClass: 0,
|
|
9786
|
+
droppedByZone: 0,
|
|
9787
|
+
droppedByImportance: 0,
|
|
9788
|
+
droppedByDwell: 0
|
|
9789
|
+
};
|
|
9790
|
+
const include = filters.classes;
|
|
9791
|
+
const exclude = new Set(filters.classesExclude ?? []);
|
|
9792
|
+
const kept = [];
|
|
9793
|
+
let droppedByClass = 0;
|
|
9794
|
+
let droppedByZone = 0;
|
|
9795
|
+
let droppedByImportance = 0;
|
|
9796
|
+
let droppedByDwell = 0;
|
|
9797
|
+
for (const candidate of candidates) {
|
|
9798
|
+
const own = classesOf(candidate);
|
|
9799
|
+
if (own.some((c) => exclude.has(c))) {
|
|
9800
|
+
droppedByClass += 1;
|
|
9801
|
+
continue;
|
|
9802
|
+
}
|
|
9803
|
+
if (include !== void 0 && include.length > 0 && !own.some((c) => include.includes(c))) {
|
|
9804
|
+
droppedByClass += 1;
|
|
9805
|
+
continue;
|
|
9806
|
+
}
|
|
9807
|
+
const wantedZones = filters.zones;
|
|
9808
|
+
if (wantedZones !== void 0 && wantedZones.length > 0) {
|
|
9809
|
+
const scope = scopeZoneIdsToDevice(wantedZones, candidate.deviceId, zoneOwner);
|
|
9810
|
+
if (scope.kind !== "inert") {
|
|
9811
|
+
const ids = scope.kind === "scoped" ? scope.ids : wantedZones;
|
|
9812
|
+
if (!candidate.zones.some((z) => ids.includes(z))) {
|
|
9813
|
+
droppedByZone += 1;
|
|
9814
|
+
continue;
|
|
9815
|
+
}
|
|
9816
|
+
}
|
|
9817
|
+
}
|
|
9818
|
+
if (filters.minImportance !== void 0 && (candidate.importance ?? 0) < filters.minImportance) {
|
|
9819
|
+
droppedByImportance += 1;
|
|
9820
|
+
continue;
|
|
9821
|
+
}
|
|
9822
|
+
if (filters.minDwellSeconds !== void 0 && dwellSeconds(candidate) < filters.minDwellSeconds) {
|
|
9823
|
+
droppedByDwell += 1;
|
|
9824
|
+
continue;
|
|
9825
|
+
}
|
|
9826
|
+
kept.push(candidate);
|
|
9827
|
+
}
|
|
9828
|
+
return {
|
|
9829
|
+
kept,
|
|
9830
|
+
droppedByClass,
|
|
9831
|
+
droppedByZone,
|
|
9832
|
+
droppedByImportance,
|
|
9833
|
+
droppedByDwell
|
|
9834
|
+
};
|
|
9835
|
+
}
|
|
9836
|
+
/**
|
|
9837
|
+
* The rank value, higher is better. Ties break on `trackId` so a selection is
|
|
9838
|
+
* REPRODUCIBLE — two runs over the same window must produce the same mosaic,
|
|
9839
|
+
* or a retry after a failed publish silently changes what was delivered.
|
|
9840
|
+
*/
|
|
9841
|
+
function rankValue(candidate, rank) {
|
|
9842
|
+
switch (rank) {
|
|
9843
|
+
case "importance": return candidate.importance ?? 0;
|
|
9844
|
+
case "recency": return candidate.lastSeen;
|
|
9845
|
+
case "dwell": return candidate.lastSeen - candidate.firstSeen;
|
|
9846
|
+
}
|
|
9847
|
+
}
|
|
9848
|
+
function byRankDesc(rank) {
|
|
9849
|
+
return (a, b) => {
|
|
9850
|
+
const delta = rankValue(b, rank) - rankValue(a, rank);
|
|
9851
|
+
return delta !== 0 ? delta : a.trackId.localeCompare(b.trackId);
|
|
9852
|
+
};
|
|
9853
|
+
}
|
|
9854
|
+
/**
|
|
9855
|
+
* Choose the tiles: one guaranteed slot per contributing camera first, then the
|
|
9856
|
+
* global ranking for what remains.
|
|
9857
|
+
*/
|
|
9858
|
+
function selectSummaryTiles(candidates, opts) {
|
|
9859
|
+
const sorted = candidates.toSorted(byRankDesc(opts.rank));
|
|
9860
|
+
const byDevice = /* @__PURE__ */ new Map();
|
|
9861
|
+
for (const candidate of sorted) {
|
|
9862
|
+
const bucket = byDevice.get(candidate.deviceId);
|
|
9863
|
+
if (bucket === void 0) byDevice.set(candidate.deviceId, [candidate]);
|
|
9864
|
+
else bucket.push(candidate);
|
|
9865
|
+
}
|
|
9866
|
+
const chosen = [];
|
|
9867
|
+
const taken = /* @__PURE__ */ new Set();
|
|
9868
|
+
const servedDevices = /* @__PURE__ */ new Set();
|
|
9869
|
+
for (const candidate of sorted) {
|
|
9870
|
+
if (chosen.length >= opts.maxTiles) break;
|
|
9871
|
+
if (servedDevices.has(candidate.deviceId)) continue;
|
|
9872
|
+
servedDevices.add(candidate.deviceId);
|
|
9873
|
+
taken.add(candidate.trackId);
|
|
9874
|
+
chosen.push(candidate);
|
|
9875
|
+
}
|
|
9876
|
+
for (const candidate of sorted) {
|
|
9877
|
+
if (chosen.length >= opts.maxTiles) break;
|
|
9878
|
+
if (taken.has(candidate.trackId)) continue;
|
|
9879
|
+
taken.add(candidate.trackId);
|
|
9880
|
+
chosen.push(candidate);
|
|
9881
|
+
}
|
|
9882
|
+
const tiles = chosen.toSorted((a, b) => a.firstSeen !== b.firstSeen ? a.firstSeen - b.firstSeen : a.trackId.localeCompare(b.trackId));
|
|
9883
|
+
return {
|
|
9884
|
+
tiles,
|
|
9885
|
+
camerasContributing: byDevice.size,
|
|
9886
|
+
camerasDropped: byDevice.size - servedDevices.size,
|
|
9887
|
+
overflow: Math.max(0, candidates.length - tiles.length)
|
|
9888
|
+
};
|
|
9889
|
+
}
|
|
9890
|
+
/**
|
|
9891
|
+
* The media a tile is drawn from, best first.
|
|
9892
|
+
*
|
|
9893
|
+
* `keyFrameSmall` heads the ladder because it is the CLEAN best-shot frame at
|
|
9894
|
+
* 960 px — the whole scene, legible on a phone, cheap. `fullFrameBoxed` and
|
|
9895
|
+
* `firstFrame` are deliberately absent: a drawn rectangle is a permanent lie in
|
|
9896
|
+
* a digest tile, the operator asked for a mosaic that is ALWAYS CLEAN, and (for
|
|
9897
|
+
* P2) a box burnt into a pixel is text inside an image — an injection channel
|
|
9898
|
+
* and a source of hallucination. Boxes stay data.
|
|
9899
|
+
*/
|
|
9900
|
+
var SUMMARY_MEDIA_LADDER = [
|
|
9901
|
+
"keyFrameSmall",
|
|
9902
|
+
"keyFrame",
|
|
9903
|
+
"snapshot",
|
|
9904
|
+
"fullFrame",
|
|
9905
|
+
"thumbnailSmall",
|
|
9906
|
+
"thumbnail"
|
|
9907
|
+
];
|
|
9908
|
+
/**
|
|
9909
|
+
* Pick ONE media row for a track — the cheap question first (index rows only),
|
|
9910
|
+
* so exactly one blob is ever read.
|
|
9911
|
+
*
|
|
9912
|
+
* Within a rung the row CLOSEST to the track's peak is chosen; `snapshot` is a
|
|
9913
|
+
* filmstrip and its rows accumulate, so "the first one" would systematically
|
|
9914
|
+
* pick the moment the subject entered rather than the moment it was best seen.
|
|
9915
|
+
*/
|
|
9916
|
+
function pickSummaryMedia(rows, aroundMs) {
|
|
9917
|
+
for (const rung of SUMMARY_MEDIA_LADDER) {
|
|
9918
|
+
const matches = rows.filter((row) => row.kind === rung);
|
|
9919
|
+
if (matches.length === 0) continue;
|
|
9920
|
+
return matches.reduce((best, row) => Math.abs(row.timestamp - aroundMs) < Math.abs(best.timestamp - aroundMs) ? row : best);
|
|
9921
|
+
}
|
|
9922
|
+
return null;
|
|
9923
|
+
}
|
|
9924
|
+
//#endregion
|
|
9925
|
+
//#region src/notification-center/summary/summary-window.ts
|
|
9926
|
+
/**
|
|
9927
|
+
* The instant an open motion window closes — computable BEFORE it happens,
|
|
9928
|
+
* which is what makes the barrier deadline derivable rather than stateful.
|
|
9929
|
+
*
|
|
9930
|
+
* `min(quiet, ceiling)`: whichever bound arrives first wins, and the ceiling is
|
|
9931
|
+
* the one that guarantees termination.
|
|
9932
|
+
*/
|
|
9933
|
+
function motionWindowCloseAt(open, bounds) {
|
|
9934
|
+
const quietCloseAt = open.lastMotionMs + bounds.quietMs;
|
|
9935
|
+
const hardCloseAt = open.openedAtMs + bounds.maxWindowMs;
|
|
9936
|
+
return hardCloseAt <= quietCloseAt ? {
|
|
9937
|
+
closeAtMs: hardCloseAt,
|
|
9938
|
+
cappedByCeiling: true
|
|
9939
|
+
} : {
|
|
9940
|
+
closeAtMs: quietCloseAt,
|
|
9941
|
+
cappedByCeiling: false
|
|
9942
|
+
};
|
|
9943
|
+
}
|
|
9944
|
+
/**
|
|
9945
|
+
* The one decision, shared by both window kinds.
|
|
9946
|
+
*
|
|
9947
|
+
* A `schedule` rule passes the schedule's close as `closeAtMs`; a `motion` rule
|
|
9948
|
+
* passes {@link motionWindowCloseAt}. From there the barrier logic is
|
|
9949
|
+
* identical, which is the point: the operator asked for the barrier on the
|
|
9950
|
+
* digest, not on one flavour of it.
|
|
9951
|
+
*/
|
|
9952
|
+
function decideSummaryWindow(input) {
|
|
9953
|
+
if (input.nowMs < input.closeAtMs) return { kind: "collecting" };
|
|
9954
|
+
const deadlineMs = input.closeAtMs + input.barrierMs;
|
|
9955
|
+
if (input.openTracks > 0 && input.nowMs < deadlineMs) return {
|
|
9956
|
+
kind: "barrier",
|
|
9957
|
+
openTracks: input.openTracks,
|
|
9958
|
+
deadlineMs
|
|
9959
|
+
};
|
|
9960
|
+
return {
|
|
9961
|
+
kind: "ready",
|
|
9962
|
+
window: {
|
|
9963
|
+
startMs: input.openedAtMs,
|
|
9964
|
+
endMs: input.closeAtMs
|
|
9965
|
+
},
|
|
9966
|
+
barrierBroken: input.openTracks,
|
|
9967
|
+
cappedByCeiling: input.cappedByCeiling === true
|
|
9968
|
+
};
|
|
9969
|
+
}
|
|
9970
|
+
/**
|
|
9971
|
+
* How many tracks ONE window may read, per camera.
|
|
9972
|
+
*
|
|
9973
|
+
* `queryHistorical` defaults to `limit ?? 50`, and at that default a busy night
|
|
9974
|
+
* would have planned the digest from the fifty most recent tracks and reported
|
|
9975
|
+
* "50" for a night that held four hundred. The timelapse hit exactly this.
|
|
9976
|
+
*/
|
|
9977
|
+
var SUMMARY_TRACK_QUERY_LIMIT = 2e3;
|
|
9978
|
+
/** The mosaic's owner id in the media store — see {@link NcSummaryPorts.storeMosaic}. */
|
|
9979
|
+
function summaryMediaOwnerId(ruleId, windowEndMs) {
|
|
9980
|
+
return `summary-${ruleId}-${windowEndMs}`;
|
|
9981
|
+
}
|
|
9982
|
+
var EMPTY_REPORT = {
|
|
9983
|
+
produced: 0,
|
|
9984
|
+
collecting: 0,
|
|
9985
|
+
barrier: 0,
|
|
9986
|
+
beforeFloor: 0,
|
|
9987
|
+
failed: 0,
|
|
9988
|
+
idle: 0,
|
|
9989
|
+
empty: 0
|
|
9990
|
+
};
|
|
9991
|
+
/**
|
|
9992
|
+
* The earliest window CLOSE a rule may ever be offered.
|
|
9993
|
+
*
|
|
9994
|
+
* RULE-level, because a digest's output is rule-level: unlike the timelapse
|
|
9995
|
+
* there is no per-camera artefact a shared stamp could destroy.
|
|
9996
|
+
*/
|
|
9997
|
+
function summaryEligibilityFloor(rule) {
|
|
9998
|
+
return Math.max(rule.generatedAt ?? 0, rule.createdAt);
|
|
9999
|
+
}
|
|
10000
|
+
var NcSummaryProducer = class {
|
|
10001
|
+
deps;
|
|
10002
|
+
timer = null;
|
|
10003
|
+
tickInFlight = false;
|
|
10004
|
+
settleMs;
|
|
10005
|
+
lookbackMs;
|
|
10006
|
+
tickMs;
|
|
10007
|
+
texts;
|
|
10008
|
+
constructor(deps) {
|
|
10009
|
+
this.deps = deps;
|
|
10010
|
+
this.settleMs = deps.settleMs ?? 3e5;
|
|
10011
|
+
this.lookbackMs = deps.lookbackMs ?? 6912e5;
|
|
10012
|
+
this.tickMs = deps.tickMs ?? 6e4;
|
|
10013
|
+
this.texts = deps.texts ?? new NcTextCatalog();
|
|
10014
|
+
}
|
|
10015
|
+
async start() {
|
|
10016
|
+
await this.tick();
|
|
10017
|
+
this.timer = setInterval(() => {
|
|
10018
|
+
this.tick();
|
|
10019
|
+
}, this.tickMs);
|
|
10020
|
+
}
|
|
10021
|
+
stop() {
|
|
10022
|
+
if (this.timer !== null) {
|
|
10023
|
+
clearInterval(this.timer);
|
|
10024
|
+
this.timer = null;
|
|
10025
|
+
}
|
|
10026
|
+
}
|
|
10027
|
+
/**
|
|
10028
|
+
* One pass over every enabled rule. SINGLE-FLIGHT, and that is the only thing
|
|
10029
|
+
* bounding concurrency on this path: ten rules whose windows close together
|
|
10030
|
+
* must not open ten simultaneous render/publish sequences.
|
|
10031
|
+
*/
|
|
10032
|
+
async tick() {
|
|
10033
|
+
if (this.tickInFlight) return EMPTY_REPORT;
|
|
10034
|
+
this.tickInFlight = true;
|
|
10035
|
+
const report = { ...EMPTY_REPORT };
|
|
10036
|
+
try {
|
|
10037
|
+
const now = this.deps.now();
|
|
10038
|
+
const rules = this.deps.store.listEnabled();
|
|
10039
|
+
this.deps.collector.retainRules(rules.map((r) => r.id));
|
|
10040
|
+
const abandoned = this.deps.collector.sweep(now);
|
|
10041
|
+
if (abandoned > 0) this.deps.logger.warn("summary collector abandoned tracks that never closed", { meta: {
|
|
10042
|
+
abandoned,
|
|
10043
|
+
...this.deps.collector.stats()
|
|
10044
|
+
} });
|
|
10045
|
+
for (const rule of rules) await this.tickRule(rule, now, report);
|
|
10046
|
+
} finally {
|
|
10047
|
+
this.tickInFlight = false;
|
|
10048
|
+
}
|
|
10049
|
+
return report;
|
|
10050
|
+
}
|
|
10051
|
+
/**
|
|
10052
|
+
* The operator's Test button: produce the given window right now, ignoring
|
|
10053
|
+
* the settle, the barrier and the floor.
|
|
10054
|
+
*
|
|
10055
|
+
* It DELIVERS — same as `nc.injectTestEvent` and `nc.testTimelapseRule`, for
|
|
10056
|
+
* the same reason: a test that stops short of the notifier does not test the
|
|
10057
|
+
* thing that breaks. It deliberately does NOT stamp, so pressing Test at noon
|
|
10058
|
+
* cannot cancel tonight's scheduled run.
|
|
10059
|
+
*/
|
|
10060
|
+
async runNow(input) {
|
|
10061
|
+
const rule = this.deps.store.get(input.ruleId);
|
|
10062
|
+
if (rule === null) throw new Error(`summary rule not found: ${input.ruleId}`);
|
|
10063
|
+
const window = input.window ?? this.lastClosedWindow(rule, this.deps.now());
|
|
10064
|
+
if (window === null) return {
|
|
10065
|
+
produced: 0,
|
|
10066
|
+
window: null,
|
|
10067
|
+
error: "no window has closed recently for this rule"
|
|
10068
|
+
};
|
|
10069
|
+
return this.produce(rule, window, {
|
|
10070
|
+
stamp: false,
|
|
10071
|
+
deliverEmpty: true
|
|
10072
|
+
});
|
|
10073
|
+
}
|
|
10074
|
+
async tickRule(rule, now, report) {
|
|
10075
|
+
const decision = this.decide(rule, now);
|
|
10076
|
+
if (decision === null) {
|
|
10077
|
+
report.idle += 1;
|
|
10078
|
+
return;
|
|
10079
|
+
}
|
|
10080
|
+
if (decision.kind === "collecting") {
|
|
10081
|
+
report.collecting += 1;
|
|
10082
|
+
return;
|
|
10083
|
+
}
|
|
10084
|
+
if (decision.kind === "barrier") {
|
|
10085
|
+
report.barrier += 1;
|
|
10086
|
+
this.deps.logger.debug("summary window waiting for its tracks to close", { meta: {
|
|
10087
|
+
ruleId: rule.id,
|
|
10088
|
+
openTracks: decision.openTracks,
|
|
10089
|
+
deadlineMs: decision.deadlineMs,
|
|
10090
|
+
trackIds: this.deps.collector.openTrackIds(rule.deviceIds, decision.deadlineMs - rule.barrierSeconds * 1e3).join(",")
|
|
10091
|
+
} });
|
|
10092
|
+
return;
|
|
10093
|
+
}
|
|
10094
|
+
if (summaryEligibilityFloor(rule) >= decision.window.endMs) {
|
|
10095
|
+
report.beforeFloor += 1;
|
|
10096
|
+
this.deps.logger.debug("summary window predates the rule — not attempted", { meta: {
|
|
10097
|
+
ruleId: rule.id,
|
|
10098
|
+
windowStart: decision.window.startMs,
|
|
10099
|
+
windowEnd: decision.window.endMs,
|
|
10100
|
+
createdAt: rule.createdAt
|
|
10101
|
+
} });
|
|
10102
|
+
if (rule.window.kind === "motion") this.deps.collector.closeMotionWindow(rule.id, decision.window.endMs);
|
|
10103
|
+
return;
|
|
10104
|
+
}
|
|
10105
|
+
if (decision.barrierBroken > 0) this.deps.logger.warn("summary barrier expired with tracks still open", { meta: {
|
|
10106
|
+
ruleId: rule.id,
|
|
10107
|
+
stillOpen: decision.barrierBroken,
|
|
10108
|
+
windowEnd: decision.window.endMs,
|
|
10109
|
+
barrierSeconds: rule.barrierSeconds
|
|
10110
|
+
} });
|
|
10111
|
+
const outcome = await this.produce(rule, decision.window, {
|
|
10112
|
+
stamp: true,
|
|
10113
|
+
deliverEmpty: rule.deliverEmpty
|
|
10114
|
+
});
|
|
10115
|
+
if (rule.window.kind === "motion") this.deps.collector.closeMotionWindow(rule.id, decision.window.endMs);
|
|
10116
|
+
if (outcome.error !== void 0) report.failed += 1;
|
|
10117
|
+
else if (outcome.produced > 0) report.produced += 1;
|
|
10118
|
+
else report.empty += 1;
|
|
10119
|
+
}
|
|
10120
|
+
/** Which window (if any) this rule is standing at, and whether it may run. */
|
|
10121
|
+
decide(rule, now) {
|
|
10122
|
+
const barrierMs = rule.barrierSeconds * 1e3;
|
|
10123
|
+
if (rule.window.kind === "schedule") {
|
|
10124
|
+
const window = findLastClosedWindow({
|
|
10125
|
+
schedule: rule.window.schedule,
|
|
10126
|
+
nowMs: now,
|
|
10127
|
+
lookbackMs: this.lookbackMs
|
|
10128
|
+
});
|
|
10129
|
+
if (window === null) return null;
|
|
10130
|
+
return decideSummaryWindow({
|
|
10131
|
+
openedAtMs: window.startMs,
|
|
10132
|
+
closeAtMs: window.endMs,
|
|
10133
|
+
openTracks: this.deps.collector.openTrackCount(rule.deviceIds, window.startMs),
|
|
10134
|
+
barrierMs,
|
|
10135
|
+
nowMs: now - this.settleMs
|
|
10136
|
+
});
|
|
10137
|
+
}
|
|
10138
|
+
const open = this.deps.collector.refreshMotionWindow(rule.id, rule.deviceIds);
|
|
10139
|
+
if (open === null) return null;
|
|
10140
|
+
const close = motionWindowCloseAt(open, {
|
|
10141
|
+
quietMs: rule.window.quietSeconds * 1e3,
|
|
10142
|
+
maxWindowMs: rule.window.maxWindowSeconds * 1e3
|
|
10143
|
+
});
|
|
10144
|
+
return decideSummaryWindow({
|
|
10145
|
+
openedAtMs: open.openedAtMs,
|
|
10146
|
+
closeAtMs: close.closeAtMs,
|
|
10147
|
+
openTracks: this.deps.collector.openTrackCount(rule.deviceIds, open.openedAtMs),
|
|
10148
|
+
barrierMs,
|
|
10149
|
+
nowMs: now,
|
|
10150
|
+
cappedByCeiling: close.cappedByCeiling
|
|
10151
|
+
});
|
|
10152
|
+
}
|
|
10153
|
+
lastClosedWindow(rule, now) {
|
|
10154
|
+
if (rule.window.kind === "schedule") return findLastClosedWindow({
|
|
10155
|
+
schedule: rule.window.schedule,
|
|
10156
|
+
nowMs: now,
|
|
10157
|
+
lookbackMs: this.lookbackMs
|
|
10158
|
+
});
|
|
10159
|
+
const open = this.deps.collector.motionWindow(rule.id);
|
|
10160
|
+
if (open !== null) return {
|
|
10161
|
+
startMs: open.openedAtMs,
|
|
10162
|
+
endMs: now
|
|
10163
|
+
};
|
|
10164
|
+
return {
|
|
10165
|
+
startMs: now - rule.window.maxWindowSeconds * 1e3,
|
|
10166
|
+
endMs: now
|
|
10167
|
+
};
|
|
10168
|
+
}
|
|
10169
|
+
/**
|
|
10170
|
+
* Collect → select → read → render → publish → enqueue → (optionally) stamp.
|
|
10171
|
+
* Never throws.
|
|
10172
|
+
*/
|
|
10173
|
+
async produce(rule, window, opts) {
|
|
10174
|
+
const meta = {
|
|
10175
|
+
ruleId: rule.id,
|
|
10176
|
+
deviceIds: rule.deviceIds.join(","),
|
|
10177
|
+
windowStart: window.startMs,
|
|
10178
|
+
windowEnd: window.endMs
|
|
10179
|
+
};
|
|
10180
|
+
try {
|
|
10181
|
+
const candidates = await this.collectCandidates(rule, window);
|
|
10182
|
+
const filtered = filterSummaryCandidates(candidates, rule.filters, this.deps.zoneOwner);
|
|
10183
|
+
const selection = selectSummaryTiles(filtered.kept, {
|
|
10184
|
+
maxTiles: rule.maxTiles,
|
|
10185
|
+
rank: rule.rank
|
|
10186
|
+
});
|
|
10187
|
+
if (selection.tiles.length === 0 && !opts.deliverEmpty) {
|
|
10188
|
+
this.deps.logger.info("summary window held nothing — not delivered", { meta: {
|
|
10189
|
+
...meta,
|
|
10190
|
+
scanned: candidates.length,
|
|
10191
|
+
droppedByClass: filtered.droppedByClass,
|
|
10192
|
+
droppedByZone: filtered.droppedByZone,
|
|
10193
|
+
droppedByImportance: filtered.droppedByImportance,
|
|
10194
|
+
droppedByDwell: filtered.droppedByDwell
|
|
10195
|
+
} });
|
|
10196
|
+
if (opts.stamp) await this.deps.store.markGenerated(rule.id, window.endMs);
|
|
10197
|
+
return {
|
|
10198
|
+
produced: 0,
|
|
10199
|
+
window,
|
|
10200
|
+
tiles: 0,
|
|
10201
|
+
cameras: 0,
|
|
10202
|
+
matched: 0
|
|
10203
|
+
};
|
|
10204
|
+
}
|
|
10205
|
+
const loaded = await this.loadTiles(rule, selection.tiles);
|
|
10206
|
+
const detections = countDetections({ tracks: selection.tiles.map((t) => ({
|
|
10207
|
+
firstSeen: t.firstSeen,
|
|
10208
|
+
lastSeen: t.lastSeen,
|
|
10209
|
+
className: t.className
|
|
10210
|
+
})) });
|
|
10211
|
+
const mosaic = await this.renderAndPublish(rule, window, loaded);
|
|
10212
|
+
const rows = buildSummaryOutboxInputs({
|
|
10213
|
+
texts: this.texts,
|
|
10214
|
+
ruleId: rule.id,
|
|
10215
|
+
ruleName: rule.name,
|
|
10216
|
+
window,
|
|
10217
|
+
deviceIds: rule.deviceIds,
|
|
10218
|
+
primaryDeviceId: rule.deviceIds[0] ?? 0,
|
|
10219
|
+
targets: rule.targets,
|
|
10220
|
+
...this.deps.disabledTargetIds !== void 0 ? { disabledTargetIds: this.deps.disabledTargetIds(rule.id) } : {},
|
|
10221
|
+
priority: rule.priority,
|
|
10222
|
+
tileCount: loaded.length,
|
|
10223
|
+
cameraCount: new Set(loaded.map((t) => t.candidate.deviceId)).size,
|
|
10224
|
+
matchedCount: filtered.kept.length,
|
|
10225
|
+
detections,
|
|
10226
|
+
...mosaic.ref !== null ? { mosaic: mosaic.ref } : {},
|
|
10227
|
+
generatedAt: this.deps.now(),
|
|
10228
|
+
...rule.template !== void 0 ? { template: rule.template } : {}
|
|
10229
|
+
});
|
|
10230
|
+
const enqueued = await this.deps.enqueue(rows);
|
|
10231
|
+
if (opts.stamp) await this.deps.store.markGenerated(rule.id, window.endMs);
|
|
10232
|
+
this.deps.logger.info("summary produced", { meta: {
|
|
10233
|
+
...meta,
|
|
10234
|
+
scanned: candidates.length,
|
|
10235
|
+
matched: filtered.kept.length,
|
|
10236
|
+
tiles: loaded.length,
|
|
10237
|
+
cameras: new Set(loaded.map((t) => t.candidate.deviceId)).size,
|
|
10238
|
+
camerasDropped: selection.camerasDropped,
|
|
10239
|
+
droppedNoMedia: selection.tiles.length - loaded.length,
|
|
10240
|
+
mosaicBytes: mosaic.bytes,
|
|
10241
|
+
mosaicPublished: mosaic.ref !== null,
|
|
10242
|
+
enqueued,
|
|
10243
|
+
stamped: opts.stamp
|
|
10244
|
+
} });
|
|
10245
|
+
return {
|
|
10246
|
+
produced: 1,
|
|
10247
|
+
window,
|
|
10248
|
+
tiles: loaded.length,
|
|
10249
|
+
cameras: new Set(loaded.map((t) => t.candidate.deviceId)).size,
|
|
10250
|
+
matched: filtered.kept.length,
|
|
10251
|
+
enqueued,
|
|
10252
|
+
...mosaic.bytes > 0 ? { mosaicBytes: mosaic.bytes } : {},
|
|
10253
|
+
...mosaic.ref !== null ? { mosaicUrl: mosaic.ref.url } : {}
|
|
10254
|
+
};
|
|
10255
|
+
} catch (err) {
|
|
10256
|
+
this.deps.logger.warn("summary not produced — the window stays eligible", { meta: {
|
|
10257
|
+
...meta,
|
|
10258
|
+
error: String(err)
|
|
10259
|
+
} });
|
|
10260
|
+
return {
|
|
10261
|
+
produced: 0,
|
|
10262
|
+
window,
|
|
10263
|
+
error: err instanceof Error ? err.message : String(err)
|
|
10264
|
+
};
|
|
10265
|
+
}
|
|
10266
|
+
}
|
|
10267
|
+
/** Every camera's tracks in the window, capped per camera and counted. */
|
|
10268
|
+
async collectCandidates(rule, window) {
|
|
10269
|
+
const all = [];
|
|
10270
|
+
for (const deviceId of rule.deviceIds) try {
|
|
10271
|
+
const rows = await this.deps.listTracks({
|
|
10272
|
+
deviceId,
|
|
10273
|
+
sinceMs: window.startMs,
|
|
10274
|
+
untilMs: window.endMs,
|
|
10275
|
+
limit: SUMMARY_TRACK_QUERY_LIMIT
|
|
10276
|
+
});
|
|
10277
|
+
if (rows.length >= 2e3) this.deps.logger.warn("summary track scan hit the per-camera ceiling", {
|
|
10278
|
+
tags: { deviceId },
|
|
10279
|
+
meta: {
|
|
10280
|
+
ruleId: rule.id,
|
|
10281
|
+
limit: SUMMARY_TRACK_QUERY_LIMIT
|
|
10282
|
+
}
|
|
10283
|
+
});
|
|
10284
|
+
all.push(...rows);
|
|
10285
|
+
} catch (err) {
|
|
10286
|
+
this.deps.logger.warn("summary could not read a camera for this window", {
|
|
10287
|
+
tags: { deviceId },
|
|
10288
|
+
meta: {
|
|
10289
|
+
ruleId: rule.id,
|
|
10290
|
+
error: String(err)
|
|
10291
|
+
}
|
|
10292
|
+
});
|
|
10293
|
+
}
|
|
10294
|
+
return all;
|
|
10295
|
+
}
|
|
10296
|
+
/** One blob per tile — the cheap question (index rows) always first. */
|
|
10297
|
+
async loadTiles(rule, tiles) {
|
|
10298
|
+
const out = [];
|
|
10299
|
+
for (const candidate of tiles) {
|
|
10300
|
+
const tags = { deviceId: candidate.deviceId };
|
|
10301
|
+
try {
|
|
10302
|
+
const rows = await this.deps.listMediaInfo(candidate.trackId);
|
|
10303
|
+
const picked = pickSummaryMedia(rows, (candidate.firstSeen + candidate.lastSeen) / 2);
|
|
10304
|
+
if (picked === null) {
|
|
10305
|
+
this.deps.logger.debug("summary tile dropped: the track carries no clean media", {
|
|
10306
|
+
tags,
|
|
10307
|
+
meta: {
|
|
10308
|
+
ruleId: rule.id,
|
|
10309
|
+
trackId: candidate.trackId,
|
|
10310
|
+
mediaRows: rows.length
|
|
10311
|
+
}
|
|
10312
|
+
});
|
|
10313
|
+
continue;
|
|
10314
|
+
}
|
|
10315
|
+
const jpeg = await this.deps.readMedia(picked.key);
|
|
10316
|
+
if (jpeg === null || jpeg.byteLength === 0) {
|
|
10317
|
+
this.deps.logger.debug("summary tile dropped: the media blob could not be read", {
|
|
10318
|
+
tags,
|
|
10319
|
+
meta: {
|
|
10320
|
+
ruleId: rule.id,
|
|
10321
|
+
trackId: candidate.trackId,
|
|
10322
|
+
mediaKey: picked.key
|
|
10323
|
+
}
|
|
10324
|
+
});
|
|
10325
|
+
continue;
|
|
10326
|
+
}
|
|
10327
|
+
out.push({
|
|
10328
|
+
candidate,
|
|
10329
|
+
jpeg
|
|
10330
|
+
});
|
|
10331
|
+
} catch (err) {
|
|
10332
|
+
this.deps.logger.warn("summary tile dropped: reading its media threw", {
|
|
10333
|
+
tags,
|
|
10334
|
+
meta: {
|
|
10335
|
+
ruleId: rule.id,
|
|
10336
|
+
trackId: candidate.trackId,
|
|
10337
|
+
error: String(err)
|
|
10338
|
+
}
|
|
10339
|
+
});
|
|
10340
|
+
}
|
|
10341
|
+
}
|
|
10342
|
+
return out;
|
|
10343
|
+
}
|
|
10344
|
+
/**
|
|
10345
|
+
* Render the mosaic, file it where event media lives, and mint the public
|
|
10346
|
+
* link the notifier BACKEND will fetch.
|
|
10347
|
+
*
|
|
10348
|
+
* Every step is best-effort in the delivery direction: a mosaic that could
|
|
10349
|
+
* not be rendered, filed or published still leaves a digest with correct
|
|
10350
|
+
* counts, and that is worth more than silence.
|
|
10351
|
+
*/
|
|
10352
|
+
async renderAndPublish(rule, window, tiles) {
|
|
10353
|
+
if (tiles.length === 0) return {
|
|
10354
|
+
ref: null,
|
|
10355
|
+
bytes: 0
|
|
10356
|
+
};
|
|
10357
|
+
const names = /* @__PURE__ */ new Map();
|
|
10358
|
+
for (const { candidate } of tiles) {
|
|
10359
|
+
if (names.has(candidate.deviceId)) continue;
|
|
10360
|
+
const name = await this.resolveDeviceName(candidate.deviceId);
|
|
10361
|
+
names.set(candidate.deviceId, name ?? `camera ${candidate.deviceId}`);
|
|
10362
|
+
}
|
|
10363
|
+
let rendered;
|
|
10364
|
+
try {
|
|
10365
|
+
rendered = await renderMosaic({
|
|
10366
|
+
tiles: tiles.map(({ candidate, jpeg }) => ({
|
|
10367
|
+
jpeg,
|
|
10368
|
+
label: `${names.get(candidate.deviceId) ?? candidate.deviceId} · ${clockOf(candidate.firstSeen)} · ${candidate.className}`,
|
|
10369
|
+
deviceId: candidate.deviceId,
|
|
10370
|
+
trackId: candidate.trackId
|
|
10371
|
+
})),
|
|
10372
|
+
caption: summaryCaptionText({
|
|
10373
|
+
...rule.captionText !== void 0 ? { captionText: rule.captionText } : {},
|
|
10374
|
+
ruleName: rule.name,
|
|
10375
|
+
window
|
|
10376
|
+
})
|
|
10377
|
+
}, this.deps.logger);
|
|
10378
|
+
} catch (err) {
|
|
10379
|
+
this.deps.logger.warn("summary mosaic could not be composed — delivering the counts alone", { meta: {
|
|
10380
|
+
ruleId: rule.id,
|
|
10381
|
+
tiles: tiles.length,
|
|
10382
|
+
error: String(err)
|
|
10383
|
+
} });
|
|
10384
|
+
return {
|
|
10385
|
+
ref: null,
|
|
10386
|
+
bytes: 0
|
|
10387
|
+
};
|
|
10388
|
+
}
|
|
10389
|
+
const file = this.deps.storeMosaic;
|
|
10390
|
+
if (file !== void 0) try {
|
|
10391
|
+
await file({
|
|
10392
|
+
deviceId: rule.deviceIds[0] ?? 0,
|
|
10393
|
+
ownerId: summaryMediaOwnerId(rule.id, window.endMs),
|
|
10394
|
+
timestamp: window.endMs,
|
|
10395
|
+
jpeg: rendered.jpeg
|
|
10396
|
+
});
|
|
10397
|
+
} catch (err) {
|
|
10398
|
+
this.deps.logger.warn("summary mosaic was not filed — the notification still ships", { meta: {
|
|
10399
|
+
ruleId: rule.id,
|
|
10400
|
+
error: String(err)
|
|
10401
|
+
} });
|
|
10402
|
+
}
|
|
10403
|
+
const bytes = new Uint8Array(rendered.jpeg.byteLength);
|
|
10404
|
+
bytes.set(rendered.jpeg);
|
|
10405
|
+
const url = await this.deps.publish(bytes, "image/jpeg");
|
|
10406
|
+
if (url === null) {
|
|
10407
|
+
this.deps.logger.warn("summary mosaic not published: no reachable public endpoint", { meta: { ruleId: rule.id } });
|
|
10408
|
+
return {
|
|
10409
|
+
ref: null,
|
|
10410
|
+
bytes: bytes.byteLength
|
|
10411
|
+
};
|
|
10412
|
+
}
|
|
10413
|
+
return {
|
|
10414
|
+
ref: {
|
|
10415
|
+
url,
|
|
10416
|
+
mime: "image/jpeg",
|
|
10417
|
+
name: "summary.jpg"
|
|
10418
|
+
},
|
|
10419
|
+
bytes: bytes.byteLength
|
|
10420
|
+
};
|
|
10421
|
+
}
|
|
10422
|
+
async resolveDeviceName(deviceId) {
|
|
10423
|
+
const get = this.deps.getDeviceName;
|
|
10424
|
+
if (get === void 0) return null;
|
|
10425
|
+
return get(deviceId).catch(() => null);
|
|
10426
|
+
}
|
|
10427
|
+
};
|
|
10428
|
+
/** `HH:MM` in the host timezone — the two numbers a tile label needs. */
|
|
10429
|
+
function clockOf(atMs) {
|
|
10430
|
+
const d = new Date(atMs);
|
|
10431
|
+
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
10432
|
+
}
|
|
10433
|
+
//#endregion
|
|
10434
|
+
//#region src/notification-center/summary/summary-rule.ts
|
|
10435
|
+
/**
|
|
10436
|
+
* NcSummaryRule — the MULTI-CAMERA digest rule.
|
|
10437
|
+
*
|
|
10438
|
+
* One rule, N cameras, ONE artefact per window: a clean mosaic of the best shot
|
|
10439
|
+
* of every track the window contained, delivered through the ordinary outbox.
|
|
10440
|
+
* That is the whole of P1. The `ai` section below is declared and persisted but
|
|
10441
|
+
* NOTHING reads it yet — P2 wires the joint vision call behind it, and because
|
|
10442
|
+
* the section is `.optional()` a rule authored today keeps delivering
|
|
10443
|
+
* byte-for-byte identically when it lands.
|
|
10444
|
+
*
|
|
10445
|
+
* ## Why this schema lives in the ADDON and not in `packages/types`
|
|
10446
|
+
*
|
|
10447
|
+
* `@camstack/types` is `external` in every mode of the build preset, so the
|
|
10448
|
+
* addon resolves it at runtime from the `@camstack/server` closure. A field
|
|
10449
|
+
* added there is not live until a framework train is published AND installed —
|
|
10450
|
+
* and worse, a NEW field on a cap-validated shape is *silently stripped* at save
|
|
10451
|
+
* until that train lands. The timelapse rule paid that price with three cascaded
|
|
10452
|
+
* bugs in one day.
|
|
10453
|
+
*
|
|
10454
|
+
* This rule rides no `NcDelivery` member, no cap method and no codegen. It
|
|
10455
|
+
* reaches the outside world through the `nc.*SummaryRule*` bridge actions
|
|
10456
|
+
* (`addons.custom`), exactly like `NcTestEventInputSchema` does — so a field
|
|
10457
|
+
* added here is live on the next `camstack deploy packages/addon-post-analysis`.
|
|
10458
|
+
* Promotion into `packages/types` later is additive and needs no migration.
|
|
10459
|
+
*
|
|
10460
|
+
* ## The `.partial()` trap this file exists to avoid
|
|
10461
|
+
*
|
|
10462
|
+
* Commit `e0921ac6e`, found in production 2026-08-12: `NcRulePatchSchema` was
|
|
10463
|
+
* `NcRuleInputSchema.partial()`, and Zod 4 `.partial()` KEEPS the inner
|
|
10464
|
+
* `.default()`. Parsing `{ name: 'x' }` therefore materialised every defaulted
|
|
10465
|
+
* key, and `{ ...existing, ...patch }` wrote them over the rule — a camera- and
|
|
10466
|
+
* zone-scoped rule silently became a rule that matched everything.
|
|
10467
|
+
*
|
|
10468
|
+
* So: every constraint is declared ONCE below; `.default()` appears ONLY in the
|
|
10469
|
+
* Input schema and `.optional()` ONLY in the hand-written Patch. `summary-rule.spec.ts`
|
|
10470
|
+
* asserts the two key sets stay identical AND that a one-key patch parses to
|
|
10471
|
+
* exactly one key.
|
|
10472
|
+
*/
|
|
10473
|
+
/** `{{var}}` templating — the same vocabulary the timelapse and rule bodies use. */
|
|
10474
|
+
var NcSummaryTemplateSchema = require_dist.object({
|
|
10475
|
+
title: require_dist.string().max(500).optional(),
|
|
10476
|
+
body: require_dist.string().max(2e3).optional()
|
|
10477
|
+
});
|
|
10478
|
+
var NameField = require_dist.string().min(1).max(200);
|
|
10479
|
+
/** MULTI-CAMERA is the point of the feature — a one-camera digest is a
|
|
10480
|
+
* timelapse with extra steps, but it is allowed and it is cheap. */
|
|
10481
|
+
var DeviceIdsField = require_dist.array(require_dist.number()).min(1).max(64);
|
|
10482
|
+
var TargetsField = require_dist.array(require_dist.NcRuleTargetSchema).min(1);
|
|
10483
|
+
var PriorityField = require_dist.number().int().min(1).max(5);
|
|
10484
|
+
var MaxTilesField = require_dist.number().int().min(1).max(24);
|
|
10485
|
+
var MaxAiImagesField = require_dist.number().int().min(1).max(8);
|
|
10486
|
+
/**
|
|
10487
|
+
* The MOTION window's quiet period, seconds.
|
|
10488
|
+
*
|
|
10489
|
+
* The window opens on the first motion seen on ANY of the rule's cameras and
|
|
10490
|
+
* stays open while motion keeps arriving. `quietSeconds` with nothing new on
|
|
10491
|
+
* any of them is what closes it — the operator's "windows driven by MOTION of
|
|
10492
|
+
* the selected cameras".
|
|
10493
|
+
*/
|
|
10494
|
+
var QuietSecondsField = require_dist.number().int().min(30).max(3600);
|
|
10495
|
+
/**
|
|
10496
|
+
* The hard ceiling on ONE motion window, seconds.
|
|
10497
|
+
*
|
|
10498
|
+
* The operator's "max-wait bounded by the camera with the widest window": a
|
|
10499
|
+
* continuously busy driveway would otherwise keep a window open forever and the
|
|
10500
|
+
* digest would never be delivered. When this bites, the window closes with
|
|
10501
|
+
* whatever it holds and SAYS so on the log line.
|
|
10502
|
+
*/
|
|
10503
|
+
var MaxWindowSecondsField = require_dist.number().int().min(60).max(86400);
|
|
10504
|
+
/**
|
|
10505
|
+
* How long the producer waits for the window's own tracks to CLOSE before it
|
|
10506
|
+
* analyses anything — the operator's track-close barrier, in seconds.
|
|
10507
|
+
*
|
|
10508
|
+
* A track's best shot (`keyFrameSmall` / `thumbnail`) is written when the track
|
|
10509
|
+
* CLOSES. Rendering the instant the window ends therefore renders exactly the
|
|
10510
|
+
* subjects that were still moving — the ones the digest is most about — with no
|
|
10511
|
+
* picture at all. The barrier waits for every track the window opened to close,
|
|
10512
|
+
* and is itself bounded: at the bound the window renders with the tracks it has
|
|
10513
|
+
* and logs the ones it gave up on.
|
|
10514
|
+
*/
|
|
10515
|
+
var BarrierSecondsField = require_dist.number().int().min(0).max(3600);
|
|
10516
|
+
/**
|
|
10517
|
+
* How the window is defined. A discriminated union, because two optional
|
|
10518
|
+
* fields would make "neither" and "both" representable and both would have to
|
|
10519
|
+
* be refused by hand.
|
|
10520
|
+
*/
|
|
10521
|
+
var NcSummaryWindowSchema = require_dist.discriminatedUnion("kind", [require_dist.object({
|
|
10522
|
+
kind: require_dist.literal("schedule"),
|
|
10523
|
+
schedule: require_dist.NcScheduleSchema
|
|
10524
|
+
}), require_dist.object({
|
|
10525
|
+
kind: require_dist.literal("motion"),
|
|
10526
|
+
quietSeconds: QuietSecondsField,
|
|
10527
|
+
maxWindowSeconds: MaxWindowSecondsField
|
|
10528
|
+
})]);
|
|
10529
|
+
/**
|
|
10530
|
+
* What enters the window. Evaluated on TRACKS, never on object events: one
|
|
10531
|
+
* track is one passage is one tile, and `importance` / dwell only exist at
|
|
10532
|
+
* track level (the same reason `NcRule` offers them only on `track-end`).
|
|
10533
|
+
*
|
|
10534
|
+
* `zones` is a FLAT id list and is scoped PER CAMERA by
|
|
10535
|
+
* `zone-ownership.ts#scopeZoneIdsToDevice` — picking a zone on camera A must
|
|
10536
|
+
* not silently mute camera B, which is the defect that helper exists for.
|
|
10537
|
+
*/
|
|
10538
|
+
var NcSummaryFiltersSchema = require_dist.object({
|
|
10539
|
+
classes: require_dist.array(require_dist.string().min(1).max(40)).max(20).optional(),
|
|
10540
|
+
classesExclude: require_dist.array(require_dist.string().min(1).max(40)).max(20).optional(),
|
|
10541
|
+
zones: require_dist.array(require_dist.string()).max(40).optional(),
|
|
10542
|
+
minImportance: require_dist.number().min(0).max(1).optional(),
|
|
10543
|
+
minDwellSeconds: require_dist.number().min(0).max(3600).optional()
|
|
10544
|
+
});
|
|
10545
|
+
/**
|
|
10546
|
+
* How tiles are chosen when the window holds more tracks than the budget.
|
|
10547
|
+
*
|
|
10548
|
+
* `importance` is the pipeline's own deterministic score and the default. The
|
|
10549
|
+
* per-camera quota that guarantees every contributing camera at least one tile
|
|
10550
|
+
* is NOT a rank — it applies to all of them, because a multi-camera digest in
|
|
10551
|
+
* which two of six cameras never appear is not multi-camera.
|
|
10552
|
+
*/
|
|
10553
|
+
var NcSummaryRankSchema = require_dist._enum([
|
|
10554
|
+
"importance",
|
|
10555
|
+
"recency",
|
|
10556
|
+
"dwell"
|
|
10557
|
+
]);
|
|
10558
|
+
/**
|
|
10559
|
+
* The AI section — DECLARED IN P1, WIRED IN P2.
|
|
10560
|
+
*
|
|
10561
|
+
* Nothing in this addon reads it today. It is here so the rule shape does not
|
|
10562
|
+
* change under an operator who already authored rules, and `.optional()` so an
|
|
10563
|
+
* absent section is the ordinary case rather than a migration.
|
|
10564
|
+
*
|
|
10565
|
+
* No `.default()` anywhere inside it, for the reason in the module docblock.
|
|
10566
|
+
*/
|
|
10567
|
+
var NcSummaryAiSchema = require_dist.object({
|
|
10568
|
+
enabled: require_dist.boolean(),
|
|
10569
|
+
profileId: require_dist.string().optional(),
|
|
10570
|
+
/** The operator's own question. NEVER composed with text READ FROM A FRAME —
|
|
10571
|
+
* D121: a model reads OSD, plates and signs as instructions. */
|
|
10572
|
+
prompt: require_dist.string().max(2e3).optional(),
|
|
10573
|
+
language: require_dist._enum(["en", "it"]).optional(),
|
|
10574
|
+
maxImagePx: require_dist.number().int().min(64).max(2048).optional(),
|
|
10575
|
+
/** An EXTERNAL race, not the HTTP timeout — that one is `profile.timeoutMs`
|
|
10576
|
+
* and this can never lengthen it. */
|
|
10577
|
+
timeoutMs: require_dist.number().int().min(1e4).max(6e5).optional(),
|
|
10578
|
+
onFailure: require_dist._enum(["deliver-without-text", "skip"]).optional()
|
|
10579
|
+
});
|
|
10580
|
+
var NcSummaryRuleInputSchema = require_dist.object({
|
|
10581
|
+
name: NameField,
|
|
10582
|
+
enabled: require_dist.boolean().default(true),
|
|
10583
|
+
deviceIds: DeviceIdsField,
|
|
10584
|
+
window: NcSummaryWindowSchema,
|
|
10585
|
+
filters: NcSummaryFiltersSchema.optional(),
|
|
10586
|
+
maxTiles: MaxTilesField.default(9),
|
|
10587
|
+
maxAiImages: MaxAiImagesField.default(6),
|
|
10588
|
+
rank: NcSummaryRankSchema.default("importance"),
|
|
10589
|
+
barrierSeconds: BarrierSecondsField.default(120),
|
|
10590
|
+
targets: TargetsField,
|
|
10591
|
+
template: NcSummaryTemplateSchema.optional(),
|
|
10592
|
+
/** Burned onto the mosaic's title band. `''` is reachable and means "no
|
|
10593
|
+
* caption" — which is why this is not `.min(1)`. */
|
|
10594
|
+
captionText: require_dist.string().max(200).optional(),
|
|
10595
|
+
/** Deliver a window that matched NOTHING? Default no: a nightly "0 events"
|
|
10596
|
+
* is a line nobody reads, and it costs the lines beside it their reader. */
|
|
10597
|
+
deliverEmpty: require_dist.boolean().default(false),
|
|
10598
|
+
priority: PriorityField.default(3),
|
|
10599
|
+
ai: NcSummaryAiSchema.optional()
|
|
10600
|
+
});
|
|
10601
|
+
/**
|
|
10602
|
+
* The partial update. NOT `Input.partial()` (see the module docblock).
|
|
10603
|
+
*
|
|
10604
|
+
* `template` and `ai` are the two CLEARABLE fields: an absent key leaves them
|
|
10605
|
+
* alone, an explicit `null` removes them, an object replaces them. Keeping
|
|
10606
|
+
* `null` out of the persisted rule is what lets the persisted schema stay a
|
|
10607
|
+
* plain `.optional()`.
|
|
10608
|
+
*/
|
|
10609
|
+
var NcSummaryRulePatchSchema = require_dist.object({
|
|
10610
|
+
name: NameField.optional(),
|
|
10611
|
+
enabled: require_dist.boolean().optional(),
|
|
10612
|
+
deviceIds: DeviceIdsField.optional(),
|
|
10613
|
+
window: NcSummaryWindowSchema.optional(),
|
|
10614
|
+
filters: NcSummaryFiltersSchema.nullable().optional(),
|
|
10615
|
+
maxTiles: MaxTilesField.optional(),
|
|
10616
|
+
maxAiImages: MaxAiImagesField.optional(),
|
|
10617
|
+
rank: NcSummaryRankSchema.optional(),
|
|
10618
|
+
barrierSeconds: BarrierSecondsField.optional(),
|
|
10619
|
+
targets: TargetsField.optional(),
|
|
10620
|
+
template: NcSummaryTemplateSchema.nullable().optional(),
|
|
10621
|
+
captionText: require_dist.string().max(200).optional(),
|
|
10622
|
+
deliverEmpty: require_dist.boolean().optional(),
|
|
10623
|
+
priority: PriorityField.optional(),
|
|
10624
|
+
ai: NcSummaryAiSchema.nullable().optional()
|
|
10625
|
+
});
|
|
10626
|
+
/**
|
|
10627
|
+
* The stored rule. Identity, ownership, provenance and the generation stamp are
|
|
10628
|
+
* SERVER-STAMPED and appear on neither Input nor Patch, so a forged blob can
|
|
10629
|
+
* never claim or re-own a rule (Zod strips unknown keys).
|
|
10630
|
+
*
|
|
10631
|
+
* `generatedAt` is PER-RULE and not per-camera — unlike the timelapse. One
|
|
10632
|
+
* window produces exactly ONE artefact spanning every camera, so there is no
|
|
10633
|
+
* per-camera output a shared stamp could destroy.
|
|
10634
|
+
*/
|
|
10635
|
+
var NcSummaryRuleSchema = NcSummaryRuleInputSchema.extend({
|
|
10636
|
+
id: require_dist.string(),
|
|
10637
|
+
ownerUserId: require_dist.string().optional(),
|
|
10638
|
+
/** Epoch-ms of the END of the last window successfully delivered. */
|
|
10639
|
+
generatedAt: require_dist.number().optional(),
|
|
10640
|
+
createdBy: require_dist.string(),
|
|
10641
|
+
createdAt: require_dist.number(),
|
|
10642
|
+
updatedAt: require_dist.number()
|
|
10643
|
+
});
|
|
10644
|
+
/**
|
|
10645
|
+
* The one cross-field invariant, checked on the MERGED rule at upsert (never on
|
|
10646
|
+
* the patch alone — lowering `maxTiles` by itself can invalidate a `maxAiImages`
|
|
10647
|
+
* set weeks earlier, and checking the patch in isolation persists that pair).
|
|
10648
|
+
*
|
|
10649
|
+
* Failing at SAVE time is the whole point: the alternative is discovering it at
|
|
10650
|
+
* 07:05 when the window has already closed and cannot come back.
|
|
10651
|
+
*/
|
|
10652
|
+
function assertSummaryBudget(rule) {
|
|
10653
|
+
if (rule.maxAiImages > rule.maxTiles) throw new Error(`maxAiImages (${rule.maxAiImages}) cannot exceed maxTiles (${rule.maxTiles}) — the model cannot judge frames the mosaic does not contain`);
|
|
10654
|
+
}
|
|
10655
|
+
//#endregion
|
|
10656
|
+
//#region src/notification-center/summary/summary-store.ts
|
|
10657
|
+
/**
|
|
10658
|
+
* NcSummaryStore — the durable multi-camera digest rule set.
|
|
10659
|
+
*
|
|
10660
|
+
* A deliberate copy of `TimelapseStore`'s shape, because that shape encodes
|
|
10661
|
+
* things this repo learned the hard way and a second opinion would lose:
|
|
10662
|
+
*
|
|
10663
|
+
* - a DECLARED SQLite collection mirrored into RAM, written through (a failed
|
|
10664
|
+
* persist never leaves a phantom in-RAM rule);
|
|
10665
|
+
* - ownership is an ARGUMENT, never read from the blob — and `create`
|
|
10666
|
+
* re-parses the input through the Input schema BEFORE stamping, so a future
|
|
10667
|
+
* "duplicate rule" (`create(existingRule, caller)`) cannot clone the
|
|
10668
|
+
* original owner (a `NcSummaryRule` is structurally assignable to
|
|
10669
|
+
* `NcSummaryRuleInput`, so it would compile clean);
|
|
10670
|
+
* - identity/ownership/provenance/`generatedAt` are re-pinned AFTER the spread
|
|
10671
|
+
* in `update`, which makes a hand-built (unparsed) patch equally unable to
|
|
10672
|
+
* re-own a rule;
|
|
10673
|
+
* - the merged candidate is re-validated before it is persisted, so a patch
|
|
10674
|
+
* can never write a rule that the next `load()` would skip.
|
|
10675
|
+
*
|
|
10676
|
+
* The one structural difference from the timelapse: `generatedAt` is PER-RULE.
|
|
10677
|
+
* A window produces ONE artefact across every camera, so there is no per-camera
|
|
10678
|
+
* output that a shared stamp could silently destroy — which is exactly why the
|
|
10679
|
+
* timelapse needed a per-device map and this does not.
|
|
10680
|
+
*/
|
|
10681
|
+
var NC_SUMMARY_RULES_COLLECTION = "notification-center:summary-rules";
|
|
10682
|
+
var NC_SUMMARY_RULES_COLUMNS = [
|
|
10683
|
+
{
|
|
10684
|
+
name: "id",
|
|
10685
|
+
type: "TEXT",
|
|
10686
|
+
primaryKey: true,
|
|
10687
|
+
notNull: true
|
|
10688
|
+
},
|
|
10689
|
+
{
|
|
10690
|
+
name: "name",
|
|
10691
|
+
type: "TEXT",
|
|
10692
|
+
notNull: true
|
|
10693
|
+
},
|
|
10694
|
+
{
|
|
10695
|
+
name: "enabled",
|
|
10696
|
+
type: "BOOLEAN",
|
|
10697
|
+
notNull: true
|
|
10698
|
+
},
|
|
10699
|
+
{
|
|
10700
|
+
name: "updatedAt",
|
|
10701
|
+
type: "INTEGER",
|
|
10702
|
+
notNull: true
|
|
10703
|
+
},
|
|
10704
|
+
(
|
|
10705
|
+
/** The FULL rule object (Zod-validated on read); the scalars above are
|
|
8916
10706
|
* indexed projections only. */
|
|
8917
10707
|
{
|
|
8918
10708
|
name: "rule",
|
|
@@ -8920,17 +10710,20 @@ var NC_TIMELAPSE_RULES_COLUMNS = [
|
|
|
8920
10710
|
notNull: true
|
|
8921
10711
|
})
|
|
8922
10712
|
];
|
|
8923
|
-
var
|
|
8924
|
-
name: "
|
|
10713
|
+
var NC_SUMMARY_RULES_INDEXES = [{
|
|
10714
|
+
name: "idx_nc_summary_rules_enabled",
|
|
8925
10715
|
columns: ["enabled"]
|
|
8926
10716
|
}];
|
|
8927
|
-
/**
|
|
10717
|
+
/** The rule set is operator-authored and tiny; a generous ceiling. */
|
|
8928
10718
|
var LOAD_LIMIT = 1e4;
|
|
8929
10719
|
/**
|
|
8930
|
-
*
|
|
8931
|
-
*
|
|
8932
|
-
*
|
|
8933
|
-
*
|
|
10720
|
+
* The three-way CLEARABLE patch signal, one function per field: `undefined`
|
|
10721
|
+
* leaves the key alone, `null` DROPS it, a value replaces it.
|
|
10722
|
+
*
|
|
10723
|
+
* Written out three times rather than generically, deliberately: a generic
|
|
10724
|
+
* "delete key K" cannot be expressed over an object type without a cast, and a
|
|
10725
|
+
* cast here would be a cast on the exact path that decides whether a persisted
|
|
10726
|
+
* rule keeps a field. Three literal destructurings are three provable ones.
|
|
8934
10727
|
*/
|
|
8935
10728
|
function applyTemplatePatch(merged, template) {
|
|
8936
10729
|
if (template === void 0) return merged;
|
|
@@ -8938,23 +10731,29 @@ function applyTemplatePatch(merged, template) {
|
|
|
8938
10731
|
...merged,
|
|
8939
10732
|
template
|
|
8940
10733
|
};
|
|
8941
|
-
const { template: _cleared, ...
|
|
8942
|
-
return
|
|
10734
|
+
const { template: _cleared, ...rest } = merged;
|
|
10735
|
+
return rest;
|
|
8943
10736
|
}
|
|
8944
|
-
|
|
8945
|
-
|
|
8946
|
-
|
|
10737
|
+
function applyFiltersPatch(merged, filters) {
|
|
10738
|
+
if (filters === void 0) return merged;
|
|
10739
|
+
if (filters !== null) return {
|
|
10740
|
+
...merged,
|
|
10741
|
+
filters
|
|
10742
|
+
};
|
|
10743
|
+
const { filters: _cleared, ...rest } = merged;
|
|
10744
|
+
return rest;
|
|
8947
10745
|
}
|
|
8948
|
-
|
|
10746
|
+
function applyAiPatch(merged, ai) {
|
|
10747
|
+
if (ai === void 0) return merged;
|
|
10748
|
+
if (ai !== null) return {
|
|
10749
|
+
...merged,
|
|
10750
|
+
ai
|
|
10751
|
+
};
|
|
10752
|
+
const { ai: _cleared, ...rest } = merged;
|
|
10753
|
+
return rest;
|
|
10754
|
+
}
|
|
10755
|
+
var NcSummaryStore = class {
|
|
8949
10756
|
byId = /* @__PURE__ */ new Map();
|
|
8950
|
-
/**
|
|
8951
|
-
* Birth stamps synthesised for LEGACY rows (see {@link withCreatedAt}), kept
|
|
8952
|
-
* so a repeated `load()` re-uses the FIRST one. Without it the synthesised
|
|
8953
|
-
* `createdAt` would advance to "now" on every refresh, and the scheduler's
|
|
8954
|
-
* eligibility floor — which is `max(stamp, createdAt)` — would creep past
|
|
8955
|
-
* every window such a rule was ever offered, silently producing nothing.
|
|
8956
|
-
*/
|
|
8957
|
-
synthesisedCreatedAt = /* @__PURE__ */ new Map();
|
|
8958
10757
|
store;
|
|
8959
10758
|
logger;
|
|
8960
10759
|
now;
|
|
@@ -8967,77 +10766,45 @@ var TimelapseStore = class {
|
|
|
8967
10766
|
}
|
|
8968
10767
|
static async declare(store) {
|
|
8969
10768
|
await store.declareCollection.mutate({
|
|
8970
|
-
collection:
|
|
8971
|
-
columns: [...
|
|
8972
|
-
indexes: [...
|
|
10769
|
+
collection: NC_SUMMARY_RULES_COLLECTION,
|
|
10770
|
+
columns: [...NC_SUMMARY_RULES_COLUMNS],
|
|
10771
|
+
indexes: [...NC_SUMMARY_RULES_INDEXES]
|
|
8973
10772
|
});
|
|
8974
10773
|
}
|
|
8975
10774
|
/**
|
|
8976
|
-
* (Re)hydrate the FULL rule set
|
|
8977
|
-
*
|
|
8978
|
-
*
|
|
8979
|
-
* crash the scheduler).
|
|
10775
|
+
* (Re)hydrate the FULL rule set — boot and the periodic refresh tick.
|
|
10776
|
+
* A row whose JSON no longer validates is SKIPPED with a count, never
|
|
10777
|
+
* allowed to crash the producer.
|
|
8980
10778
|
*/
|
|
8981
10779
|
async load() {
|
|
8982
10780
|
try {
|
|
8983
10781
|
const rows = await this.store.query.query({
|
|
8984
|
-
collection:
|
|
10782
|
+
collection: NC_SUMMARY_RULES_COLLECTION,
|
|
8985
10783
|
filter: { limit: LOAD_LIMIT }
|
|
8986
10784
|
});
|
|
8987
10785
|
this.byId.clear();
|
|
8988
10786
|
let skipped = 0;
|
|
8989
|
-
let synthesised = 0;
|
|
8990
10787
|
for (const row of rows) {
|
|
8991
|
-
const
|
|
8992
|
-
if (raw !== row.data["rule"]) synthesised += 1;
|
|
8993
|
-
const parsed = require_dist.TimelapseRuleSchema.safeParse(raw);
|
|
10788
|
+
const parsed = NcSummaryRuleSchema.safeParse(row.data["rule"]);
|
|
8994
10789
|
if (!parsed.success) {
|
|
8995
10790
|
skipped += 1;
|
|
8996
10791
|
continue;
|
|
8997
10792
|
}
|
|
8998
10793
|
this.byId.set(parsed.data.id, parsed.data);
|
|
8999
10794
|
}
|
|
9000
|
-
this.logger.debug("
|
|
10795
|
+
this.logger.debug("summary rules loaded", { meta: {
|
|
9001
10796
|
rules: this.byId.size,
|
|
9002
|
-
...skipped > 0 ? { skippedInvalid: skipped } : {}
|
|
9003
|
-
...synthesised > 0 ? { synthesisedCreatedAt: synthesised } : {}
|
|
10797
|
+
...skipped > 0 ? { skippedInvalid: skipped } : {}
|
|
9004
10798
|
} });
|
|
9005
10799
|
} catch (err) {
|
|
9006
|
-
this.logger.warn("
|
|
10800
|
+
this.logger.warn("summary rules load failed", { meta: { error: String(err) } });
|
|
9007
10801
|
}
|
|
9008
10802
|
}
|
|
9009
|
-
/**
|
|
9010
|
-
* Give a persisted blob a `createdAt` if it has none, WITHOUT persisting
|
|
9011
|
-
* anything: the rule reads as born at this load.
|
|
9012
|
-
*
|
|
9013
|
-
* `createdAt` has always been required by `TimelapseRuleSchema`, so a row
|
|
9014
|
-
* without one is a pre-schema artefact — and before this it was dropped
|
|
9015
|
-
* outright at load (a silently vanished rule). Reading it as "born now" is
|
|
9016
|
-
* the conservative choice in BOTH directions that matter: the rule survives,
|
|
9017
|
-
* and the scheduler's eligibility floor stops it back-filling a window that
|
|
9018
|
-
* closed years before anyone looked at it. The cost is one skipped window if
|
|
9019
|
-
* a legacy rule is loaded between a close and its settle — a bounded miss,
|
|
9020
|
-
* against an unbounded retry of a window no footage can satisfy.
|
|
9021
|
-
*
|
|
9022
|
-
* The synthesised value is remembered per rule id so a later refresh does not
|
|
9023
|
-
* move the floor, and the first `update`/`markGenerated` writes it through —
|
|
9024
|
-
* after which the row is no longer legacy.
|
|
9025
|
-
*/
|
|
9026
|
-
withCreatedAt(rowId, raw) {
|
|
9027
|
-
if (!isRecord$1(raw)) return raw;
|
|
9028
|
-
if (typeof raw["createdAt"] === "number") return raw;
|
|
9029
|
-
const stamped = this.synthesisedCreatedAt.get(rowId) ?? this.now();
|
|
9030
|
-
this.synthesisedCreatedAt.set(rowId, stamped);
|
|
9031
|
-
return {
|
|
9032
|
-
...raw,
|
|
9033
|
-
createdAt: stamped
|
|
9034
|
-
};
|
|
9035
|
-
}
|
|
9036
|
-
/** Every rule, newest-first (admin path). */
|
|
10803
|
+
/** Every rule, newest-first. */
|
|
9037
10804
|
list() {
|
|
9038
|
-
return [...this.byId.values()].
|
|
10805
|
+
return [...this.byId.values()].toSorted((a, b) => b.updatedAt - a.updatedAt);
|
|
9039
10806
|
}
|
|
9040
|
-
/** The
|
|
10807
|
+
/** The producer's read: only rules that should be ticked. */
|
|
9041
10808
|
listEnabled() {
|
|
9042
10809
|
return this.list().filter((r) => r.enabled);
|
|
9043
10810
|
}
|
|
@@ -9045,44 +10812,22 @@ var TimelapseStore = class {
|
|
|
9045
10812
|
return this.byId.get(ruleId) ?? null;
|
|
9046
10813
|
}
|
|
9047
10814
|
/**
|
|
9048
|
-
* Rules visible to `userId`: their
|
|
9049
|
-
*
|
|
9050
|
-
*
|
|
9051
|
-
*
|
|
9052
|
-
* The caller identity is server-derived; an absent/undefined caller must be
|
|
9053
|
-
* resolved to a fail-closed value by the bridge action BEFORE calling here —
|
|
9054
|
-
* this store never treats a missing caller as admin/global.
|
|
10815
|
+
* Rules visible to `userId`: their own personal rules plus every
|
|
10816
|
+
* admin/global one. Never another user's personal row. The caller identity is
|
|
10817
|
+
* server-derived — this store never treats a missing caller as admin.
|
|
9055
10818
|
*/
|
|
9056
10819
|
listForOwner(userId) {
|
|
9057
10820
|
return this.list().filter((r) => r.ownerUserId === void 0 || r.ownerUserId === userId);
|
|
9058
10821
|
}
|
|
9059
10822
|
/**
|
|
9060
|
-
*
|
|
9061
|
-
*
|
|
9062
|
-
*
|
|
9063
|
-
* consulting this check. An unknown rule id is false (fail-closed).
|
|
9064
|
-
*/
|
|
9065
|
-
isOwnedBy(ruleId, userId) {
|
|
9066
|
-
const rule = this.byId.get(ruleId);
|
|
9067
|
-
return rule?.ownerUserId !== void 0 && rule.ownerUserId === userId;
|
|
9068
|
-
}
|
|
9069
|
-
/**
|
|
9070
|
-
* Create a rule. `createdBy` is the SERVER-injected caller userId;
|
|
9071
|
-
* `ownerUserId` is the server-derived owner (omit for an admin/global rule).
|
|
9072
|
-
* Neither is ever read from `input`.
|
|
9073
|
-
*
|
|
9074
|
-
* The input is re-parsed through {@link TimelapseRuleInputSchema} BEFORE the
|
|
9075
|
-
* spread — that schema carries no ownership/provenance keys, so it strips any
|
|
9076
|
-
* that rode in on the blob. Without it, an `ownerUserId` on `input` would
|
|
9077
|
-
* survive whenever the `ownerUserId` ARGUMENT is omitted (the admin/global
|
|
9078
|
-
* path): a `TimelapseRule` is structurally assignable to `TimelapseRuleInput`,
|
|
9079
|
-
* so a future "duplicate rule" action (`create(existingRule, caller)`) would
|
|
9080
|
-
* compile cleanly and silently clone the ORIGINAL owner.
|
|
10823
|
+
* Create. `createdBy` is the server-injected caller; `ownerUserId` is the
|
|
10824
|
+
* server-derived owner (omit for an admin/global rule). Neither is ever read
|
|
10825
|
+
* from `input` — see the module docblock for what the re-parse prevents.
|
|
9081
10826
|
*/
|
|
9082
10827
|
async create(input, createdBy, ownerUserId) {
|
|
9083
10828
|
const now = this.now();
|
|
9084
|
-
const rule =
|
|
9085
|
-
...
|
|
10829
|
+
const rule = NcSummaryRuleSchema.parse({
|
|
10830
|
+
...NcSummaryRuleInputSchema.parse(input),
|
|
9086
10831
|
id: this.newId(),
|
|
9087
10832
|
...ownerUserId !== void 0 ? { ownerUserId } : {},
|
|
9088
10833
|
createdBy,
|
|
@@ -9094,103 +10839,70 @@ var TimelapseStore = class {
|
|
|
9094
10839
|
return rule;
|
|
9095
10840
|
}
|
|
9096
10841
|
/**
|
|
9097
|
-
* Apply a partial patch. Immutable
|
|
9098
|
-
* ownership and
|
|
9099
|
-
*
|
|
9100
|
-
* hand-built (unparsed) patch object equally unable to re-own a rule.
|
|
9101
|
-
*
|
|
9102
|
-
* `template` is the one clearable field: an absent key leaves it unchanged,
|
|
9103
|
-
* an explicit `null` CLEARS it (the persisted rule loses the key — `null`
|
|
9104
|
-
* never reaches {@link TimelapseRuleSchema}). See the patch schema's wire
|
|
9105
|
-
* note.
|
|
10842
|
+
* Apply a partial patch. Immutable — returns the NEW rule object. Identity,
|
|
10843
|
+
* ownership, provenance and `generatedAt` are re-pinned from the existing
|
|
10844
|
+
* rule AFTER the spread.
|
|
9106
10845
|
*/
|
|
9107
10846
|
async update(ruleId, patch) {
|
|
9108
10847
|
const existing = this.byId.get(ruleId);
|
|
9109
|
-
if (!existing) throw new Error(`
|
|
9110
|
-
const { template, ...rest } = patch;
|
|
10848
|
+
if (!existing) throw new Error(`summary rule not found: ${ruleId}`);
|
|
10849
|
+
const { template, filters, ai, ...rest } = patch;
|
|
9111
10850
|
const merged = {
|
|
9112
10851
|
...existing,
|
|
9113
10852
|
...rest,
|
|
9114
10853
|
id: existing.id,
|
|
9115
|
-
ownerUserId: existing.ownerUserId,
|
|
9116
|
-
|
|
9117
|
-
generatedByDevice: existing.generatedByDevice,
|
|
10854
|
+
...existing.ownerUserId !== void 0 ? { ownerUserId: existing.ownerUserId } : {},
|
|
10855
|
+
...existing.generatedAt !== void 0 ? { generatedAt: existing.generatedAt } : {},
|
|
9118
10856
|
createdBy: existing.createdBy,
|
|
9119
10857
|
createdAt: existing.createdAt,
|
|
9120
10858
|
updatedAt: this.now()
|
|
9121
10859
|
};
|
|
9122
|
-
return this.write(applyTemplatePatch(merged, template));
|
|
10860
|
+
return this.write(applyAiPatch(applyFiltersPatch(applyTemplatePatch(merged, template), filters), ai));
|
|
9123
10861
|
}
|
|
9124
10862
|
async setEnabled(ruleId, enabled) {
|
|
9125
10863
|
return this.update(ruleId, { enabled });
|
|
9126
10864
|
}
|
|
9127
10865
|
/**
|
|
9128
|
-
* Record a
|
|
9129
|
-
*
|
|
9130
|
-
*
|
|
10866
|
+
* Record a delivered window. `at` is the window's END, not the wall clock:
|
|
10867
|
+
* the eligibility floor compares against a window close, so stamping "now"
|
|
10868
|
+
* would make a late delivery skip the next window too.
|
|
9131
10869
|
*
|
|
9132
|
-
*
|
|
9133
|
-
*
|
|
9134
|
-
* tells the other two they are already done. Their night is then gone for
|
|
9135
|
-
* good — a closed window does not come back. Call this ONLY after the export
|
|
9136
|
-
* reached `ready` and the notification was enqueued; a failed render must
|
|
9137
|
-
* leave the pair unstamped so the next tick can retry it.
|
|
9138
|
-
*
|
|
9139
|
-
* The rule-wide `lastGeneratedAt` is kept as the NEWEST of the stamps (what
|
|
9140
|
-
* a UI shows) and never regresses when an older device generation lands.
|
|
9141
|
-
*
|
|
9142
|
-
* A rule that still carries only the LEGACY rule-wide stamp has its map
|
|
9143
|
-
* SEEDED from it for every camera the rule covers, before the new stamp is
|
|
9144
|
-
* applied — writing `{ [deviceId]: at }` alone would drop the other cameras
|
|
9145
|
-
* to "never generated" and re-render them once.
|
|
10870
|
+
* Call this ONLY after the enqueue succeeded — the stamp is the LAST thing a
|
|
10871
|
+
* production does, because a closed window does not come back.
|
|
9146
10872
|
*/
|
|
9147
|
-
async markGenerated(ruleId,
|
|
10873
|
+
async markGenerated(ruleId, windowEndMs) {
|
|
9148
10874
|
const existing = this.byId.get(ruleId);
|
|
9149
|
-
if (!existing) throw new Error(`
|
|
9150
|
-
const seeded = {};
|
|
9151
|
-
if (existing.generatedByDevice === void 0 && existing.lastGeneratedAt !== void 0) for (const id of existing.deviceIds) seeded[String(id)] = existing.lastGeneratedAt;
|
|
9152
|
-
const generatedByDevice = {
|
|
9153
|
-
...seeded,
|
|
9154
|
-
...existing.generatedByDevice,
|
|
9155
|
-
[String(deviceId)]: at
|
|
9156
|
-
};
|
|
10875
|
+
if (!existing) throw new Error(`summary rule not found: ${ruleId}`);
|
|
9157
10876
|
return this.write({
|
|
9158
10877
|
...existing,
|
|
9159
|
-
|
|
9160
|
-
lastGeneratedAt: Math.max(existing.lastGeneratedAt ?? 0, at)
|
|
10878
|
+
generatedAt: Math.max(existing.generatedAt ?? 0, windowEndMs)
|
|
9161
10879
|
});
|
|
9162
10880
|
}
|
|
9163
|
-
/** Idempotent delete — unknown ids are a no-op. */
|
|
10881
|
+
/** Idempotent delete — unknown ids are a no-op in RAM, still attempted on disk. */
|
|
9164
10882
|
async delete(ruleId) {
|
|
9165
10883
|
this.byId.delete(ruleId);
|
|
9166
10884
|
try {
|
|
9167
10885
|
await this.store.delete.mutate({
|
|
9168
|
-
collection:
|
|
10886
|
+
collection: NC_SUMMARY_RULES_COLLECTION,
|
|
9169
10887
|
key: ruleId
|
|
9170
10888
|
});
|
|
9171
10889
|
} catch (err) {
|
|
9172
|
-
this.logger.warn("
|
|
10890
|
+
this.logger.warn("summary rule delete failed", { meta: {
|
|
9173
10891
|
ruleId,
|
|
9174
10892
|
error: String(err)
|
|
9175
10893
|
} });
|
|
9176
10894
|
throw err instanceof Error ? err : new Error(String(err));
|
|
9177
10895
|
}
|
|
9178
10896
|
}
|
|
9179
|
-
/**
|
|
9180
|
-
* Re-validate a merged candidate, persist it, then cache it. Re-validating
|
|
9181
|
-
* means a patch can never persist a rule that would be skipped at the next
|
|
9182
|
-
* `load()`; provenance/ownership survive because they are spread from the
|
|
9183
|
-
* existing rule and never present on a patch.
|
|
9184
|
-
*/
|
|
9185
10897
|
async write(candidate) {
|
|
9186
|
-
const rule =
|
|
10898
|
+
const rule = NcSummaryRuleSchema.parse(candidate);
|
|
9187
10899
|
await this.persist(rule);
|
|
9188
10900
|
this.byId.set(rule.id, rule);
|
|
9189
10901
|
return rule;
|
|
9190
10902
|
}
|
|
9191
10903
|
async persist(rule) {
|
|
9192
10904
|
await this.store.set.mutate({
|
|
9193
|
-
collection:
|
|
10905
|
+
collection: NC_SUMMARY_RULES_COLLECTION,
|
|
9194
10906
|
key: rule.id,
|
|
9195
10907
|
value: {
|
|
9196
10908
|
name: rule.name,
|
|
@@ -9412,7 +11124,7 @@ function packagePhaseOf(ev) {
|
|
|
9412
11124
|
*/
|
|
9413
11125
|
function historyRecordKind(kind) {
|
|
9414
11126
|
if (kind === "audio-event" || kind === "audio-window") return "object-event";
|
|
9415
|
-
if (kind === "timelapse") return "system-event";
|
|
11127
|
+
if (kind === "timelapse" || kind === "ai-summary") return "system-event";
|
|
9416
11128
|
return kind;
|
|
9417
11129
|
}
|
|
9418
11130
|
/**
|
|
@@ -9448,7 +11160,7 @@ function outboxEntryToHistory(texts, entry) {
|
|
|
9448
11160
|
id: entry.id,
|
|
9449
11161
|
ruleId: entry.ruleId,
|
|
9450
11162
|
ruleName: entry.payload.ruleName,
|
|
9451
|
-
delivery: entry.payload.delivery === "timelapse" ? "system-event" : entry.payload.delivery,
|
|
11163
|
+
delivery: entry.payload.delivery === "timelapse" || entry.payload.delivery === "ai-summary" ? "system-event" : entry.payload.delivery,
|
|
9452
11164
|
targetId: entry.targetId,
|
|
9453
11165
|
deviceId: entry.deviceId,
|
|
9454
11166
|
recordKind: historyRecordKind(entry.recordKind),
|
|
@@ -9536,6 +11248,23 @@ var NotificationCenter = class NotificationCenter {
|
|
|
9536
11248
|
*/
|
|
9537
11249
|
timelapseRules;
|
|
9538
11250
|
timelapseScheduler = null;
|
|
11251
|
+
/**
|
|
11252
|
+
* The multi-camera digest rule set, and the producer that runs it. Held on
|
|
11253
|
+
* EVERY node for the same reason as {@link timelapseRules}: the bridge
|
|
11254
|
+
* actions serve CRUD from wherever they are asked. Only the hub, with
|
|
11255
|
+
* `deps.summary` wired, gets a producer.
|
|
11256
|
+
*/
|
|
11257
|
+
summaryRules;
|
|
11258
|
+
summaryProducer = null;
|
|
11259
|
+
/**
|
|
11260
|
+
* What the cameras are doing right now, for the digest windows.
|
|
11261
|
+
*
|
|
11262
|
+
* Constructed unconditionally and fed from the intake hooks even on a node
|
|
11263
|
+
* with no producer: the two `observe*` calls are a map write each, and a
|
|
11264
|
+
* collector that only started observing when a producer existed would give
|
|
11265
|
+
* the first window after a wiring change an empty barrier.
|
|
11266
|
+
*/
|
|
11267
|
+
summaryCollector = new NcSummaryCollector();
|
|
9539
11268
|
/** True when ≥1 enabled `device-event` rule declares an occupancy condition —
|
|
9540
11269
|
* the watcher is idle (zero per-frame cost) otherwise. */
|
|
9541
11270
|
occupancyEnabled = false;
|
|
@@ -9788,6 +11517,23 @@ var NotificationCenter = class NotificationCenter {
|
|
|
9788
11517
|
getDeviceName: (deviceId) => deps.dispatcher.getDeviceName(deviceId)
|
|
9789
11518
|
});
|
|
9790
11519
|
}
|
|
11520
|
+
this.summaryRules = new NcSummaryStore({
|
|
11521
|
+
store: deps.store,
|
|
11522
|
+
logger: this.logger.child("summary"),
|
|
11523
|
+
...deps.now !== void 0 ? { now: deps.now } : {}
|
|
11524
|
+
});
|
|
11525
|
+
const summaryPorts = deps.summary;
|
|
11526
|
+
if (summaryPorts !== void 0) this.summaryProducer = new NcSummaryProducer({
|
|
11527
|
+
...summaryPorts,
|
|
11528
|
+
logger: this.logger.child("summary"),
|
|
11529
|
+
now: this.now,
|
|
11530
|
+
texts: this.texts,
|
|
11531
|
+
store: this.summaryRules,
|
|
11532
|
+
collector: this.summaryCollector,
|
|
11533
|
+
enqueue: (rows) => this.outbox.enqueue(rows),
|
|
11534
|
+
getDeviceName: (deviceId) => deps.dispatcher.getDeviceName(deviceId),
|
|
11535
|
+
zoneOwner: this.zoneOwners.lookup()
|
|
11536
|
+
});
|
|
9791
11537
|
}
|
|
9792
11538
|
/**
|
|
9793
11539
|
* The durable timelapse rule set — exposed for the `nc.*Timelapse*` bridge
|
|
@@ -9796,6 +11542,22 @@ var NotificationCenter = class NotificationCenter {
|
|
|
9796
11542
|
get timelapseStore() {
|
|
9797
11543
|
return this.timelapseRules;
|
|
9798
11544
|
}
|
|
11545
|
+
/** The durable digest rule set — exposed for the `nc.*Summary*` bridge
|
|
11546
|
+
* actions, on the same reasoning as {@link timelapseStore}. */
|
|
11547
|
+
get summaryStore() {
|
|
11548
|
+
return this.summaryRules;
|
|
11549
|
+
}
|
|
11550
|
+
/**
|
|
11551
|
+
* Produce one digest window for one rule, right now.
|
|
11552
|
+
*
|
|
11553
|
+
* Throws when this node hosts no producer — an honest refusal beats a
|
|
11554
|
+
* `produced: 0` an operator reads as "there was nothing to make".
|
|
11555
|
+
*/
|
|
11556
|
+
async runSummaryNow(input) {
|
|
11557
|
+
const producer = this.summaryProducer;
|
|
11558
|
+
if (producer === null) throw new Error("this node does not host the summary producer — produce it on the hub");
|
|
11559
|
+
return producer.runNow(input);
|
|
11560
|
+
}
|
|
9799
11561
|
/**
|
|
9800
11562
|
* The text catalog's edit surface — exposed for the `nc.getTexts` /
|
|
9801
11563
|
* `nc.setLanguage` / `nc.setTextOverrides` bridge actions, on the same
|
|
@@ -9841,6 +11603,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
9841
11603
|
await NcSnoozeStore.declare(store);
|
|
9842
11604
|
await NcDeviceMuteStore.declare(store);
|
|
9843
11605
|
await TimelapseStore.declare(store);
|
|
11606
|
+
await NcSummaryStore.declare(store);
|
|
9844
11607
|
await NcTextCatalogStore.declare(store);
|
|
9845
11608
|
}
|
|
9846
11609
|
/**
|
|
@@ -9855,6 +11618,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
9855
11618
|
await this.snoozes.load();
|
|
9856
11619
|
await this.deviceMutes.load();
|
|
9857
11620
|
await this.timelapseRules.load();
|
|
11621
|
+
await this.summaryRules.load();
|
|
9858
11622
|
this.refreshOccupancyWatch();
|
|
9859
11623
|
if (!opts.evaluation) return;
|
|
9860
11624
|
await this.deviceStates.refresh();
|
|
@@ -9875,11 +11639,16 @@ var NotificationCenter = class NotificationCenter {
|
|
|
9875
11639
|
if (this.timelapseScheduler !== null) await this.timelapseScheduler.start().catch((err) => {
|
|
9876
11640
|
this.logger.warn("timelapse producer did not start", { meta: { error: String(err) } });
|
|
9877
11641
|
});
|
|
11642
|
+
if (this.summaryProducer !== null) await this.summaryProducer.start().catch((err) => {
|
|
11643
|
+
this.logger.warn("summary producer did not start", { meta: { error: String(err) } });
|
|
11644
|
+
});
|
|
9878
11645
|
this.logger.info("notification center started", { meta: {
|
|
9879
11646
|
rules: this.rules.list().length,
|
|
9880
11647
|
pendingOutbox: this.outbox.pendingCount(),
|
|
9881
11648
|
timelapseRules: this.timelapseRules.list().length,
|
|
9882
|
-
timelapseProducer: this.timelapseScheduler !== null
|
|
11649
|
+
timelapseProducer: this.timelapseScheduler !== null,
|
|
11650
|
+
summaryRules: this.summaryRules.list().length,
|
|
11651
|
+
summaryProducer: this.summaryProducer !== null
|
|
9883
11652
|
} });
|
|
9884
11653
|
}
|
|
9885
11654
|
async stop() {
|
|
@@ -9892,6 +11661,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
9892
11661
|
this.reloadTimer = null;
|
|
9893
11662
|
}
|
|
9894
11663
|
this.timelapseScheduler?.stop();
|
|
11664
|
+
this.summaryProducer?.stop();
|
|
9895
11665
|
this.evaluationActive = false;
|
|
9896
11666
|
}
|
|
9897
11667
|
/**
|
|
@@ -10045,6 +11815,11 @@ var NotificationCenter = class NotificationCenter {
|
|
|
10045
11815
|
*/
|
|
10046
11816
|
onObjectEventPersisted(event, _track) {
|
|
10047
11817
|
if (packagePhaseOf(event) !== null) return;
|
|
11818
|
+
this.summaryCollector.observeMotion({
|
|
11819
|
+
deviceId: event.deviceId,
|
|
11820
|
+
atMs: event.timestamp,
|
|
11821
|
+
...event.trackId !== void 0 ? { trackId: event.trackId } : {}
|
|
11822
|
+
});
|
|
10048
11823
|
this.consumeEvent(incomingFromObjectEvent(event));
|
|
10049
11824
|
}
|
|
10050
11825
|
/**
|
|
@@ -10054,6 +11829,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
10054
11829
|
* post-close promotions are picked up).
|
|
10055
11830
|
*/
|
|
10056
11831
|
onTrackClosed(track, _ownedMedia, info) {
|
|
11832
|
+
this.summaryCollector.observeTrackClosed(track.trackId);
|
|
10057
11833
|
this.consumeEvent(incomingFromTrack(track, info));
|
|
10058
11834
|
}
|
|
10059
11835
|
/**
|
|
@@ -11019,6 +12795,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
11019
12795
|
async reloadRules() {
|
|
11020
12796
|
await this.rules.load();
|
|
11021
12797
|
await this.timelapseRules.load();
|
|
12798
|
+
await this.summaryRules.load();
|
|
11022
12799
|
await this.reloadTexts();
|
|
11023
12800
|
this.refreshOccupancyWatch();
|
|
11024
12801
|
if (!this.evaluationActive) return;
|
|
@@ -11380,6 +13157,8 @@ var NotificationCenter = class NotificationCenter {
|
|
|
11380
13157
|
var NcViewerRuleSchema = require_dist.NcRuleSchema.extend({ readOnly: require_dist.boolean() });
|
|
11381
13158
|
/** Same verdict, for a timelapse rule. */
|
|
11382
13159
|
var NcViewerTimelapseRuleSchema = require_dist.TimelapseRuleSchema.extend({ readOnly: require_dist.boolean() });
|
|
13160
|
+
/** Same verdict, for a multi-camera digest rule. */
|
|
13161
|
+
var NcViewerSummaryRuleSchema = NcSummaryRuleSchema.extend({ readOnly: require_dist.boolean() });
|
|
11383
13162
|
/**
|
|
11384
13163
|
* The action catalog — the tRPC contract Group B (the viewer client) consumes
|
|
11385
13164
|
* against `addonId: 'pipeline-analytics'`. Every entry that depends on the
|
|
@@ -11496,6 +13275,66 @@ var ncActions = require_dist.defineCustomActions({
|
|
|
11496
13275
|
auth: "admin",
|
|
11497
13276
|
caller: "required"
|
|
11498
13277
|
}),
|
|
13278
|
+
"nc.listSummaryRules": require_dist.customAction(require_dist.object({}), require_dist.object({ rules: require_dist.array(NcViewerSummaryRuleSchema) }), { caller: "required" }),
|
|
13279
|
+
/**
|
|
13280
|
+
* ONE action for create AND update, like `nc.upsertTimelapseRule`: a digest
|
|
13281
|
+
* rule is authored as a whole (cameras + window + filters + targets) and the
|
|
13282
|
+
* guards a create must pass are the guards a patch that widens it must pass
|
|
13283
|
+
* too. Two entry points would be two places to forget one.
|
|
13284
|
+
*
|
|
13285
|
+
* `ruleId` absent ⇒ create from `rule`; present ⇒ patch.
|
|
13286
|
+
*
|
|
13287
|
+
* `auth: 'admin'` in v1 (operator decision): a digest spans cameras and, once
|
|
13288
|
+
* P2 lands, consumes the local vision model. A personal rule that fans vision
|
|
13289
|
+
* calls across other people's cameras is a decision to take after the real
|
|
13290
|
+
* cost has been watched for a few weeks, not before.
|
|
13291
|
+
*/
|
|
13292
|
+
"nc.upsertSummaryRule": require_dist.customAction(require_dist.object({
|
|
13293
|
+
ruleId: require_dist.string().optional(),
|
|
13294
|
+
rule: NcSummaryRuleInputSchema.optional(),
|
|
13295
|
+
patch: NcSummaryRulePatchSchema.optional()
|
|
13296
|
+
}), require_dist.object({ rule: NcSummaryRuleSchema }), {
|
|
13297
|
+
kind: "mutation",
|
|
13298
|
+
auth: "admin",
|
|
13299
|
+
caller: "required"
|
|
13300
|
+
}),
|
|
13301
|
+
"nc.deleteSummaryRule": require_dist.customAction(require_dist.object({ ruleId: require_dist.string() }), require_dist.object({ success: require_dist.literal(true) }), {
|
|
13302
|
+
kind: "mutation",
|
|
13303
|
+
auth: "admin",
|
|
13304
|
+
caller: "required"
|
|
13305
|
+
}),
|
|
13306
|
+
/**
|
|
13307
|
+
* Produce one digest window RIGHT NOW — the operator's Test button.
|
|
13308
|
+
*
|
|
13309
|
+
* `auth: 'admin'` for the same reason as `nc.injectTestEvent` and
|
|
13310
|
+
* `nc.testTimelapseRule`: it DELIVERS, to every target the rule addresses, on
|
|
13311
|
+
* other people's phones. It does not consume the schedule guard — pressing
|
|
13312
|
+
* Test at noon must not cancel tonight's run.
|
|
13313
|
+
*
|
|
13314
|
+
* `windowStartMs`/`windowEndMs` are optional and are the whole point of the
|
|
13315
|
+
* action for a live check: they let an operator aim it at a range he KNOWS
|
|
13316
|
+
* held traffic, instead of waiting for one to close.
|
|
13317
|
+
*/
|
|
13318
|
+
"nc.testSummaryRule": require_dist.customAction(require_dist.object({
|
|
13319
|
+
ruleId: require_dist.string(),
|
|
13320
|
+
windowStartMs: require_dist.number().optional(),
|
|
13321
|
+
windowEndMs: require_dist.number().optional()
|
|
13322
|
+
}), require_dist.object({
|
|
13323
|
+
produced: require_dist.number().int(),
|
|
13324
|
+
tiles: require_dist.number().int().optional(),
|
|
13325
|
+
cameras: require_dist.number().int().optional(),
|
|
13326
|
+
matched: require_dist.number().int().optional(),
|
|
13327
|
+
enqueued: require_dist.number().int().optional(),
|
|
13328
|
+
mosaicBytes: require_dist.number().int().optional(),
|
|
13329
|
+
mosaicUrl: require_dist.string().optional(),
|
|
13330
|
+
windowStartMs: require_dist.number().optional(),
|
|
13331
|
+
windowEndMs: require_dist.number().optional(),
|
|
13332
|
+
error: require_dist.string().optional()
|
|
13333
|
+
}), {
|
|
13334
|
+
kind: "mutation",
|
|
13335
|
+
auth: "admin",
|
|
13336
|
+
caller: "required"
|
|
13337
|
+
}),
|
|
11499
13338
|
/**
|
|
11500
13339
|
* The whole catalog for ONE language, each key carrying which layer answered
|
|
11501
13340
|
* it. `language` defaults to the hub's own; passing another one inspects (and
|
|
@@ -11563,6 +13402,12 @@ function makeNcActionHandlers(deps) {
|
|
|
11563
13402
|
if (tl === void 0) throw new Error("this node does not host the timelapse producer — use the hub");
|
|
11564
13403
|
return tl;
|
|
11565
13404
|
};
|
|
13405
|
+
/** The digest producer half, or an honest refusal. */
|
|
13406
|
+
const requireSummary = () => {
|
|
13407
|
+
const sum = deps.summary;
|
|
13408
|
+
if (sum === void 0) throw new Error("this node does not host the summary producer — use the hub");
|
|
13409
|
+
return sum;
|
|
13410
|
+
};
|
|
11566
13411
|
/** The text catalog editor, or an honest refusal. */
|
|
11567
13412
|
const requireTexts = () => {
|
|
11568
13413
|
const editor = deps.texts;
|
|
@@ -11782,6 +13627,93 @@ function makeNcActionHandlers(deps) {
|
|
|
11782
13627
|
await deps.ruleStore.setRuleTargetEnabled(input.ruleId, input.targetId, input.enabled);
|
|
11783
13628
|
return { success: true };
|
|
11784
13629
|
},
|
|
13630
|
+
"nc.listSummaryRules": async (_input, caller) => {
|
|
13631
|
+
const c = requireCaller(caller);
|
|
13632
|
+
const sum = requireSummary();
|
|
13633
|
+
return { rules: (c.isAdmin ? sum.store.list() : sum.store.listForOwner(c.userId)).map((r) => ({
|
|
13634
|
+
...r,
|
|
13635
|
+
readOnly: !c.isAdmin
|
|
13636
|
+
})) };
|
|
13637
|
+
},
|
|
13638
|
+
"nc.upsertSummaryRule": async (input, caller) => {
|
|
13639
|
+
const c = requireCaller(caller);
|
|
13640
|
+
if (!c.isAdmin) throw new Error("forbidden: admin required to author a summary rule");
|
|
13641
|
+
const sum = requireSummary();
|
|
13642
|
+
if (input.ruleId === void 0) {
|
|
13643
|
+
if (input.rule === void 0) throw new Error("upsertSummaryRule: rule or ruleId required");
|
|
13644
|
+
const parsed = NcSummaryRuleInputSchema.parse(input.rule);
|
|
13645
|
+
assertSummaryBudget(parsed);
|
|
13646
|
+
await assertTargetsOwned(parsed.targets.map((t) => t.targetId), c);
|
|
13647
|
+
const rule = await sum.store.create(parsed, c.userId);
|
|
13648
|
+
deps.logger.info("summary rule created", { meta: {
|
|
13649
|
+
ruleId: rule.id,
|
|
13650
|
+
owner: c.userId,
|
|
13651
|
+
devices: rule.deviceIds.join(",")
|
|
13652
|
+
} });
|
|
13653
|
+
return { rule };
|
|
13654
|
+
}
|
|
13655
|
+
const existing = sum.store.get(input.ruleId);
|
|
13656
|
+
if (existing === null) throw new Error(`forbidden: summary rule not found: ${input.ruleId}`);
|
|
13657
|
+
if (input.patch === void 0) throw new Error("upsertSummaryRule: patch required");
|
|
13658
|
+
const patch = NcSummaryRulePatchSchema.parse(input.patch);
|
|
13659
|
+
assertSummaryBudget({
|
|
13660
|
+
maxTiles: patch.maxTiles ?? existing.maxTiles,
|
|
13661
|
+
maxAiImages: patch.maxAiImages ?? existing.maxAiImages
|
|
13662
|
+
});
|
|
13663
|
+
if (patch.targets !== void 0) await assertTargetsOwned(patch.targets.map((t) => t.targetId), c);
|
|
13664
|
+
const rule = await sum.store.update(input.ruleId, patch);
|
|
13665
|
+
deps.logger.info("summary rule updated", { meta: {
|
|
13666
|
+
ruleId: rule.id,
|
|
13667
|
+
owner: c.userId
|
|
13668
|
+
} });
|
|
13669
|
+
return { rule };
|
|
13670
|
+
},
|
|
13671
|
+
"nc.deleteSummaryRule": async (input, caller) => {
|
|
13672
|
+
const c = requireCaller(caller);
|
|
13673
|
+
if (!c.isAdmin) throw new Error("forbidden: admin required to delete a summary rule");
|
|
13674
|
+
await requireSummary().store.delete(input.ruleId);
|
|
13675
|
+
deps.logger.info("summary rule deleted", { meta: {
|
|
13676
|
+
ruleId: input.ruleId,
|
|
13677
|
+
owner: c.userId
|
|
13678
|
+
} });
|
|
13679
|
+
return { success: true };
|
|
13680
|
+
},
|
|
13681
|
+
"nc.testSummaryRule": async (input, caller) => {
|
|
13682
|
+
const c = requireCaller(caller);
|
|
13683
|
+
if (!c.isAdmin) throw new Error("forbidden: admin required to produce a test summary");
|
|
13684
|
+
const sum = requireSummary();
|
|
13685
|
+
const startMs = input.windowStartMs;
|
|
13686
|
+
const endMs = input.windowEndMs;
|
|
13687
|
+
if (startMs === void 0 !== (endMs === void 0)) throw new Error("testSummaryRule: pass BOTH windowStartMs and windowEndMs, or neither");
|
|
13688
|
+
if (startMs !== void 0 && endMs !== void 0 && endMs <= startMs) throw new Error("testSummaryRule: windowEndMs must be after windowStartMs");
|
|
13689
|
+
deps.logger.info("summary test requested", { meta: {
|
|
13690
|
+
by: c.userId,
|
|
13691
|
+
ruleId: input.ruleId,
|
|
13692
|
+
windowStartMs: startMs,
|
|
13693
|
+
windowEndMs: endMs
|
|
13694
|
+
} });
|
|
13695
|
+
const out = await sum.runNow({
|
|
13696
|
+
ruleId: input.ruleId,
|
|
13697
|
+
...startMs !== void 0 && endMs !== void 0 ? { window: {
|
|
13698
|
+
startMs,
|
|
13699
|
+
endMs
|
|
13700
|
+
} } : {}
|
|
13701
|
+
});
|
|
13702
|
+
return {
|
|
13703
|
+
produced: out.produced,
|
|
13704
|
+
...out.tiles !== void 0 ? { tiles: out.tiles } : {},
|
|
13705
|
+
...out.cameras !== void 0 ? { cameras: out.cameras } : {},
|
|
13706
|
+
...out.matched !== void 0 ? { matched: out.matched } : {},
|
|
13707
|
+
...out.enqueued !== void 0 ? { enqueued: out.enqueued } : {},
|
|
13708
|
+
...out.mosaicBytes !== void 0 ? { mosaicBytes: out.mosaicBytes } : {},
|
|
13709
|
+
...out.mosaicUrl !== void 0 ? { mosaicUrl: out.mosaicUrl } : {},
|
|
13710
|
+
...out.window !== null ? {
|
|
13711
|
+
windowStartMs: out.window.startMs,
|
|
13712
|
+
windowEndMs: out.window.endMs
|
|
13713
|
+
} : {},
|
|
13714
|
+
...out.error !== void 0 ? { error: out.error } : {}
|
|
13715
|
+
};
|
|
13716
|
+
},
|
|
11785
13717
|
"nc.getTexts": async (input) => {
|
|
11786
13718
|
const texts = requireTexts();
|
|
11787
13719
|
return input.language === void 0 ? texts.view() : texts.view(input.language);
|
|
@@ -12180,7 +14112,7 @@ async function drawPreviewTitle(jpeg, text, logger, tags) {
|
|
|
12180
14112
|
}
|
|
12181
14113
|
/** Widest stdout one preview may produce. A 960 px JPEG is ~200 KB; this is
|
|
12182
14114
|
* headroom, not a budget. */
|
|
12183
|
-
var RUN_MAX_BUFFER = 16 * 1024 * 1024;
|
|
14115
|
+
var RUN_MAX_BUFFER$1 = 16 * 1024 * 1024;
|
|
12184
14116
|
/**
|
|
12185
14117
|
* The production {@link RunCommand}: `execFile`, bounded, stdout as bytes.
|
|
12186
14118
|
*
|
|
@@ -12191,7 +14123,7 @@ var RUN_MAX_BUFFER = 16 * 1024 * 1024;
|
|
|
12191
14123
|
var execFileCapturingStdout = async (cmd, args) => new Promise((resolve) => {
|
|
12192
14124
|
(0, node_child_process.execFile)(cmd, [...args], {
|
|
12193
14125
|
encoding: "buffer",
|
|
12194
|
-
maxBuffer: RUN_MAX_BUFFER,
|
|
14126
|
+
maxBuffer: RUN_MAX_BUFFER$1,
|
|
12195
14127
|
timeout: PREVIEW_FRAME_TIMEOUT_MS
|
|
12196
14128
|
}, (err, stdout) => resolve(err !== null ? null : stdout));
|
|
12197
14129
|
});
|
|
@@ -20094,6 +22026,7 @@ async function rebuildObjectEmbeddings(deps, options = {}) {
|
|
|
20094
22026
|
const pacingMs = options.pacingMs ?? 50;
|
|
20095
22027
|
let rebuilt = 0;
|
|
20096
22028
|
let missingKeyFrame = 0;
|
|
22029
|
+
let recoveredKeyFrame = 0;
|
|
20097
22030
|
let missingEvent = 0;
|
|
20098
22031
|
let missingBbox = 0;
|
|
20099
22032
|
let notRunnable = 0;
|
|
@@ -20132,15 +22065,25 @@ async function rebuildObjectEmbeddings(deps, options = {}) {
|
|
|
20132
22065
|
missingEvent += 1;
|
|
20133
22066
|
return;
|
|
20134
22067
|
}
|
|
20135
|
-
|
|
22068
|
+
let keyFrame = source.keyFrame;
|
|
22069
|
+
let recovered = false;
|
|
22070
|
+
if (keyFrame === null && deps.recoverKeyFrame) {
|
|
22071
|
+
keyFrame = await deps.recoverKeyFrame(source);
|
|
22072
|
+
recovered = keyFrame !== null;
|
|
22073
|
+
}
|
|
22074
|
+
if (keyFrame === null) {
|
|
20136
22075
|
missingKeyFrame += 1;
|
|
20137
22076
|
return;
|
|
20138
22077
|
}
|
|
22078
|
+
if (recovered) recoveredKeyFrame += 1;
|
|
20139
22079
|
if (source.bbox === null || source.bbox.w <= 0 || source.bbox.h <= 0) {
|
|
20140
22080
|
missingBbox += 1;
|
|
20141
22081
|
return;
|
|
20142
22082
|
}
|
|
20143
|
-
const outcome = await deps.embed(
|
|
22083
|
+
const outcome = await deps.embed(recovered ? {
|
|
22084
|
+
...source,
|
|
22085
|
+
keyFrame
|
|
22086
|
+
} : source);
|
|
20144
22087
|
if (outcome.kind === "no-capable-node") {
|
|
20145
22088
|
noCapableNode += 1;
|
|
20146
22089
|
abandoned = outcome.reason;
|
|
@@ -20201,6 +22144,7 @@ async function rebuildObjectEmbeddings(deps, options = {}) {
|
|
|
20201
22144
|
scanned,
|
|
20202
22145
|
rebuilt,
|
|
20203
22146
|
missingKeyFrame,
|
|
22147
|
+
recoveredKeyFrame,
|
|
20204
22148
|
missingEvent,
|
|
20205
22149
|
missingBbox,
|
|
20206
22150
|
notRunnable,
|
|
@@ -20222,6 +22166,7 @@ async function rebuildObjectEmbeddings(deps, options = {}) {
|
|
|
20222
22166
|
scanned,
|
|
20223
22167
|
rebuilt,
|
|
20224
22168
|
missingKeyFrame,
|
|
22169
|
+
recoveredKeyFrame,
|
|
20225
22170
|
missingEvent,
|
|
20226
22171
|
missingBbox,
|
|
20227
22172
|
notRunnable,
|
|
@@ -20234,6 +22179,7 @@ async function rebuildObjectEmbeddings(deps, options = {}) {
|
|
|
20234
22179
|
scanned,
|
|
20235
22180
|
rebuilt,
|
|
20236
22181
|
missingKeyFrame,
|
|
22182
|
+
recoveredKeyFrame,
|
|
20237
22183
|
missingEvent,
|
|
20238
22184
|
missingBbox,
|
|
20239
22185
|
notRunnable,
|
|
@@ -26422,6 +28368,248 @@ var RebuildStepDispatcher = class {
|
|
|
26422
28368
|
});
|
|
26423
28369
|
}
|
|
26424
28370
|
};
|
|
28371
|
+
/**
|
|
28372
|
+
* Where inside its GOP the wanted frame sits, in seconds.
|
|
28373
|
+
*
|
|
28374
|
+
* Clamped into the fragment: a request whose instant lands outside the GOP the
|
|
28375
|
+
* recorder returned takes the nearest edge rather than seeking past the end and
|
|
28376
|
+
* getting nothing. The clamp is honest because the caller is told
|
|
28377
|
+
* {@link RecoveredStill.gopStartMs} and can see the offset it actually got.
|
|
28378
|
+
*/
|
|
28379
|
+
function stillSeekSec(epochMs, gop) {
|
|
28380
|
+
const offsetMs = epochMs - gop.gopStartMs;
|
|
28381
|
+
const maxMs = Math.max(0, gop.gopDurMs - 1);
|
|
28382
|
+
return Math.min(Math.max(0, offsetMs), maxMs) / 1e3;
|
|
28383
|
+
}
|
|
28384
|
+
/**
|
|
28385
|
+
* ffmpeg argv for "one JPEG at `atSec` from this fMP4 fragment, on stdout".
|
|
28386
|
+
*
|
|
28387
|
+
* `-ss` goes AFTER `-i` on purpose — the inverse of the timelapse preview's
|
|
28388
|
+
* choice, and for the inverse reason. The input is a PIPE (the fragment never
|
|
28389
|
+
* touches disk: it is ~200 KB and staging it would add an fs round trip to a
|
|
28390
|
+
* path that already has a deadline), and an input seek on a non-seekable pipe
|
|
28391
|
+
* is unreliable. The fragment is ONE GOP, so an output seek decodes at most a
|
|
28392
|
+
* keyframe interval — the thing that makes an output seek expensive on a whole
|
|
28393
|
+
* file does not apply here.
|
|
28394
|
+
*/
|
|
28395
|
+
function buildRecordedStillArgs(atSec, maxWidth) {
|
|
28396
|
+
return [
|
|
28397
|
+
"-hide_banner",
|
|
28398
|
+
"-loglevel",
|
|
28399
|
+
"error",
|
|
28400
|
+
"-nostdin",
|
|
28401
|
+
"-i",
|
|
28402
|
+
"pipe:0",
|
|
28403
|
+
"-ss",
|
|
28404
|
+
String(Math.max(0, atSec)),
|
|
28405
|
+
"-frames:v",
|
|
28406
|
+
"1",
|
|
28407
|
+
"-vf",
|
|
28408
|
+
`scale='min(${maxWidth},iw)':-2`,
|
|
28409
|
+
"-f",
|
|
28410
|
+
"image2",
|
|
28411
|
+
"-vcodec",
|
|
28412
|
+
"mjpeg",
|
|
28413
|
+
"pipe:1"
|
|
28414
|
+
];
|
|
28415
|
+
}
|
|
28416
|
+
/**
|
|
28417
|
+
* Recover the native frame nearest `epochMs` for `deviceId`.
|
|
28418
|
+
*
|
|
28419
|
+
* Never throws: every failure is an outcome, because the caller's next move
|
|
28420
|
+
* differs by outcome and an exception would collapse four of them into one.
|
|
28421
|
+
* The cheap questions come first — profile, then coverage, then bytes, then the
|
|
28422
|
+
* decode — so a camera that was not recording costs a settings read and not a
|
|
28423
|
+
* disk read (the same ordering rule `runStatelessStep` follows for stored
|
|
28424
|
+
* media, [D56](../../../../docs/decisions/adr-0056.md)).
|
|
28425
|
+
*/
|
|
28426
|
+
var RecordedStillService = class {
|
|
28427
|
+
ports;
|
|
28428
|
+
maxConcurrent;
|
|
28429
|
+
inFlight = 0;
|
|
28430
|
+
constructor(ports, maxConcurrent = 2) {
|
|
28431
|
+
this.ports = ports;
|
|
28432
|
+
this.maxConcurrent = maxConcurrent;
|
|
28433
|
+
}
|
|
28434
|
+
/** Decodes currently running (tests / metrics). */
|
|
28435
|
+
get running() {
|
|
28436
|
+
return this.inFlight;
|
|
28437
|
+
}
|
|
28438
|
+
async recover(deviceId, epochMs, maxWidth) {
|
|
28439
|
+
const tags = { deviceId };
|
|
28440
|
+
if (this.inFlight >= this.maxConcurrent) {
|
|
28441
|
+
this.ports.logger.debug("recorded still refused: node already at its decode bound", {
|
|
28442
|
+
tags,
|
|
28443
|
+
meta: {
|
|
28444
|
+
epochMs,
|
|
28445
|
+
inFlight: this.inFlight
|
|
28446
|
+
}
|
|
28447
|
+
});
|
|
28448
|
+
return { outcome: "busy" };
|
|
28449
|
+
}
|
|
28450
|
+
this.inFlight += 1;
|
|
28451
|
+
try {
|
|
28452
|
+
const profile = await this.ports.resolveProfile(deviceId);
|
|
28453
|
+
if (profile === null) {
|
|
28454
|
+
this.ports.logger.debug("recorded still unavailable: camera is not recording", {
|
|
28455
|
+
tags,
|
|
28456
|
+
meta: { epochMs }
|
|
28457
|
+
});
|
|
28458
|
+
return { outcome: "no-recording" };
|
|
28459
|
+
}
|
|
28460
|
+
const located = await this.ports.locate(deviceId, profile, epochMs);
|
|
28461
|
+
if (located === null) {
|
|
28462
|
+
this.ports.logger.debug("recorded still unavailable: no footage covers that instant", {
|
|
28463
|
+
tags,
|
|
28464
|
+
meta: {
|
|
28465
|
+
epochMs,
|
|
28466
|
+
profile
|
|
28467
|
+
}
|
|
28468
|
+
});
|
|
28469
|
+
return { outcome: "no-footage" };
|
|
28470
|
+
}
|
|
28471
|
+
const gop = await this.ports.readGop(deviceId, profile, located.startMs, epochMs);
|
|
28472
|
+
if (gop === null || gop.data.byteLength === 0) {
|
|
28473
|
+
this.ports.logger.warn("recorded still: the GOP read produced no bytes", {
|
|
28474
|
+
tags,
|
|
28475
|
+
meta: {
|
|
28476
|
+
epochMs,
|
|
28477
|
+
profile,
|
|
28478
|
+
startMs: located.startMs
|
|
28479
|
+
}
|
|
28480
|
+
});
|
|
28481
|
+
return { outcome: "no-footage" };
|
|
28482
|
+
}
|
|
28483
|
+
const jpeg = await this.ports.decodeStill(gop.data, stillSeekSec(epochMs, gop), maxWidth);
|
|
28484
|
+
if (jpeg === null || jpeg.byteLength === 0) {
|
|
28485
|
+
this.ports.logger.warn("recorded still: GOP located but the decode produced nothing", {
|
|
28486
|
+
tags,
|
|
28487
|
+
meta: {
|
|
28488
|
+
epochMs,
|
|
28489
|
+
profile,
|
|
28490
|
+
gopStartMs: gop.gopStartMs,
|
|
28491
|
+
bytes: gop.data.byteLength
|
|
28492
|
+
}
|
|
28493
|
+
});
|
|
28494
|
+
return { outcome: "decode-failed" };
|
|
28495
|
+
}
|
|
28496
|
+
return {
|
|
28497
|
+
outcome: "ok",
|
|
28498
|
+
jpeg,
|
|
28499
|
+
gopStartMs: gop.gopStartMs
|
|
28500
|
+
};
|
|
28501
|
+
} catch (err) {
|
|
28502
|
+
this.ports.logger.warn("recorded still failed", {
|
|
28503
|
+
tags,
|
|
28504
|
+
meta: {
|
|
28505
|
+
epochMs,
|
|
28506
|
+
error: err instanceof Error ? err.message : String(err)
|
|
28507
|
+
}
|
|
28508
|
+
});
|
|
28509
|
+
return { outcome: "decode-failed" };
|
|
28510
|
+
} finally {
|
|
28511
|
+
this.inFlight -= 1;
|
|
28512
|
+
}
|
|
28513
|
+
}
|
|
28514
|
+
};
|
|
28515
|
+
//#endregion
|
|
28516
|
+
//#region src/pipeline-analytics/recorded-still-ports.ts
|
|
28517
|
+
/**
|
|
28518
|
+
* The production wiring for {@link RecordedStillService} — the recorder's READ
|
|
28519
|
+
* surface plus one bounded ffmpeg.
|
|
28520
|
+
*
|
|
28521
|
+
* Kept apart from `recorded-still.ts` so the policy (what counts as a refusal,
|
|
28522
|
+
* what the concurrency bound is, where the seek lands) stays testable with no
|
|
28523
|
+
* cap client, no recorder and no ffmpeg on the machine running the suite. This
|
|
28524
|
+
* file is the only place that knows the cap method names.
|
|
28525
|
+
*
|
|
28526
|
+
* **Read-side only.** `getDeviceConfig`, `locateSegment` and `readGopBytes` —
|
|
28527
|
+
* nothing here writes, evicts, prunes or relocates. That is deliberate and it
|
|
28528
|
+
* is a boundary, not an accident: the recorder's write path is the one thing a
|
|
28529
|
+
* background maintenance pass must never be able to perturb.
|
|
28530
|
+
*/
|
|
28531
|
+
/**
|
|
28532
|
+
* Hard bound on one still decode. Generous because the work is background and a
|
|
28533
|
+
* stuck ffmpeg costs a slot rather than a delivery; tight enough that two stuck
|
|
28534
|
+
* ones cannot wedge the service for a whole pass.
|
|
28535
|
+
*/
|
|
28536
|
+
var RECORDED_STILL_TIMEOUT_MS = 15e3;
|
|
28537
|
+
/** A native JPEG is worth having at full size; this is headroom, not a budget. */
|
|
28538
|
+
var RUN_MAX_BUFFER = 32 * 1024 * 1024;
|
|
28539
|
+
/**
|
|
28540
|
+
* The profile a recovered still is read from.
|
|
28541
|
+
*
|
|
28542
|
+
* `high` and not the low rendition, because the whole reason to re-decode is
|
|
28543
|
+
* that the model wants NATIVE pixels — a still cut from the scrub-grade copy
|
|
28544
|
+
* would be a worse version of the ≤640 fallback the design already has.
|
|
28545
|
+
*/
|
|
28546
|
+
var RECORDED_STILL_PROFILE = "high";
|
|
28547
|
+
/**
|
|
28548
|
+
* Width cap for a recovered key frame.
|
|
28549
|
+
*
|
|
28550
|
+
* The same 1920 the live native `keyFrame` capture uses, so a vector rebuilt
|
|
28551
|
+
* from recovered pixels and one rebuilt from a captured key frame are cut from
|
|
28552
|
+
* rasters of the same scale. Different scales would not be wrong — the runner
|
|
28553
|
+
* derives the rectangle from normalised coordinates either way — but they would
|
|
28554
|
+
* make two populations in one index differ by resampling, and this index has
|
|
28555
|
+
* already had two feature spaces in it once ([D52](../../../../docs/decisions/adr-0052.md)).
|
|
28556
|
+
*/
|
|
28557
|
+
var RECOVERED_KEY_FRAME_MAX_WIDTH = 1920;
|
|
28558
|
+
/**
|
|
28559
|
+
* Build the production ports.
|
|
28560
|
+
*
|
|
28561
|
+
* Every port swallows its own failure into the `null` the policy expects: a
|
|
28562
|
+
* maintenance pass that walks thousands of tracks must not end because one
|
|
28563
|
+
* device's config read timed out, and the outcome the caller reports is already
|
|
28564
|
+
* the honest one.
|
|
28565
|
+
*/
|
|
28566
|
+
function buildRecordedStillPorts(input) {
|
|
28567
|
+
const ffmpeg = input.ffmpegPath ?? "ffmpeg";
|
|
28568
|
+
return {
|
|
28569
|
+
logger: input.logger,
|
|
28570
|
+
resolveProfile: async (deviceId) => {
|
|
28571
|
+
try {
|
|
28572
|
+
return (await input.api.getDeviceConfig.query({ deviceId })).enabled === false ? null : RECORDED_STILL_PROFILE;
|
|
28573
|
+
} catch {
|
|
28574
|
+
return null;
|
|
28575
|
+
}
|
|
28576
|
+
},
|
|
28577
|
+
locate: async (deviceId, profile, epochMs) => {
|
|
28578
|
+
try {
|
|
28579
|
+
const located = await input.api.locateSegment.query({
|
|
28580
|
+
deviceId,
|
|
28581
|
+
profile,
|
|
28582
|
+
epochMs
|
|
28583
|
+
});
|
|
28584
|
+
if (located.kind !== "segment" || typeof located.startMs !== "number") return null;
|
|
28585
|
+
return { startMs: located.startMs };
|
|
28586
|
+
} catch {
|
|
28587
|
+
return null;
|
|
28588
|
+
}
|
|
28589
|
+
},
|
|
28590
|
+
readGop: async (deviceId, profile, startMs, epochMs) => {
|
|
28591
|
+
try {
|
|
28592
|
+
return await input.api.readGopBytes.query({
|
|
28593
|
+
deviceId,
|
|
28594
|
+
profile,
|
|
28595
|
+
startMs,
|
|
28596
|
+
epochMs
|
|
28597
|
+
});
|
|
28598
|
+
} catch {
|
|
28599
|
+
return null;
|
|
28600
|
+
}
|
|
28601
|
+
},
|
|
28602
|
+
decodeStill: (gop, atSec, maxWidth) => new Promise((resolve) => {
|
|
28603
|
+
const child = (0, node_child_process.execFile)(ffmpeg, [...buildRecordedStillArgs(atSec, maxWidth)], {
|
|
28604
|
+
encoding: "buffer",
|
|
28605
|
+
maxBuffer: RUN_MAX_BUFFER,
|
|
28606
|
+
timeout: RECORDED_STILL_TIMEOUT_MS
|
|
28607
|
+
}, (err, stdout) => resolve(err !== null ? null : stdout));
|
|
28608
|
+
child.stdin?.on("error", () => void 0);
|
|
28609
|
+
child.stdin?.end(Buffer.from(gop));
|
|
28610
|
+
})
|
|
28611
|
+
};
|
|
28612
|
+
}
|
|
26425
28613
|
//#endregion
|
|
26426
28614
|
//#region src/pipeline-analytics/store/tiered-label-columns.ts
|
|
26427
28615
|
/**
|
|
@@ -37401,6 +39589,10 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
37401
39589
|
getRecordingConfig: (deviceId) => api.recording.getDeviceConfig.query({ deviceId }).catch(() => null),
|
|
37402
39590
|
runNow: (input) => center.runTimelapseNow(input)
|
|
37403
39591
|
},
|
|
39592
|
+
summary: {
|
|
39593
|
+
store: center.summaryStore,
|
|
39594
|
+
runNow: (input) => center.runSummaryNow(input)
|
|
39595
|
+
},
|
|
37404
39596
|
texts: center.textCatalogEditor
|
|
37405
39597
|
});
|
|
37406
39598
|
}
|
|
@@ -38092,13 +40284,75 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
38092
40284
|
projection: "slim"
|
|
38093
40285
|
})).tracks;
|
|
38094
40286
|
},
|
|
38095
|
-
...this.isPostProcessingNode ? { timelapse: this.buildTimelapsePorts(api, stores) } : {}
|
|
40287
|
+
...this.isPostProcessingNode ? { timelapse: this.buildTimelapsePorts(api, stores) } : {},
|
|
40288
|
+
...this.isPostProcessingNode ? { summary: this.buildSummaryPorts(stores) } : {}
|
|
38096
40289
|
});
|
|
38097
40290
|
this.wireAlarmPanel(this.notificationCenter);
|
|
38098
40291
|
await this.serveNcActionPlane(this.notificationCenter);
|
|
38099
40292
|
await this.notificationCenter.start({ evaluation: this.isPostProcessingNode });
|
|
38100
40293
|
}
|
|
38101
40294
|
/**
|
|
40295
|
+
* The multi-camera digest producer's ports.
|
|
40296
|
+
*
|
|
40297
|
+
* Every one reads something this process already owns: the LOCAL track store
|
|
40298
|
+
* for the window (no self-cap call, `projection: 'slim'` because the selector
|
|
40299
|
+
* reads timestamps, class, zones and importance and nothing else), the LOCAL
|
|
40300
|
+
* media store for the index rows and the one blob per tile, and the NC
|
|
40301
|
+
* artifact plane for the public link.
|
|
40302
|
+
*
|
|
40303
|
+
* The mosaic is filed through the SAME `mediaStore` every event thumbnail
|
|
40304
|
+
* goes through — the operator's directive, verbatim: same data structure,
|
|
40305
|
+
* same storage location, no second retained store. It is written under
|
|
40306
|
+
* `ownerKind: 'event'` with the window's own owner id, so it lands beside the
|
|
40307
|
+
* cameras' event media (`<deviceId>/events/event/<ownerId>/…`) and is swept
|
|
40308
|
+
* by the same machinery. It is deliberately NOT owned by a track: a track's
|
|
40309
|
+
* media list is a LIVE viewer surface, and a cross-camera mosaic appearing in
|
|
40310
|
+
* one track's tiles would be a picture that lies about what it is of.
|
|
40311
|
+
*/
|
|
40312
|
+
buildSummaryPorts(stores) {
|
|
40313
|
+
return {
|
|
40314
|
+
listTracks: async ({ deviceId, sinceMs, untilMs, limit }) => {
|
|
40315
|
+
return (await stores.trackStore.queryHistorical({
|
|
40316
|
+
deviceId,
|
|
40317
|
+
since: sinceMs,
|
|
40318
|
+
until: untilMs,
|
|
40319
|
+
projection: "slim",
|
|
40320
|
+
limit
|
|
40321
|
+
})).map((t) => ({
|
|
40322
|
+
trackId: t.trackId,
|
|
40323
|
+
deviceId: t.deviceId,
|
|
40324
|
+
className: t.className,
|
|
40325
|
+
firstSeen: t.firstSeen,
|
|
40326
|
+
lastSeen: t.lastSeen,
|
|
40327
|
+
...t.importance !== void 0 ? { importance: t.importance } : {},
|
|
40328
|
+
zones: [...t.zonesVisited],
|
|
40329
|
+
...t.classes !== void 0 ? { classes: [...t.classes] } : {}
|
|
40330
|
+
}));
|
|
40331
|
+
},
|
|
40332
|
+
listMediaInfo: async (trackId) => {
|
|
40333
|
+
return (await stores.mediaStore.listInfoByOwner("track", trackId)).map((r) => ({
|
|
40334
|
+
key: r.key,
|
|
40335
|
+
kind: r.kind,
|
|
40336
|
+
timestamp: r.timestamp
|
|
40337
|
+
}));
|
|
40338
|
+
},
|
|
40339
|
+
readMedia: async (key) => {
|
|
40340
|
+
const file = await stores.mediaStore.getByKey(key);
|
|
40341
|
+
if (file === null) return null;
|
|
40342
|
+
return Buffer.from(file.base64, "base64");
|
|
40343
|
+
},
|
|
40344
|
+
storeMosaic: async ({ deviceId, ownerId, timestamp, jpeg }) => stores.mediaStore.put({
|
|
40345
|
+
deviceId,
|
|
40346
|
+
ownerKind: "event",
|
|
40347
|
+
ownerId,
|
|
40348
|
+
kind: "fullFrame",
|
|
40349
|
+
timestamp,
|
|
40350
|
+
data: jpeg
|
|
40351
|
+
}),
|
|
40352
|
+
publish: async (bytes, mime) => await this.ncArtifactPlane?.publish(bytes, mime) ?? null
|
|
40353
|
+
};
|
|
40354
|
+
}
|
|
40355
|
+
/**
|
|
38102
40356
|
* The scheduled-timelapse producer's ports.
|
|
38103
40357
|
*
|
|
38104
40358
|
* Every one of them is something that already exists: `recording` for the
|
|
@@ -41684,6 +43938,10 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
41684
43938
|
pacingMs: input.pacingMs ?? 50
|
|
41685
43939
|
} });
|
|
41686
43940
|
const deviceIds = input.deviceId !== void 0 ? [input.deviceId] : await trackStore.listDeviceIds();
|
|
43941
|
+
const recordedStills = new RecordedStillService(buildRecordedStillPorts({
|
|
43942
|
+
api: this.ctx.api.recording,
|
|
43943
|
+
logger: this.ctx.logger
|
|
43944
|
+
}));
|
|
41687
43945
|
const deps = {
|
|
41688
43946
|
listTrackIdPage: async (cursor) => {
|
|
41689
43947
|
const page = await trackStore.queryRecent({
|
|
@@ -41702,6 +43960,10 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
41702
43960
|
media: mediaStore,
|
|
41703
43961
|
events: eventStore
|
|
41704
43962
|
}, trackId),
|
|
43963
|
+
recoverKeyFrame: async (source) => {
|
|
43964
|
+
const recovered = await recordedStills.recover(source.deviceId, source.timestamp, RECOVERED_KEY_FRAME_MAX_WIDTH);
|
|
43965
|
+
return recovered.outcome === "ok" ? recovered.jpeg ?? null : null;
|
|
43966
|
+
},
|
|
41705
43967
|
canDispatch: () => dispatcher.canDispatch,
|
|
41706
43968
|
pause: (ms) => require_dist.sleep(ms),
|
|
41707
43969
|
embed: (source) => dispatcher.embed(source),
|
|
@@ -41729,6 +43991,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
41729
43991
|
scanned: 0,
|
|
41730
43992
|
rebuilt: 0,
|
|
41731
43993
|
missingKeyFrame: 0,
|
|
43994
|
+
recoveredKeyFrame: 0,
|
|
41732
43995
|
missingEvent: 0,
|
|
41733
43996
|
missingBbox: 0,
|
|
41734
43997
|
notRunnable: 0,
|