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