@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.
@@ -1,4 +1,4 @@
1
- import { $ as BaseAddon, A as assertTimelapseCadences, C as TIMELAPSE_DENSE_FLOOR_SEC, D as TrackSourceSchema, E as TimelapseRuleSchema, F as defineCustomActions, G as plateGalleryCapability, H as kebabToCamel, I as deriveRecordingMode, J as subKindsOf, K as readDeviceStateFrom, M as buildEventKindDescriptor, N as cosineSimilarity, O as addonWidgetsSourceCapability, P as customAction, Q as errMsg, R as encodeVectorBase64, S as RetrainStatusSchema, T as TimelapseRulePatchSchema, U as notificationRulesCapability, V as isScheduleActive, W as pipelineAnalyticsCapability, X as videoclipsCapability, Y as vectorDimFromBase64, Z as zoneAnalyticsCapability, _ as NcSnoozeSchema, a as DeclaredDevices, at as sleep, b as OpsLogEntrySchema, c as LabelAttributionSchema, ct as boolean, d as NC_TAXONOMY, dt as object, et as DeviceType, f as NcConditionDescriptorSchema, ft as partialRecord, g as NcSnoozeInputSchema, gt as EventCategory, h as NcRuleSchema, ht as unknown, it as nodePin, j as audioMetricsCapability, k as alarmPanelCapability, l as MACRO_LABELS, lt as literal, m as NcRulePatchSchema, mt as string, n as DEFAULT_EVENT_COLOR, nt as hydrateSchema, o as EVENT_KIND_BY_CAP, ot as _enum, p as NcRuleInputSchema, pt as record, q as readTimelapseGeneratedAt, rt as isDeviceScopedCap, s as EVENT_PAD_MS, st as array, t as BaseDevice, tt as createEvent, u as NC_CONDITION_CATALOG, ut as number, v as NcSnoozeSuppressedSchema, w as TimelapseRuleInputSchema, x as RECORDING_EXPORT_MAX_READ_BYTES, y as NcTaxonomySchema, z as faceGalleryCapability } from "../dist-DJC4JrMD.mjs";
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
- * ── Why an UNSCOPED rule keeps the flat behaviour ──────────────────────────
2715
+ * ── One semantic for scoped AND unscoped rules (operator, 2026-08-13) ───────
2716
2716
  *
2717
- * Widening is not free. On a rule that names its cameras, treating the other
2718
- * named cameras as whole-frame restores what the operator asked for: they put
2719
- * that camera in the rule and then got nothing from it. On a rule that names NO
2720
- * camera ("all devices") the same move would arm every camera on the hub —
2721
- * including sixteen the operator never mentioned — and turn "notify me when
2722
- * someone is in the driveway" into "notify me when someone is anywhere". That
2723
- * is a notification flood, and floods are how a household turns notifications
2724
- * off entirely.
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$1 = 1e5;
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$1 }
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 FROM THE
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$1(atMs) {
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$1(window.startMs)}→${clockOf$1(window.endMs)}`,
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
- * The COVERAGE sentence is the load-bearing part and only appears when it
8472
- * carries information: a video assembled from two of the eight hours it claims
8473
- * to show is visually indistinguishable from a very quiet night, and shipping
8474
- * it silently is how a three-hour recorder outage looks like nothing happened.
8475
- * A complete window says nothing — a line every night is a line nobody reads.
8476
- *
8477
- * "Carries information" is {@link coverageWorthReporting} and not
8478
- * `!coverage.complete`: the two disagree exactly when the printed percent
8479
- * rounds up to 100, which is how this body once warned that the recording was
8480
- * "available for only 100% of the window".
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
- if (coverageWorthReporting(input.coverage)) parts.push(texts.text({
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 "&lt;";
9404
+ case ">": return "&gt;";
9405
+ case "&": return "&amp;";
9406
+ case "'": return "&apos;";
9407
+ default: return "&quot;";
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 NC_TIMELAPSE_RULES_INDEXES = [{
8910
- name: "idx_nc_timelapse_rules_enabled",
10699
+ var NC_SUMMARY_RULES_INDEXES = [{
10700
+ name: "idx_nc_summary_rules_enabled",
8911
10701
  columns: ["enabled"]
8912
10702
  }];
8913
- /** Query cap — the rule set is operator-authored and tiny; a generous ceiling. */
10703
+ /** The rule set is operator-authored and tiny; a generous ceiling. */
8914
10704
  var LOAD_LIMIT = 1e4;
8915
10705
  /**
8916
- * Resolve the three-way `template` patch signal onto a merged rule, immutably:
8917
- * `undefined` (key absent) leaves it as-is, `null` DROPS the key, an object
8918
- * replaces it. Keeping `null` out of the persisted rule is what lets
8919
- * `TimelapseRuleSchema` stay a plain `.optional()`.
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, ...withoutTemplate } = merged;
8928
- return withoutTemplate;
10720
+ const { template: _cleared, ...rest } = merged;
10721
+ return rest;
8929
10722
  }
8930
- /** Narrowing guard — the persisted `rule` column is `unknown` until parsed. */
8931
- function isRecord$1(value) {
8932
- return typeof value === "object" && value !== null && !Array.isArray(value);
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
- var TimelapseStore = class {
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: NC_TIMELAPSE_RULES_COLLECTION,
8957
- columns: [...NC_TIMELAPSE_RULES_COLUMNS],
8958
- indexes: [...NC_TIMELAPSE_RULES_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 from the store — called at boot and on the
8963
- * periodic refresh tick. Replaces the cache wholesale; a row whose JSON no
8964
- * longer validates is skipped with a warning (a degraded rule must never
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: NC_TIMELAPSE_RULES_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 raw = this.withCreatedAt(row.id, row.data["rule"]);
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("timelapse rules loaded", { meta: {
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("timelapse rules load failed", { meta: { error: String(err) } });
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()].sort((a, b) => b.updatedAt - a.updatedAt);
10791
+ return [...this.byId.values()].toSorted((a, b) => b.updatedAt - a.updatedAt);
9025
10792
  }
9026
- /** The scheduler's read: only rules that should be ticked. */
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 OWN personal rules (`ownerUserId ===
9035
- * userId`) plus every admin/global rule (`ownerUserId` absent). Never another
9036
- * user's personal rows. Newest-first (inherits {@link list}).
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
- * Mutation gate for a NON-admin caller: true only for a PERSONAL rule this
9047
- * user owns. A global rule (no `ownerUserId`) returns false — global rules
9048
- * are admin-only, and the bridge action grants admins the mutation without
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 = TimelapseRuleSchema.parse({
9071
- ...TimelapseRuleInputSchema.parse(input),
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: returns the NEW rule object. Identity,
9084
- * ownership and generation state are re-pinned from the existing rule AFTER
9085
- * the spread — the patch schema carries none of them, and this makes a
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(`timelapse rule not found: ${ruleId}`);
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
- lastGeneratedAt: existing.lastGeneratedAt,
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 successful generation for ONE camera of the rule. `at` is the
9115
- * generation epoch-ms — the durable state behind the re-generation guard.
9116
- * Does NOT bump `updatedAt` (a generation is not an edit of the rule).
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
- * PER-DEVICE, and that is the whole point: a rule covering three cameras
9119
- * renders three videos, and stamping the rule when the first one succeeds
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, deviceId, at) {
10859
+ async markGenerated(ruleId, windowEndMs) {
9134
10860
  const existing = this.byId.get(ruleId);
9135
- if (!existing) throw new Error(`timelapse rule not found: ${ruleId}`);
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
- generatedByDevice,
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: NC_TIMELAPSE_RULES_COLLECTION,
10872
+ collection: NC_SUMMARY_RULES_COLLECTION,
9155
10873
  key: ruleId
9156
10874
  });
9157
10875
  } catch (err) {
9158
- this.logger.warn("timelapse rule delete failed", { meta: {
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 = TimelapseRuleSchema.parse(candidate);
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: NC_TIMELAPSE_RULES_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
- if (source.keyFrame === null) {
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(source);
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,