@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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as
|
|
1
|
+
import { $ as zoneAnalyticsCapability, A as addonWidgetsSourceCapability, B as encodeVectorBase64, C as RECORDING_EXPORT_MAX_READ_BYTES, D as TimelapseRulePatchSchema, E as TimelapseRuleInputSchema, F as cosineSimilarity, G as notificationRulesCapability, I as customAction, J as readDeviceStateFrom, K as pipelineAnalyticsCapability, L as defineCustomActions, M as assertTimelapseCadences, N as audioMetricsCapability, O as TimelapseRuleSchema, P as buildEventKindDescriptor, Q as videoclipsCapability, R as deriveRecordingMode, S as OpsLogEntrySchema, T as TIMELAPSE_DENSE_FLOOR_SEC, U as isScheduleActive, V as faceGalleryCapability, W as kebabToCamel, X as subKindsOf, Y as readTimelapseGeneratedAt, Z as vectorDimFromBase64, _ as NcScheduleSchema, _t as string, a as DeclaredDevices, at as isDeviceScopedCap, b as NcSnoozeSuppressedSchema, c as LabelAttributionSchema, ct as _enum, d as NC_TAXONOMY, dt as discriminatedUnion, et as errMsg, f as NcConditionDescriptorSchema, ft as literal, g as NcRuleTargetSchema, gt as record, h as NcRuleSchema, ht as partialRecord, it as hydrateSchema, j as alarmPanelCapability, k as TrackSourceSchema, l as MACRO_LABELS, lt as array, m as NcRulePatchSchema, mt as object, n as DEFAULT_EVENT_COLOR, nt as DeviceType, o as EVENT_KIND_BY_CAP, ot as nodePin, p as NcRuleInputSchema, pt as number, q as plateGalleryCapability, rt as createEvent, s as EVENT_PAD_MS, st as sleep, t as BaseDevice, tt as BaseAddon, u as NC_CONDITION_CATALOG, ut as boolean, v as NcSnoozeInputSchema, vt as unknown, w as RetrainStatusSchema, x as NcTaxonomySchema, y as NcSnoozeSchema, yt as EventCategory } from "../dist-rpHe5qpB.mjs";
|
|
2
2
|
import { t as __exportAll } from "../embedding-encoder/index.mjs";
|
|
3
3
|
import * as fs from "node:fs";
|
|
4
4
|
import { promises } from "node:fs";
|
|
@@ -7,8 +7,8 @@ import path from "node:path";
|
|
|
7
7
|
import { createHash, createHmac, randomUUID, timingSafeEqual } from "node:crypto";
|
|
8
8
|
import { execFile } from "node:child_process";
|
|
9
9
|
import os from "node:os";
|
|
10
|
-
import { Buffer as Buffer$1 } from "node:buffer";
|
|
11
10
|
import sharp from "sharp";
|
|
11
|
+
import { Buffer as Buffer$1 } from "node:buffer";
|
|
12
12
|
//#region src/notification-center/action-token.ts
|
|
13
13
|
/**
|
|
14
14
|
* The authority behind a notification button.
|
|
@@ -2712,25 +2712,18 @@ function scopeZoneIdsToDevice(ids, deviceId, owner) {
|
|
|
2712
2712
|
/**
|
|
2713
2713
|
* The rule-level entry point: scope this rule's zones to this camera.
|
|
2714
2714
|
*
|
|
2715
|
-
* ──
|
|
2715
|
+
* ── One semantic for scoped AND unscoped rules (operator, 2026-08-13) ───────
|
|
2716
2716
|
*
|
|
2717
|
-
*
|
|
2718
|
-
*
|
|
2719
|
-
*
|
|
2720
|
-
*
|
|
2721
|
-
*
|
|
2722
|
-
*
|
|
2723
|
-
*
|
|
2724
|
-
*
|
|
2725
|
-
*
|
|
2726
|
-
* The rule editor takes zone ids as free text (`zoneIdList` / `zoneSelection`
|
|
2727
|
-
* tag inputs, no per-camera picker), so an all-devices rule carrying one
|
|
2728
|
-
* camera's zone ids is fully authorable and exists in the wild. Its zones stay
|
|
2729
|
-
* flat: the rule remains, as today, effectively about the camera that owns
|
|
2730
|
-
* those zones. Naming the cameras is the operator's way of asking for more.
|
|
2717
|
+
* "No zones set on a camera means everywhere." A zone in the list narrows ONLY
|
|
2718
|
+
* the camera that owns it; every other camera the rule reaches — named or
|
|
2719
|
+
* reached because the rule names no camera at all — matches whole-frame. The
|
|
2720
|
+
* first cut kept unscoped rules flat to avoid widening "notify me when someone
|
|
2721
|
+
* is in the driveway" into an all-camera rule; the operator overruled it: an
|
|
2722
|
+
* unscoped rule already IS an all-camera rule, and the zone is a per-camera
|
|
2723
|
+
* narrowing, not a rule-wide one. Scoping a rule to cameras remains the way to
|
|
2724
|
+
* limit where it fires.
|
|
2731
2725
|
*/
|
|
2732
2726
|
function scopeZoneIdsForRule(input, owner) {
|
|
2733
|
-
if (input.deviceScope === void 0 || input.deviceScope.length === 0) return UNCHANGED;
|
|
2734
2727
|
return scopeZoneIdsToDevice(input.ids, input.deviceId, owner);
|
|
2735
2728
|
}
|
|
2736
2729
|
//#endregion
|
|
@@ -3516,7 +3509,6 @@ var en_default = {
|
|
|
3516
3509
|
"other": "{{events}} stretches filmed at a dense cadence"
|
|
3517
3510
|
},
|
|
3518
3511
|
"timelapse.body.detections": "{{detectionSummary}}",
|
|
3519
|
-
"timelapse.body.coverageWarning": "WARNING: recording available for only {{coverage}}% of the window",
|
|
3520
3512
|
"snooze.digest.title": {
|
|
3521
3513
|
"one": "Snooze ended — {{total}} notification hidden",
|
|
3522
3514
|
"other": "Snooze ended — {{total}} notifications hidden"
|
|
@@ -3548,7 +3540,20 @@ var en_default = {
|
|
|
3548
3540
|
"system.alarm-disarmed.body": "The alarm was disarmed",
|
|
3549
3541
|
"system.alarm-triggered.title": "ALARM",
|
|
3550
3542
|
"system.alarm-triggered.body": "The alarm was triggered",
|
|
3551
|
-
"system.alarm-triggered.body.rule": "The alarm was triggered by {{rule}} on {{deviceName}}"
|
|
3543
|
+
"system.alarm-triggered.body.rule": "The alarm was triggered by {{rule}} on {{deviceName}}",
|
|
3544
|
+
"summary.title": "Summary — {{rule}}",
|
|
3545
|
+
"summary.body.window": "{{from}}→{{to}}",
|
|
3546
|
+
"summary.body.events": {
|
|
3547
|
+
"one": "{{events}} event",
|
|
3548
|
+
"other": "{{events}} events"
|
|
3549
|
+
},
|
|
3550
|
+
"summary.body.cameras": {
|
|
3551
|
+
"one": "{{cameras}} camera",
|
|
3552
|
+
"other": "{{cameras}} cameras"
|
|
3553
|
+
},
|
|
3554
|
+
"summary.body.detections": "{{detectionSummary}}",
|
|
3555
|
+
"summary.body.truncated": "showing the {{shown}} most relevant of {{total}}",
|
|
3556
|
+
"summary.body.empty": "nothing was seen in this window"
|
|
3552
3557
|
};
|
|
3553
3558
|
//#endregion
|
|
3554
3559
|
//#region src/notification-center/texts/it.json
|
|
@@ -3645,7 +3650,6 @@ var it_default = {
|
|
|
3645
3650
|
"other": "{{events}} intervalli ripresi a cadenza fitta"
|
|
3646
3651
|
},
|
|
3647
3652
|
"timelapse.body.detections": "{{detectionSummary}}",
|
|
3648
|
-
"timelapse.body.coverageWarning": "ATTENZIONE: registrazione disponibile solo per il {{coverage}}% della finestra",
|
|
3649
3653
|
"snooze.digest.title": {
|
|
3650
3654
|
"one": "Pausa terminata — {{total}} notifica nascosta",
|
|
3651
3655
|
"other": "Pausa terminata — {{total}} notifiche nascoste"
|
|
@@ -3677,7 +3681,20 @@ var it_default = {
|
|
|
3677
3681
|
"system.alarm-disarmed.body": "L'allarme è stato disattivato",
|
|
3678
3682
|
"system.alarm-triggered.title": "ALLARME",
|
|
3679
3683
|
"system.alarm-triggered.body": "L'allarme è scattato",
|
|
3680
|
-
"system.alarm-triggered.body.rule": "L'allarme è scattato per {{rule}} su {{deviceName}}"
|
|
3684
|
+
"system.alarm-triggered.body.rule": "L'allarme è scattato per {{rule}} su {{deviceName}}",
|
|
3685
|
+
"summary.title": "Riepilogo — {{rule}}",
|
|
3686
|
+
"summary.body.window": "{{from}}→{{to}}",
|
|
3687
|
+
"summary.body.events": {
|
|
3688
|
+
"one": "{{events}} evento",
|
|
3689
|
+
"other": "{{events}} eventi"
|
|
3690
|
+
},
|
|
3691
|
+
"summary.body.cameras": {
|
|
3692
|
+
"one": "{{cameras}} camera",
|
|
3693
|
+
"other": "{{cameras}} camere"
|
|
3694
|
+
},
|
|
3695
|
+
"summary.body.detections": "{{detectionSummary}}",
|
|
3696
|
+
"summary.body.truncated": "mostrati i {{shown}} più rilevanti su {{total}}",
|
|
3697
|
+
"summary.body.empty": "nessun evento in questa finestra"
|
|
3681
3698
|
};
|
|
3682
3699
|
//#endregion
|
|
3683
3700
|
//#region src/notification-center/text-catalog.ts
|
|
@@ -5752,7 +5769,7 @@ var NC_OCCUPANCY_INDEXES = [{
|
|
|
5752
5769
|
}];
|
|
5753
5770
|
/** Query cap — a per-(device, zone, class) key set is small; this is a
|
|
5754
5771
|
* generous ceiling that still bounds a pathological read. */
|
|
5755
|
-
var LOAD_LIMIT$
|
|
5772
|
+
var LOAD_LIMIT$2 = 1e5;
|
|
5756
5773
|
var OccupancyStore = class {
|
|
5757
5774
|
cache = /* @__PURE__ */ new Map();
|
|
5758
5775
|
store;
|
|
@@ -5779,7 +5796,7 @@ var OccupancyStore = class {
|
|
|
5779
5796
|
try {
|
|
5780
5797
|
const records = await this.store.query.query({
|
|
5781
5798
|
collection: NC_OCCUPANCY_COLLECTION,
|
|
5782
|
-
filter: { limit: LOAD_LIMIT$
|
|
5799
|
+
filter: { limit: LOAD_LIMIT$2 }
|
|
5783
5800
|
});
|
|
5784
5801
|
this.cache.clear();
|
|
5785
5802
|
let skipped = 0;
|
|
@@ -6370,6 +6387,18 @@ function entryToRow(entry) {
|
|
|
6370
6387
|
payload: entry.payload
|
|
6371
6388
|
};
|
|
6372
6389
|
}
|
|
6390
|
+
/**
|
|
6391
|
+
* Every kind a persisted row may carry, for the READ path.
|
|
6392
|
+
*
|
|
6393
|
+
* This must list the WHOLE union. A kind missing here makes `rowToEntry` answer
|
|
6394
|
+
* `null`, and a `null` row is invisible three ways at once: it is not
|
|
6395
|
+
* re-hydrated as pending at boot (so a row still queued when the process
|
|
6396
|
+
* restarted is dropped on the floor), it never appears in `queryHistory`, and
|
|
6397
|
+
* it never appears in the recent-rows scan. `'timelapse'` was missing from this
|
|
6398
|
+
* set from the day it shipped — the live deliveries hid it, because an enqueue
|
|
6399
|
+
* hands the entry straight to the drain from memory and only a RESTART goes
|
|
6400
|
+
* through this function.
|
|
6401
|
+
*/
|
|
6373
6402
|
var OUTBOX_RECORD_KINDS = new Set([
|
|
6374
6403
|
"object-event",
|
|
6375
6404
|
"track-end",
|
|
@@ -6377,7 +6406,9 @@ var OUTBOX_RECORD_KINDS = new Set([
|
|
|
6377
6406
|
"package-event",
|
|
6378
6407
|
"audio-event",
|
|
6379
6408
|
"audio-window",
|
|
6380
|
-
"system-event"
|
|
6409
|
+
"system-event",
|
|
6410
|
+
"timelapse",
|
|
6411
|
+
"ai-summary"
|
|
6381
6412
|
]);
|
|
6382
6413
|
function isOutboxRecordKind(x) {
|
|
6383
6414
|
return typeof x === "string" && OUTBOX_RECORD_KINDS.has(x);
|
|
@@ -7978,10 +8009,18 @@ function clampToFloor(everyMs) {
|
|
|
7978
8009
|
*
|
|
7979
8010
|
* Pure — no clock, no I/O, no store. The scheduler hands over the tracks the
|
|
7980
8011
|
* window actually contained (`pipelineAnalytics.listTracks`, which carry
|
|
7981
|
-
* `firstSeen`/`lastSeen`) and gets back ranges expressed as SECONDS
|
|
7982
|
-
* WINDOW START, which is exactly the coordinate system
|
|
8012
|
+
* `firstSeen`/`lastSeen`) and gets back ranges expressed as WALL-CLOCK SECONDS
|
|
8013
|
+
* FROM THE WINDOW START, which is exactly the coordinate system
|
|
7983
8014
|
* `ExportTimelapseSchema.dense.ranges` speaks.
|
|
7984
8015
|
*
|
|
8016
|
+
* WALL clock is the load-bearing word. It is NOT ffmpeg's `t`: `t` runs over
|
|
8017
|
+
* the concatenation of the segments that exist, so every recording gap is
|
|
8018
|
+
* missing from it. The recorder translates one into the other at render time
|
|
8019
|
+
* (`export-dense-map.ts` in addon-pipeline), because only the side that builds
|
|
8020
|
+
* the source playlist knows where the gaps are. Handing `t` seconds from here
|
|
8021
|
+
* is impossible — a track knows when it happened, not how much footage
|
|
8022
|
+
* preceded it.
|
|
8023
|
+
*
|
|
7985
8024
|
* Why post-hoc and not a live sampler: the footage already exists, the tracks
|
|
7986
8025
|
* already exist, and the recorder already knows how to render a range. A live
|
|
7987
8026
|
* sampler would hold a frame ring open for eight hours per camera to
|
|
@@ -8130,27 +8169,6 @@ function detectionTemplateVars(texts, counts) {
|
|
|
8130
8169
|
* short video gets skipped too.
|
|
8131
8170
|
*/
|
|
8132
8171
|
var COVERAGE_COMPLETE_SLACK_MS = 3e4;
|
|
8133
|
-
/**
|
|
8134
|
-
* Is this coverage worth a sentence in the notification body?
|
|
8135
|
-
*
|
|
8136
|
-
* TWO conditions, and the second one is the whole point: the shortfall must be
|
|
8137
|
-
* past the slack AND the figure the sentence would PRINT must itself be short.
|
|
8138
|
-
*
|
|
8139
|
-
* They came apart live on 2026-08-12. A 10 h window on device 615 was covered
|
|
8140
|
-
* to 35,858,961 / 36,000,000 ms — 2m21s missing, so `complete` was false — and
|
|
8141
|
-
* `percent` was `Math.round(99.608) = 100`. The body read *"ATTENZIONE:
|
|
8142
|
-
* registrazione disponibile solo per il 100% della finestra"*: a warning about
|
|
8143
|
-
* a complete window, which is worse than no warning at all. The operator
|
|
8144
|
-
* learns to skip that line, and the night the video really IS a quarter of the
|
|
8145
|
-
* window gets skipped with it.
|
|
8146
|
-
*
|
|
8147
|
-
* So the gate is tied to the number the reader sees. Reporting is the only
|
|
8148
|
-
* consumer of this predicate — {@link FootageCoverage.complete} still states
|
|
8149
|
-
* the physical fact for anything that needs it.
|
|
8150
|
-
*/
|
|
8151
|
-
function coverageWorthReporting(coverage) {
|
|
8152
|
-
return !coverage.complete && coverage.percent < 100;
|
|
8153
|
-
}
|
|
8154
8172
|
/** Merge + clamp + sum. */
|
|
8155
8173
|
function footageCoverage(ranges, windowStartMs, windowEndMs) {
|
|
8156
8174
|
const windowMs = Math.max(0, windowEndMs - windowStartMs);
|
|
@@ -8222,7 +8240,7 @@ function pickProfile(ranges, window) {
|
|
|
8222
8240
|
return best;
|
|
8223
8241
|
}
|
|
8224
8242
|
/** `HH:MM`, for the export title an operator sees in the exports list. */
|
|
8225
|
-
function clockOf$
|
|
8243
|
+
function clockOf$3(atMs) {
|
|
8226
8244
|
const d = new Date(atMs);
|
|
8227
8245
|
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
8228
8246
|
}
|
|
@@ -8315,7 +8333,7 @@ var TimelapseRenderer = class {
|
|
|
8315
8333
|
includeAudio: false,
|
|
8316
8334
|
maxLifeMs: TIMELAPSE_EXPORT_LIFE_MS,
|
|
8317
8335
|
deleteAfterDownload: false,
|
|
8318
|
-
title: `${request.ruleName} ${clockOf$
|
|
8336
|
+
title: `${request.ruleName} ${clockOf$3(window.startMs)}→${clockOf$3(window.endMs)}`,
|
|
8319
8337
|
timelapse: {
|
|
8320
8338
|
everyMs,
|
|
8321
8339
|
outputFps: request.framerate,
|
|
@@ -8433,7 +8451,7 @@ var TimelapseRenderer = class {
|
|
|
8433
8451
|
//#region src/notification-center/timelapse/timelapse-delivery.ts
|
|
8434
8452
|
/** What a caller that counted nothing looks like — so `{{detections}}` renders
|
|
8435
8453
|
* `0` and `{{detectionSummary}}` renders empty, never the literal braces. */
|
|
8436
|
-
var NO_DETECTIONS = {
|
|
8454
|
+
var NO_DETECTIONS$1 = {
|
|
8437
8455
|
total: 0,
|
|
8438
8456
|
byClass: {},
|
|
8439
8457
|
classes: []
|
|
@@ -8445,41 +8463,40 @@ var NO_DETECTIONS = {
|
|
|
8445
8463
|
* prose. A translator has nothing to say about it, and every key the guard has
|
|
8446
8464
|
* to check is a key somebody can get wrong.
|
|
8447
8465
|
*/
|
|
8448
|
-
var BODY_SEPARATOR = " · ";
|
|
8466
|
+
var BODY_SEPARATOR$1 = " · ";
|
|
8449
8467
|
/** `HH:MM` in the host timezone — the two numbers a body actually needs. */
|
|
8450
|
-
function clockOf(atMs) {
|
|
8468
|
+
function clockOf$2(atMs) {
|
|
8451
8469
|
const d = new Date(atMs);
|
|
8452
8470
|
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
8453
8471
|
}
|
|
8454
8472
|
/** Template vocabulary. Deliberately the same NAMES the rule templates use
|
|
8455
8473
|
* (`camera`, `rule`, `time`) plus the timelapse-only ones. */
|
|
8456
|
-
function templateVars(input) {
|
|
8474
|
+
function templateVars$1(input) {
|
|
8457
8475
|
return {
|
|
8458
8476
|
camera: input.deviceName ?? `camera ${input.deviceId}`,
|
|
8459
8477
|
rule: input.ruleName,
|
|
8460
|
-
from: clockOf(input.window.startMs),
|
|
8461
|
-
to: clockOf(input.window.endMs),
|
|
8462
|
-
time: clockOf(input.generatedAt),
|
|
8478
|
+
from: clockOf$2(input.window.startMs),
|
|
8479
|
+
to: clockOf$2(input.window.endMs),
|
|
8480
|
+
time: clockOf$2(input.generatedAt),
|
|
8463
8481
|
coverage: String(input.coverage.percent),
|
|
8464
8482
|
events: String(input.denseRanges),
|
|
8465
|
-
...detectionTemplateVars(input.texts, input.detections ?? NO_DETECTIONS)
|
|
8483
|
+
...detectionTemplateVars(input.texts, input.detections ?? NO_DETECTIONS$1)
|
|
8466
8484
|
};
|
|
8467
8485
|
}
|
|
8468
8486
|
/**
|
|
8469
8487
|
* The body an operator reads when he did not author one.
|
|
8470
8488
|
*
|
|
8471
|
-
*
|
|
8472
|
-
*
|
|
8473
|
-
*
|
|
8474
|
-
*
|
|
8475
|
-
*
|
|
8476
|
-
*
|
|
8477
|
-
*
|
|
8478
|
-
*
|
|
8479
|
-
*
|
|
8480
|
-
|
|
8481
|
-
|
|
8482
|
-
function derivedBody(input, vars) {
|
|
8489
|
+
* **No coverage sentence.** It used to end with one whenever the window was
|
|
8490
|
+
* short, and it was removed on the operator's instruction: the clause fired on
|
|
8491
|
+
* every night the recorder had stumbled at all — including once at a printed
|
|
8492
|
+
* *100%* (2026-08-12, device 615: 141 s missing out of 10 h, so the window was
|
|
8493
|
+
* incomplete while the rounded figure was 100) — and a line that cries wolf is
|
|
8494
|
+
* a line the reader skips, which costs the clauses beside it too. The figure
|
|
8495
|
+
* is not lost: it rides the `timelapse produced` log line as `coveragePct`,
|
|
8496
|
+
* which is where a short video is diagnosed, and `{{coverage}}` still resolves
|
|
8497
|
+
* for an operator who puts it in a body HE wrote.
|
|
8498
|
+
*/
|
|
8499
|
+
function derivedBody$1(input, vars) {
|
|
8483
8500
|
const texts = input.texts;
|
|
8484
8501
|
const parts = [texts.text({
|
|
8485
8502
|
key: "timelapse.body.window",
|
|
@@ -8494,18 +8511,14 @@ function derivedBody(input, vars) {
|
|
|
8494
8511
|
count: input.denseRanges,
|
|
8495
8512
|
vars
|
|
8496
8513
|
}));
|
|
8497
|
-
if ((input.detections ?? NO_DETECTIONS).total > 0) parts.push(texts.text({
|
|
8514
|
+
if ((input.detections ?? NO_DETECTIONS$1).total > 0) parts.push(texts.text({
|
|
8498
8515
|
key: "timelapse.body.detections",
|
|
8499
8516
|
vars
|
|
8500
8517
|
}));
|
|
8501
|
-
|
|
8502
|
-
key: "timelapse.body.coverageWarning",
|
|
8503
|
-
vars
|
|
8504
|
-
}));
|
|
8505
|
-
return parts.filter((part) => part.length > 0).join(BODY_SEPARATOR);
|
|
8518
|
+
return parts.filter((part) => part.length > 0).join(BODY_SEPARATOR$1);
|
|
8506
8519
|
}
|
|
8507
8520
|
/** Per-target params with the derived `clickUrl` filled in where absent. */
|
|
8508
|
-
function paramsFor(target, clickUrl) {
|
|
8521
|
+
function paramsFor$1(target, clickUrl) {
|
|
8509
8522
|
if (clickUrl === void 0) return target.params;
|
|
8510
8523
|
if (target.params === void 0) return { clickUrl };
|
|
8511
8524
|
if (typeof target.params["clickUrl"] === "string") return target.params;
|
|
@@ -8529,8 +8542,8 @@ function timelapsePreviewText(input) {
|
|
|
8529
8542
|
camera: input.deviceName ?? `camera ${input.deviceId}`,
|
|
8530
8543
|
...input.ruleName !== void 0 ? { rule: input.ruleName } : {},
|
|
8531
8544
|
...input.window !== void 0 ? {
|
|
8532
|
-
from: clockOf(input.window.startMs),
|
|
8533
|
-
to: clockOf(input.window.endMs)
|
|
8545
|
+
from: clockOf$2(input.window.startMs),
|
|
8546
|
+
to: clockOf$2(input.window.endMs)
|
|
8534
8547
|
} : {}
|
|
8535
8548
|
};
|
|
8536
8549
|
return renderTemplate(input.text ?? "Timelapse {{camera}}", vars) ?? "";
|
|
@@ -8545,13 +8558,13 @@ function timelapseRecordId(deviceId, windowEndMs) {
|
|
|
8545
8558
|
* @returns rows ready for `NcOutbox.enqueue` — never sent from here.
|
|
8546
8559
|
*/
|
|
8547
8560
|
function buildTimelapseOutboxInputs(input) {
|
|
8548
|
-
const vars = templateVars(input);
|
|
8561
|
+
const vars = templateVars$1(input);
|
|
8549
8562
|
const optedOut = new Set(input.disabledTargetIds ?? []);
|
|
8550
8563
|
const title = renderTemplate(input.template?.title, vars) ?? input.texts.text({
|
|
8551
8564
|
key: "timelapse.title",
|
|
8552
8565
|
vars
|
|
8553
8566
|
});
|
|
8554
|
-
const body = renderTemplate(input.template?.body, vars) ?? derivedBody(input, vars);
|
|
8567
|
+
const body = renderTemplate(input.template?.body, vars) ?? derivedBody$1(input, vars);
|
|
8555
8568
|
const recordId = timelapseRecordId(input.deviceId, input.window.endMs);
|
|
8556
8569
|
const artifacts = [...input.preview !== void 0 ? [{
|
|
8557
8570
|
mediaType: "image",
|
|
@@ -8571,7 +8584,7 @@ function buildTimelapseOutboxInputs(input) {
|
|
|
8571
8584
|
},
|
|
8572
8585
|
media: "none",
|
|
8573
8586
|
...artifacts.length > 0 ? { artifacts } : {},
|
|
8574
|
-
...paramsFor(target, input.clickUrl) !== void 0 ? { params: paramsFor(target, input.clickUrl) } : {},
|
|
8587
|
+
...paramsFor$1(target, input.clickUrl) !== void 0 ? { params: paramsFor$1(target, input.clickUrl) } : {},
|
|
8575
8588
|
subject: {
|
|
8576
8589
|
deviceId: input.deviceId,
|
|
8577
8590
|
className: "timelapse",
|
|
@@ -8898,7 +8911,1784 @@ var NC_TIMELAPSE_RULES_COLUMNS = [
|
|
|
8898
8911
|
notNull: true
|
|
8899
8912
|
},
|
|
8900
8913
|
(
|
|
8901
|
-
/** The FULL rule object (Zod-validated on read) — scalars above are
|
|
8914
|
+
/** The FULL rule object (Zod-validated on read) — scalars above are
|
|
8915
|
+
* indexed projections only. */
|
|
8916
|
+
{
|
|
8917
|
+
name: "rule",
|
|
8918
|
+
type: "JSON",
|
|
8919
|
+
notNull: true
|
|
8920
|
+
})
|
|
8921
|
+
];
|
|
8922
|
+
var NC_TIMELAPSE_RULES_INDEXES = [{
|
|
8923
|
+
name: "idx_nc_timelapse_rules_enabled",
|
|
8924
|
+
columns: ["enabled"]
|
|
8925
|
+
}];
|
|
8926
|
+
/** Query cap — the rule set is operator-authored and tiny; a generous ceiling. */
|
|
8927
|
+
var LOAD_LIMIT$1 = 1e4;
|
|
8928
|
+
/**
|
|
8929
|
+
* Resolve the three-way `template` patch signal onto a merged rule, immutably:
|
|
8930
|
+
* `undefined` (key absent) leaves it as-is, `null` DROPS the key, an object
|
|
8931
|
+
* replaces it. Keeping `null` out of the persisted rule is what lets
|
|
8932
|
+
* `TimelapseRuleSchema` stay a plain `.optional()`.
|
|
8933
|
+
*/
|
|
8934
|
+
function applyTemplatePatch$1(merged, template) {
|
|
8935
|
+
if (template === void 0) return merged;
|
|
8936
|
+
if (template !== null) return {
|
|
8937
|
+
...merged,
|
|
8938
|
+
template
|
|
8939
|
+
};
|
|
8940
|
+
const { template: _cleared, ...withoutTemplate } = merged;
|
|
8941
|
+
return withoutTemplate;
|
|
8942
|
+
}
|
|
8943
|
+
/** Narrowing guard — the persisted `rule` column is `unknown` until parsed. */
|
|
8944
|
+
function isRecord$1(value) {
|
|
8945
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
8946
|
+
}
|
|
8947
|
+
var TimelapseStore = class {
|
|
8948
|
+
byId = /* @__PURE__ */ new Map();
|
|
8949
|
+
/**
|
|
8950
|
+
* Birth stamps synthesised for LEGACY rows (see {@link withCreatedAt}), kept
|
|
8951
|
+
* so a repeated `load()` re-uses the FIRST one. Without it the synthesised
|
|
8952
|
+
* `createdAt` would advance to "now" on every refresh, and the scheduler's
|
|
8953
|
+
* eligibility floor — which is `max(stamp, createdAt)` — would creep past
|
|
8954
|
+
* every window such a rule was ever offered, silently producing nothing.
|
|
8955
|
+
*/
|
|
8956
|
+
synthesisedCreatedAt = /* @__PURE__ */ new Map();
|
|
8957
|
+
store;
|
|
8958
|
+
logger;
|
|
8959
|
+
now;
|
|
8960
|
+
newId;
|
|
8961
|
+
constructor(deps) {
|
|
8962
|
+
this.store = deps.store;
|
|
8963
|
+
this.logger = deps.logger;
|
|
8964
|
+
this.now = deps.now ?? (() => Date.now());
|
|
8965
|
+
this.newId = deps.newId ?? (() => randomUUID());
|
|
8966
|
+
}
|
|
8967
|
+
static async declare(store) {
|
|
8968
|
+
await store.declareCollection.mutate({
|
|
8969
|
+
collection: NC_TIMELAPSE_RULES_COLLECTION,
|
|
8970
|
+
columns: [...NC_TIMELAPSE_RULES_COLUMNS],
|
|
8971
|
+
indexes: [...NC_TIMELAPSE_RULES_INDEXES]
|
|
8972
|
+
});
|
|
8973
|
+
}
|
|
8974
|
+
/**
|
|
8975
|
+
* (Re)hydrate the FULL rule set from the store — called at boot and on the
|
|
8976
|
+
* periodic refresh tick. Replaces the cache wholesale; a row whose JSON no
|
|
8977
|
+
* longer validates is skipped with a warning (a degraded rule must never
|
|
8978
|
+
* crash the scheduler).
|
|
8979
|
+
*/
|
|
8980
|
+
async load() {
|
|
8981
|
+
try {
|
|
8982
|
+
const rows = await this.store.query.query({
|
|
8983
|
+
collection: NC_TIMELAPSE_RULES_COLLECTION,
|
|
8984
|
+
filter: { limit: LOAD_LIMIT$1 }
|
|
8985
|
+
});
|
|
8986
|
+
this.byId.clear();
|
|
8987
|
+
let skipped = 0;
|
|
8988
|
+
let synthesised = 0;
|
|
8989
|
+
for (const row of rows) {
|
|
8990
|
+
const raw = this.withCreatedAt(row.id, row.data["rule"]);
|
|
8991
|
+
if (raw !== row.data["rule"]) synthesised += 1;
|
|
8992
|
+
const parsed = TimelapseRuleSchema.safeParse(raw);
|
|
8993
|
+
if (!parsed.success) {
|
|
8994
|
+
skipped += 1;
|
|
8995
|
+
continue;
|
|
8996
|
+
}
|
|
8997
|
+
this.byId.set(parsed.data.id, parsed.data);
|
|
8998
|
+
}
|
|
8999
|
+
this.logger.debug("timelapse rules loaded", { meta: {
|
|
9000
|
+
rules: this.byId.size,
|
|
9001
|
+
...skipped > 0 ? { skippedInvalid: skipped } : {},
|
|
9002
|
+
...synthesised > 0 ? { synthesisedCreatedAt: synthesised } : {}
|
|
9003
|
+
} });
|
|
9004
|
+
} catch (err) {
|
|
9005
|
+
this.logger.warn("timelapse rules load failed", { meta: { error: String(err) } });
|
|
9006
|
+
}
|
|
9007
|
+
}
|
|
9008
|
+
/**
|
|
9009
|
+
* Give a persisted blob a `createdAt` if it has none, WITHOUT persisting
|
|
9010
|
+
* anything: the rule reads as born at this load.
|
|
9011
|
+
*
|
|
9012
|
+
* `createdAt` has always been required by `TimelapseRuleSchema`, so a row
|
|
9013
|
+
* without one is a pre-schema artefact — and before this it was dropped
|
|
9014
|
+
* outright at load (a silently vanished rule). Reading it as "born now" is
|
|
9015
|
+
* the conservative choice in BOTH directions that matter: the rule survives,
|
|
9016
|
+
* and the scheduler's eligibility floor stops it back-filling a window that
|
|
9017
|
+
* closed years before anyone looked at it. The cost is one skipped window if
|
|
9018
|
+
* a legacy rule is loaded between a close and its settle — a bounded miss,
|
|
9019
|
+
* against an unbounded retry of a window no footage can satisfy.
|
|
9020
|
+
*
|
|
9021
|
+
* The synthesised value is remembered per rule id so a later refresh does not
|
|
9022
|
+
* move the floor, and the first `update`/`markGenerated` writes it through —
|
|
9023
|
+
* after which the row is no longer legacy.
|
|
9024
|
+
*/
|
|
9025
|
+
withCreatedAt(rowId, raw) {
|
|
9026
|
+
if (!isRecord$1(raw)) return raw;
|
|
9027
|
+
if (typeof raw["createdAt"] === "number") return raw;
|
|
9028
|
+
const stamped = this.synthesisedCreatedAt.get(rowId) ?? this.now();
|
|
9029
|
+
this.synthesisedCreatedAt.set(rowId, stamped);
|
|
9030
|
+
return {
|
|
9031
|
+
...raw,
|
|
9032
|
+
createdAt: stamped
|
|
9033
|
+
};
|
|
9034
|
+
}
|
|
9035
|
+
/** Every rule, newest-first (admin path). */
|
|
9036
|
+
list() {
|
|
9037
|
+
return [...this.byId.values()].sort((a, b) => b.updatedAt - a.updatedAt);
|
|
9038
|
+
}
|
|
9039
|
+
/** The scheduler's read: only rules that should be ticked. */
|
|
9040
|
+
listEnabled() {
|
|
9041
|
+
return this.list().filter((r) => r.enabled);
|
|
9042
|
+
}
|
|
9043
|
+
get(ruleId) {
|
|
9044
|
+
return this.byId.get(ruleId) ?? null;
|
|
9045
|
+
}
|
|
9046
|
+
/**
|
|
9047
|
+
* Rules visible to `userId`: their OWN personal rules (`ownerUserId ===
|
|
9048
|
+
* userId`) plus every admin/global rule (`ownerUserId` absent). Never another
|
|
9049
|
+
* user's personal rows. Newest-first (inherits {@link list}).
|
|
9050
|
+
*
|
|
9051
|
+
* The caller identity is server-derived; an absent/undefined caller must be
|
|
9052
|
+
* resolved to a fail-closed value by the bridge action BEFORE calling here —
|
|
9053
|
+
* this store never treats a missing caller as admin/global.
|
|
9054
|
+
*/
|
|
9055
|
+
listForOwner(userId) {
|
|
9056
|
+
return this.list().filter((r) => r.ownerUserId === void 0 || r.ownerUserId === userId);
|
|
9057
|
+
}
|
|
9058
|
+
/**
|
|
9059
|
+
* Mutation gate for a NON-admin caller: true only for a PERSONAL rule this
|
|
9060
|
+
* user owns. A global rule (no `ownerUserId`) returns false — global rules
|
|
9061
|
+
* are admin-only, and the bridge action grants admins the mutation without
|
|
9062
|
+
* consulting this check. An unknown rule id is false (fail-closed).
|
|
9063
|
+
*/
|
|
9064
|
+
isOwnedBy(ruleId, userId) {
|
|
9065
|
+
const rule = this.byId.get(ruleId);
|
|
9066
|
+
return rule?.ownerUserId !== void 0 && rule.ownerUserId === userId;
|
|
9067
|
+
}
|
|
9068
|
+
/**
|
|
9069
|
+
* Create a rule. `createdBy` is the SERVER-injected caller userId;
|
|
9070
|
+
* `ownerUserId` is the server-derived owner (omit for an admin/global rule).
|
|
9071
|
+
* Neither is ever read from `input`.
|
|
9072
|
+
*
|
|
9073
|
+
* The input is re-parsed through {@link TimelapseRuleInputSchema} BEFORE the
|
|
9074
|
+
* spread — that schema carries no ownership/provenance keys, so it strips any
|
|
9075
|
+
* that rode in on the blob. Without it, an `ownerUserId` on `input` would
|
|
9076
|
+
* survive whenever the `ownerUserId` ARGUMENT is omitted (the admin/global
|
|
9077
|
+
* path): a `TimelapseRule` is structurally assignable to `TimelapseRuleInput`,
|
|
9078
|
+
* so a future "duplicate rule" action (`create(existingRule, caller)`) would
|
|
9079
|
+
* compile cleanly and silently clone the ORIGINAL owner.
|
|
9080
|
+
*/
|
|
9081
|
+
async create(input, createdBy, ownerUserId) {
|
|
9082
|
+
const now = this.now();
|
|
9083
|
+
const rule = TimelapseRuleSchema.parse({
|
|
9084
|
+
...TimelapseRuleInputSchema.parse(input),
|
|
9085
|
+
id: this.newId(),
|
|
9086
|
+
...ownerUserId !== void 0 ? { ownerUserId } : {},
|
|
9087
|
+
createdBy,
|
|
9088
|
+
createdAt: now,
|
|
9089
|
+
updatedAt: now
|
|
9090
|
+
});
|
|
9091
|
+
await this.persist(rule);
|
|
9092
|
+
this.byId.set(rule.id, rule);
|
|
9093
|
+
return rule;
|
|
9094
|
+
}
|
|
9095
|
+
/**
|
|
9096
|
+
* Apply a partial patch. Immutable: returns the NEW rule object. Identity,
|
|
9097
|
+
* ownership and generation state are re-pinned from the existing rule AFTER
|
|
9098
|
+
* the spread — the patch schema carries none of them, and this makes a
|
|
9099
|
+
* hand-built (unparsed) patch object equally unable to re-own a rule.
|
|
9100
|
+
*
|
|
9101
|
+
* `template` is the one clearable field: an absent key leaves it unchanged,
|
|
9102
|
+
* an explicit `null` CLEARS it (the persisted rule loses the key — `null`
|
|
9103
|
+
* never reaches {@link TimelapseRuleSchema}). See the patch schema's wire
|
|
9104
|
+
* note.
|
|
9105
|
+
*/
|
|
9106
|
+
async update(ruleId, patch) {
|
|
9107
|
+
const existing = this.byId.get(ruleId);
|
|
9108
|
+
if (!existing) throw new Error(`timelapse rule not found: ${ruleId}`);
|
|
9109
|
+
const { template, ...rest } = patch;
|
|
9110
|
+
const merged = {
|
|
9111
|
+
...existing,
|
|
9112
|
+
...rest,
|
|
9113
|
+
id: existing.id,
|
|
9114
|
+
ownerUserId: existing.ownerUserId,
|
|
9115
|
+
lastGeneratedAt: existing.lastGeneratedAt,
|
|
9116
|
+
generatedByDevice: existing.generatedByDevice,
|
|
9117
|
+
createdBy: existing.createdBy,
|
|
9118
|
+
createdAt: existing.createdAt,
|
|
9119
|
+
updatedAt: this.now()
|
|
9120
|
+
};
|
|
9121
|
+
return this.write(applyTemplatePatch$1(merged, template));
|
|
9122
|
+
}
|
|
9123
|
+
async setEnabled(ruleId, enabled) {
|
|
9124
|
+
return this.update(ruleId, { enabled });
|
|
9125
|
+
}
|
|
9126
|
+
/**
|
|
9127
|
+
* Record a successful generation for ONE camera of the rule. `at` is the
|
|
9128
|
+
* generation epoch-ms — the durable state behind the re-generation guard.
|
|
9129
|
+
* Does NOT bump `updatedAt` (a generation is not an edit of the rule).
|
|
9130
|
+
*
|
|
9131
|
+
* PER-DEVICE, and that is the whole point: a rule covering three cameras
|
|
9132
|
+
* renders three videos, and stamping the rule when the first one succeeds
|
|
9133
|
+
* tells the other two they are already done. Their night is then gone for
|
|
9134
|
+
* good — a closed window does not come back. Call this ONLY after the export
|
|
9135
|
+
* reached `ready` and the notification was enqueued; a failed render must
|
|
9136
|
+
* leave the pair unstamped so the next tick can retry it.
|
|
9137
|
+
*
|
|
9138
|
+
* The rule-wide `lastGeneratedAt` is kept as the NEWEST of the stamps (what
|
|
9139
|
+
* a UI shows) and never regresses when an older device generation lands.
|
|
9140
|
+
*
|
|
9141
|
+
* A rule that still carries only the LEGACY rule-wide stamp has its map
|
|
9142
|
+
* SEEDED from it for every camera the rule covers, before the new stamp is
|
|
9143
|
+
* applied — writing `{ [deviceId]: at }` alone would drop the other cameras
|
|
9144
|
+
* to "never generated" and re-render them once.
|
|
9145
|
+
*/
|
|
9146
|
+
async markGenerated(ruleId, deviceId, at) {
|
|
9147
|
+
const existing = this.byId.get(ruleId);
|
|
9148
|
+
if (!existing) throw new Error(`timelapse rule not found: ${ruleId}`);
|
|
9149
|
+
const seeded = {};
|
|
9150
|
+
if (existing.generatedByDevice === void 0 && existing.lastGeneratedAt !== void 0) for (const id of existing.deviceIds) seeded[String(id)] = existing.lastGeneratedAt;
|
|
9151
|
+
const generatedByDevice = {
|
|
9152
|
+
...seeded,
|
|
9153
|
+
...existing.generatedByDevice,
|
|
9154
|
+
[String(deviceId)]: at
|
|
9155
|
+
};
|
|
9156
|
+
return this.write({
|
|
9157
|
+
...existing,
|
|
9158
|
+
generatedByDevice,
|
|
9159
|
+
lastGeneratedAt: Math.max(existing.lastGeneratedAt ?? 0, at)
|
|
9160
|
+
});
|
|
9161
|
+
}
|
|
9162
|
+
/** Idempotent delete — unknown ids are a no-op. */
|
|
9163
|
+
async delete(ruleId) {
|
|
9164
|
+
this.byId.delete(ruleId);
|
|
9165
|
+
try {
|
|
9166
|
+
await this.store.delete.mutate({
|
|
9167
|
+
collection: NC_TIMELAPSE_RULES_COLLECTION,
|
|
9168
|
+
key: ruleId
|
|
9169
|
+
});
|
|
9170
|
+
} catch (err) {
|
|
9171
|
+
this.logger.warn("timelapse rule delete failed", { meta: {
|
|
9172
|
+
ruleId,
|
|
9173
|
+
error: String(err)
|
|
9174
|
+
} });
|
|
9175
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
9176
|
+
}
|
|
9177
|
+
}
|
|
9178
|
+
/**
|
|
9179
|
+
* Re-validate a merged candidate, persist it, then cache it. Re-validating
|
|
9180
|
+
* means a patch can never persist a rule that would be skipped at the next
|
|
9181
|
+
* `load()`; provenance/ownership survive because they are spread from the
|
|
9182
|
+
* existing rule and never present on a patch.
|
|
9183
|
+
*/
|
|
9184
|
+
async write(candidate) {
|
|
9185
|
+
const rule = TimelapseRuleSchema.parse(candidate);
|
|
9186
|
+
await this.persist(rule);
|
|
9187
|
+
this.byId.set(rule.id, rule);
|
|
9188
|
+
return rule;
|
|
9189
|
+
}
|
|
9190
|
+
async persist(rule) {
|
|
9191
|
+
await this.store.set.mutate({
|
|
9192
|
+
collection: NC_TIMELAPSE_RULES_COLLECTION,
|
|
9193
|
+
key: rule.id,
|
|
9194
|
+
value: {
|
|
9195
|
+
name: rule.name,
|
|
9196
|
+
enabled: rule.enabled,
|
|
9197
|
+
updatedAt: rule.updatedAt,
|
|
9198
|
+
rule
|
|
9199
|
+
}
|
|
9200
|
+
});
|
|
9201
|
+
}
|
|
9202
|
+
};
|
|
9203
|
+
var NcSummaryCollector = class {
|
|
9204
|
+
openTracks = /* @__PURE__ */ new Map();
|
|
9205
|
+
/** First+last motion per camera SINCE that camera's last window close. The
|
|
9206
|
+
* union across a rule's cameras is what opens and keeps a window alive. */
|
|
9207
|
+
motionByDevice = /* @__PURE__ */ new Map();
|
|
9208
|
+
/** Per-rule motion window. Absent = no window open for that rule. */
|
|
9209
|
+
motionByRule = /* @__PURE__ */ new Map();
|
|
9210
|
+
/**
|
|
9211
|
+
* Motion was observed on a camera. Called once per persisted object event —
|
|
9212
|
+
* the cheapest true signal of "something is happening" this process has.
|
|
9213
|
+
*
|
|
9214
|
+
* `trackId` is optional because the slim event projection omits it; an event
|
|
9215
|
+
* without one still counts as motion, it simply contributes no barrier entry.
|
|
9216
|
+
*/
|
|
9217
|
+
observeMotion(input) {
|
|
9218
|
+
const previous = this.motionByDevice.get(input.deviceId);
|
|
9219
|
+
this.motionByDevice.set(input.deviceId, previous === void 0 ? {
|
|
9220
|
+
firstMotionMs: input.atMs,
|
|
9221
|
+
lastMotionMs: input.atMs
|
|
9222
|
+
} : {
|
|
9223
|
+
firstMotionMs: Math.min(previous.firstMotionMs, input.atMs),
|
|
9224
|
+
lastMotionMs: Math.max(previous.lastMotionMs, input.atMs)
|
|
9225
|
+
});
|
|
9226
|
+
if (input.trackId === void 0) return;
|
|
9227
|
+
if (this.openTracks.has(input.trackId)) return;
|
|
9228
|
+
if (this.openTracks.size >= 5e3) this.evictOldest();
|
|
9229
|
+
this.openTracks.set(input.trackId, {
|
|
9230
|
+
deviceId: input.deviceId,
|
|
9231
|
+
openedAtMs: input.atMs
|
|
9232
|
+
});
|
|
9233
|
+
}
|
|
9234
|
+
/** A track closed — the tracker's own single close point. */
|
|
9235
|
+
observeTrackClosed(trackId) {
|
|
9236
|
+
this.openTracks.delete(trackId);
|
|
9237
|
+
}
|
|
9238
|
+
/**
|
|
9239
|
+
* The span of motion across these cameras since their last window close, or
|
|
9240
|
+
* null when none of them has seen any.
|
|
9241
|
+
*/
|
|
9242
|
+
motionAcross(deviceIds) {
|
|
9243
|
+
let first = Number.MAX_SAFE_INTEGER;
|
|
9244
|
+
let last = 0;
|
|
9245
|
+
for (const deviceId of deviceIds) {
|
|
9246
|
+
const seen = this.motionByDevice.get(deviceId);
|
|
9247
|
+
if (seen === void 0) continue;
|
|
9248
|
+
if (seen.firstMotionMs < first) first = seen.firstMotionMs;
|
|
9249
|
+
if (seen.lastMotionMs > last) last = seen.lastMotionMs;
|
|
9250
|
+
}
|
|
9251
|
+
return last === 0 ? null : {
|
|
9252
|
+
firstMotionMs: first,
|
|
9253
|
+
lastMotionMs: last
|
|
9254
|
+
};
|
|
9255
|
+
}
|
|
9256
|
+
/**
|
|
9257
|
+
* How many of this rule's cameras still have a track open that STARTED inside
|
|
9258
|
+
* the window — the barrier's count.
|
|
9259
|
+
*
|
|
9260
|
+
* The `openedAtMs >= sinceMs` test is what stops a track that began hours
|
|
9261
|
+
* before the window from holding it: that track is not part of this digest,
|
|
9262
|
+
* and waiting for it would make a rule with one resident object undeliverable.
|
|
9263
|
+
*/
|
|
9264
|
+
openTrackCount(deviceIds, sinceMs) {
|
|
9265
|
+
const wanted = new Set(deviceIds);
|
|
9266
|
+
let count = 0;
|
|
9267
|
+
for (const open of this.openTracks.values()) if (wanted.has(open.deviceId) && open.openedAtMs >= sinceMs) count += 1;
|
|
9268
|
+
return count;
|
|
9269
|
+
}
|
|
9270
|
+
/** The ids behind {@link openTrackCount} — for the log line when the barrier
|
|
9271
|
+
* is broken, because "2 tracks" is not diagnosable and two ids are. */
|
|
9272
|
+
openTrackIds(deviceIds, sinceMs) {
|
|
9273
|
+
const wanted = new Set(deviceIds);
|
|
9274
|
+
const out = [];
|
|
9275
|
+
for (const [trackId, open] of this.openTracks) if (wanted.has(open.deviceId) && open.openedAtMs >= sinceMs) out.push(trackId);
|
|
9276
|
+
return out;
|
|
9277
|
+
}
|
|
9278
|
+
/** The rule's currently open motion window, or null. */
|
|
9279
|
+
motionWindow(ruleId) {
|
|
9280
|
+
return this.motionByRule.get(ruleId) ?? null;
|
|
9281
|
+
}
|
|
9282
|
+
/**
|
|
9283
|
+
* Bring a rule's motion window up to date against the observed motion.
|
|
9284
|
+
*
|
|
9285
|
+
* Opening and extending are the SAME operation seen from two states, so they
|
|
9286
|
+
* are one function: a rule with no window that sees motion opens one at that
|
|
9287
|
+
* instant; a rule with a window that sees newer motion extends it. Motion
|
|
9288
|
+
* older than the window's own `lastMotionMs` changes nothing, which makes
|
|
9289
|
+
* this idempotent and safe to call on every tick.
|
|
9290
|
+
*/
|
|
9291
|
+
refreshMotionWindow(ruleId, deviceIds) {
|
|
9292
|
+
const motion = this.motionAcross(deviceIds);
|
|
9293
|
+
if (motion === null) return this.motionByRule.get(ruleId) ?? null;
|
|
9294
|
+
const existing = this.motionByRule.get(ruleId);
|
|
9295
|
+
if (existing === void 0) {
|
|
9296
|
+
const opened = {
|
|
9297
|
+
openedAtMs: motion.firstMotionMs,
|
|
9298
|
+
lastMotionMs: motion.lastMotionMs
|
|
9299
|
+
};
|
|
9300
|
+
this.motionByRule.set(ruleId, opened);
|
|
9301
|
+
return opened;
|
|
9302
|
+
}
|
|
9303
|
+
if (motion.lastMotionMs <= existing.lastMotionMs) return existing;
|
|
9304
|
+
const extended = {
|
|
9305
|
+
...existing,
|
|
9306
|
+
lastMotionMs: motion.lastMotionMs
|
|
9307
|
+
};
|
|
9308
|
+
this.motionByRule.set(ruleId, extended);
|
|
9309
|
+
return extended;
|
|
9310
|
+
}
|
|
9311
|
+
/**
|
|
9312
|
+
* Forget a rule's window — called once it has been produced (or abandoned).
|
|
9313
|
+
*
|
|
9314
|
+
* A camera whose last motion is at or before the close is cleared outright:
|
|
9315
|
+
* it is quiet, and the next window should wait for it to move again. A camera
|
|
9316
|
+
* still busy keeps its mark, and its `firstMotionMs` is CLAMPED to the close
|
|
9317
|
+
* — which is what makes back-to-back windows on a permanently busy camera
|
|
9318
|
+
* contiguous instead of overlapping. Without the clamp the next window would
|
|
9319
|
+
* re-open at the original first motion and re-deliver the same passage.
|
|
9320
|
+
*/
|
|
9321
|
+
closeMotionWindow(ruleId, atMs) {
|
|
9322
|
+
this.motionByRule.delete(ruleId);
|
|
9323
|
+
for (const [deviceId, seen] of this.motionByDevice) {
|
|
9324
|
+
if (seen.lastMotionMs <= atMs) {
|
|
9325
|
+
this.motionByDevice.delete(deviceId);
|
|
9326
|
+
continue;
|
|
9327
|
+
}
|
|
9328
|
+
if (seen.firstMotionMs < atMs) this.motionByDevice.set(deviceId, {
|
|
9329
|
+
...seen,
|
|
9330
|
+
firstMotionMs: atMs
|
|
9331
|
+
});
|
|
9332
|
+
}
|
|
9333
|
+
}
|
|
9334
|
+
/** Drop rules that no longer exist, so a deleted rule cannot leak a window. */
|
|
9335
|
+
retainRules(ruleIds) {
|
|
9336
|
+
const alive = new Set(ruleIds);
|
|
9337
|
+
for (const ruleId of Array.from(this.motionByRule.keys())) if (!alive.has(ruleId)) this.motionByRule.delete(ruleId);
|
|
9338
|
+
}
|
|
9339
|
+
/**
|
|
9340
|
+
* Drop tracks that were never closed. @returns how many were abandoned — a
|
|
9341
|
+
* number that belongs on a log line, because a non-zero one means the close
|
|
9342
|
+
* hook is missing closes and every barrier is silently degraded.
|
|
9343
|
+
*/
|
|
9344
|
+
sweep(nowMs) {
|
|
9345
|
+
let dropped = 0;
|
|
9346
|
+
for (const [trackId, open] of this.openTracks) if (nowMs - open.openedAtMs > 216e5) {
|
|
9347
|
+
this.openTracks.delete(trackId);
|
|
9348
|
+
dropped += 1;
|
|
9349
|
+
}
|
|
9350
|
+
return dropped;
|
|
9351
|
+
}
|
|
9352
|
+
/** Observability — the producer puts this on its tick line. */
|
|
9353
|
+
stats() {
|
|
9354
|
+
return {
|
|
9355
|
+
openTracks: this.openTracks.size,
|
|
9356
|
+
cameras: this.motionByDevice.size,
|
|
9357
|
+
windows: this.motionByRule.size
|
|
9358
|
+
};
|
|
9359
|
+
}
|
|
9360
|
+
evictOldest() {
|
|
9361
|
+
let oldestId = null;
|
|
9362
|
+
let oldestAt = Number.MAX_SAFE_INTEGER;
|
|
9363
|
+
for (const [trackId, open] of this.openTracks) if (open.openedAtMs < oldestAt) {
|
|
9364
|
+
oldestAt = open.openedAtMs;
|
|
9365
|
+
oldestId = trackId;
|
|
9366
|
+
}
|
|
9367
|
+
if (oldestId !== null) this.openTracks.delete(oldestId);
|
|
9368
|
+
}
|
|
9369
|
+
};
|
|
9370
|
+
/** The canvas behind a tile whose aspect does not fill its cell. */
|
|
9371
|
+
var MOSAIC_BACKGROUND = {
|
|
9372
|
+
r: 17,
|
|
9373
|
+
g: 17,
|
|
9374
|
+
b: 17
|
|
9375
|
+
};
|
|
9376
|
+
/** Tile aspect — cameras are 16:9 and a square cell would letterbox every one. */
|
|
9377
|
+
var TILE_ASPECT = 9 / 16;
|
|
9378
|
+
/** Height of the per-tile label strip, as a fraction of the tile height. */
|
|
9379
|
+
var LABEL_BAND_FRACTION = .14;
|
|
9380
|
+
var LABEL_MIN_PX = 11;
|
|
9381
|
+
/** Height of the mosaic's own title band, as a fraction of a tile's height. */
|
|
9382
|
+
var TITLE_BAND_FRACTION = .22;
|
|
9383
|
+
var TITLE_MIN_PX = 16;
|
|
9384
|
+
/**
|
|
9385
|
+
* Average glyph width as a fraction of the font size, for bold sans-serif.
|
|
9386
|
+
*
|
|
9387
|
+
* An ESTIMATE, and it has to be: librsvg lays the text out long after this
|
|
9388
|
+
* string is written, so there are no metrics to measure against. The same
|
|
9389
|
+
* number and the same reasoning as `preview-frame.ts#fitTitle` — deliberately
|
|
9390
|
+
* GENEROUS, because a label that shrank slightly more than it had to is
|
|
9391
|
+
* invisible and one that overflowed runs into the tile beside it. The first
|
|
9392
|
+
* live mosaic did exactly that: `Videocamera cortile · 07:49 · animal` was cut
|
|
9393
|
+
* to `…· anima` at the tile edge, mid-word.
|
|
9394
|
+
*/
|
|
9395
|
+
var GLYPH_ADVANCE = .56;
|
|
9396
|
+
/** Fraction of the band a label may occupy, leaving the padding either side. */
|
|
9397
|
+
var LABEL_USABLE_FRACTION = .94;
|
|
9398
|
+
/** `<`, `>`, `&`, `'`, `"` in a camera name or an operator caption would each
|
|
9399
|
+
* turn the overlay into a document librsvg refuses. */
|
|
9400
|
+
function escapeXml$2(s) {
|
|
9401
|
+
return s.replace(/[<>&'"]/g, (ch) => {
|
|
9402
|
+
switch (ch) {
|
|
9403
|
+
case "<": return "<";
|
|
9404
|
+
case ">": return ">";
|
|
9405
|
+
case "&": return "&";
|
|
9406
|
+
case "'": return "'";
|
|
9407
|
+
default: return """;
|
|
9408
|
+
}
|
|
9409
|
+
});
|
|
9410
|
+
}
|
|
9411
|
+
/** Grid shape for `n` tiles: as square as possible, rows never exceeding cols
|
|
9412
|
+
* by more than one — a 3×3 for nine, a 3×2 for five, never a 1×9 strip. */
|
|
9413
|
+
function mosaicGrid(n) {
|
|
9414
|
+
const count = Math.max(1, Math.floor(n));
|
|
9415
|
+
const cols = Math.ceil(Math.sqrt(count));
|
|
9416
|
+
return {
|
|
9417
|
+
cols,
|
|
9418
|
+
rows: Math.ceil(count / cols)
|
|
9419
|
+
};
|
|
9420
|
+
}
|
|
9421
|
+
/**
|
|
9422
|
+
* Where every tile lands. Pure — no bytes, no sharp, no clock.
|
|
9423
|
+
*
|
|
9424
|
+
* Exported because the rectangles are DATA the artefact should carry: a viewer
|
|
9425
|
+
* that wants a tappable mosaic reads these rather than re-deriving a layout
|
|
9426
|
+
* that could drift from the one actually rendered.
|
|
9427
|
+
*/
|
|
9428
|
+
function mosaicLayout(input) {
|
|
9429
|
+
const { cols, rows } = mosaicGrid(input.tileCount);
|
|
9430
|
+
const maxWidth = input.maxWidth ?? 1920;
|
|
9431
|
+
const tileWidth = Math.max(160, Math.floor(maxWidth / cols));
|
|
9432
|
+
const tileHeight = Math.round(tileWidth * TILE_ASPECT);
|
|
9433
|
+
const titleHeight = input.withTitle === true ? Math.round(tileHeight * TITLE_BAND_FRACTION) : 0;
|
|
9434
|
+
const tiles = [];
|
|
9435
|
+
for (let index = 0; index < Math.max(1, input.tileCount); index += 1) tiles.push({
|
|
9436
|
+
index,
|
|
9437
|
+
x: index % cols * tileWidth,
|
|
9438
|
+
y: titleHeight + Math.floor(index / cols) * tileHeight,
|
|
9439
|
+
w: tileWidth,
|
|
9440
|
+
h: tileHeight
|
|
9441
|
+
});
|
|
9442
|
+
return {
|
|
9443
|
+
width: cols * tileWidth,
|
|
9444
|
+
height: titleHeight + rows * tileHeight,
|
|
9445
|
+
cols,
|
|
9446
|
+
rows,
|
|
9447
|
+
tileWidth,
|
|
9448
|
+
tileHeight,
|
|
9449
|
+
titleHeight,
|
|
9450
|
+
tiles
|
|
9451
|
+
};
|
|
9452
|
+
}
|
|
9453
|
+
/**
|
|
9454
|
+
* Shrink, then ELIDE, until the line fits `usablePx`.
|
|
9455
|
+
*
|
|
9456
|
+
* Shrinking is tried first because a slightly smaller label still says
|
|
9457
|
+
* everything; only past `minPx` is the tail cut, and then with an ellipsis so
|
|
9458
|
+
* the reader can SEE that something was cut. The failure this replaces was
|
|
9459
|
+
* silent: SVG simply clips at the viewport, so an overlong label lost its last
|
|
9460
|
+
* word mid-glyph and looked like a rendering bug rather than a truncation.
|
|
9461
|
+
*
|
|
9462
|
+
* Pure and exported so the arithmetic is testable without rendering a pixel.
|
|
9463
|
+
*/
|
|
9464
|
+
function fitLine(text, usablePx, basePx, minPx) {
|
|
9465
|
+
const flat = text.trim().replace(/\s+/g, " ");
|
|
9466
|
+
const fits = (s, fontPx) => s.length * fontPx * GLYPH_ADVANCE <= usablePx;
|
|
9467
|
+
let fontPx = Math.max(minPx, Math.round(basePx));
|
|
9468
|
+
while (fontPx > minPx && !fits(flat, fontPx)) fontPx = Math.max(minPx, fontPx - 1);
|
|
9469
|
+
if (fits(flat, fontPx)) return {
|
|
9470
|
+
text: flat,
|
|
9471
|
+
fontPx
|
|
9472
|
+
};
|
|
9473
|
+
const budget = Math.max(1, Math.floor(usablePx / (fontPx * GLYPH_ADVANCE)));
|
|
9474
|
+
return {
|
|
9475
|
+
text: `${flat.slice(0, Math.max(1, budget - 1)).trimEnd()}…`,
|
|
9476
|
+
fontPx
|
|
9477
|
+
};
|
|
9478
|
+
}
|
|
9479
|
+
/** The label strip drawn across the bottom of one tile. */
|
|
9480
|
+
function tileLabelSvg(width, height, label) {
|
|
9481
|
+
const bandHeight = Math.max(18, Math.round(height * LABEL_BAND_FRACTION));
|
|
9482
|
+
const basePx = Math.round(bandHeight * .62);
|
|
9483
|
+
const padding = Math.round(basePx * .6);
|
|
9484
|
+
const fitted = fitLine(label, width * LABEL_USABLE_FRACTION - padding, basePx, LABEL_MIN_PX);
|
|
9485
|
+
const baseline = height - Math.round((bandHeight - fitted.fontPx) / 2) - Math.round(fitted.fontPx * .18);
|
|
9486
|
+
return Buffer.from(`<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
|
|
9487
|
+
<rect x="0" y="${height - bandHeight}" width="${width}" height="${bandHeight}" fill="black" fill-opacity="0.55"/>
|
|
9488
|
+
<text x="${padding}" y="${baseline}" font-size="${fitted.fontPx}" font-family="sans-serif" font-weight="bold" fill="white">${escapeXml$2(fitted.text)}</text>
|
|
9489
|
+
</svg>`);
|
|
9490
|
+
}
|
|
9491
|
+
/** The mosaic's own title band. */
|
|
9492
|
+
function titleSvg(width, height, caption) {
|
|
9493
|
+
const fitted = fitLine(caption, width * LABEL_USABLE_FRACTION, Math.round(height * .5), TITLE_MIN_PX);
|
|
9494
|
+
const baseline = Math.round(height / 2 + fitted.fontPx * .36);
|
|
9495
|
+
return Buffer.from(`<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
|
|
9496
|
+
<rect x="0" y="0" width="${width}" height="${height}" fill="#111111"/>
|
|
9497
|
+
<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>
|
|
9498
|
+
</svg>`);
|
|
9499
|
+
}
|
|
9500
|
+
/**
|
|
9501
|
+
* Compose the mosaic.
|
|
9502
|
+
*
|
|
9503
|
+
* Every tile is fitted with `contain` onto the cell background rather than
|
|
9504
|
+
* cropped: a `cover` fit would silently cut the subject out of a portrait crop,
|
|
9505
|
+
* and a tile that does not show what it is about is worse than a letterboxed
|
|
9506
|
+
* one.
|
|
9507
|
+
*
|
|
9508
|
+
* A tile sharp cannot read is DROPPED and counted, never composited as a blank
|
|
9509
|
+
* cell — a black square in a digest reads as "the camera saw nothing", which is
|
|
9510
|
+
* the opposite of what happened. The grid is recomputed from the survivors so
|
|
9511
|
+
* the mosaic has no hole.
|
|
9512
|
+
*
|
|
9513
|
+
* @throws only if sharp cannot produce the final canvas at all; the caller
|
|
9514
|
+
* treats that as "no mosaic" and still delivers the counts.
|
|
9515
|
+
*/
|
|
9516
|
+
async function renderMosaic(request, logger) {
|
|
9517
|
+
const decoded = [];
|
|
9518
|
+
const probeLayout = mosaicLayout({
|
|
9519
|
+
tileCount: Math.max(1, request.tiles.length),
|
|
9520
|
+
...request.maxWidth !== void 0 ? { maxWidth: request.maxWidth } : {}
|
|
9521
|
+
});
|
|
9522
|
+
for (const tile of request.tiles) try {
|
|
9523
|
+
const resized = await sharp(tile.jpeg).resize({
|
|
9524
|
+
width: probeLayout.tileWidth,
|
|
9525
|
+
height: probeLayout.tileHeight,
|
|
9526
|
+
fit: "contain",
|
|
9527
|
+
background: MOSAIC_BACKGROUND
|
|
9528
|
+
}).composite([{
|
|
9529
|
+
input: tileLabelSvg(probeLayout.tileWidth, probeLayout.tileHeight, tile.label),
|
|
9530
|
+
top: 0,
|
|
9531
|
+
left: 0
|
|
9532
|
+
}]).jpeg({ quality: 82 }).toBuffer();
|
|
9533
|
+
decoded.push({
|
|
9534
|
+
tile,
|
|
9535
|
+
resized
|
|
9536
|
+
});
|
|
9537
|
+
} catch (err) {
|
|
9538
|
+
logger.warn("summary mosaic: a tile could not be decoded and was dropped", {
|
|
9539
|
+
tags: { deviceId: tile.deviceId },
|
|
9540
|
+
meta: {
|
|
9541
|
+
trackId: tile.trackId,
|
|
9542
|
+
error: String(err)
|
|
9543
|
+
}
|
|
9544
|
+
});
|
|
9545
|
+
}
|
|
9546
|
+
const droppedTiles = request.tiles.length - decoded.length;
|
|
9547
|
+
const caption = request.caption?.trim() ?? "";
|
|
9548
|
+
const layout = mosaicLayout({
|
|
9549
|
+
tileCount: Math.max(1, decoded.length),
|
|
9550
|
+
...request.maxWidth !== void 0 ? { maxWidth: request.maxWidth } : {},
|
|
9551
|
+
withTitle: caption.length > 0
|
|
9552
|
+
});
|
|
9553
|
+
const composites = await Promise.all(decoded.map(async ({ resized }, index) => ({
|
|
9554
|
+
input: layout.tileWidth === probeLayout.tileWidth && layout.tileHeight === probeLayout.tileHeight ? resized : await sharp(resized).resize({
|
|
9555
|
+
width: layout.tileWidth,
|
|
9556
|
+
height: layout.tileHeight,
|
|
9557
|
+
fit: "contain",
|
|
9558
|
+
background: MOSAIC_BACKGROUND
|
|
9559
|
+
}).jpeg({ quality: 82 }).toBuffer(),
|
|
9560
|
+
top: layout.tiles[index]?.y ?? 0,
|
|
9561
|
+
left: layout.tiles[index]?.x ?? 0
|
|
9562
|
+
})));
|
|
9563
|
+
if (caption.length > 0 && layout.titleHeight > 0) composites.unshift({
|
|
9564
|
+
input: titleSvg(layout.width, layout.titleHeight, caption),
|
|
9565
|
+
top: 0,
|
|
9566
|
+
left: 0
|
|
9567
|
+
});
|
|
9568
|
+
return {
|
|
9569
|
+
jpeg: await sharp({ create: {
|
|
9570
|
+
width: layout.width,
|
|
9571
|
+
height: layout.height,
|
|
9572
|
+
channels: 3,
|
|
9573
|
+
background: MOSAIC_BACKGROUND
|
|
9574
|
+
} }).composite(composites).jpeg({ quality: 82 }).toBuffer(),
|
|
9575
|
+
layout,
|
|
9576
|
+
droppedTiles
|
|
9577
|
+
};
|
|
9578
|
+
}
|
|
9579
|
+
//#endregion
|
|
9580
|
+
//#region src/notification-center/summary/summary-delivery.ts
|
|
9581
|
+
var NO_DETECTIONS = {
|
|
9582
|
+
total: 0,
|
|
9583
|
+
byClass: {},
|
|
9584
|
+
classes: []
|
|
9585
|
+
};
|
|
9586
|
+
/** Punctuation, not prose — a module constant rather than a catalog key,
|
|
9587
|
+
* because a translator has nothing to say about it and every key the guard
|
|
9588
|
+
* has to check is a key somebody can get wrong. */
|
|
9589
|
+
var BODY_SEPARATOR = " · ";
|
|
9590
|
+
/** `HH:MM` in the host timezone. */
|
|
9591
|
+
function clockOf$1(atMs) {
|
|
9592
|
+
const d = new Date(atMs);
|
|
9593
|
+
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
9594
|
+
}
|
|
9595
|
+
/** Template vocabulary. Deliberately the SAME names the detection and timelapse
|
|
9596
|
+
* bodies use (`rule`, `from`, `to`, `time`) plus the digest-only ones. */
|
|
9597
|
+
function templateVars(input) {
|
|
9598
|
+
return {
|
|
9599
|
+
rule: input.ruleName,
|
|
9600
|
+
from: clockOf$1(input.window.startMs),
|
|
9601
|
+
to: clockOf$1(input.window.endMs),
|
|
9602
|
+
time: clockOf$1(input.generatedAt),
|
|
9603
|
+
events: String(input.tileCount),
|
|
9604
|
+
cameras: String(input.cameraCount),
|
|
9605
|
+
matched: String(input.matchedCount),
|
|
9606
|
+
shown: String(input.tileCount),
|
|
9607
|
+
total: String(input.matchedCount),
|
|
9608
|
+
...detectionTemplateVars(input.texts, input.detections ?? NO_DETECTIONS)
|
|
9609
|
+
};
|
|
9610
|
+
}
|
|
9611
|
+
/**
|
|
9612
|
+
* The body an operator reads when he did not write one.
|
|
9613
|
+
*
|
|
9614
|
+
* Every clause is a catalog key with real CLDR plurals rather than a template
|
|
9615
|
+
* string, for the reason the timelapse learned: a hand-rolled `n === 1` shipped
|
|
9616
|
+
* "1 intervalli ripresi" to a live phone.
|
|
9617
|
+
*/
|
|
9618
|
+
function derivedBody(input, vars) {
|
|
9619
|
+
const texts = input.texts;
|
|
9620
|
+
const parts = [texts.text({
|
|
9621
|
+
key: "summary.body.window",
|
|
9622
|
+
vars
|
|
9623
|
+
})];
|
|
9624
|
+
if (input.tileCount === 0) {
|
|
9625
|
+
parts.push(texts.text({
|
|
9626
|
+
key: "summary.body.empty",
|
|
9627
|
+
vars
|
|
9628
|
+
}));
|
|
9629
|
+
return parts.filter((p) => p.length > 0).join(BODY_SEPARATOR);
|
|
9630
|
+
}
|
|
9631
|
+
parts.push(texts.text({
|
|
9632
|
+
key: "summary.body.events",
|
|
9633
|
+
count: input.tileCount,
|
|
9634
|
+
vars
|
|
9635
|
+
}));
|
|
9636
|
+
parts.push(texts.text({
|
|
9637
|
+
key: "summary.body.cameras",
|
|
9638
|
+
count: input.cameraCount,
|
|
9639
|
+
vars
|
|
9640
|
+
}));
|
|
9641
|
+
if ((input.detections ?? NO_DETECTIONS).total > 0) parts.push(texts.text({
|
|
9642
|
+
key: "summary.body.detections",
|
|
9643
|
+
vars
|
|
9644
|
+
}));
|
|
9645
|
+
if (input.matchedCount > input.tileCount) parts.push(texts.text({
|
|
9646
|
+
key: "summary.body.truncated",
|
|
9647
|
+
vars
|
|
9648
|
+
}));
|
|
9649
|
+
return parts.filter((p) => p.length > 0).join(BODY_SEPARATOR);
|
|
9650
|
+
}
|
|
9651
|
+
/** Per-target params with the derived `clickUrl` filled in where absent. A
|
|
9652
|
+
* value the operator set on the target WINS — he chose it. */
|
|
9653
|
+
function paramsFor(target, clickUrl) {
|
|
9654
|
+
if (clickUrl === void 0) return target.params;
|
|
9655
|
+
if (target.params === void 0) return { clickUrl };
|
|
9656
|
+
if (typeof target.params["clickUrl"] === "string") return target.params;
|
|
9657
|
+
return {
|
|
9658
|
+
...target.params,
|
|
9659
|
+
clickUrl
|
|
9660
|
+
};
|
|
9661
|
+
}
|
|
9662
|
+
/** Stable, retry-safe outbox record id for one closed window of one rule. */
|
|
9663
|
+
function summaryRecordId(ruleId, windowEndMs) {
|
|
9664
|
+
return `summary:${ruleId}:${windowEndMs}`;
|
|
9665
|
+
}
|
|
9666
|
+
/** The caption burned onto the mosaic's title band, with the same `{{var}}`
|
|
9667
|
+
* vocabulary the title and body use. `''` is a real answer ("no caption"). */
|
|
9668
|
+
function summaryCaptionText(input) {
|
|
9669
|
+
if (input.captionText === void 0) return `${input.ruleName} — ${clockOf$1(input.window.startMs)}→${clockOf$1(input.window.endMs)}`;
|
|
9670
|
+
return renderTemplate(input.captionText, {
|
|
9671
|
+
rule: input.ruleName,
|
|
9672
|
+
from: clockOf$1(input.window.startMs),
|
|
9673
|
+
to: clockOf$1(input.window.endMs)
|
|
9674
|
+
}) ?? "";
|
|
9675
|
+
}
|
|
9676
|
+
/**
|
|
9677
|
+
* One outbox row per (still-enabled) target.
|
|
9678
|
+
*
|
|
9679
|
+
* @returns rows ready for `NcOutbox.enqueue` — never sent from here.
|
|
9680
|
+
*/
|
|
9681
|
+
function buildSummaryOutboxInputs(input) {
|
|
9682
|
+
const vars = templateVars(input);
|
|
9683
|
+
const optedOut = new Set(input.disabledTargetIds ?? []);
|
|
9684
|
+
const title = renderTemplate(input.template?.title, vars) ?? input.texts.text({
|
|
9685
|
+
key: "summary.title",
|
|
9686
|
+
vars
|
|
9687
|
+
});
|
|
9688
|
+
const body = renderTemplate(input.template?.body, vars) ?? derivedBody(input, vars);
|
|
9689
|
+
const recordId = summaryRecordId(input.ruleId, input.window.endMs);
|
|
9690
|
+
const artifacts = input.mosaic !== void 0 ? [{
|
|
9691
|
+
mediaType: "image",
|
|
9692
|
+
...input.mosaic
|
|
9693
|
+
}] : [];
|
|
9694
|
+
return input.targets.filter((t) => !optedOut.has(t.targetId)).map((target) => {
|
|
9695
|
+
const payload = {
|
|
9696
|
+
ruleName: input.ruleName,
|
|
9697
|
+
delivery: "ai-summary",
|
|
9698
|
+
priority: input.priority,
|
|
9699
|
+
template: {
|
|
9700
|
+
title,
|
|
9701
|
+
body
|
|
9702
|
+
},
|
|
9703
|
+
media: "none",
|
|
9704
|
+
...artifacts.length > 0 ? { artifacts } : {},
|
|
9705
|
+
...paramsFor(target, input.clickUrl) !== void 0 ? { params: paramsFor(target, input.clickUrl) } : {},
|
|
9706
|
+
subject: {
|
|
9707
|
+
deviceId: input.primaryDeviceId,
|
|
9708
|
+
className: "summary",
|
|
9709
|
+
zones: [],
|
|
9710
|
+
timestamp: input.window.endMs
|
|
9711
|
+
}
|
|
9712
|
+
};
|
|
9713
|
+
return {
|
|
9714
|
+
ruleId: input.ruleId,
|
|
9715
|
+
targetId: target.targetId,
|
|
9716
|
+
deviceId: input.primaryDeviceId,
|
|
9717
|
+
recordKind: "ai-summary",
|
|
9718
|
+
recordId,
|
|
9719
|
+
payload
|
|
9720
|
+
};
|
|
9721
|
+
});
|
|
9722
|
+
}
|
|
9723
|
+
//#endregion
|
|
9724
|
+
//#region src/notification-center/summary/summary-selection.ts
|
|
9725
|
+
/**
|
|
9726
|
+
* Which tracks become tiles — filter, rank, and the per-camera quota.
|
|
9727
|
+
*
|
|
9728
|
+
* Pure. No clock, no store, no I/O: a window's whole selection is a function of
|
|
9729
|
+
* the candidate list and the rule, which is what makes the quota provable
|
|
9730
|
+
* rather than plausible.
|
|
9731
|
+
*
|
|
9732
|
+
* ## The quota, and what it is protecting
|
|
9733
|
+
*
|
|
9734
|
+
* A multi-camera digest in which two of six cameras never appear is not a
|
|
9735
|
+
* multi-camera digest — it is a busy camera's digest with five names on it. The
|
|
9736
|
+
* driveway that sees forty passes a night would take every tile of a 3×3 grid
|
|
9737
|
+
* by pure ranking, every single night, and the operator would conclude the
|
|
9738
|
+
* other cameras were broken.
|
|
9739
|
+
*
|
|
9740
|
+
* So the first pass gives EVERY contributing camera its single best track,
|
|
9741
|
+
* cheapest-first in the rule's own rank order, and only the remaining slots go
|
|
9742
|
+
* to the global ranking. When there are more contributing cameras than tiles
|
|
9743
|
+
* the quota degrades honestly: the best cameras get their one tile, the rest
|
|
9744
|
+
* are counted as dropped and the count rides the log line — never silently.
|
|
9745
|
+
*
|
|
9746
|
+
* ## Zones are scoped PER CAMERA
|
|
9747
|
+
*
|
|
9748
|
+
* `filters.zones` is a flat id list on a rule that spans cameras, and zone ids
|
|
9749
|
+
* are unique per camera. Evaluated flatly, picking a zone on camera A mutes
|
|
9750
|
+
* camera B forever: B's tracks can never visit A's polygon. `zone-ownership.ts`
|
|
9751
|
+
* is the one place that turns "the rule's ids" into "the ids that apply to THIS
|
|
9752
|
+
* camera", and it is reused here rather than re-derived — including its
|
|
9753
|
+
* fail-closed posture, where an ownership lookup that could not answer leaves
|
|
9754
|
+
* the flat behaviour untouched instead of widening the rule.
|
|
9755
|
+
*/
|
|
9756
|
+
function classesOf(candidate) {
|
|
9757
|
+
const extra = candidate.classes ?? [];
|
|
9758
|
+
return extra.includes(candidate.className) ? extra : [candidate.className, ...extra];
|
|
9759
|
+
}
|
|
9760
|
+
function dwellSeconds(candidate) {
|
|
9761
|
+
return Math.max(0, candidate.lastSeen - candidate.firstSeen) / 1e3;
|
|
9762
|
+
}
|
|
9763
|
+
/**
|
|
9764
|
+
* Apply the rule's filters. Counting every drop reason is not decoration: the
|
|
9765
|
+
* question an operator asks about a digest is always "why is it empty", and a
|
|
9766
|
+
* branch that discards work silently reads as "it never happened".
|
|
9767
|
+
*/
|
|
9768
|
+
function filterSummaryCandidates(candidates, filters, zoneOwner) {
|
|
9769
|
+
if (filters === void 0) return {
|
|
9770
|
+
kept: [...candidates],
|
|
9771
|
+
droppedByClass: 0,
|
|
9772
|
+
droppedByZone: 0,
|
|
9773
|
+
droppedByImportance: 0,
|
|
9774
|
+
droppedByDwell: 0
|
|
9775
|
+
};
|
|
9776
|
+
const include = filters.classes;
|
|
9777
|
+
const exclude = new Set(filters.classesExclude ?? []);
|
|
9778
|
+
const kept = [];
|
|
9779
|
+
let droppedByClass = 0;
|
|
9780
|
+
let droppedByZone = 0;
|
|
9781
|
+
let droppedByImportance = 0;
|
|
9782
|
+
let droppedByDwell = 0;
|
|
9783
|
+
for (const candidate of candidates) {
|
|
9784
|
+
const own = classesOf(candidate);
|
|
9785
|
+
if (own.some((c) => exclude.has(c))) {
|
|
9786
|
+
droppedByClass += 1;
|
|
9787
|
+
continue;
|
|
9788
|
+
}
|
|
9789
|
+
if (include !== void 0 && include.length > 0 && !own.some((c) => include.includes(c))) {
|
|
9790
|
+
droppedByClass += 1;
|
|
9791
|
+
continue;
|
|
9792
|
+
}
|
|
9793
|
+
const wantedZones = filters.zones;
|
|
9794
|
+
if (wantedZones !== void 0 && wantedZones.length > 0) {
|
|
9795
|
+
const scope = scopeZoneIdsToDevice(wantedZones, candidate.deviceId, zoneOwner);
|
|
9796
|
+
if (scope.kind !== "inert") {
|
|
9797
|
+
const ids = scope.kind === "scoped" ? scope.ids : wantedZones;
|
|
9798
|
+
if (!candidate.zones.some((z) => ids.includes(z))) {
|
|
9799
|
+
droppedByZone += 1;
|
|
9800
|
+
continue;
|
|
9801
|
+
}
|
|
9802
|
+
}
|
|
9803
|
+
}
|
|
9804
|
+
if (filters.minImportance !== void 0 && (candidate.importance ?? 0) < filters.minImportance) {
|
|
9805
|
+
droppedByImportance += 1;
|
|
9806
|
+
continue;
|
|
9807
|
+
}
|
|
9808
|
+
if (filters.minDwellSeconds !== void 0 && dwellSeconds(candidate) < filters.minDwellSeconds) {
|
|
9809
|
+
droppedByDwell += 1;
|
|
9810
|
+
continue;
|
|
9811
|
+
}
|
|
9812
|
+
kept.push(candidate);
|
|
9813
|
+
}
|
|
9814
|
+
return {
|
|
9815
|
+
kept,
|
|
9816
|
+
droppedByClass,
|
|
9817
|
+
droppedByZone,
|
|
9818
|
+
droppedByImportance,
|
|
9819
|
+
droppedByDwell
|
|
9820
|
+
};
|
|
9821
|
+
}
|
|
9822
|
+
/**
|
|
9823
|
+
* The rank value, higher is better. Ties break on `trackId` so a selection is
|
|
9824
|
+
* REPRODUCIBLE — two runs over the same window must produce the same mosaic,
|
|
9825
|
+
* or a retry after a failed publish silently changes what was delivered.
|
|
9826
|
+
*/
|
|
9827
|
+
function rankValue(candidate, rank) {
|
|
9828
|
+
switch (rank) {
|
|
9829
|
+
case "importance": return candidate.importance ?? 0;
|
|
9830
|
+
case "recency": return candidate.lastSeen;
|
|
9831
|
+
case "dwell": return candidate.lastSeen - candidate.firstSeen;
|
|
9832
|
+
}
|
|
9833
|
+
}
|
|
9834
|
+
function byRankDesc(rank) {
|
|
9835
|
+
return (a, b) => {
|
|
9836
|
+
const delta = rankValue(b, rank) - rankValue(a, rank);
|
|
9837
|
+
return delta !== 0 ? delta : a.trackId.localeCompare(b.trackId);
|
|
9838
|
+
};
|
|
9839
|
+
}
|
|
9840
|
+
/**
|
|
9841
|
+
* Choose the tiles: one guaranteed slot per contributing camera first, then the
|
|
9842
|
+
* global ranking for what remains.
|
|
9843
|
+
*/
|
|
9844
|
+
function selectSummaryTiles(candidates, opts) {
|
|
9845
|
+
const sorted = candidates.toSorted(byRankDesc(opts.rank));
|
|
9846
|
+
const byDevice = /* @__PURE__ */ new Map();
|
|
9847
|
+
for (const candidate of sorted) {
|
|
9848
|
+
const bucket = byDevice.get(candidate.deviceId);
|
|
9849
|
+
if (bucket === void 0) byDevice.set(candidate.deviceId, [candidate]);
|
|
9850
|
+
else bucket.push(candidate);
|
|
9851
|
+
}
|
|
9852
|
+
const chosen = [];
|
|
9853
|
+
const taken = /* @__PURE__ */ new Set();
|
|
9854
|
+
const servedDevices = /* @__PURE__ */ new Set();
|
|
9855
|
+
for (const candidate of sorted) {
|
|
9856
|
+
if (chosen.length >= opts.maxTiles) break;
|
|
9857
|
+
if (servedDevices.has(candidate.deviceId)) continue;
|
|
9858
|
+
servedDevices.add(candidate.deviceId);
|
|
9859
|
+
taken.add(candidate.trackId);
|
|
9860
|
+
chosen.push(candidate);
|
|
9861
|
+
}
|
|
9862
|
+
for (const candidate of sorted) {
|
|
9863
|
+
if (chosen.length >= opts.maxTiles) break;
|
|
9864
|
+
if (taken.has(candidate.trackId)) continue;
|
|
9865
|
+
taken.add(candidate.trackId);
|
|
9866
|
+
chosen.push(candidate);
|
|
9867
|
+
}
|
|
9868
|
+
const tiles = chosen.toSorted((a, b) => a.firstSeen !== b.firstSeen ? a.firstSeen - b.firstSeen : a.trackId.localeCompare(b.trackId));
|
|
9869
|
+
return {
|
|
9870
|
+
tiles,
|
|
9871
|
+
camerasContributing: byDevice.size,
|
|
9872
|
+
camerasDropped: byDevice.size - servedDevices.size,
|
|
9873
|
+
overflow: Math.max(0, candidates.length - tiles.length)
|
|
9874
|
+
};
|
|
9875
|
+
}
|
|
9876
|
+
/**
|
|
9877
|
+
* The media a tile is drawn from, best first.
|
|
9878
|
+
*
|
|
9879
|
+
* `keyFrameSmall` heads the ladder because it is the CLEAN best-shot frame at
|
|
9880
|
+
* 960 px — the whole scene, legible on a phone, cheap. `fullFrameBoxed` and
|
|
9881
|
+
* `firstFrame` are deliberately absent: a drawn rectangle is a permanent lie in
|
|
9882
|
+
* a digest tile, the operator asked for a mosaic that is ALWAYS CLEAN, and (for
|
|
9883
|
+
* P2) a box burnt into a pixel is text inside an image — an injection channel
|
|
9884
|
+
* and a source of hallucination. Boxes stay data.
|
|
9885
|
+
*/
|
|
9886
|
+
var SUMMARY_MEDIA_LADDER = [
|
|
9887
|
+
"keyFrameSmall",
|
|
9888
|
+
"keyFrame",
|
|
9889
|
+
"snapshot",
|
|
9890
|
+
"fullFrame",
|
|
9891
|
+
"thumbnailSmall",
|
|
9892
|
+
"thumbnail"
|
|
9893
|
+
];
|
|
9894
|
+
/**
|
|
9895
|
+
* Pick ONE media row for a track — the cheap question first (index rows only),
|
|
9896
|
+
* so exactly one blob is ever read.
|
|
9897
|
+
*
|
|
9898
|
+
* Within a rung the row CLOSEST to the track's peak is chosen; `snapshot` is a
|
|
9899
|
+
* filmstrip and its rows accumulate, so "the first one" would systematically
|
|
9900
|
+
* pick the moment the subject entered rather than the moment it was best seen.
|
|
9901
|
+
*/
|
|
9902
|
+
function pickSummaryMedia(rows, aroundMs) {
|
|
9903
|
+
for (const rung of SUMMARY_MEDIA_LADDER) {
|
|
9904
|
+
const matches = rows.filter((row) => row.kind === rung);
|
|
9905
|
+
if (matches.length === 0) continue;
|
|
9906
|
+
return matches.reduce((best, row) => Math.abs(row.timestamp - aroundMs) < Math.abs(best.timestamp - aroundMs) ? row : best);
|
|
9907
|
+
}
|
|
9908
|
+
return null;
|
|
9909
|
+
}
|
|
9910
|
+
//#endregion
|
|
9911
|
+
//#region src/notification-center/summary/summary-window.ts
|
|
9912
|
+
/**
|
|
9913
|
+
* The instant an open motion window closes — computable BEFORE it happens,
|
|
9914
|
+
* which is what makes the barrier deadline derivable rather than stateful.
|
|
9915
|
+
*
|
|
9916
|
+
* `min(quiet, ceiling)`: whichever bound arrives first wins, and the ceiling is
|
|
9917
|
+
* the one that guarantees termination.
|
|
9918
|
+
*/
|
|
9919
|
+
function motionWindowCloseAt(open, bounds) {
|
|
9920
|
+
const quietCloseAt = open.lastMotionMs + bounds.quietMs;
|
|
9921
|
+
const hardCloseAt = open.openedAtMs + bounds.maxWindowMs;
|
|
9922
|
+
return hardCloseAt <= quietCloseAt ? {
|
|
9923
|
+
closeAtMs: hardCloseAt,
|
|
9924
|
+
cappedByCeiling: true
|
|
9925
|
+
} : {
|
|
9926
|
+
closeAtMs: quietCloseAt,
|
|
9927
|
+
cappedByCeiling: false
|
|
9928
|
+
};
|
|
9929
|
+
}
|
|
9930
|
+
/**
|
|
9931
|
+
* The one decision, shared by both window kinds.
|
|
9932
|
+
*
|
|
9933
|
+
* A `schedule` rule passes the schedule's close as `closeAtMs`; a `motion` rule
|
|
9934
|
+
* passes {@link motionWindowCloseAt}. From there the barrier logic is
|
|
9935
|
+
* identical, which is the point: the operator asked for the barrier on the
|
|
9936
|
+
* digest, not on one flavour of it.
|
|
9937
|
+
*/
|
|
9938
|
+
function decideSummaryWindow(input) {
|
|
9939
|
+
if (input.nowMs < input.closeAtMs) return { kind: "collecting" };
|
|
9940
|
+
const deadlineMs = input.closeAtMs + input.barrierMs;
|
|
9941
|
+
if (input.openTracks > 0 && input.nowMs < deadlineMs) return {
|
|
9942
|
+
kind: "barrier",
|
|
9943
|
+
openTracks: input.openTracks,
|
|
9944
|
+
deadlineMs
|
|
9945
|
+
};
|
|
9946
|
+
return {
|
|
9947
|
+
kind: "ready",
|
|
9948
|
+
window: {
|
|
9949
|
+
startMs: input.openedAtMs,
|
|
9950
|
+
endMs: input.closeAtMs
|
|
9951
|
+
},
|
|
9952
|
+
barrierBroken: input.openTracks,
|
|
9953
|
+
cappedByCeiling: input.cappedByCeiling === true
|
|
9954
|
+
};
|
|
9955
|
+
}
|
|
9956
|
+
/**
|
|
9957
|
+
* How many tracks ONE window may read, per camera.
|
|
9958
|
+
*
|
|
9959
|
+
* `queryHistorical` defaults to `limit ?? 50`, and at that default a busy night
|
|
9960
|
+
* would have planned the digest from the fifty most recent tracks and reported
|
|
9961
|
+
* "50" for a night that held four hundred. The timelapse hit exactly this.
|
|
9962
|
+
*/
|
|
9963
|
+
var SUMMARY_TRACK_QUERY_LIMIT = 2e3;
|
|
9964
|
+
/** The mosaic's owner id in the media store — see {@link NcSummaryPorts.storeMosaic}. */
|
|
9965
|
+
function summaryMediaOwnerId(ruleId, windowEndMs) {
|
|
9966
|
+
return `summary-${ruleId}-${windowEndMs}`;
|
|
9967
|
+
}
|
|
9968
|
+
var EMPTY_REPORT = {
|
|
9969
|
+
produced: 0,
|
|
9970
|
+
collecting: 0,
|
|
9971
|
+
barrier: 0,
|
|
9972
|
+
beforeFloor: 0,
|
|
9973
|
+
failed: 0,
|
|
9974
|
+
idle: 0,
|
|
9975
|
+
empty: 0
|
|
9976
|
+
};
|
|
9977
|
+
/**
|
|
9978
|
+
* The earliest window CLOSE a rule may ever be offered.
|
|
9979
|
+
*
|
|
9980
|
+
* RULE-level, because a digest's output is rule-level: unlike the timelapse
|
|
9981
|
+
* there is no per-camera artefact a shared stamp could destroy.
|
|
9982
|
+
*/
|
|
9983
|
+
function summaryEligibilityFloor(rule) {
|
|
9984
|
+
return Math.max(rule.generatedAt ?? 0, rule.createdAt);
|
|
9985
|
+
}
|
|
9986
|
+
var NcSummaryProducer = class {
|
|
9987
|
+
deps;
|
|
9988
|
+
timer = null;
|
|
9989
|
+
tickInFlight = false;
|
|
9990
|
+
settleMs;
|
|
9991
|
+
lookbackMs;
|
|
9992
|
+
tickMs;
|
|
9993
|
+
texts;
|
|
9994
|
+
constructor(deps) {
|
|
9995
|
+
this.deps = deps;
|
|
9996
|
+
this.settleMs = deps.settleMs ?? 3e5;
|
|
9997
|
+
this.lookbackMs = deps.lookbackMs ?? 6912e5;
|
|
9998
|
+
this.tickMs = deps.tickMs ?? 6e4;
|
|
9999
|
+
this.texts = deps.texts ?? new NcTextCatalog();
|
|
10000
|
+
}
|
|
10001
|
+
async start() {
|
|
10002
|
+
await this.tick();
|
|
10003
|
+
this.timer = setInterval(() => {
|
|
10004
|
+
this.tick();
|
|
10005
|
+
}, this.tickMs);
|
|
10006
|
+
}
|
|
10007
|
+
stop() {
|
|
10008
|
+
if (this.timer !== null) {
|
|
10009
|
+
clearInterval(this.timer);
|
|
10010
|
+
this.timer = null;
|
|
10011
|
+
}
|
|
10012
|
+
}
|
|
10013
|
+
/**
|
|
10014
|
+
* One pass over every enabled rule. SINGLE-FLIGHT, and that is the only thing
|
|
10015
|
+
* bounding concurrency on this path: ten rules whose windows close together
|
|
10016
|
+
* must not open ten simultaneous render/publish sequences.
|
|
10017
|
+
*/
|
|
10018
|
+
async tick() {
|
|
10019
|
+
if (this.tickInFlight) return EMPTY_REPORT;
|
|
10020
|
+
this.tickInFlight = true;
|
|
10021
|
+
const report = { ...EMPTY_REPORT };
|
|
10022
|
+
try {
|
|
10023
|
+
const now = this.deps.now();
|
|
10024
|
+
const rules = this.deps.store.listEnabled();
|
|
10025
|
+
this.deps.collector.retainRules(rules.map((r) => r.id));
|
|
10026
|
+
const abandoned = this.deps.collector.sweep(now);
|
|
10027
|
+
if (abandoned > 0) this.deps.logger.warn("summary collector abandoned tracks that never closed", { meta: {
|
|
10028
|
+
abandoned,
|
|
10029
|
+
...this.deps.collector.stats()
|
|
10030
|
+
} });
|
|
10031
|
+
for (const rule of rules) await this.tickRule(rule, now, report);
|
|
10032
|
+
} finally {
|
|
10033
|
+
this.tickInFlight = false;
|
|
10034
|
+
}
|
|
10035
|
+
return report;
|
|
10036
|
+
}
|
|
10037
|
+
/**
|
|
10038
|
+
* The operator's Test button: produce the given window right now, ignoring
|
|
10039
|
+
* the settle, the barrier and the floor.
|
|
10040
|
+
*
|
|
10041
|
+
* It DELIVERS — same as `nc.injectTestEvent` and `nc.testTimelapseRule`, for
|
|
10042
|
+
* the same reason: a test that stops short of the notifier does not test the
|
|
10043
|
+
* thing that breaks. It deliberately does NOT stamp, so pressing Test at noon
|
|
10044
|
+
* cannot cancel tonight's scheduled run.
|
|
10045
|
+
*/
|
|
10046
|
+
async runNow(input) {
|
|
10047
|
+
const rule = this.deps.store.get(input.ruleId);
|
|
10048
|
+
if (rule === null) throw new Error(`summary rule not found: ${input.ruleId}`);
|
|
10049
|
+
const window = input.window ?? this.lastClosedWindow(rule, this.deps.now());
|
|
10050
|
+
if (window === null) return {
|
|
10051
|
+
produced: 0,
|
|
10052
|
+
window: null,
|
|
10053
|
+
error: "no window has closed recently for this rule"
|
|
10054
|
+
};
|
|
10055
|
+
return this.produce(rule, window, {
|
|
10056
|
+
stamp: false,
|
|
10057
|
+
deliverEmpty: true
|
|
10058
|
+
});
|
|
10059
|
+
}
|
|
10060
|
+
async tickRule(rule, now, report) {
|
|
10061
|
+
const decision = this.decide(rule, now);
|
|
10062
|
+
if (decision === null) {
|
|
10063
|
+
report.idle += 1;
|
|
10064
|
+
return;
|
|
10065
|
+
}
|
|
10066
|
+
if (decision.kind === "collecting") {
|
|
10067
|
+
report.collecting += 1;
|
|
10068
|
+
return;
|
|
10069
|
+
}
|
|
10070
|
+
if (decision.kind === "barrier") {
|
|
10071
|
+
report.barrier += 1;
|
|
10072
|
+
this.deps.logger.debug("summary window waiting for its tracks to close", { meta: {
|
|
10073
|
+
ruleId: rule.id,
|
|
10074
|
+
openTracks: decision.openTracks,
|
|
10075
|
+
deadlineMs: decision.deadlineMs,
|
|
10076
|
+
trackIds: this.deps.collector.openTrackIds(rule.deviceIds, decision.deadlineMs - rule.barrierSeconds * 1e3).join(",")
|
|
10077
|
+
} });
|
|
10078
|
+
return;
|
|
10079
|
+
}
|
|
10080
|
+
if (summaryEligibilityFloor(rule) >= decision.window.endMs) {
|
|
10081
|
+
report.beforeFloor += 1;
|
|
10082
|
+
this.deps.logger.debug("summary window predates the rule — not attempted", { meta: {
|
|
10083
|
+
ruleId: rule.id,
|
|
10084
|
+
windowStart: decision.window.startMs,
|
|
10085
|
+
windowEnd: decision.window.endMs,
|
|
10086
|
+
createdAt: rule.createdAt
|
|
10087
|
+
} });
|
|
10088
|
+
if (rule.window.kind === "motion") this.deps.collector.closeMotionWindow(rule.id, decision.window.endMs);
|
|
10089
|
+
return;
|
|
10090
|
+
}
|
|
10091
|
+
if (decision.barrierBroken > 0) this.deps.logger.warn("summary barrier expired with tracks still open", { meta: {
|
|
10092
|
+
ruleId: rule.id,
|
|
10093
|
+
stillOpen: decision.barrierBroken,
|
|
10094
|
+
windowEnd: decision.window.endMs,
|
|
10095
|
+
barrierSeconds: rule.barrierSeconds
|
|
10096
|
+
} });
|
|
10097
|
+
const outcome = await this.produce(rule, decision.window, {
|
|
10098
|
+
stamp: true,
|
|
10099
|
+
deliverEmpty: rule.deliverEmpty
|
|
10100
|
+
});
|
|
10101
|
+
if (rule.window.kind === "motion") this.deps.collector.closeMotionWindow(rule.id, decision.window.endMs);
|
|
10102
|
+
if (outcome.error !== void 0) report.failed += 1;
|
|
10103
|
+
else if (outcome.produced > 0) report.produced += 1;
|
|
10104
|
+
else report.empty += 1;
|
|
10105
|
+
}
|
|
10106
|
+
/** Which window (if any) this rule is standing at, and whether it may run. */
|
|
10107
|
+
decide(rule, now) {
|
|
10108
|
+
const barrierMs = rule.barrierSeconds * 1e3;
|
|
10109
|
+
if (rule.window.kind === "schedule") {
|
|
10110
|
+
const window = findLastClosedWindow({
|
|
10111
|
+
schedule: rule.window.schedule,
|
|
10112
|
+
nowMs: now,
|
|
10113
|
+
lookbackMs: this.lookbackMs
|
|
10114
|
+
});
|
|
10115
|
+
if (window === null) return null;
|
|
10116
|
+
return decideSummaryWindow({
|
|
10117
|
+
openedAtMs: window.startMs,
|
|
10118
|
+
closeAtMs: window.endMs,
|
|
10119
|
+
openTracks: this.deps.collector.openTrackCount(rule.deviceIds, window.startMs),
|
|
10120
|
+
barrierMs,
|
|
10121
|
+
nowMs: now - this.settleMs
|
|
10122
|
+
});
|
|
10123
|
+
}
|
|
10124
|
+
const open = this.deps.collector.refreshMotionWindow(rule.id, rule.deviceIds);
|
|
10125
|
+
if (open === null) return null;
|
|
10126
|
+
const close = motionWindowCloseAt(open, {
|
|
10127
|
+
quietMs: rule.window.quietSeconds * 1e3,
|
|
10128
|
+
maxWindowMs: rule.window.maxWindowSeconds * 1e3
|
|
10129
|
+
});
|
|
10130
|
+
return decideSummaryWindow({
|
|
10131
|
+
openedAtMs: open.openedAtMs,
|
|
10132
|
+
closeAtMs: close.closeAtMs,
|
|
10133
|
+
openTracks: this.deps.collector.openTrackCount(rule.deviceIds, open.openedAtMs),
|
|
10134
|
+
barrierMs,
|
|
10135
|
+
nowMs: now,
|
|
10136
|
+
cappedByCeiling: close.cappedByCeiling
|
|
10137
|
+
});
|
|
10138
|
+
}
|
|
10139
|
+
lastClosedWindow(rule, now) {
|
|
10140
|
+
if (rule.window.kind === "schedule") return findLastClosedWindow({
|
|
10141
|
+
schedule: rule.window.schedule,
|
|
10142
|
+
nowMs: now,
|
|
10143
|
+
lookbackMs: this.lookbackMs
|
|
10144
|
+
});
|
|
10145
|
+
const open = this.deps.collector.motionWindow(rule.id);
|
|
10146
|
+
if (open !== null) return {
|
|
10147
|
+
startMs: open.openedAtMs,
|
|
10148
|
+
endMs: now
|
|
10149
|
+
};
|
|
10150
|
+
return {
|
|
10151
|
+
startMs: now - rule.window.maxWindowSeconds * 1e3,
|
|
10152
|
+
endMs: now
|
|
10153
|
+
};
|
|
10154
|
+
}
|
|
10155
|
+
/**
|
|
10156
|
+
* Collect → select → read → render → publish → enqueue → (optionally) stamp.
|
|
10157
|
+
* Never throws.
|
|
10158
|
+
*/
|
|
10159
|
+
async produce(rule, window, opts) {
|
|
10160
|
+
const meta = {
|
|
10161
|
+
ruleId: rule.id,
|
|
10162
|
+
deviceIds: rule.deviceIds.join(","),
|
|
10163
|
+
windowStart: window.startMs,
|
|
10164
|
+
windowEnd: window.endMs
|
|
10165
|
+
};
|
|
10166
|
+
try {
|
|
10167
|
+
const candidates = await this.collectCandidates(rule, window);
|
|
10168
|
+
const filtered = filterSummaryCandidates(candidates, rule.filters, this.deps.zoneOwner);
|
|
10169
|
+
const selection = selectSummaryTiles(filtered.kept, {
|
|
10170
|
+
maxTiles: rule.maxTiles,
|
|
10171
|
+
rank: rule.rank
|
|
10172
|
+
});
|
|
10173
|
+
if (selection.tiles.length === 0 && !opts.deliverEmpty) {
|
|
10174
|
+
this.deps.logger.info("summary window held nothing — not delivered", { meta: {
|
|
10175
|
+
...meta,
|
|
10176
|
+
scanned: candidates.length,
|
|
10177
|
+
droppedByClass: filtered.droppedByClass,
|
|
10178
|
+
droppedByZone: filtered.droppedByZone,
|
|
10179
|
+
droppedByImportance: filtered.droppedByImportance,
|
|
10180
|
+
droppedByDwell: filtered.droppedByDwell
|
|
10181
|
+
} });
|
|
10182
|
+
if (opts.stamp) await this.deps.store.markGenerated(rule.id, window.endMs);
|
|
10183
|
+
return {
|
|
10184
|
+
produced: 0,
|
|
10185
|
+
window,
|
|
10186
|
+
tiles: 0,
|
|
10187
|
+
cameras: 0,
|
|
10188
|
+
matched: 0
|
|
10189
|
+
};
|
|
10190
|
+
}
|
|
10191
|
+
const loaded = await this.loadTiles(rule, selection.tiles);
|
|
10192
|
+
const detections = countDetections({ tracks: selection.tiles.map((t) => ({
|
|
10193
|
+
firstSeen: t.firstSeen,
|
|
10194
|
+
lastSeen: t.lastSeen,
|
|
10195
|
+
className: t.className
|
|
10196
|
+
})) });
|
|
10197
|
+
const mosaic = await this.renderAndPublish(rule, window, loaded);
|
|
10198
|
+
const rows = buildSummaryOutboxInputs({
|
|
10199
|
+
texts: this.texts,
|
|
10200
|
+
ruleId: rule.id,
|
|
10201
|
+
ruleName: rule.name,
|
|
10202
|
+
window,
|
|
10203
|
+
deviceIds: rule.deviceIds,
|
|
10204
|
+
primaryDeviceId: rule.deviceIds[0] ?? 0,
|
|
10205
|
+
targets: rule.targets,
|
|
10206
|
+
...this.deps.disabledTargetIds !== void 0 ? { disabledTargetIds: this.deps.disabledTargetIds(rule.id) } : {},
|
|
10207
|
+
priority: rule.priority,
|
|
10208
|
+
tileCount: loaded.length,
|
|
10209
|
+
cameraCount: new Set(loaded.map((t) => t.candidate.deviceId)).size,
|
|
10210
|
+
matchedCount: filtered.kept.length,
|
|
10211
|
+
detections,
|
|
10212
|
+
...mosaic.ref !== null ? { mosaic: mosaic.ref } : {},
|
|
10213
|
+
generatedAt: this.deps.now(),
|
|
10214
|
+
...rule.template !== void 0 ? { template: rule.template } : {}
|
|
10215
|
+
});
|
|
10216
|
+
const enqueued = await this.deps.enqueue(rows);
|
|
10217
|
+
if (opts.stamp) await this.deps.store.markGenerated(rule.id, window.endMs);
|
|
10218
|
+
this.deps.logger.info("summary produced", { meta: {
|
|
10219
|
+
...meta,
|
|
10220
|
+
scanned: candidates.length,
|
|
10221
|
+
matched: filtered.kept.length,
|
|
10222
|
+
tiles: loaded.length,
|
|
10223
|
+
cameras: new Set(loaded.map((t) => t.candidate.deviceId)).size,
|
|
10224
|
+
camerasDropped: selection.camerasDropped,
|
|
10225
|
+
droppedNoMedia: selection.tiles.length - loaded.length,
|
|
10226
|
+
mosaicBytes: mosaic.bytes,
|
|
10227
|
+
mosaicPublished: mosaic.ref !== null,
|
|
10228
|
+
enqueued,
|
|
10229
|
+
stamped: opts.stamp
|
|
10230
|
+
} });
|
|
10231
|
+
return {
|
|
10232
|
+
produced: 1,
|
|
10233
|
+
window,
|
|
10234
|
+
tiles: loaded.length,
|
|
10235
|
+
cameras: new Set(loaded.map((t) => t.candidate.deviceId)).size,
|
|
10236
|
+
matched: filtered.kept.length,
|
|
10237
|
+
enqueued,
|
|
10238
|
+
...mosaic.bytes > 0 ? { mosaicBytes: mosaic.bytes } : {},
|
|
10239
|
+
...mosaic.ref !== null ? { mosaicUrl: mosaic.ref.url } : {}
|
|
10240
|
+
};
|
|
10241
|
+
} catch (err) {
|
|
10242
|
+
this.deps.logger.warn("summary not produced — the window stays eligible", { meta: {
|
|
10243
|
+
...meta,
|
|
10244
|
+
error: String(err)
|
|
10245
|
+
} });
|
|
10246
|
+
return {
|
|
10247
|
+
produced: 0,
|
|
10248
|
+
window,
|
|
10249
|
+
error: err instanceof Error ? err.message : String(err)
|
|
10250
|
+
};
|
|
10251
|
+
}
|
|
10252
|
+
}
|
|
10253
|
+
/** Every camera's tracks in the window, capped per camera and counted. */
|
|
10254
|
+
async collectCandidates(rule, window) {
|
|
10255
|
+
const all = [];
|
|
10256
|
+
for (const deviceId of rule.deviceIds) try {
|
|
10257
|
+
const rows = await this.deps.listTracks({
|
|
10258
|
+
deviceId,
|
|
10259
|
+
sinceMs: window.startMs,
|
|
10260
|
+
untilMs: window.endMs,
|
|
10261
|
+
limit: SUMMARY_TRACK_QUERY_LIMIT
|
|
10262
|
+
});
|
|
10263
|
+
if (rows.length >= 2e3) this.deps.logger.warn("summary track scan hit the per-camera ceiling", {
|
|
10264
|
+
tags: { deviceId },
|
|
10265
|
+
meta: {
|
|
10266
|
+
ruleId: rule.id,
|
|
10267
|
+
limit: SUMMARY_TRACK_QUERY_LIMIT
|
|
10268
|
+
}
|
|
10269
|
+
});
|
|
10270
|
+
all.push(...rows);
|
|
10271
|
+
} catch (err) {
|
|
10272
|
+
this.deps.logger.warn("summary could not read a camera for this window", {
|
|
10273
|
+
tags: { deviceId },
|
|
10274
|
+
meta: {
|
|
10275
|
+
ruleId: rule.id,
|
|
10276
|
+
error: String(err)
|
|
10277
|
+
}
|
|
10278
|
+
});
|
|
10279
|
+
}
|
|
10280
|
+
return all;
|
|
10281
|
+
}
|
|
10282
|
+
/** One blob per tile — the cheap question (index rows) always first. */
|
|
10283
|
+
async loadTiles(rule, tiles) {
|
|
10284
|
+
const out = [];
|
|
10285
|
+
for (const candidate of tiles) {
|
|
10286
|
+
const tags = { deviceId: candidate.deviceId };
|
|
10287
|
+
try {
|
|
10288
|
+
const rows = await this.deps.listMediaInfo(candidate.trackId);
|
|
10289
|
+
const picked = pickSummaryMedia(rows, (candidate.firstSeen + candidate.lastSeen) / 2);
|
|
10290
|
+
if (picked === null) {
|
|
10291
|
+
this.deps.logger.debug("summary tile dropped: the track carries no clean media", {
|
|
10292
|
+
tags,
|
|
10293
|
+
meta: {
|
|
10294
|
+
ruleId: rule.id,
|
|
10295
|
+
trackId: candidate.trackId,
|
|
10296
|
+
mediaRows: rows.length
|
|
10297
|
+
}
|
|
10298
|
+
});
|
|
10299
|
+
continue;
|
|
10300
|
+
}
|
|
10301
|
+
const jpeg = await this.deps.readMedia(picked.key);
|
|
10302
|
+
if (jpeg === null || jpeg.byteLength === 0) {
|
|
10303
|
+
this.deps.logger.debug("summary tile dropped: the media blob could not be read", {
|
|
10304
|
+
tags,
|
|
10305
|
+
meta: {
|
|
10306
|
+
ruleId: rule.id,
|
|
10307
|
+
trackId: candidate.trackId,
|
|
10308
|
+
mediaKey: picked.key
|
|
10309
|
+
}
|
|
10310
|
+
});
|
|
10311
|
+
continue;
|
|
10312
|
+
}
|
|
10313
|
+
out.push({
|
|
10314
|
+
candidate,
|
|
10315
|
+
jpeg
|
|
10316
|
+
});
|
|
10317
|
+
} catch (err) {
|
|
10318
|
+
this.deps.logger.warn("summary tile dropped: reading its media threw", {
|
|
10319
|
+
tags,
|
|
10320
|
+
meta: {
|
|
10321
|
+
ruleId: rule.id,
|
|
10322
|
+
trackId: candidate.trackId,
|
|
10323
|
+
error: String(err)
|
|
10324
|
+
}
|
|
10325
|
+
});
|
|
10326
|
+
}
|
|
10327
|
+
}
|
|
10328
|
+
return out;
|
|
10329
|
+
}
|
|
10330
|
+
/**
|
|
10331
|
+
* Render the mosaic, file it where event media lives, and mint the public
|
|
10332
|
+
* link the notifier BACKEND will fetch.
|
|
10333
|
+
*
|
|
10334
|
+
* Every step is best-effort in the delivery direction: a mosaic that could
|
|
10335
|
+
* not be rendered, filed or published still leaves a digest with correct
|
|
10336
|
+
* counts, and that is worth more than silence.
|
|
10337
|
+
*/
|
|
10338
|
+
async renderAndPublish(rule, window, tiles) {
|
|
10339
|
+
if (tiles.length === 0) return {
|
|
10340
|
+
ref: null,
|
|
10341
|
+
bytes: 0
|
|
10342
|
+
};
|
|
10343
|
+
const names = /* @__PURE__ */ new Map();
|
|
10344
|
+
for (const { candidate } of tiles) {
|
|
10345
|
+
if (names.has(candidate.deviceId)) continue;
|
|
10346
|
+
const name = await this.resolveDeviceName(candidate.deviceId);
|
|
10347
|
+
names.set(candidate.deviceId, name ?? `camera ${candidate.deviceId}`);
|
|
10348
|
+
}
|
|
10349
|
+
let rendered;
|
|
10350
|
+
try {
|
|
10351
|
+
rendered = await renderMosaic({
|
|
10352
|
+
tiles: tiles.map(({ candidate, jpeg }) => ({
|
|
10353
|
+
jpeg,
|
|
10354
|
+
label: `${names.get(candidate.deviceId) ?? candidate.deviceId} · ${clockOf(candidate.firstSeen)} · ${candidate.className}`,
|
|
10355
|
+
deviceId: candidate.deviceId,
|
|
10356
|
+
trackId: candidate.trackId
|
|
10357
|
+
})),
|
|
10358
|
+
caption: summaryCaptionText({
|
|
10359
|
+
...rule.captionText !== void 0 ? { captionText: rule.captionText } : {},
|
|
10360
|
+
ruleName: rule.name,
|
|
10361
|
+
window
|
|
10362
|
+
})
|
|
10363
|
+
}, this.deps.logger);
|
|
10364
|
+
} catch (err) {
|
|
10365
|
+
this.deps.logger.warn("summary mosaic could not be composed — delivering the counts alone", { meta: {
|
|
10366
|
+
ruleId: rule.id,
|
|
10367
|
+
tiles: tiles.length,
|
|
10368
|
+
error: String(err)
|
|
10369
|
+
} });
|
|
10370
|
+
return {
|
|
10371
|
+
ref: null,
|
|
10372
|
+
bytes: 0
|
|
10373
|
+
};
|
|
10374
|
+
}
|
|
10375
|
+
const file = this.deps.storeMosaic;
|
|
10376
|
+
if (file !== void 0) try {
|
|
10377
|
+
await file({
|
|
10378
|
+
deviceId: rule.deviceIds[0] ?? 0,
|
|
10379
|
+
ownerId: summaryMediaOwnerId(rule.id, window.endMs),
|
|
10380
|
+
timestamp: window.endMs,
|
|
10381
|
+
jpeg: rendered.jpeg
|
|
10382
|
+
});
|
|
10383
|
+
} catch (err) {
|
|
10384
|
+
this.deps.logger.warn("summary mosaic was not filed — the notification still ships", { meta: {
|
|
10385
|
+
ruleId: rule.id,
|
|
10386
|
+
error: String(err)
|
|
10387
|
+
} });
|
|
10388
|
+
}
|
|
10389
|
+
const bytes = new Uint8Array(rendered.jpeg.byteLength);
|
|
10390
|
+
bytes.set(rendered.jpeg);
|
|
10391
|
+
const url = await this.deps.publish(bytes, "image/jpeg");
|
|
10392
|
+
if (url === null) {
|
|
10393
|
+
this.deps.logger.warn("summary mosaic not published: no reachable public endpoint", { meta: { ruleId: rule.id } });
|
|
10394
|
+
return {
|
|
10395
|
+
ref: null,
|
|
10396
|
+
bytes: bytes.byteLength
|
|
10397
|
+
};
|
|
10398
|
+
}
|
|
10399
|
+
return {
|
|
10400
|
+
ref: {
|
|
10401
|
+
url,
|
|
10402
|
+
mime: "image/jpeg",
|
|
10403
|
+
name: "summary.jpg"
|
|
10404
|
+
},
|
|
10405
|
+
bytes: bytes.byteLength
|
|
10406
|
+
};
|
|
10407
|
+
}
|
|
10408
|
+
async resolveDeviceName(deviceId) {
|
|
10409
|
+
const get = this.deps.getDeviceName;
|
|
10410
|
+
if (get === void 0) return null;
|
|
10411
|
+
return get(deviceId).catch(() => null);
|
|
10412
|
+
}
|
|
10413
|
+
};
|
|
10414
|
+
/** `HH:MM` in the host timezone — the two numbers a tile label needs. */
|
|
10415
|
+
function clockOf(atMs) {
|
|
10416
|
+
const d = new Date(atMs);
|
|
10417
|
+
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
10418
|
+
}
|
|
10419
|
+
//#endregion
|
|
10420
|
+
//#region src/notification-center/summary/summary-rule.ts
|
|
10421
|
+
/**
|
|
10422
|
+
* NcSummaryRule — the MULTI-CAMERA digest rule.
|
|
10423
|
+
*
|
|
10424
|
+
* One rule, N cameras, ONE artefact per window: a clean mosaic of the best shot
|
|
10425
|
+
* of every track the window contained, delivered through the ordinary outbox.
|
|
10426
|
+
* That is the whole of P1. The `ai` section below is declared and persisted but
|
|
10427
|
+
* NOTHING reads it yet — P2 wires the joint vision call behind it, and because
|
|
10428
|
+
* the section is `.optional()` a rule authored today keeps delivering
|
|
10429
|
+
* byte-for-byte identically when it lands.
|
|
10430
|
+
*
|
|
10431
|
+
* ## Why this schema lives in the ADDON and not in `packages/types`
|
|
10432
|
+
*
|
|
10433
|
+
* `@camstack/types` is `external` in every mode of the build preset, so the
|
|
10434
|
+
* addon resolves it at runtime from the `@camstack/server` closure. A field
|
|
10435
|
+
* added there is not live until a framework train is published AND installed —
|
|
10436
|
+
* and worse, a NEW field on a cap-validated shape is *silently stripped* at save
|
|
10437
|
+
* until that train lands. The timelapse rule paid that price with three cascaded
|
|
10438
|
+
* bugs in one day.
|
|
10439
|
+
*
|
|
10440
|
+
* This rule rides no `NcDelivery` member, no cap method and no codegen. It
|
|
10441
|
+
* reaches the outside world through the `nc.*SummaryRule*` bridge actions
|
|
10442
|
+
* (`addons.custom`), exactly like `NcTestEventInputSchema` does — so a field
|
|
10443
|
+
* added here is live on the next `camstack deploy packages/addon-post-analysis`.
|
|
10444
|
+
* Promotion into `packages/types` later is additive and needs no migration.
|
|
10445
|
+
*
|
|
10446
|
+
* ## The `.partial()` trap this file exists to avoid
|
|
10447
|
+
*
|
|
10448
|
+
* Commit `e0921ac6e`, found in production 2026-08-12: `NcRulePatchSchema` was
|
|
10449
|
+
* `NcRuleInputSchema.partial()`, and Zod 4 `.partial()` KEEPS the inner
|
|
10450
|
+
* `.default()`. Parsing `{ name: 'x' }` therefore materialised every defaulted
|
|
10451
|
+
* key, and `{ ...existing, ...patch }` wrote them over the rule — a camera- and
|
|
10452
|
+
* zone-scoped rule silently became a rule that matched everything.
|
|
10453
|
+
*
|
|
10454
|
+
* So: every constraint is declared ONCE below; `.default()` appears ONLY in the
|
|
10455
|
+
* Input schema and `.optional()` ONLY in the hand-written Patch. `summary-rule.spec.ts`
|
|
10456
|
+
* asserts the two key sets stay identical AND that a one-key patch parses to
|
|
10457
|
+
* exactly one key.
|
|
10458
|
+
*/
|
|
10459
|
+
/** `{{var}}` templating — the same vocabulary the timelapse and rule bodies use. */
|
|
10460
|
+
var NcSummaryTemplateSchema = object({
|
|
10461
|
+
title: string().max(500).optional(),
|
|
10462
|
+
body: string().max(2e3).optional()
|
|
10463
|
+
});
|
|
10464
|
+
var NameField = string().min(1).max(200);
|
|
10465
|
+
/** MULTI-CAMERA is the point of the feature — a one-camera digest is a
|
|
10466
|
+
* timelapse with extra steps, but it is allowed and it is cheap. */
|
|
10467
|
+
var DeviceIdsField = array(number()).min(1).max(64);
|
|
10468
|
+
var TargetsField = array(NcRuleTargetSchema).min(1);
|
|
10469
|
+
var PriorityField = number().int().min(1).max(5);
|
|
10470
|
+
var MaxTilesField = number().int().min(1).max(24);
|
|
10471
|
+
var MaxAiImagesField = number().int().min(1).max(8);
|
|
10472
|
+
/**
|
|
10473
|
+
* The MOTION window's quiet period, seconds.
|
|
10474
|
+
*
|
|
10475
|
+
* The window opens on the first motion seen on ANY of the rule's cameras and
|
|
10476
|
+
* stays open while motion keeps arriving. `quietSeconds` with nothing new on
|
|
10477
|
+
* any of them is what closes it — the operator's "windows driven by MOTION of
|
|
10478
|
+
* the selected cameras".
|
|
10479
|
+
*/
|
|
10480
|
+
var QuietSecondsField = number().int().min(30).max(3600);
|
|
10481
|
+
/**
|
|
10482
|
+
* The hard ceiling on ONE motion window, seconds.
|
|
10483
|
+
*
|
|
10484
|
+
* The operator's "max-wait bounded by the camera with the widest window": a
|
|
10485
|
+
* continuously busy driveway would otherwise keep a window open forever and the
|
|
10486
|
+
* digest would never be delivered. When this bites, the window closes with
|
|
10487
|
+
* whatever it holds and SAYS so on the log line.
|
|
10488
|
+
*/
|
|
10489
|
+
var MaxWindowSecondsField = number().int().min(60).max(86400);
|
|
10490
|
+
/**
|
|
10491
|
+
* How long the producer waits for the window's own tracks to CLOSE before it
|
|
10492
|
+
* analyses anything — the operator's track-close barrier, in seconds.
|
|
10493
|
+
*
|
|
10494
|
+
* A track's best shot (`keyFrameSmall` / `thumbnail`) is written when the track
|
|
10495
|
+
* CLOSES. Rendering the instant the window ends therefore renders exactly the
|
|
10496
|
+
* subjects that were still moving — the ones the digest is most about — with no
|
|
10497
|
+
* picture at all. The barrier waits for every track the window opened to close,
|
|
10498
|
+
* and is itself bounded: at the bound the window renders with the tracks it has
|
|
10499
|
+
* and logs the ones it gave up on.
|
|
10500
|
+
*/
|
|
10501
|
+
var BarrierSecondsField = number().int().min(0).max(3600);
|
|
10502
|
+
/**
|
|
10503
|
+
* How the window is defined. A discriminated union, because two optional
|
|
10504
|
+
* fields would make "neither" and "both" representable and both would have to
|
|
10505
|
+
* be refused by hand.
|
|
10506
|
+
*/
|
|
10507
|
+
var NcSummaryWindowSchema = discriminatedUnion("kind", [object({
|
|
10508
|
+
kind: literal("schedule"),
|
|
10509
|
+
schedule: NcScheduleSchema
|
|
10510
|
+
}), object({
|
|
10511
|
+
kind: literal("motion"),
|
|
10512
|
+
quietSeconds: QuietSecondsField,
|
|
10513
|
+
maxWindowSeconds: MaxWindowSecondsField
|
|
10514
|
+
})]);
|
|
10515
|
+
/**
|
|
10516
|
+
* What enters the window. Evaluated on TRACKS, never on object events: one
|
|
10517
|
+
* track is one passage is one tile, and `importance` / dwell only exist at
|
|
10518
|
+
* track level (the same reason `NcRule` offers them only on `track-end`).
|
|
10519
|
+
*
|
|
10520
|
+
* `zones` is a FLAT id list and is scoped PER CAMERA by
|
|
10521
|
+
* `zone-ownership.ts#scopeZoneIdsToDevice` — picking a zone on camera A must
|
|
10522
|
+
* not silently mute camera B, which is the defect that helper exists for.
|
|
10523
|
+
*/
|
|
10524
|
+
var NcSummaryFiltersSchema = object({
|
|
10525
|
+
classes: array(string().min(1).max(40)).max(20).optional(),
|
|
10526
|
+
classesExclude: array(string().min(1).max(40)).max(20).optional(),
|
|
10527
|
+
zones: array(string()).max(40).optional(),
|
|
10528
|
+
minImportance: number().min(0).max(1).optional(),
|
|
10529
|
+
minDwellSeconds: number().min(0).max(3600).optional()
|
|
10530
|
+
});
|
|
10531
|
+
/**
|
|
10532
|
+
* How tiles are chosen when the window holds more tracks than the budget.
|
|
10533
|
+
*
|
|
10534
|
+
* `importance` is the pipeline's own deterministic score and the default. The
|
|
10535
|
+
* per-camera quota that guarantees every contributing camera at least one tile
|
|
10536
|
+
* is NOT a rank — it applies to all of them, because a multi-camera digest in
|
|
10537
|
+
* which two of six cameras never appear is not multi-camera.
|
|
10538
|
+
*/
|
|
10539
|
+
var NcSummaryRankSchema = _enum([
|
|
10540
|
+
"importance",
|
|
10541
|
+
"recency",
|
|
10542
|
+
"dwell"
|
|
10543
|
+
]);
|
|
10544
|
+
/**
|
|
10545
|
+
* The AI section — DECLARED IN P1, WIRED IN P2.
|
|
10546
|
+
*
|
|
10547
|
+
* Nothing in this addon reads it today. It is here so the rule shape does not
|
|
10548
|
+
* change under an operator who already authored rules, and `.optional()` so an
|
|
10549
|
+
* absent section is the ordinary case rather than a migration.
|
|
10550
|
+
*
|
|
10551
|
+
* No `.default()` anywhere inside it, for the reason in the module docblock.
|
|
10552
|
+
*/
|
|
10553
|
+
var NcSummaryAiSchema = object({
|
|
10554
|
+
enabled: boolean(),
|
|
10555
|
+
profileId: string().optional(),
|
|
10556
|
+
/** The operator's own question. NEVER composed with text READ FROM A FRAME —
|
|
10557
|
+
* D121: a model reads OSD, plates and signs as instructions. */
|
|
10558
|
+
prompt: string().max(2e3).optional(),
|
|
10559
|
+
language: _enum(["en", "it"]).optional(),
|
|
10560
|
+
maxImagePx: number().int().min(64).max(2048).optional(),
|
|
10561
|
+
/** An EXTERNAL race, not the HTTP timeout — that one is `profile.timeoutMs`
|
|
10562
|
+
* and this can never lengthen it. */
|
|
10563
|
+
timeoutMs: number().int().min(1e4).max(6e5).optional(),
|
|
10564
|
+
onFailure: _enum(["deliver-without-text", "skip"]).optional()
|
|
10565
|
+
});
|
|
10566
|
+
var NcSummaryRuleInputSchema = object({
|
|
10567
|
+
name: NameField,
|
|
10568
|
+
enabled: boolean().default(true),
|
|
10569
|
+
deviceIds: DeviceIdsField,
|
|
10570
|
+
window: NcSummaryWindowSchema,
|
|
10571
|
+
filters: NcSummaryFiltersSchema.optional(),
|
|
10572
|
+
maxTiles: MaxTilesField.default(9),
|
|
10573
|
+
maxAiImages: MaxAiImagesField.default(6),
|
|
10574
|
+
rank: NcSummaryRankSchema.default("importance"),
|
|
10575
|
+
barrierSeconds: BarrierSecondsField.default(120),
|
|
10576
|
+
targets: TargetsField,
|
|
10577
|
+
template: NcSummaryTemplateSchema.optional(),
|
|
10578
|
+
/** Burned onto the mosaic's title band. `''` is reachable and means "no
|
|
10579
|
+
* caption" — which is why this is not `.min(1)`. */
|
|
10580
|
+
captionText: string().max(200).optional(),
|
|
10581
|
+
/** Deliver a window that matched NOTHING? Default no: a nightly "0 events"
|
|
10582
|
+
* is a line nobody reads, and it costs the lines beside it their reader. */
|
|
10583
|
+
deliverEmpty: boolean().default(false),
|
|
10584
|
+
priority: PriorityField.default(3),
|
|
10585
|
+
ai: NcSummaryAiSchema.optional()
|
|
10586
|
+
});
|
|
10587
|
+
/**
|
|
10588
|
+
* The partial update. NOT `Input.partial()` (see the module docblock).
|
|
10589
|
+
*
|
|
10590
|
+
* `template` and `ai` are the two CLEARABLE fields: an absent key leaves them
|
|
10591
|
+
* alone, an explicit `null` removes them, an object replaces them. Keeping
|
|
10592
|
+
* `null` out of the persisted rule is what lets the persisted schema stay a
|
|
10593
|
+
* plain `.optional()`.
|
|
10594
|
+
*/
|
|
10595
|
+
var NcSummaryRulePatchSchema = object({
|
|
10596
|
+
name: NameField.optional(),
|
|
10597
|
+
enabled: boolean().optional(),
|
|
10598
|
+
deviceIds: DeviceIdsField.optional(),
|
|
10599
|
+
window: NcSummaryWindowSchema.optional(),
|
|
10600
|
+
filters: NcSummaryFiltersSchema.nullable().optional(),
|
|
10601
|
+
maxTiles: MaxTilesField.optional(),
|
|
10602
|
+
maxAiImages: MaxAiImagesField.optional(),
|
|
10603
|
+
rank: NcSummaryRankSchema.optional(),
|
|
10604
|
+
barrierSeconds: BarrierSecondsField.optional(),
|
|
10605
|
+
targets: TargetsField.optional(),
|
|
10606
|
+
template: NcSummaryTemplateSchema.nullable().optional(),
|
|
10607
|
+
captionText: string().max(200).optional(),
|
|
10608
|
+
deliverEmpty: boolean().optional(),
|
|
10609
|
+
priority: PriorityField.optional(),
|
|
10610
|
+
ai: NcSummaryAiSchema.nullable().optional()
|
|
10611
|
+
});
|
|
10612
|
+
/**
|
|
10613
|
+
* The stored rule. Identity, ownership, provenance and the generation stamp are
|
|
10614
|
+
* SERVER-STAMPED and appear on neither Input nor Patch, so a forged blob can
|
|
10615
|
+
* never claim or re-own a rule (Zod strips unknown keys).
|
|
10616
|
+
*
|
|
10617
|
+
* `generatedAt` is PER-RULE and not per-camera — unlike the timelapse. One
|
|
10618
|
+
* window produces exactly ONE artefact spanning every camera, so there is no
|
|
10619
|
+
* per-camera output a shared stamp could destroy.
|
|
10620
|
+
*/
|
|
10621
|
+
var NcSummaryRuleSchema = NcSummaryRuleInputSchema.extend({
|
|
10622
|
+
id: string(),
|
|
10623
|
+
ownerUserId: string().optional(),
|
|
10624
|
+
/** Epoch-ms of the END of the last window successfully delivered. */
|
|
10625
|
+
generatedAt: number().optional(),
|
|
10626
|
+
createdBy: string(),
|
|
10627
|
+
createdAt: number(),
|
|
10628
|
+
updatedAt: number()
|
|
10629
|
+
});
|
|
10630
|
+
/**
|
|
10631
|
+
* The one cross-field invariant, checked on the MERGED rule at upsert (never on
|
|
10632
|
+
* the patch alone — lowering `maxTiles` by itself can invalidate a `maxAiImages`
|
|
10633
|
+
* set weeks earlier, and checking the patch in isolation persists that pair).
|
|
10634
|
+
*
|
|
10635
|
+
* Failing at SAVE time is the whole point: the alternative is discovering it at
|
|
10636
|
+
* 07:05 when the window has already closed and cannot come back.
|
|
10637
|
+
*/
|
|
10638
|
+
function assertSummaryBudget(rule) {
|
|
10639
|
+
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`);
|
|
10640
|
+
}
|
|
10641
|
+
//#endregion
|
|
10642
|
+
//#region src/notification-center/summary/summary-store.ts
|
|
10643
|
+
/**
|
|
10644
|
+
* NcSummaryStore — the durable multi-camera digest rule set.
|
|
10645
|
+
*
|
|
10646
|
+
* A deliberate copy of `TimelapseStore`'s shape, because that shape encodes
|
|
10647
|
+
* things this repo learned the hard way and a second opinion would lose:
|
|
10648
|
+
*
|
|
10649
|
+
* - a DECLARED SQLite collection mirrored into RAM, written through (a failed
|
|
10650
|
+
* persist never leaves a phantom in-RAM rule);
|
|
10651
|
+
* - ownership is an ARGUMENT, never read from the blob — and `create`
|
|
10652
|
+
* re-parses the input through the Input schema BEFORE stamping, so a future
|
|
10653
|
+
* "duplicate rule" (`create(existingRule, caller)`) cannot clone the
|
|
10654
|
+
* original owner (a `NcSummaryRule` is structurally assignable to
|
|
10655
|
+
* `NcSummaryRuleInput`, so it would compile clean);
|
|
10656
|
+
* - identity/ownership/provenance/`generatedAt` are re-pinned AFTER the spread
|
|
10657
|
+
* in `update`, which makes a hand-built (unparsed) patch equally unable to
|
|
10658
|
+
* re-own a rule;
|
|
10659
|
+
* - the merged candidate is re-validated before it is persisted, so a patch
|
|
10660
|
+
* can never write a rule that the next `load()` would skip.
|
|
10661
|
+
*
|
|
10662
|
+
* The one structural difference from the timelapse: `generatedAt` is PER-RULE.
|
|
10663
|
+
* A window produces ONE artefact across every camera, so there is no per-camera
|
|
10664
|
+
* output that a shared stamp could silently destroy — which is exactly why the
|
|
10665
|
+
* timelapse needed a per-device map and this does not.
|
|
10666
|
+
*/
|
|
10667
|
+
var NC_SUMMARY_RULES_COLLECTION = "notification-center:summary-rules";
|
|
10668
|
+
var NC_SUMMARY_RULES_COLUMNS = [
|
|
10669
|
+
{
|
|
10670
|
+
name: "id",
|
|
10671
|
+
type: "TEXT",
|
|
10672
|
+
primaryKey: true,
|
|
10673
|
+
notNull: true
|
|
10674
|
+
},
|
|
10675
|
+
{
|
|
10676
|
+
name: "name",
|
|
10677
|
+
type: "TEXT",
|
|
10678
|
+
notNull: true
|
|
10679
|
+
},
|
|
10680
|
+
{
|
|
10681
|
+
name: "enabled",
|
|
10682
|
+
type: "BOOLEAN",
|
|
10683
|
+
notNull: true
|
|
10684
|
+
},
|
|
10685
|
+
{
|
|
10686
|
+
name: "updatedAt",
|
|
10687
|
+
type: "INTEGER",
|
|
10688
|
+
notNull: true
|
|
10689
|
+
},
|
|
10690
|
+
(
|
|
10691
|
+
/** The FULL rule object (Zod-validated on read); the scalars above are
|
|
8902
10692
|
* indexed projections only. */
|
|
8903
10693
|
{
|
|
8904
10694
|
name: "rule",
|
|
@@ -8906,17 +10696,20 @@ var NC_TIMELAPSE_RULES_COLUMNS = [
|
|
|
8906
10696
|
notNull: true
|
|
8907
10697
|
})
|
|
8908
10698
|
];
|
|
8909
|
-
var
|
|
8910
|
-
name: "
|
|
10699
|
+
var NC_SUMMARY_RULES_INDEXES = [{
|
|
10700
|
+
name: "idx_nc_summary_rules_enabled",
|
|
8911
10701
|
columns: ["enabled"]
|
|
8912
10702
|
}];
|
|
8913
|
-
/**
|
|
10703
|
+
/** The rule set is operator-authored and tiny; a generous ceiling. */
|
|
8914
10704
|
var LOAD_LIMIT = 1e4;
|
|
8915
10705
|
/**
|
|
8916
|
-
*
|
|
8917
|
-
*
|
|
8918
|
-
*
|
|
8919
|
-
*
|
|
10706
|
+
* The three-way CLEARABLE patch signal, one function per field: `undefined`
|
|
10707
|
+
* leaves the key alone, `null` DROPS it, a value replaces it.
|
|
10708
|
+
*
|
|
10709
|
+
* Written out three times rather than generically, deliberately: a generic
|
|
10710
|
+
* "delete key K" cannot be expressed over an object type without a cast, and a
|
|
10711
|
+
* cast here would be a cast on the exact path that decides whether a persisted
|
|
10712
|
+
* rule keeps a field. Three literal destructurings are three provable ones.
|
|
8920
10713
|
*/
|
|
8921
10714
|
function applyTemplatePatch(merged, template) {
|
|
8922
10715
|
if (template === void 0) return merged;
|
|
@@ -8924,23 +10717,29 @@ function applyTemplatePatch(merged, template) {
|
|
|
8924
10717
|
...merged,
|
|
8925
10718
|
template
|
|
8926
10719
|
};
|
|
8927
|
-
const { template: _cleared, ...
|
|
8928
|
-
return
|
|
10720
|
+
const { template: _cleared, ...rest } = merged;
|
|
10721
|
+
return rest;
|
|
8929
10722
|
}
|
|
8930
|
-
|
|
8931
|
-
|
|
8932
|
-
|
|
10723
|
+
function applyFiltersPatch(merged, filters) {
|
|
10724
|
+
if (filters === void 0) return merged;
|
|
10725
|
+
if (filters !== null) return {
|
|
10726
|
+
...merged,
|
|
10727
|
+
filters
|
|
10728
|
+
};
|
|
10729
|
+
const { filters: _cleared, ...rest } = merged;
|
|
10730
|
+
return rest;
|
|
8933
10731
|
}
|
|
8934
|
-
|
|
10732
|
+
function applyAiPatch(merged, ai) {
|
|
10733
|
+
if (ai === void 0) return merged;
|
|
10734
|
+
if (ai !== null) return {
|
|
10735
|
+
...merged,
|
|
10736
|
+
ai
|
|
10737
|
+
};
|
|
10738
|
+
const { ai: _cleared, ...rest } = merged;
|
|
10739
|
+
return rest;
|
|
10740
|
+
}
|
|
10741
|
+
var NcSummaryStore = class {
|
|
8935
10742
|
byId = /* @__PURE__ */ new Map();
|
|
8936
|
-
/**
|
|
8937
|
-
* Birth stamps synthesised for LEGACY rows (see {@link withCreatedAt}), kept
|
|
8938
|
-
* so a repeated `load()` re-uses the FIRST one. Without it the synthesised
|
|
8939
|
-
* `createdAt` would advance to "now" on every refresh, and the scheduler's
|
|
8940
|
-
* eligibility floor — which is `max(stamp, createdAt)` — would creep past
|
|
8941
|
-
* every window such a rule was ever offered, silently producing nothing.
|
|
8942
|
-
*/
|
|
8943
|
-
synthesisedCreatedAt = /* @__PURE__ */ new Map();
|
|
8944
10743
|
store;
|
|
8945
10744
|
logger;
|
|
8946
10745
|
now;
|
|
@@ -8953,77 +10752,45 @@ var TimelapseStore = class {
|
|
|
8953
10752
|
}
|
|
8954
10753
|
static async declare(store) {
|
|
8955
10754
|
await store.declareCollection.mutate({
|
|
8956
|
-
collection:
|
|
8957
|
-
columns: [...
|
|
8958
|
-
indexes: [...
|
|
10755
|
+
collection: NC_SUMMARY_RULES_COLLECTION,
|
|
10756
|
+
columns: [...NC_SUMMARY_RULES_COLUMNS],
|
|
10757
|
+
indexes: [...NC_SUMMARY_RULES_INDEXES]
|
|
8959
10758
|
});
|
|
8960
10759
|
}
|
|
8961
10760
|
/**
|
|
8962
|
-
* (Re)hydrate the FULL rule set
|
|
8963
|
-
*
|
|
8964
|
-
*
|
|
8965
|
-
* crash the scheduler).
|
|
10761
|
+
* (Re)hydrate the FULL rule set — boot and the periodic refresh tick.
|
|
10762
|
+
* A row whose JSON no longer validates is SKIPPED with a count, never
|
|
10763
|
+
* allowed to crash the producer.
|
|
8966
10764
|
*/
|
|
8967
10765
|
async load() {
|
|
8968
10766
|
try {
|
|
8969
10767
|
const rows = await this.store.query.query({
|
|
8970
|
-
collection:
|
|
10768
|
+
collection: NC_SUMMARY_RULES_COLLECTION,
|
|
8971
10769
|
filter: { limit: LOAD_LIMIT }
|
|
8972
10770
|
});
|
|
8973
10771
|
this.byId.clear();
|
|
8974
10772
|
let skipped = 0;
|
|
8975
|
-
let synthesised = 0;
|
|
8976
10773
|
for (const row of rows) {
|
|
8977
|
-
const
|
|
8978
|
-
if (raw !== row.data["rule"]) synthesised += 1;
|
|
8979
|
-
const parsed = TimelapseRuleSchema.safeParse(raw);
|
|
10774
|
+
const parsed = NcSummaryRuleSchema.safeParse(row.data["rule"]);
|
|
8980
10775
|
if (!parsed.success) {
|
|
8981
10776
|
skipped += 1;
|
|
8982
10777
|
continue;
|
|
8983
10778
|
}
|
|
8984
10779
|
this.byId.set(parsed.data.id, parsed.data);
|
|
8985
10780
|
}
|
|
8986
|
-
this.logger.debug("
|
|
10781
|
+
this.logger.debug("summary rules loaded", { meta: {
|
|
8987
10782
|
rules: this.byId.size,
|
|
8988
|
-
...skipped > 0 ? { skippedInvalid: skipped } : {}
|
|
8989
|
-
...synthesised > 0 ? { synthesisedCreatedAt: synthesised } : {}
|
|
10783
|
+
...skipped > 0 ? { skippedInvalid: skipped } : {}
|
|
8990
10784
|
} });
|
|
8991
10785
|
} catch (err) {
|
|
8992
|
-
this.logger.warn("
|
|
10786
|
+
this.logger.warn("summary rules load failed", { meta: { error: String(err) } });
|
|
8993
10787
|
}
|
|
8994
10788
|
}
|
|
8995
|
-
/**
|
|
8996
|
-
* Give a persisted blob a `createdAt` if it has none, WITHOUT persisting
|
|
8997
|
-
* anything: the rule reads as born at this load.
|
|
8998
|
-
*
|
|
8999
|
-
* `createdAt` has always been required by `TimelapseRuleSchema`, so a row
|
|
9000
|
-
* without one is a pre-schema artefact — and before this it was dropped
|
|
9001
|
-
* outright at load (a silently vanished rule). Reading it as "born now" is
|
|
9002
|
-
* the conservative choice in BOTH directions that matter: the rule survives,
|
|
9003
|
-
* and the scheduler's eligibility floor stops it back-filling a window that
|
|
9004
|
-
* closed years before anyone looked at it. The cost is one skipped window if
|
|
9005
|
-
* a legacy rule is loaded between a close and its settle — a bounded miss,
|
|
9006
|
-
* against an unbounded retry of a window no footage can satisfy.
|
|
9007
|
-
*
|
|
9008
|
-
* The synthesised value is remembered per rule id so a later refresh does not
|
|
9009
|
-
* move the floor, and the first `update`/`markGenerated` writes it through —
|
|
9010
|
-
* after which the row is no longer legacy.
|
|
9011
|
-
*/
|
|
9012
|
-
withCreatedAt(rowId, raw) {
|
|
9013
|
-
if (!isRecord$1(raw)) return raw;
|
|
9014
|
-
if (typeof raw["createdAt"] === "number") return raw;
|
|
9015
|
-
const stamped = this.synthesisedCreatedAt.get(rowId) ?? this.now();
|
|
9016
|
-
this.synthesisedCreatedAt.set(rowId, stamped);
|
|
9017
|
-
return {
|
|
9018
|
-
...raw,
|
|
9019
|
-
createdAt: stamped
|
|
9020
|
-
};
|
|
9021
|
-
}
|
|
9022
|
-
/** Every rule, newest-first (admin path). */
|
|
10789
|
+
/** Every rule, newest-first. */
|
|
9023
10790
|
list() {
|
|
9024
|
-
return [...this.byId.values()].
|
|
10791
|
+
return [...this.byId.values()].toSorted((a, b) => b.updatedAt - a.updatedAt);
|
|
9025
10792
|
}
|
|
9026
|
-
/** The
|
|
10793
|
+
/** The producer's read: only rules that should be ticked. */
|
|
9027
10794
|
listEnabled() {
|
|
9028
10795
|
return this.list().filter((r) => r.enabled);
|
|
9029
10796
|
}
|
|
@@ -9031,44 +10798,22 @@ var TimelapseStore = class {
|
|
|
9031
10798
|
return this.byId.get(ruleId) ?? null;
|
|
9032
10799
|
}
|
|
9033
10800
|
/**
|
|
9034
|
-
* Rules visible to `userId`: their
|
|
9035
|
-
*
|
|
9036
|
-
*
|
|
9037
|
-
*
|
|
9038
|
-
* The caller identity is server-derived; an absent/undefined caller must be
|
|
9039
|
-
* resolved to a fail-closed value by the bridge action BEFORE calling here —
|
|
9040
|
-
* this store never treats a missing caller as admin/global.
|
|
10801
|
+
* Rules visible to `userId`: their own personal rules plus every
|
|
10802
|
+
* admin/global one. Never another user's personal row. The caller identity is
|
|
10803
|
+
* server-derived — this store never treats a missing caller as admin.
|
|
9041
10804
|
*/
|
|
9042
10805
|
listForOwner(userId) {
|
|
9043
10806
|
return this.list().filter((r) => r.ownerUserId === void 0 || r.ownerUserId === userId);
|
|
9044
10807
|
}
|
|
9045
10808
|
/**
|
|
9046
|
-
*
|
|
9047
|
-
*
|
|
9048
|
-
*
|
|
9049
|
-
* consulting this check. An unknown rule id is false (fail-closed).
|
|
9050
|
-
*/
|
|
9051
|
-
isOwnedBy(ruleId, userId) {
|
|
9052
|
-
const rule = this.byId.get(ruleId);
|
|
9053
|
-
return rule?.ownerUserId !== void 0 && rule.ownerUserId === userId;
|
|
9054
|
-
}
|
|
9055
|
-
/**
|
|
9056
|
-
* Create a rule. `createdBy` is the SERVER-injected caller userId;
|
|
9057
|
-
* `ownerUserId` is the server-derived owner (omit for an admin/global rule).
|
|
9058
|
-
* Neither is ever read from `input`.
|
|
9059
|
-
*
|
|
9060
|
-
* The input is re-parsed through {@link TimelapseRuleInputSchema} BEFORE the
|
|
9061
|
-
* spread — that schema carries no ownership/provenance keys, so it strips any
|
|
9062
|
-
* that rode in on the blob. Without it, an `ownerUserId` on `input` would
|
|
9063
|
-
* survive whenever the `ownerUserId` ARGUMENT is omitted (the admin/global
|
|
9064
|
-
* path): a `TimelapseRule` is structurally assignable to `TimelapseRuleInput`,
|
|
9065
|
-
* so a future "duplicate rule" action (`create(existingRule, caller)`) would
|
|
9066
|
-
* compile cleanly and silently clone the ORIGINAL owner.
|
|
10809
|
+
* Create. `createdBy` is the server-injected caller; `ownerUserId` is the
|
|
10810
|
+
* server-derived owner (omit for an admin/global rule). Neither is ever read
|
|
10811
|
+
* from `input` — see the module docblock for what the re-parse prevents.
|
|
9067
10812
|
*/
|
|
9068
10813
|
async create(input, createdBy, ownerUserId) {
|
|
9069
10814
|
const now = this.now();
|
|
9070
|
-
const rule =
|
|
9071
|
-
...
|
|
10815
|
+
const rule = NcSummaryRuleSchema.parse({
|
|
10816
|
+
...NcSummaryRuleInputSchema.parse(input),
|
|
9072
10817
|
id: this.newId(),
|
|
9073
10818
|
...ownerUserId !== void 0 ? { ownerUserId } : {},
|
|
9074
10819
|
createdBy,
|
|
@@ -9080,103 +10825,70 @@ var TimelapseStore = class {
|
|
|
9080
10825
|
return rule;
|
|
9081
10826
|
}
|
|
9082
10827
|
/**
|
|
9083
|
-
* Apply a partial patch. Immutable
|
|
9084
|
-
* ownership and
|
|
9085
|
-
*
|
|
9086
|
-
* hand-built (unparsed) patch object equally unable to re-own a rule.
|
|
9087
|
-
*
|
|
9088
|
-
* `template` is the one clearable field: an absent key leaves it unchanged,
|
|
9089
|
-
* an explicit `null` CLEARS it (the persisted rule loses the key — `null`
|
|
9090
|
-
* never reaches {@link TimelapseRuleSchema}). See the patch schema's wire
|
|
9091
|
-
* note.
|
|
10828
|
+
* Apply a partial patch. Immutable — returns the NEW rule object. Identity,
|
|
10829
|
+
* ownership, provenance and `generatedAt` are re-pinned from the existing
|
|
10830
|
+
* rule AFTER the spread.
|
|
9092
10831
|
*/
|
|
9093
10832
|
async update(ruleId, patch) {
|
|
9094
10833
|
const existing = this.byId.get(ruleId);
|
|
9095
|
-
if (!existing) throw new Error(`
|
|
9096
|
-
const { template, ...rest } = patch;
|
|
10834
|
+
if (!existing) throw new Error(`summary rule not found: ${ruleId}`);
|
|
10835
|
+
const { template, filters, ai, ...rest } = patch;
|
|
9097
10836
|
const merged = {
|
|
9098
10837
|
...existing,
|
|
9099
10838
|
...rest,
|
|
9100
10839
|
id: existing.id,
|
|
9101
|
-
ownerUserId: existing.ownerUserId,
|
|
9102
|
-
|
|
9103
|
-
generatedByDevice: existing.generatedByDevice,
|
|
10840
|
+
...existing.ownerUserId !== void 0 ? { ownerUserId: existing.ownerUserId } : {},
|
|
10841
|
+
...existing.generatedAt !== void 0 ? { generatedAt: existing.generatedAt } : {},
|
|
9104
10842
|
createdBy: existing.createdBy,
|
|
9105
10843
|
createdAt: existing.createdAt,
|
|
9106
10844
|
updatedAt: this.now()
|
|
9107
10845
|
};
|
|
9108
|
-
return this.write(applyTemplatePatch(merged, template));
|
|
10846
|
+
return this.write(applyAiPatch(applyFiltersPatch(applyTemplatePatch(merged, template), filters), ai));
|
|
9109
10847
|
}
|
|
9110
10848
|
async setEnabled(ruleId, enabled) {
|
|
9111
10849
|
return this.update(ruleId, { enabled });
|
|
9112
10850
|
}
|
|
9113
10851
|
/**
|
|
9114
|
-
* Record a
|
|
9115
|
-
*
|
|
9116
|
-
*
|
|
10852
|
+
* Record a delivered window. `at` is the window's END, not the wall clock:
|
|
10853
|
+
* the eligibility floor compares against a window close, so stamping "now"
|
|
10854
|
+
* would make a late delivery skip the next window too.
|
|
9117
10855
|
*
|
|
9118
|
-
*
|
|
9119
|
-
*
|
|
9120
|
-
* tells the other two they are already done. Their night is then gone for
|
|
9121
|
-
* good — a closed window does not come back. Call this ONLY after the export
|
|
9122
|
-
* reached `ready` and the notification was enqueued; a failed render must
|
|
9123
|
-
* leave the pair unstamped so the next tick can retry it.
|
|
9124
|
-
*
|
|
9125
|
-
* The rule-wide `lastGeneratedAt` is kept as the NEWEST of the stamps (what
|
|
9126
|
-
* a UI shows) and never regresses when an older device generation lands.
|
|
9127
|
-
*
|
|
9128
|
-
* A rule that still carries only the LEGACY rule-wide stamp has its map
|
|
9129
|
-
* SEEDED from it for every camera the rule covers, before the new stamp is
|
|
9130
|
-
* applied — writing `{ [deviceId]: at }` alone would drop the other cameras
|
|
9131
|
-
* to "never generated" and re-render them once.
|
|
10856
|
+
* Call this ONLY after the enqueue succeeded — the stamp is the LAST thing a
|
|
10857
|
+
* production does, because a closed window does not come back.
|
|
9132
10858
|
*/
|
|
9133
|
-
async markGenerated(ruleId,
|
|
10859
|
+
async markGenerated(ruleId, windowEndMs) {
|
|
9134
10860
|
const existing = this.byId.get(ruleId);
|
|
9135
|
-
if (!existing) throw new Error(`
|
|
9136
|
-
const seeded = {};
|
|
9137
|
-
if (existing.generatedByDevice === void 0 && existing.lastGeneratedAt !== void 0) for (const id of existing.deviceIds) seeded[String(id)] = existing.lastGeneratedAt;
|
|
9138
|
-
const generatedByDevice = {
|
|
9139
|
-
...seeded,
|
|
9140
|
-
...existing.generatedByDevice,
|
|
9141
|
-
[String(deviceId)]: at
|
|
9142
|
-
};
|
|
10861
|
+
if (!existing) throw new Error(`summary rule not found: ${ruleId}`);
|
|
9143
10862
|
return this.write({
|
|
9144
10863
|
...existing,
|
|
9145
|
-
|
|
9146
|
-
lastGeneratedAt: Math.max(existing.lastGeneratedAt ?? 0, at)
|
|
10864
|
+
generatedAt: Math.max(existing.generatedAt ?? 0, windowEndMs)
|
|
9147
10865
|
});
|
|
9148
10866
|
}
|
|
9149
|
-
/** Idempotent delete — unknown ids are a no-op. */
|
|
10867
|
+
/** Idempotent delete — unknown ids are a no-op in RAM, still attempted on disk. */
|
|
9150
10868
|
async delete(ruleId) {
|
|
9151
10869
|
this.byId.delete(ruleId);
|
|
9152
10870
|
try {
|
|
9153
10871
|
await this.store.delete.mutate({
|
|
9154
|
-
collection:
|
|
10872
|
+
collection: NC_SUMMARY_RULES_COLLECTION,
|
|
9155
10873
|
key: ruleId
|
|
9156
10874
|
});
|
|
9157
10875
|
} catch (err) {
|
|
9158
|
-
this.logger.warn("
|
|
10876
|
+
this.logger.warn("summary rule delete failed", { meta: {
|
|
9159
10877
|
ruleId,
|
|
9160
10878
|
error: String(err)
|
|
9161
10879
|
} });
|
|
9162
10880
|
throw err instanceof Error ? err : new Error(String(err));
|
|
9163
10881
|
}
|
|
9164
10882
|
}
|
|
9165
|
-
/**
|
|
9166
|
-
* Re-validate a merged candidate, persist it, then cache it. Re-validating
|
|
9167
|
-
* means a patch can never persist a rule that would be skipped at the next
|
|
9168
|
-
* `load()`; provenance/ownership survive because they are spread from the
|
|
9169
|
-
* existing rule and never present on a patch.
|
|
9170
|
-
*/
|
|
9171
10883
|
async write(candidate) {
|
|
9172
|
-
const rule =
|
|
10884
|
+
const rule = NcSummaryRuleSchema.parse(candidate);
|
|
9173
10885
|
await this.persist(rule);
|
|
9174
10886
|
this.byId.set(rule.id, rule);
|
|
9175
10887
|
return rule;
|
|
9176
10888
|
}
|
|
9177
10889
|
async persist(rule) {
|
|
9178
10890
|
await this.store.set.mutate({
|
|
9179
|
-
collection:
|
|
10891
|
+
collection: NC_SUMMARY_RULES_COLLECTION,
|
|
9180
10892
|
key: rule.id,
|
|
9181
10893
|
value: {
|
|
9182
10894
|
name: rule.name,
|
|
@@ -9398,7 +11110,7 @@ function packagePhaseOf(ev) {
|
|
|
9398
11110
|
*/
|
|
9399
11111
|
function historyRecordKind(kind) {
|
|
9400
11112
|
if (kind === "audio-event" || kind === "audio-window") return "object-event";
|
|
9401
|
-
if (kind === "timelapse") return "system-event";
|
|
11113
|
+
if (kind === "timelapse" || kind === "ai-summary") return "system-event";
|
|
9402
11114
|
return kind;
|
|
9403
11115
|
}
|
|
9404
11116
|
/**
|
|
@@ -9434,7 +11146,7 @@ function outboxEntryToHistory(texts, entry) {
|
|
|
9434
11146
|
id: entry.id,
|
|
9435
11147
|
ruleId: entry.ruleId,
|
|
9436
11148
|
ruleName: entry.payload.ruleName,
|
|
9437
|
-
delivery: entry.payload.delivery === "timelapse" ? "system-event" : entry.payload.delivery,
|
|
11149
|
+
delivery: entry.payload.delivery === "timelapse" || entry.payload.delivery === "ai-summary" ? "system-event" : entry.payload.delivery,
|
|
9438
11150
|
targetId: entry.targetId,
|
|
9439
11151
|
deviceId: entry.deviceId,
|
|
9440
11152
|
recordKind: historyRecordKind(entry.recordKind),
|
|
@@ -9522,6 +11234,23 @@ var NotificationCenter = class NotificationCenter {
|
|
|
9522
11234
|
*/
|
|
9523
11235
|
timelapseRules;
|
|
9524
11236
|
timelapseScheduler = null;
|
|
11237
|
+
/**
|
|
11238
|
+
* The multi-camera digest rule set, and the producer that runs it. Held on
|
|
11239
|
+
* EVERY node for the same reason as {@link timelapseRules}: the bridge
|
|
11240
|
+
* actions serve CRUD from wherever they are asked. Only the hub, with
|
|
11241
|
+
* `deps.summary` wired, gets a producer.
|
|
11242
|
+
*/
|
|
11243
|
+
summaryRules;
|
|
11244
|
+
summaryProducer = null;
|
|
11245
|
+
/**
|
|
11246
|
+
* What the cameras are doing right now, for the digest windows.
|
|
11247
|
+
*
|
|
11248
|
+
* Constructed unconditionally and fed from the intake hooks even on a node
|
|
11249
|
+
* with no producer: the two `observe*` calls are a map write each, and a
|
|
11250
|
+
* collector that only started observing when a producer existed would give
|
|
11251
|
+
* the first window after a wiring change an empty barrier.
|
|
11252
|
+
*/
|
|
11253
|
+
summaryCollector = new NcSummaryCollector();
|
|
9525
11254
|
/** True when ≥1 enabled `device-event` rule declares an occupancy condition —
|
|
9526
11255
|
* the watcher is idle (zero per-frame cost) otherwise. */
|
|
9527
11256
|
occupancyEnabled = false;
|
|
@@ -9774,6 +11503,23 @@ var NotificationCenter = class NotificationCenter {
|
|
|
9774
11503
|
getDeviceName: (deviceId) => deps.dispatcher.getDeviceName(deviceId)
|
|
9775
11504
|
});
|
|
9776
11505
|
}
|
|
11506
|
+
this.summaryRules = new NcSummaryStore({
|
|
11507
|
+
store: deps.store,
|
|
11508
|
+
logger: this.logger.child("summary"),
|
|
11509
|
+
...deps.now !== void 0 ? { now: deps.now } : {}
|
|
11510
|
+
});
|
|
11511
|
+
const summaryPorts = deps.summary;
|
|
11512
|
+
if (summaryPorts !== void 0) this.summaryProducer = new NcSummaryProducer({
|
|
11513
|
+
...summaryPorts,
|
|
11514
|
+
logger: this.logger.child("summary"),
|
|
11515
|
+
now: this.now,
|
|
11516
|
+
texts: this.texts,
|
|
11517
|
+
store: this.summaryRules,
|
|
11518
|
+
collector: this.summaryCollector,
|
|
11519
|
+
enqueue: (rows) => this.outbox.enqueue(rows),
|
|
11520
|
+
getDeviceName: (deviceId) => deps.dispatcher.getDeviceName(deviceId),
|
|
11521
|
+
zoneOwner: this.zoneOwners.lookup()
|
|
11522
|
+
});
|
|
9777
11523
|
}
|
|
9778
11524
|
/**
|
|
9779
11525
|
* The durable timelapse rule set — exposed for the `nc.*Timelapse*` bridge
|
|
@@ -9782,6 +11528,22 @@ var NotificationCenter = class NotificationCenter {
|
|
|
9782
11528
|
get timelapseStore() {
|
|
9783
11529
|
return this.timelapseRules;
|
|
9784
11530
|
}
|
|
11531
|
+
/** The durable digest rule set — exposed for the `nc.*Summary*` bridge
|
|
11532
|
+
* actions, on the same reasoning as {@link timelapseStore}. */
|
|
11533
|
+
get summaryStore() {
|
|
11534
|
+
return this.summaryRules;
|
|
11535
|
+
}
|
|
11536
|
+
/**
|
|
11537
|
+
* Produce one digest window for one rule, right now.
|
|
11538
|
+
*
|
|
11539
|
+
* Throws when this node hosts no producer — an honest refusal beats a
|
|
11540
|
+
* `produced: 0` an operator reads as "there was nothing to make".
|
|
11541
|
+
*/
|
|
11542
|
+
async runSummaryNow(input) {
|
|
11543
|
+
const producer = this.summaryProducer;
|
|
11544
|
+
if (producer === null) throw new Error("this node does not host the summary producer — produce it on the hub");
|
|
11545
|
+
return producer.runNow(input);
|
|
11546
|
+
}
|
|
9785
11547
|
/**
|
|
9786
11548
|
* The text catalog's edit surface — exposed for the `nc.getTexts` /
|
|
9787
11549
|
* `nc.setLanguage` / `nc.setTextOverrides` bridge actions, on the same
|
|
@@ -9827,6 +11589,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
9827
11589
|
await NcSnoozeStore.declare(store);
|
|
9828
11590
|
await NcDeviceMuteStore.declare(store);
|
|
9829
11591
|
await TimelapseStore.declare(store);
|
|
11592
|
+
await NcSummaryStore.declare(store);
|
|
9830
11593
|
await NcTextCatalogStore.declare(store);
|
|
9831
11594
|
}
|
|
9832
11595
|
/**
|
|
@@ -9841,6 +11604,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
9841
11604
|
await this.snoozes.load();
|
|
9842
11605
|
await this.deviceMutes.load();
|
|
9843
11606
|
await this.timelapseRules.load();
|
|
11607
|
+
await this.summaryRules.load();
|
|
9844
11608
|
this.refreshOccupancyWatch();
|
|
9845
11609
|
if (!opts.evaluation) return;
|
|
9846
11610
|
await this.deviceStates.refresh();
|
|
@@ -9861,11 +11625,16 @@ var NotificationCenter = class NotificationCenter {
|
|
|
9861
11625
|
if (this.timelapseScheduler !== null) await this.timelapseScheduler.start().catch((err) => {
|
|
9862
11626
|
this.logger.warn("timelapse producer did not start", { meta: { error: String(err) } });
|
|
9863
11627
|
});
|
|
11628
|
+
if (this.summaryProducer !== null) await this.summaryProducer.start().catch((err) => {
|
|
11629
|
+
this.logger.warn("summary producer did not start", { meta: { error: String(err) } });
|
|
11630
|
+
});
|
|
9864
11631
|
this.logger.info("notification center started", { meta: {
|
|
9865
11632
|
rules: this.rules.list().length,
|
|
9866
11633
|
pendingOutbox: this.outbox.pendingCount(),
|
|
9867
11634
|
timelapseRules: this.timelapseRules.list().length,
|
|
9868
|
-
timelapseProducer: this.timelapseScheduler !== null
|
|
11635
|
+
timelapseProducer: this.timelapseScheduler !== null,
|
|
11636
|
+
summaryRules: this.summaryRules.list().length,
|
|
11637
|
+
summaryProducer: this.summaryProducer !== null
|
|
9869
11638
|
} });
|
|
9870
11639
|
}
|
|
9871
11640
|
async stop() {
|
|
@@ -9878,6 +11647,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
9878
11647
|
this.reloadTimer = null;
|
|
9879
11648
|
}
|
|
9880
11649
|
this.timelapseScheduler?.stop();
|
|
11650
|
+
this.summaryProducer?.stop();
|
|
9881
11651
|
this.evaluationActive = false;
|
|
9882
11652
|
}
|
|
9883
11653
|
/**
|
|
@@ -10031,6 +11801,11 @@ var NotificationCenter = class NotificationCenter {
|
|
|
10031
11801
|
*/
|
|
10032
11802
|
onObjectEventPersisted(event, _track) {
|
|
10033
11803
|
if (packagePhaseOf(event) !== null) return;
|
|
11804
|
+
this.summaryCollector.observeMotion({
|
|
11805
|
+
deviceId: event.deviceId,
|
|
11806
|
+
atMs: event.timestamp,
|
|
11807
|
+
...event.trackId !== void 0 ? { trackId: event.trackId } : {}
|
|
11808
|
+
});
|
|
10034
11809
|
this.consumeEvent(incomingFromObjectEvent(event));
|
|
10035
11810
|
}
|
|
10036
11811
|
/**
|
|
@@ -10040,6 +11815,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
10040
11815
|
* post-close promotions are picked up).
|
|
10041
11816
|
*/
|
|
10042
11817
|
onTrackClosed(track, _ownedMedia, info) {
|
|
11818
|
+
this.summaryCollector.observeTrackClosed(track.trackId);
|
|
10043
11819
|
this.consumeEvent(incomingFromTrack(track, info));
|
|
10044
11820
|
}
|
|
10045
11821
|
/**
|
|
@@ -11005,6 +12781,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
11005
12781
|
async reloadRules() {
|
|
11006
12782
|
await this.rules.load();
|
|
11007
12783
|
await this.timelapseRules.load();
|
|
12784
|
+
await this.summaryRules.load();
|
|
11008
12785
|
await this.reloadTexts();
|
|
11009
12786
|
this.refreshOccupancyWatch();
|
|
11010
12787
|
if (!this.evaluationActive) return;
|
|
@@ -11366,6 +13143,8 @@ var NotificationCenter = class NotificationCenter {
|
|
|
11366
13143
|
var NcViewerRuleSchema = NcRuleSchema.extend({ readOnly: boolean() });
|
|
11367
13144
|
/** Same verdict, for a timelapse rule. */
|
|
11368
13145
|
var NcViewerTimelapseRuleSchema = TimelapseRuleSchema.extend({ readOnly: boolean() });
|
|
13146
|
+
/** Same verdict, for a multi-camera digest rule. */
|
|
13147
|
+
var NcViewerSummaryRuleSchema = NcSummaryRuleSchema.extend({ readOnly: boolean() });
|
|
11369
13148
|
/**
|
|
11370
13149
|
* The action catalog — the tRPC contract Group B (the viewer client) consumes
|
|
11371
13150
|
* against `addonId: 'pipeline-analytics'`. Every entry that depends on the
|
|
@@ -11482,6 +13261,66 @@ var ncActions = defineCustomActions({
|
|
|
11482
13261
|
auth: "admin",
|
|
11483
13262
|
caller: "required"
|
|
11484
13263
|
}),
|
|
13264
|
+
"nc.listSummaryRules": customAction(object({}), object({ rules: array(NcViewerSummaryRuleSchema) }), { caller: "required" }),
|
|
13265
|
+
/**
|
|
13266
|
+
* ONE action for create AND update, like `nc.upsertTimelapseRule`: a digest
|
|
13267
|
+
* rule is authored as a whole (cameras + window + filters + targets) and the
|
|
13268
|
+
* guards a create must pass are the guards a patch that widens it must pass
|
|
13269
|
+
* too. Two entry points would be two places to forget one.
|
|
13270
|
+
*
|
|
13271
|
+
* `ruleId` absent ⇒ create from `rule`; present ⇒ patch.
|
|
13272
|
+
*
|
|
13273
|
+
* `auth: 'admin'` in v1 (operator decision): a digest spans cameras and, once
|
|
13274
|
+
* P2 lands, consumes the local vision model. A personal rule that fans vision
|
|
13275
|
+
* calls across other people's cameras is a decision to take after the real
|
|
13276
|
+
* cost has been watched for a few weeks, not before.
|
|
13277
|
+
*/
|
|
13278
|
+
"nc.upsertSummaryRule": customAction(object({
|
|
13279
|
+
ruleId: string().optional(),
|
|
13280
|
+
rule: NcSummaryRuleInputSchema.optional(),
|
|
13281
|
+
patch: NcSummaryRulePatchSchema.optional()
|
|
13282
|
+
}), object({ rule: NcSummaryRuleSchema }), {
|
|
13283
|
+
kind: "mutation",
|
|
13284
|
+
auth: "admin",
|
|
13285
|
+
caller: "required"
|
|
13286
|
+
}),
|
|
13287
|
+
"nc.deleteSummaryRule": customAction(object({ ruleId: string() }), object({ success: literal(true) }), {
|
|
13288
|
+
kind: "mutation",
|
|
13289
|
+
auth: "admin",
|
|
13290
|
+
caller: "required"
|
|
13291
|
+
}),
|
|
13292
|
+
/**
|
|
13293
|
+
* Produce one digest window RIGHT NOW — the operator's Test button.
|
|
13294
|
+
*
|
|
13295
|
+
* `auth: 'admin'` for the same reason as `nc.injectTestEvent` and
|
|
13296
|
+
* `nc.testTimelapseRule`: it DELIVERS, to every target the rule addresses, on
|
|
13297
|
+
* other people's phones. It does not consume the schedule guard — pressing
|
|
13298
|
+
* Test at noon must not cancel tonight's run.
|
|
13299
|
+
*
|
|
13300
|
+
* `windowStartMs`/`windowEndMs` are optional and are the whole point of the
|
|
13301
|
+
* action for a live check: they let an operator aim it at a range he KNOWS
|
|
13302
|
+
* held traffic, instead of waiting for one to close.
|
|
13303
|
+
*/
|
|
13304
|
+
"nc.testSummaryRule": customAction(object({
|
|
13305
|
+
ruleId: string(),
|
|
13306
|
+
windowStartMs: number().optional(),
|
|
13307
|
+
windowEndMs: number().optional()
|
|
13308
|
+
}), object({
|
|
13309
|
+
produced: number().int(),
|
|
13310
|
+
tiles: number().int().optional(),
|
|
13311
|
+
cameras: number().int().optional(),
|
|
13312
|
+
matched: number().int().optional(),
|
|
13313
|
+
enqueued: number().int().optional(),
|
|
13314
|
+
mosaicBytes: number().int().optional(),
|
|
13315
|
+
mosaicUrl: string().optional(),
|
|
13316
|
+
windowStartMs: number().optional(),
|
|
13317
|
+
windowEndMs: number().optional(),
|
|
13318
|
+
error: string().optional()
|
|
13319
|
+
}), {
|
|
13320
|
+
kind: "mutation",
|
|
13321
|
+
auth: "admin",
|
|
13322
|
+
caller: "required"
|
|
13323
|
+
}),
|
|
11485
13324
|
/**
|
|
11486
13325
|
* The whole catalog for ONE language, each key carrying which layer answered
|
|
11487
13326
|
* it. `language` defaults to the hub's own; passing another one inspects (and
|
|
@@ -11549,6 +13388,12 @@ function makeNcActionHandlers(deps) {
|
|
|
11549
13388
|
if (tl === void 0) throw new Error("this node does not host the timelapse producer — use the hub");
|
|
11550
13389
|
return tl;
|
|
11551
13390
|
};
|
|
13391
|
+
/** The digest producer half, or an honest refusal. */
|
|
13392
|
+
const requireSummary = () => {
|
|
13393
|
+
const sum = deps.summary;
|
|
13394
|
+
if (sum === void 0) throw new Error("this node does not host the summary producer — use the hub");
|
|
13395
|
+
return sum;
|
|
13396
|
+
};
|
|
11552
13397
|
/** The text catalog editor, or an honest refusal. */
|
|
11553
13398
|
const requireTexts = () => {
|
|
11554
13399
|
const editor = deps.texts;
|
|
@@ -11768,6 +13613,93 @@ function makeNcActionHandlers(deps) {
|
|
|
11768
13613
|
await deps.ruleStore.setRuleTargetEnabled(input.ruleId, input.targetId, input.enabled);
|
|
11769
13614
|
return { success: true };
|
|
11770
13615
|
},
|
|
13616
|
+
"nc.listSummaryRules": async (_input, caller) => {
|
|
13617
|
+
const c = requireCaller(caller);
|
|
13618
|
+
const sum = requireSummary();
|
|
13619
|
+
return { rules: (c.isAdmin ? sum.store.list() : sum.store.listForOwner(c.userId)).map((r) => ({
|
|
13620
|
+
...r,
|
|
13621
|
+
readOnly: !c.isAdmin
|
|
13622
|
+
})) };
|
|
13623
|
+
},
|
|
13624
|
+
"nc.upsertSummaryRule": async (input, caller) => {
|
|
13625
|
+
const c = requireCaller(caller);
|
|
13626
|
+
if (!c.isAdmin) throw new Error("forbidden: admin required to author a summary rule");
|
|
13627
|
+
const sum = requireSummary();
|
|
13628
|
+
if (input.ruleId === void 0) {
|
|
13629
|
+
if (input.rule === void 0) throw new Error("upsertSummaryRule: rule or ruleId required");
|
|
13630
|
+
const parsed = NcSummaryRuleInputSchema.parse(input.rule);
|
|
13631
|
+
assertSummaryBudget(parsed);
|
|
13632
|
+
await assertTargetsOwned(parsed.targets.map((t) => t.targetId), c);
|
|
13633
|
+
const rule = await sum.store.create(parsed, c.userId);
|
|
13634
|
+
deps.logger.info("summary rule created", { meta: {
|
|
13635
|
+
ruleId: rule.id,
|
|
13636
|
+
owner: c.userId,
|
|
13637
|
+
devices: rule.deviceIds.join(",")
|
|
13638
|
+
} });
|
|
13639
|
+
return { rule };
|
|
13640
|
+
}
|
|
13641
|
+
const existing = sum.store.get(input.ruleId);
|
|
13642
|
+
if (existing === null) throw new Error(`forbidden: summary rule not found: ${input.ruleId}`);
|
|
13643
|
+
if (input.patch === void 0) throw new Error("upsertSummaryRule: patch required");
|
|
13644
|
+
const patch = NcSummaryRulePatchSchema.parse(input.patch);
|
|
13645
|
+
assertSummaryBudget({
|
|
13646
|
+
maxTiles: patch.maxTiles ?? existing.maxTiles,
|
|
13647
|
+
maxAiImages: patch.maxAiImages ?? existing.maxAiImages
|
|
13648
|
+
});
|
|
13649
|
+
if (patch.targets !== void 0) await assertTargetsOwned(patch.targets.map((t) => t.targetId), c);
|
|
13650
|
+
const rule = await sum.store.update(input.ruleId, patch);
|
|
13651
|
+
deps.logger.info("summary rule updated", { meta: {
|
|
13652
|
+
ruleId: rule.id,
|
|
13653
|
+
owner: c.userId
|
|
13654
|
+
} });
|
|
13655
|
+
return { rule };
|
|
13656
|
+
},
|
|
13657
|
+
"nc.deleteSummaryRule": async (input, caller) => {
|
|
13658
|
+
const c = requireCaller(caller);
|
|
13659
|
+
if (!c.isAdmin) throw new Error("forbidden: admin required to delete a summary rule");
|
|
13660
|
+
await requireSummary().store.delete(input.ruleId);
|
|
13661
|
+
deps.logger.info("summary rule deleted", { meta: {
|
|
13662
|
+
ruleId: input.ruleId,
|
|
13663
|
+
owner: c.userId
|
|
13664
|
+
} });
|
|
13665
|
+
return { success: true };
|
|
13666
|
+
},
|
|
13667
|
+
"nc.testSummaryRule": async (input, caller) => {
|
|
13668
|
+
const c = requireCaller(caller);
|
|
13669
|
+
if (!c.isAdmin) throw new Error("forbidden: admin required to produce a test summary");
|
|
13670
|
+
const sum = requireSummary();
|
|
13671
|
+
const startMs = input.windowStartMs;
|
|
13672
|
+
const endMs = input.windowEndMs;
|
|
13673
|
+
if (startMs === void 0 !== (endMs === void 0)) throw new Error("testSummaryRule: pass BOTH windowStartMs and windowEndMs, or neither");
|
|
13674
|
+
if (startMs !== void 0 && endMs !== void 0 && endMs <= startMs) throw new Error("testSummaryRule: windowEndMs must be after windowStartMs");
|
|
13675
|
+
deps.logger.info("summary test requested", { meta: {
|
|
13676
|
+
by: c.userId,
|
|
13677
|
+
ruleId: input.ruleId,
|
|
13678
|
+
windowStartMs: startMs,
|
|
13679
|
+
windowEndMs: endMs
|
|
13680
|
+
} });
|
|
13681
|
+
const out = await sum.runNow({
|
|
13682
|
+
ruleId: input.ruleId,
|
|
13683
|
+
...startMs !== void 0 && endMs !== void 0 ? { window: {
|
|
13684
|
+
startMs,
|
|
13685
|
+
endMs
|
|
13686
|
+
} } : {}
|
|
13687
|
+
});
|
|
13688
|
+
return {
|
|
13689
|
+
produced: out.produced,
|
|
13690
|
+
...out.tiles !== void 0 ? { tiles: out.tiles } : {},
|
|
13691
|
+
...out.cameras !== void 0 ? { cameras: out.cameras } : {},
|
|
13692
|
+
...out.matched !== void 0 ? { matched: out.matched } : {},
|
|
13693
|
+
...out.enqueued !== void 0 ? { enqueued: out.enqueued } : {},
|
|
13694
|
+
...out.mosaicBytes !== void 0 ? { mosaicBytes: out.mosaicBytes } : {},
|
|
13695
|
+
...out.mosaicUrl !== void 0 ? { mosaicUrl: out.mosaicUrl } : {},
|
|
13696
|
+
...out.window !== null ? {
|
|
13697
|
+
windowStartMs: out.window.startMs,
|
|
13698
|
+
windowEndMs: out.window.endMs
|
|
13699
|
+
} : {},
|
|
13700
|
+
...out.error !== void 0 ? { error: out.error } : {}
|
|
13701
|
+
};
|
|
13702
|
+
},
|
|
11771
13703
|
"nc.getTexts": async (input) => {
|
|
11772
13704
|
const texts = requireTexts();
|
|
11773
13705
|
return input.language === void 0 ? texts.view() : texts.view(input.language);
|
|
@@ -12166,7 +14098,7 @@ async function drawPreviewTitle(jpeg, text, logger, tags) {
|
|
|
12166
14098
|
}
|
|
12167
14099
|
/** Widest stdout one preview may produce. A 960 px JPEG is ~200 KB; this is
|
|
12168
14100
|
* headroom, not a budget. */
|
|
12169
|
-
var RUN_MAX_BUFFER = 16 * 1024 * 1024;
|
|
14101
|
+
var RUN_MAX_BUFFER$1 = 16 * 1024 * 1024;
|
|
12170
14102
|
/**
|
|
12171
14103
|
* The production {@link RunCommand}: `execFile`, bounded, stdout as bytes.
|
|
12172
14104
|
*
|
|
@@ -12177,7 +14109,7 @@ var RUN_MAX_BUFFER = 16 * 1024 * 1024;
|
|
|
12177
14109
|
var execFileCapturingStdout = async (cmd, args) => new Promise((resolve) => {
|
|
12178
14110
|
execFile(cmd, [...args], {
|
|
12179
14111
|
encoding: "buffer",
|
|
12180
|
-
maxBuffer: RUN_MAX_BUFFER,
|
|
14112
|
+
maxBuffer: RUN_MAX_BUFFER$1,
|
|
12181
14113
|
timeout: PREVIEW_FRAME_TIMEOUT_MS
|
|
12182
14114
|
}, (err, stdout) => resolve(err !== null ? null : stdout));
|
|
12183
14115
|
});
|
|
@@ -20080,6 +22012,7 @@ async function rebuildObjectEmbeddings(deps, options = {}) {
|
|
|
20080
22012
|
const pacingMs = options.pacingMs ?? 50;
|
|
20081
22013
|
let rebuilt = 0;
|
|
20082
22014
|
let missingKeyFrame = 0;
|
|
22015
|
+
let recoveredKeyFrame = 0;
|
|
20083
22016
|
let missingEvent = 0;
|
|
20084
22017
|
let missingBbox = 0;
|
|
20085
22018
|
let notRunnable = 0;
|
|
@@ -20118,15 +22051,25 @@ async function rebuildObjectEmbeddings(deps, options = {}) {
|
|
|
20118
22051
|
missingEvent += 1;
|
|
20119
22052
|
return;
|
|
20120
22053
|
}
|
|
20121
|
-
|
|
22054
|
+
let keyFrame = source.keyFrame;
|
|
22055
|
+
let recovered = false;
|
|
22056
|
+
if (keyFrame === null && deps.recoverKeyFrame) {
|
|
22057
|
+
keyFrame = await deps.recoverKeyFrame(source);
|
|
22058
|
+
recovered = keyFrame !== null;
|
|
22059
|
+
}
|
|
22060
|
+
if (keyFrame === null) {
|
|
20122
22061
|
missingKeyFrame += 1;
|
|
20123
22062
|
return;
|
|
20124
22063
|
}
|
|
22064
|
+
if (recovered) recoveredKeyFrame += 1;
|
|
20125
22065
|
if (source.bbox === null || source.bbox.w <= 0 || source.bbox.h <= 0) {
|
|
20126
22066
|
missingBbox += 1;
|
|
20127
22067
|
return;
|
|
20128
22068
|
}
|
|
20129
|
-
const outcome = await deps.embed(
|
|
22069
|
+
const outcome = await deps.embed(recovered ? {
|
|
22070
|
+
...source,
|
|
22071
|
+
keyFrame
|
|
22072
|
+
} : source);
|
|
20130
22073
|
if (outcome.kind === "no-capable-node") {
|
|
20131
22074
|
noCapableNode += 1;
|
|
20132
22075
|
abandoned = outcome.reason;
|
|
@@ -20187,6 +22130,7 @@ async function rebuildObjectEmbeddings(deps, options = {}) {
|
|
|
20187
22130
|
scanned,
|
|
20188
22131
|
rebuilt,
|
|
20189
22132
|
missingKeyFrame,
|
|
22133
|
+
recoveredKeyFrame,
|
|
20190
22134
|
missingEvent,
|
|
20191
22135
|
missingBbox,
|
|
20192
22136
|
notRunnable,
|
|
@@ -20208,6 +22152,7 @@ async function rebuildObjectEmbeddings(deps, options = {}) {
|
|
|
20208
22152
|
scanned,
|
|
20209
22153
|
rebuilt,
|
|
20210
22154
|
missingKeyFrame,
|
|
22155
|
+
recoveredKeyFrame,
|
|
20211
22156
|
missingEvent,
|
|
20212
22157
|
missingBbox,
|
|
20213
22158
|
notRunnable,
|
|
@@ -20220,6 +22165,7 @@ async function rebuildObjectEmbeddings(deps, options = {}) {
|
|
|
20220
22165
|
scanned,
|
|
20221
22166
|
rebuilt,
|
|
20222
22167
|
missingKeyFrame,
|
|
22168
|
+
recoveredKeyFrame,
|
|
20223
22169
|
missingEvent,
|
|
20224
22170
|
missingBbox,
|
|
20225
22171
|
notRunnable,
|
|
@@ -26408,6 +28354,248 @@ var RebuildStepDispatcher = class {
|
|
|
26408
28354
|
});
|
|
26409
28355
|
}
|
|
26410
28356
|
};
|
|
28357
|
+
/**
|
|
28358
|
+
* Where inside its GOP the wanted frame sits, in seconds.
|
|
28359
|
+
*
|
|
28360
|
+
* Clamped into the fragment: a request whose instant lands outside the GOP the
|
|
28361
|
+
* recorder returned takes the nearest edge rather than seeking past the end and
|
|
28362
|
+
* getting nothing. The clamp is honest because the caller is told
|
|
28363
|
+
* {@link RecoveredStill.gopStartMs} and can see the offset it actually got.
|
|
28364
|
+
*/
|
|
28365
|
+
function stillSeekSec(epochMs, gop) {
|
|
28366
|
+
const offsetMs = epochMs - gop.gopStartMs;
|
|
28367
|
+
const maxMs = Math.max(0, gop.gopDurMs - 1);
|
|
28368
|
+
return Math.min(Math.max(0, offsetMs), maxMs) / 1e3;
|
|
28369
|
+
}
|
|
28370
|
+
/**
|
|
28371
|
+
* ffmpeg argv for "one JPEG at `atSec` from this fMP4 fragment, on stdout".
|
|
28372
|
+
*
|
|
28373
|
+
* `-ss` goes AFTER `-i` on purpose — the inverse of the timelapse preview's
|
|
28374
|
+
* choice, and for the inverse reason. The input is a PIPE (the fragment never
|
|
28375
|
+
* touches disk: it is ~200 KB and staging it would add an fs round trip to a
|
|
28376
|
+
* path that already has a deadline), and an input seek on a non-seekable pipe
|
|
28377
|
+
* is unreliable. The fragment is ONE GOP, so an output seek decodes at most a
|
|
28378
|
+
* keyframe interval — the thing that makes an output seek expensive on a whole
|
|
28379
|
+
* file does not apply here.
|
|
28380
|
+
*/
|
|
28381
|
+
function buildRecordedStillArgs(atSec, maxWidth) {
|
|
28382
|
+
return [
|
|
28383
|
+
"-hide_banner",
|
|
28384
|
+
"-loglevel",
|
|
28385
|
+
"error",
|
|
28386
|
+
"-nostdin",
|
|
28387
|
+
"-i",
|
|
28388
|
+
"pipe:0",
|
|
28389
|
+
"-ss",
|
|
28390
|
+
String(Math.max(0, atSec)),
|
|
28391
|
+
"-frames:v",
|
|
28392
|
+
"1",
|
|
28393
|
+
"-vf",
|
|
28394
|
+
`scale='min(${maxWidth},iw)':-2`,
|
|
28395
|
+
"-f",
|
|
28396
|
+
"image2",
|
|
28397
|
+
"-vcodec",
|
|
28398
|
+
"mjpeg",
|
|
28399
|
+
"pipe:1"
|
|
28400
|
+
];
|
|
28401
|
+
}
|
|
28402
|
+
/**
|
|
28403
|
+
* Recover the native frame nearest `epochMs` for `deviceId`.
|
|
28404
|
+
*
|
|
28405
|
+
* Never throws: every failure is an outcome, because the caller's next move
|
|
28406
|
+
* differs by outcome and an exception would collapse four of them into one.
|
|
28407
|
+
* The cheap questions come first — profile, then coverage, then bytes, then the
|
|
28408
|
+
* decode — so a camera that was not recording costs a settings read and not a
|
|
28409
|
+
* disk read (the same ordering rule `runStatelessStep` follows for stored
|
|
28410
|
+
* media, [D56](../../../../docs/decisions/adr-0056.md)).
|
|
28411
|
+
*/
|
|
28412
|
+
var RecordedStillService = class {
|
|
28413
|
+
ports;
|
|
28414
|
+
maxConcurrent;
|
|
28415
|
+
inFlight = 0;
|
|
28416
|
+
constructor(ports, maxConcurrent = 2) {
|
|
28417
|
+
this.ports = ports;
|
|
28418
|
+
this.maxConcurrent = maxConcurrent;
|
|
28419
|
+
}
|
|
28420
|
+
/** Decodes currently running (tests / metrics). */
|
|
28421
|
+
get running() {
|
|
28422
|
+
return this.inFlight;
|
|
28423
|
+
}
|
|
28424
|
+
async recover(deviceId, epochMs, maxWidth) {
|
|
28425
|
+
const tags = { deviceId };
|
|
28426
|
+
if (this.inFlight >= this.maxConcurrent) {
|
|
28427
|
+
this.ports.logger.debug("recorded still refused: node already at its decode bound", {
|
|
28428
|
+
tags,
|
|
28429
|
+
meta: {
|
|
28430
|
+
epochMs,
|
|
28431
|
+
inFlight: this.inFlight
|
|
28432
|
+
}
|
|
28433
|
+
});
|
|
28434
|
+
return { outcome: "busy" };
|
|
28435
|
+
}
|
|
28436
|
+
this.inFlight += 1;
|
|
28437
|
+
try {
|
|
28438
|
+
const profile = await this.ports.resolveProfile(deviceId);
|
|
28439
|
+
if (profile === null) {
|
|
28440
|
+
this.ports.logger.debug("recorded still unavailable: camera is not recording", {
|
|
28441
|
+
tags,
|
|
28442
|
+
meta: { epochMs }
|
|
28443
|
+
});
|
|
28444
|
+
return { outcome: "no-recording" };
|
|
28445
|
+
}
|
|
28446
|
+
const located = await this.ports.locate(deviceId, profile, epochMs);
|
|
28447
|
+
if (located === null) {
|
|
28448
|
+
this.ports.logger.debug("recorded still unavailable: no footage covers that instant", {
|
|
28449
|
+
tags,
|
|
28450
|
+
meta: {
|
|
28451
|
+
epochMs,
|
|
28452
|
+
profile
|
|
28453
|
+
}
|
|
28454
|
+
});
|
|
28455
|
+
return { outcome: "no-footage" };
|
|
28456
|
+
}
|
|
28457
|
+
const gop = await this.ports.readGop(deviceId, profile, located.startMs, epochMs);
|
|
28458
|
+
if (gop === null || gop.data.byteLength === 0) {
|
|
28459
|
+
this.ports.logger.warn("recorded still: the GOP read produced no bytes", {
|
|
28460
|
+
tags,
|
|
28461
|
+
meta: {
|
|
28462
|
+
epochMs,
|
|
28463
|
+
profile,
|
|
28464
|
+
startMs: located.startMs
|
|
28465
|
+
}
|
|
28466
|
+
});
|
|
28467
|
+
return { outcome: "no-footage" };
|
|
28468
|
+
}
|
|
28469
|
+
const jpeg = await this.ports.decodeStill(gop.data, stillSeekSec(epochMs, gop), maxWidth);
|
|
28470
|
+
if (jpeg === null || jpeg.byteLength === 0) {
|
|
28471
|
+
this.ports.logger.warn("recorded still: GOP located but the decode produced nothing", {
|
|
28472
|
+
tags,
|
|
28473
|
+
meta: {
|
|
28474
|
+
epochMs,
|
|
28475
|
+
profile,
|
|
28476
|
+
gopStartMs: gop.gopStartMs,
|
|
28477
|
+
bytes: gop.data.byteLength
|
|
28478
|
+
}
|
|
28479
|
+
});
|
|
28480
|
+
return { outcome: "decode-failed" };
|
|
28481
|
+
}
|
|
28482
|
+
return {
|
|
28483
|
+
outcome: "ok",
|
|
28484
|
+
jpeg,
|
|
28485
|
+
gopStartMs: gop.gopStartMs
|
|
28486
|
+
};
|
|
28487
|
+
} catch (err) {
|
|
28488
|
+
this.ports.logger.warn("recorded still failed", {
|
|
28489
|
+
tags,
|
|
28490
|
+
meta: {
|
|
28491
|
+
epochMs,
|
|
28492
|
+
error: err instanceof Error ? err.message : String(err)
|
|
28493
|
+
}
|
|
28494
|
+
});
|
|
28495
|
+
return { outcome: "decode-failed" };
|
|
28496
|
+
} finally {
|
|
28497
|
+
this.inFlight -= 1;
|
|
28498
|
+
}
|
|
28499
|
+
}
|
|
28500
|
+
};
|
|
28501
|
+
//#endregion
|
|
28502
|
+
//#region src/pipeline-analytics/recorded-still-ports.ts
|
|
28503
|
+
/**
|
|
28504
|
+
* The production wiring for {@link RecordedStillService} — the recorder's READ
|
|
28505
|
+
* surface plus one bounded ffmpeg.
|
|
28506
|
+
*
|
|
28507
|
+
* Kept apart from `recorded-still.ts` so the policy (what counts as a refusal,
|
|
28508
|
+
* what the concurrency bound is, where the seek lands) stays testable with no
|
|
28509
|
+
* cap client, no recorder and no ffmpeg on the machine running the suite. This
|
|
28510
|
+
* file is the only place that knows the cap method names.
|
|
28511
|
+
*
|
|
28512
|
+
* **Read-side only.** `getDeviceConfig`, `locateSegment` and `readGopBytes` —
|
|
28513
|
+
* nothing here writes, evicts, prunes or relocates. That is deliberate and it
|
|
28514
|
+
* is a boundary, not an accident: the recorder's write path is the one thing a
|
|
28515
|
+
* background maintenance pass must never be able to perturb.
|
|
28516
|
+
*/
|
|
28517
|
+
/**
|
|
28518
|
+
* Hard bound on one still decode. Generous because the work is background and a
|
|
28519
|
+
* stuck ffmpeg costs a slot rather than a delivery; tight enough that two stuck
|
|
28520
|
+
* ones cannot wedge the service for a whole pass.
|
|
28521
|
+
*/
|
|
28522
|
+
var RECORDED_STILL_TIMEOUT_MS = 15e3;
|
|
28523
|
+
/** A native JPEG is worth having at full size; this is headroom, not a budget. */
|
|
28524
|
+
var RUN_MAX_BUFFER = 32 * 1024 * 1024;
|
|
28525
|
+
/**
|
|
28526
|
+
* The profile a recovered still is read from.
|
|
28527
|
+
*
|
|
28528
|
+
* `high` and not the low rendition, because the whole reason to re-decode is
|
|
28529
|
+
* that the model wants NATIVE pixels — a still cut from the scrub-grade copy
|
|
28530
|
+
* would be a worse version of the ≤640 fallback the design already has.
|
|
28531
|
+
*/
|
|
28532
|
+
var RECORDED_STILL_PROFILE = "high";
|
|
28533
|
+
/**
|
|
28534
|
+
* Width cap for a recovered key frame.
|
|
28535
|
+
*
|
|
28536
|
+
* The same 1920 the live native `keyFrame` capture uses, so a vector rebuilt
|
|
28537
|
+
* from recovered pixels and one rebuilt from a captured key frame are cut from
|
|
28538
|
+
* rasters of the same scale. Different scales would not be wrong — the runner
|
|
28539
|
+
* derives the rectangle from normalised coordinates either way — but they would
|
|
28540
|
+
* make two populations in one index differ by resampling, and this index has
|
|
28541
|
+
* already had two feature spaces in it once ([D52](../../../../docs/decisions/adr-0052.md)).
|
|
28542
|
+
*/
|
|
28543
|
+
var RECOVERED_KEY_FRAME_MAX_WIDTH = 1920;
|
|
28544
|
+
/**
|
|
28545
|
+
* Build the production ports.
|
|
28546
|
+
*
|
|
28547
|
+
* Every port swallows its own failure into the `null` the policy expects: a
|
|
28548
|
+
* maintenance pass that walks thousands of tracks must not end because one
|
|
28549
|
+
* device's config read timed out, and the outcome the caller reports is already
|
|
28550
|
+
* the honest one.
|
|
28551
|
+
*/
|
|
28552
|
+
function buildRecordedStillPorts(input) {
|
|
28553
|
+
const ffmpeg = input.ffmpegPath ?? "ffmpeg";
|
|
28554
|
+
return {
|
|
28555
|
+
logger: input.logger,
|
|
28556
|
+
resolveProfile: async (deviceId) => {
|
|
28557
|
+
try {
|
|
28558
|
+
return (await input.api.getDeviceConfig.query({ deviceId })).enabled === false ? null : RECORDED_STILL_PROFILE;
|
|
28559
|
+
} catch {
|
|
28560
|
+
return null;
|
|
28561
|
+
}
|
|
28562
|
+
},
|
|
28563
|
+
locate: async (deviceId, profile, epochMs) => {
|
|
28564
|
+
try {
|
|
28565
|
+
const located = await input.api.locateSegment.query({
|
|
28566
|
+
deviceId,
|
|
28567
|
+
profile,
|
|
28568
|
+
epochMs
|
|
28569
|
+
});
|
|
28570
|
+
if (located.kind !== "segment" || typeof located.startMs !== "number") return null;
|
|
28571
|
+
return { startMs: located.startMs };
|
|
28572
|
+
} catch {
|
|
28573
|
+
return null;
|
|
28574
|
+
}
|
|
28575
|
+
},
|
|
28576
|
+
readGop: async (deviceId, profile, startMs, epochMs) => {
|
|
28577
|
+
try {
|
|
28578
|
+
return await input.api.readGopBytes.query({
|
|
28579
|
+
deviceId,
|
|
28580
|
+
profile,
|
|
28581
|
+
startMs,
|
|
28582
|
+
epochMs
|
|
28583
|
+
});
|
|
28584
|
+
} catch {
|
|
28585
|
+
return null;
|
|
28586
|
+
}
|
|
28587
|
+
},
|
|
28588
|
+
decodeStill: (gop, atSec, maxWidth) => new Promise((resolve) => {
|
|
28589
|
+
const child = execFile(ffmpeg, [...buildRecordedStillArgs(atSec, maxWidth)], {
|
|
28590
|
+
encoding: "buffer",
|
|
28591
|
+
maxBuffer: RUN_MAX_BUFFER,
|
|
28592
|
+
timeout: RECORDED_STILL_TIMEOUT_MS
|
|
28593
|
+
}, (err, stdout) => resolve(err !== null ? null : stdout));
|
|
28594
|
+
child.stdin?.on("error", () => void 0);
|
|
28595
|
+
child.stdin?.end(Buffer.from(gop));
|
|
28596
|
+
})
|
|
28597
|
+
};
|
|
28598
|
+
}
|
|
26411
28599
|
//#endregion
|
|
26412
28600
|
//#region src/pipeline-analytics/store/tiered-label-columns.ts
|
|
26413
28601
|
/**
|
|
@@ -37387,6 +39575,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
37387
39575
|
getRecordingConfig: (deviceId) => api.recording.getDeviceConfig.query({ deviceId }).catch(() => null),
|
|
37388
39576
|
runNow: (input) => center.runTimelapseNow(input)
|
|
37389
39577
|
},
|
|
39578
|
+
summary: {
|
|
39579
|
+
store: center.summaryStore,
|
|
39580
|
+
runNow: (input) => center.runSummaryNow(input)
|
|
39581
|
+
},
|
|
37390
39582
|
texts: center.textCatalogEditor
|
|
37391
39583
|
});
|
|
37392
39584
|
}
|
|
@@ -38078,13 +40270,75 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
38078
40270
|
projection: "slim"
|
|
38079
40271
|
})).tracks;
|
|
38080
40272
|
},
|
|
38081
|
-
...this.isPostProcessingNode ? { timelapse: this.buildTimelapsePorts(api, stores) } : {}
|
|
40273
|
+
...this.isPostProcessingNode ? { timelapse: this.buildTimelapsePorts(api, stores) } : {},
|
|
40274
|
+
...this.isPostProcessingNode ? { summary: this.buildSummaryPorts(stores) } : {}
|
|
38082
40275
|
});
|
|
38083
40276
|
this.wireAlarmPanel(this.notificationCenter);
|
|
38084
40277
|
await this.serveNcActionPlane(this.notificationCenter);
|
|
38085
40278
|
await this.notificationCenter.start({ evaluation: this.isPostProcessingNode });
|
|
38086
40279
|
}
|
|
38087
40280
|
/**
|
|
40281
|
+
* The multi-camera digest producer's ports.
|
|
40282
|
+
*
|
|
40283
|
+
* Every one reads something this process already owns: the LOCAL track store
|
|
40284
|
+
* for the window (no self-cap call, `projection: 'slim'` because the selector
|
|
40285
|
+
* reads timestamps, class, zones and importance and nothing else), the LOCAL
|
|
40286
|
+
* media store for the index rows and the one blob per tile, and the NC
|
|
40287
|
+
* artifact plane for the public link.
|
|
40288
|
+
*
|
|
40289
|
+
* The mosaic is filed through the SAME `mediaStore` every event thumbnail
|
|
40290
|
+
* goes through — the operator's directive, verbatim: same data structure,
|
|
40291
|
+
* same storage location, no second retained store. It is written under
|
|
40292
|
+
* `ownerKind: 'event'` with the window's own owner id, so it lands beside the
|
|
40293
|
+
* cameras' event media (`<deviceId>/events/event/<ownerId>/…`) and is swept
|
|
40294
|
+
* by the same machinery. It is deliberately NOT owned by a track: a track's
|
|
40295
|
+
* media list is a LIVE viewer surface, and a cross-camera mosaic appearing in
|
|
40296
|
+
* one track's tiles would be a picture that lies about what it is of.
|
|
40297
|
+
*/
|
|
40298
|
+
buildSummaryPorts(stores) {
|
|
40299
|
+
return {
|
|
40300
|
+
listTracks: async ({ deviceId, sinceMs, untilMs, limit }) => {
|
|
40301
|
+
return (await stores.trackStore.queryHistorical({
|
|
40302
|
+
deviceId,
|
|
40303
|
+
since: sinceMs,
|
|
40304
|
+
until: untilMs,
|
|
40305
|
+
projection: "slim",
|
|
40306
|
+
limit
|
|
40307
|
+
})).map((t) => ({
|
|
40308
|
+
trackId: t.trackId,
|
|
40309
|
+
deviceId: t.deviceId,
|
|
40310
|
+
className: t.className,
|
|
40311
|
+
firstSeen: t.firstSeen,
|
|
40312
|
+
lastSeen: t.lastSeen,
|
|
40313
|
+
...t.importance !== void 0 ? { importance: t.importance } : {},
|
|
40314
|
+
zones: [...t.zonesVisited],
|
|
40315
|
+
...t.classes !== void 0 ? { classes: [...t.classes] } : {}
|
|
40316
|
+
}));
|
|
40317
|
+
},
|
|
40318
|
+
listMediaInfo: async (trackId) => {
|
|
40319
|
+
return (await stores.mediaStore.listInfoByOwner("track", trackId)).map((r) => ({
|
|
40320
|
+
key: r.key,
|
|
40321
|
+
kind: r.kind,
|
|
40322
|
+
timestamp: r.timestamp
|
|
40323
|
+
}));
|
|
40324
|
+
},
|
|
40325
|
+
readMedia: async (key) => {
|
|
40326
|
+
const file = await stores.mediaStore.getByKey(key);
|
|
40327
|
+
if (file === null) return null;
|
|
40328
|
+
return Buffer.from(file.base64, "base64");
|
|
40329
|
+
},
|
|
40330
|
+
storeMosaic: async ({ deviceId, ownerId, timestamp, jpeg }) => stores.mediaStore.put({
|
|
40331
|
+
deviceId,
|
|
40332
|
+
ownerKind: "event",
|
|
40333
|
+
ownerId,
|
|
40334
|
+
kind: "fullFrame",
|
|
40335
|
+
timestamp,
|
|
40336
|
+
data: jpeg
|
|
40337
|
+
}),
|
|
40338
|
+
publish: async (bytes, mime) => await this.ncArtifactPlane?.publish(bytes, mime) ?? null
|
|
40339
|
+
};
|
|
40340
|
+
}
|
|
40341
|
+
/**
|
|
38088
40342
|
* The scheduled-timelapse producer's ports.
|
|
38089
40343
|
*
|
|
38090
40344
|
* Every one of them is something that already exists: `recording` for the
|
|
@@ -41670,6 +43924,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
41670
43924
|
pacingMs: input.pacingMs ?? 50
|
|
41671
43925
|
} });
|
|
41672
43926
|
const deviceIds = input.deviceId !== void 0 ? [input.deviceId] : await trackStore.listDeviceIds();
|
|
43927
|
+
const recordedStills = new RecordedStillService(buildRecordedStillPorts({
|
|
43928
|
+
api: this.ctx.api.recording,
|
|
43929
|
+
logger: this.ctx.logger
|
|
43930
|
+
}));
|
|
41673
43931
|
const deps = {
|
|
41674
43932
|
listTrackIdPage: async (cursor) => {
|
|
41675
43933
|
const page = await trackStore.queryRecent({
|
|
@@ -41688,6 +43946,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
41688
43946
|
media: mediaStore,
|
|
41689
43947
|
events: eventStore
|
|
41690
43948
|
}, trackId),
|
|
43949
|
+
recoverKeyFrame: async (source) => {
|
|
43950
|
+
const recovered = await recordedStills.recover(source.deviceId, source.timestamp, RECOVERED_KEY_FRAME_MAX_WIDTH);
|
|
43951
|
+
return recovered.outcome === "ok" ? recovered.jpeg ?? null : null;
|
|
43952
|
+
},
|
|
41691
43953
|
canDispatch: () => dispatcher.canDispatch,
|
|
41692
43954
|
pause: (ms) => sleep(ms),
|
|
41693
43955
|
embed: (source) => dispatcher.embed(source),
|
|
@@ -41715,6 +43977,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
41715
43977
|
scanned: 0,
|
|
41716
43978
|
rebuilt: 0,
|
|
41717
43979
|
missingKeyFrame: 0,
|
|
43980
|
+
recoveredKeyFrame: 0,
|
|
41718
43981
|
missingEvent: 0,
|
|
41719
43982
|
missingBbox: 0,
|
|
41720
43983
|
notRunnable: 0,
|