@camstack/addon-post-analysis 1.2.611 → 1.2.612

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.
Files changed (15) hide show
  1. package/dist/{clip-model-registry-D1PS6-db.js → clip-model-registry-C7WMvftu.js} +559 -40
  2. package/dist/{clip-model-registry-3TQUwS_R.mjs → clip-model-registry-JH4cTI06.mjs} +542 -41
  3. package/dist/embedding-encoder/index.js +1 -1
  4. package/dist/embedding-encoder/index.mjs +1 -1
  5. package/dist/pipeline-analytics/_stub.js +2 -2
  6. package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-D_6mbip_.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-DQE_Wu6_.mjs} +3 -3
  7. package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-7dLQKt_H.mjs +26 -0
  8. package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DbUPeg6m.mjs +26 -0
  9. package/dist/pipeline-analytics/{hostInit-Tp2cEslm.mjs → hostInit-Bl6Zvb1p.mjs} +3 -3
  10. package/dist/pipeline-analytics/index.js +1298 -359
  11. package/dist/pipeline-analytics/index.mjs +1298 -359
  12. package/dist/pipeline-analytics/remoteEntry.js +1 -1
  13. package/package.json +1 -1
  14. package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BX7AfZj6.mjs +0 -26
  15. package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DZcBAOjR.mjs +0 -26
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_clip_model_registry = require("../clip-model-registry-D1PS6-db.js");
5
+ const require_clip_model_registry = require("../clip-model-registry-C7WMvftu.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_clip_model_registry.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -935,6 +935,7 @@ function actionTtlMs(subject) {
935
935
  case "snooze": return SNOOZE_ACTION_TTL_MS;
936
936
  case "alarm":
937
937
  if (subject.action === "arm-anyway") return ARM_ANYWAY_TTL_MS;
938
+ if (subject.action === "bypass-input") return DEFAULT_ACTION_TTL_MS;
938
939
  return subject.action === "disarm" ? ALARM_DISARM_TTL_MS : DEFAULT_ACTION_TTL_MS;
939
940
  case "sequence": return DEFAULT_ACTION_TTL_MS;
940
941
  case "addon-update": return DEFAULT_ACTION_TTL_MS;
@@ -1019,6 +1020,7 @@ function describeGrant(grant) {
1019
1020
  case "snooze": return grant.classes === void 0 || grant.classes.length === 0 ? `Silenced for ${grant.minutes} min` : `Silenced for ${grant.minutes} min — ${grant.classes.join(", ")}`;
1020
1021
  case "alarm":
1021
1022
  if (grant.action === "arm-anyway") return "Alarm armed, around the open devices";
1023
+ if (grant.action === "bypass-input") return `Input bypassed until disarm — ${grant.ruleName}`;
1022
1024
  return grant.action === "disarm" ? "Alarm disarmed" : `Alarm armed${grant.mode !== void 0 ? ` (${grant.mode})` : ""}`;
1023
1025
  case "addon-update": return `${grant.packages.length} addon update(s) on ${grant.nodeId}`;
1024
1026
  case "server-update": return `Server update on ${grant.nodeId}`;
@@ -1046,17 +1048,26 @@ function grantMeta(grant) {
1046
1048
  minutes: grant.minutes,
1047
1049
  classes: grant.classes ?? null
1048
1050
  };
1049
- case "alarm": return grant.action === "arm-anyway" ? {
1050
- ...common,
1051
- alarm: grant.action,
1052
- refusalId: grant.refusalId,
1053
- panelDeviceId: grant.panelDeviceId
1054
- } : {
1055
- ...common,
1056
- alarm: grant.action,
1057
- mode: grant.mode ?? null,
1058
- panelDeviceId: grant.panelDeviceId
1059
- };
1051
+ case "alarm":
1052
+ if (grant.action === "bypass-input") return {
1053
+ ...common,
1054
+ alarm: grant.action,
1055
+ ruleId: grant.ruleId,
1056
+ rule: grant.ruleName,
1057
+ inputDeviceId: grant.inputDeviceId,
1058
+ panelDeviceId: grant.panelDeviceId
1059
+ };
1060
+ return grant.action === "arm-anyway" ? {
1061
+ ...common,
1062
+ alarm: grant.action,
1063
+ refusalId: grant.refusalId,
1064
+ panelDeviceId: grant.panelDeviceId
1065
+ } : {
1066
+ ...common,
1067
+ alarm: grant.action,
1068
+ mode: grant.mode ?? null,
1069
+ panelDeviceId: grant.panelDeviceId
1070
+ };
1060
1071
  case "addon-update": return {
1061
1072
  ...common,
1062
1073
  nodeId: grant.nodeId,
@@ -1580,6 +1591,128 @@ var NcAlarmExclusions = class {
1580
1591
  }
1581
1592
  };
1582
1593
  //#endregion
1594
+ //#region src/notification-center/alarm/alarm-input-bypasses.ts
1595
+ /**
1596
+ * The alarm INPUTS the operator silenced for the rest of this arm — "Bypass
1597
+ * this input until disarm".
1598
+ *
1599
+ * An input is ONE (rule, device) pair: the rule gated on `armed_*` and the
1600
+ * device whose match pressed the panel (the sensor when the match was about
1601
+ * one, else the camera — `intrusionDeviceOf`). A false positive that keeps
1602
+ * re-triggering the alarm is exactly one such pair; bypassing it leaves every
1603
+ * other device of the same rule, and the same device under every other rule,
1604
+ * armed.
1605
+ *
1606
+ * Not an exclusion (`alarm-exclusions.ts`): an exclusion is an opening that was
1607
+ * open at ARM time and lifts on its own two-phase edge. A bypass never lifts by
1608
+ * itself. It belongs to the arm SESSION and ends with it — on a disarm (any
1609
+ * path: the operator, the bridge, the cap, a `disableAfter` auto-disarm) and on
1610
+ * a new arm. A bounded siren that RE-ARMS itself is the same session, and the
1611
+ * bypass survives it — that re-trigger is the thing it exists to stop.
1612
+ *
1613
+ * Persisted with the panel's machine, in the panel's own device config, for the
1614
+ * exclusions' reason: a deploy in the middle of the night must not quietly hand
1615
+ * the false positive back its siren. No second store (D62): this is state of
1616
+ * the arm, and it lives where the arm lives.
1617
+ */
1618
+ /** At most this many bypassed inputs per arm — a bound on the persisted blob, not a target. */
1619
+ var NC_ALARM_INPUT_BYPASS_MAX = 200;
1620
+ /** One bypassed input, as persisted. Read back through {@link NcAlarmInputBypasses.restore}. */
1621
+ var NcAlarmInputBypassRecordSchema = require_clip_model_registry.object({
1622
+ ruleId: require_clip_model_registry.string().min(1),
1623
+ /** The rule's name when it was bypassed — a fallback for the view; the mirror's name wins. */
1624
+ ruleName: require_clip_model_registry.string(),
1625
+ /** The INPUT: the sensor of the match, else its camera (`intrusionDeviceOf`). */
1626
+ deviceId: require_clip_model_registry.number().int(),
1627
+ /** The match's camera — kept so a rule edit that drops the camera can be recognised. */
1628
+ cameraId: require_clip_model_registry.number().int(),
1629
+ /** When the bypass was made. */
1630
+ at: require_clip_model_registry.number(),
1631
+ /** Presses dropped since — the count the status shows. */
1632
+ dropped: require_clip_model_registry.number().int().min(0)
1633
+ });
1634
+ var NcAlarmInputBypasses = class {
1635
+ entries = [];
1636
+ /** Bypass one input. Idempotent: the same pair twice is one bypass. */
1637
+ add(request, at) {
1638
+ if (this.find(request.ruleId, request.deviceId) !== void 0) return "already-bypassed";
1639
+ if (this.entries.length >= NC_ALARM_INPUT_BYPASS_MAX) return "full";
1640
+ this.entries = [...this.entries, {
1641
+ ruleId: request.ruleId,
1642
+ ruleName: request.ruleName,
1643
+ deviceId: request.deviceId,
1644
+ cameraId: request.cameraId,
1645
+ at,
1646
+ dropped: 0
1647
+ }];
1648
+ return "bypassed";
1649
+ }
1650
+ /** True while this (rule, input) pair must not press the panel. */
1651
+ blocks(ruleId, deviceId) {
1652
+ return this.find(ruleId, deviceId) !== void 0;
1653
+ }
1654
+ /** Count one dropped press of a bypassed input; returns the new count, or null when not bypassed. */
1655
+ recordDrop(ruleId, deviceId) {
1656
+ const current = this.find(ruleId, deviceId);
1657
+ if (current === void 0) return null;
1658
+ const dropped = current.dropped + 1;
1659
+ this.entries = this.entries.map((e) => e === current ? {
1660
+ ...e,
1661
+ dropped
1662
+ } : e);
1663
+ return dropped;
1664
+ }
1665
+ /** Every bypassed input, in the order it was bypassed. */
1666
+ snapshot() {
1667
+ return this.entries;
1668
+ }
1669
+ get size() {
1670
+ return this.entries.length;
1671
+ }
1672
+ /** Drop every bypass — the arm session ended. Returns what was dropped. */
1673
+ clear() {
1674
+ const dropped = this.entries;
1675
+ this.entries = [];
1676
+ return dropped;
1677
+ }
1678
+ /** Drop the bypasses `keep` refuses (a rule deleted, disabled or no longer naming the input). */
1679
+ prune(keep) {
1680
+ const removed = this.entries.filter((e) => !keep(e));
1681
+ if (removed.length > 0) this.entries = this.entries.filter((e) => keep(e));
1682
+ return removed;
1683
+ }
1684
+ /**
1685
+ * Rebuild from the persisted blob — defensively, per entry: an entry a later
1686
+ * release wrote in another shape is dropped, never the whole list. Returns
1687
+ * how many entries were dropped so the caller can say so.
1688
+ */
1689
+ restore(raw) {
1690
+ if (!Array.isArray(raw)) {
1691
+ this.entries = [];
1692
+ return 0;
1693
+ }
1694
+ const kept = [];
1695
+ let dropped = 0;
1696
+ for (const item of raw) {
1697
+ const parsed = NcAlarmInputBypassRecordSchema.safeParse(item);
1698
+ if (!parsed.success || kept.length >= NC_ALARM_INPUT_BYPASS_MAX) {
1699
+ dropped += 1;
1700
+ continue;
1701
+ }
1702
+ if (kept.some((e) => e.ruleId === parsed.data.ruleId && e.deviceId === parsed.data.deviceId)) {
1703
+ dropped += 1;
1704
+ continue;
1705
+ }
1706
+ kept.push(parsed.data);
1707
+ }
1708
+ this.entries = kept;
1709
+ return dropped;
1710
+ }
1711
+ find(ruleId, deviceId) {
1712
+ return this.entries.find((e) => e.ruleId === ruleId && e.deviceId === deviceId);
1713
+ }
1714
+ };
1715
+ //#endregion
1583
1716
  //#region src/notification-center/alarm/alarm-mode-delays.ts
1584
1717
  /**
1585
1718
  * The persisted per-mode delay overrides, read through a guard (D697).
@@ -1880,6 +2013,10 @@ function alarmPushDecision(phase, heard) {
1880
2013
  kind: "suppress",
1881
2014
  reason: "alarm-sensor-excluded"
1882
2015
  } };
2016
+ case "bypassed": return { verdict: {
2017
+ kind: "suppress",
2018
+ reason: "alarm-input-bypassed"
2019
+ } };
1883
2020
  case "triggered": return { verdict: { kind: "intrusion" } };
1884
2021
  case "starts-entry-delay": return heard.pending ? { verdict: {
1885
2022
  kind: "suppress",
@@ -2178,6 +2315,18 @@ var ncAlarmPanelSchema = require_clip_model_registry.object({
2178
2315
  */
2179
2316
  exclusions: require_clip_model_registry.array(require_clip_model_registry.unknown()).default([]),
2180
2317
  /**
2318
+ * The (rule, input) pairs the operator bypassed for the rest of THIS arm
2319
+ * session (`alarm-input-bypasses.ts`). Persisted for the exclusions' reason;
2320
+ * read back through a guard, never a cast.
2321
+ */
2322
+ inputBypasses: require_clip_model_registry.array(require_clip_model_registry.unknown()).default([]),
2323
+ /**
2324
+ * When the current arm session began (the last arm). A bypass request about a
2325
+ * match older than this belongs to an arm that is over. Absent on an older
2326
+ * blob ⇒ no session check until the next arm.
2327
+ */
2328
+ armSessionAt: require_clip_model_registry.number().optional(),
2329
+ /**
2181
2330
  * Per arm mode, the covered devices whose being open does not refuse the arm
2182
2331
  * (D630). Loose on purpose — `mode` is a string here — and read back through
2183
2332
  * {@link nonBlockingFromPersisted}, never a cast: a blob written by a later
@@ -2436,6 +2585,8 @@ var NcAlarmPanelDevice = class extends require_clip_model_registry.BaseDevice {
2436
2585
  attribution = new NcAlarmAttribution();
2437
2586
  /** Sensors this arm is ignoring — see {@link NcAlarmExclusions}. */
2438
2587
  exclusions = new NcAlarmExclusions();
2588
+ /** The inputs this arm session ignores — see {@link NcAlarmInputBypasses}. */
2589
+ bypasses = new NcAlarmInputBypasses();
2439
2590
  /** Who answers "is anything open" — null until the centre wires itself in. */
2440
2591
  armGuard = null;
2441
2592
  /**
@@ -2920,6 +3071,11 @@ var NcAlarmPanelDevice = class extends require_clip_model_registry.BaseDevice {
2920
3071
  kind: "excluded",
2921
3072
  sensorDeviceId: intrusionDeviceOf(ref)
2922
3073
  };
3074
+ if (this.bypasses.blocks(ref.ruleId, intrusionDeviceOf(ref))) return {
3075
+ kind: "bypassed",
3076
+ ruleId: ref.ruleId,
3077
+ sensorDeviceId: intrusionDeviceOf(ref)
3078
+ };
2923
3079
  const mode = armModeOf(this.machine.target, NC_ALARM_MODES);
2924
3080
  if (mode === null) return { kind: "none" };
2925
3081
  const state = stateAt(this.machine, now);
@@ -3112,6 +3268,77 @@ var NcAlarmPanelDevice = class extends require_clip_model_registry.BaseDevice {
3112
3268
  this.publish();
3113
3269
  }
3114
3270
  /**
3271
+ * "Bypass this input until disarm" — ONE (rule, input) pair stops pressing
3272
+ * the panel for the rest of this arm session.
3273
+ *
3274
+ * Refused on a disarmed panel (there is no session to bypass in) and for a
3275
+ * notification about an arm that is already over (`stale`). It never cancels
3276
+ * an entry delay or a siren already running — it stops the NEXT presses of
3277
+ * that input; the disarm stays the operator's own button.
3278
+ */
3279
+ bypassInput(input, now = Date.now()) {
3280
+ const refuse = (reason) => {
3281
+ this.ctx.logger.info("alarm input bypass refused", {
3282
+ tags: { deviceId: input.deviceId },
3283
+ meta: {
3284
+ panelDeviceId: this.id,
3285
+ ruleId: input.ruleId,
3286
+ rule: input.ruleName,
3287
+ reason
3288
+ }
3289
+ });
3290
+ return {
3291
+ outcome: "refused",
3292
+ reason
3293
+ };
3294
+ };
3295
+ if (this.machine.target === "disarmed") return refuse("disarmed");
3296
+ const sessionAt = this.config.get("armSessionAt");
3297
+ if (input.notedAt !== void 0 && sessionAt !== void 0 && input.notedAt < sessionAt) return refuse("stale");
3298
+ const added = this.bypasses.add(input, now);
3299
+ if (added === "full") return refuse("full");
3300
+ if (added === "bypassed") {
3301
+ this.persist();
3302
+ this.ctx.logger.info("alarm input bypassed until disarm", {
3303
+ tags: { deviceId: input.deviceId },
3304
+ meta: {
3305
+ panelDeviceId: this.id,
3306
+ ruleId: input.ruleId,
3307
+ rule: input.ruleName,
3308
+ cameraId: input.cameraId,
3309
+ state: stateAt(this.machine, now),
3310
+ bypassed: this.bypasses.size
3311
+ }
3312
+ });
3313
+ }
3314
+ return {
3315
+ outcome: added,
3316
+ bypassed: this.bypasses.snapshot()
3317
+ };
3318
+ }
3319
+ /** The inputs this arm session ignores — what the live view reports. A read, never a copy. */
3320
+ inputBypasses() {
3321
+ return this.bypasses.snapshot();
3322
+ }
3323
+ /**
3324
+ * Drop the bypasses whose input is no longer an input — the rule was
3325
+ * deleted, disabled, or no longer names the device. Called from the centre's
3326
+ * rule-reload tick; one line per drop, tagged with the input.
3327
+ */
3328
+ pruneInputBypasses(keep) {
3329
+ const removed = this.bypasses.prune(keep);
3330
+ if (removed.length === 0) return;
3331
+ this.persist();
3332
+ for (const entry of removed) this.ctx.logger.info("alarm input bypass dropped — the rule no longer has this input", {
3333
+ tags: { deviceId: entry.deviceId },
3334
+ meta: {
3335
+ panelDeviceId: this.id,
3336
+ ruleId: entry.ruleId,
3337
+ rule: entry.ruleName
3338
+ }
3339
+ });
3340
+ }
3341
+ /**
3115
3342
  * One opening's reading (`open` / `secure`, D630's vocabulary — a cover is
3116
3343
  * secure only when `closed`, a lock only when `locked`), from whoever saw
3117
3344
  * it: the sensor's own event, or the centre's reconcile tick.
@@ -3311,6 +3538,8 @@ var NcAlarmPanelDevice = class extends require_clip_model_registry.BaseDevice {
3311
3538
  this.persist();
3312
3539
  }
3313
3540
  const resolved = this.delaysFor(mode);
3541
+ this.endInputBypasses("arm");
3542
+ this.config.set("armSessionAt", now);
3314
3543
  this.machine = arm(mode, resolved, now);
3315
3544
  this.ctx.logger.info("alarm delays applied", {
3316
3545
  tags: { deviceId: this.id },
@@ -3436,6 +3665,21 @@ var NcAlarmPanelDevice = class extends require_clip_model_registry.BaseDevice {
3436
3665
  this.announceIgnoredTrigger(sensor, now, moved);
3437
3666
  return "ignored-excluded";
3438
3667
  }
3668
+ if (source !== null && sensor !== void 0 && this.bypasses.blocks(source.ruleId, sensor)) {
3669
+ const dropped = this.bypasses.recordDrop(source.ruleId, sensor);
3670
+ this.persist();
3671
+ this.ctx.logger.info("alarm trigger dropped — this input is bypassed until disarm", {
3672
+ tags: { deviceId: sensor },
3673
+ meta: {
3674
+ panelDeviceId: this.id,
3675
+ ruleId: source.ruleId,
3676
+ rule: source.ruleName,
3677
+ state: stateAt(this.machine, now),
3678
+ dropped
3679
+ }
3680
+ });
3681
+ return "ignored-bypassed";
3682
+ }
3439
3683
  const armed = armModeOf(this.machine.target, NC_ALARM_MODES);
3440
3684
  const resolved = this.delaysFor(armed);
3441
3685
  const next = trigger(this.machine, resolved, now);
@@ -3495,6 +3739,7 @@ var NcAlarmPanelDevice = class extends require_clip_model_registry.BaseDevice {
3495
3739
  publish(now = Date.now()) {
3496
3740
  const autoCleared = this.machine.clearAt !== void 0 && now >= this.machine.clearAt && this.machine.clearToDisarmed === true;
3497
3741
  this.machine = settle(this.machine, now);
3742
+ if (this.machine.target === "disarmed") this.endInputBypasses("disarm");
3498
3743
  const state = stateAt(this.machine, now);
3499
3744
  this.runtimeState.setCapState("alarm-panel", this.status(now));
3500
3745
  this.persist();
@@ -3743,9 +3988,31 @@ var NcAlarmPanelDevice = class extends require_clip_model_registry.BaseDevice {
3743
3988
  persist() {
3744
3989
  this.config.set("machine", { ...this.machine });
3745
3990
  this.config.set("exclusions", [...this.exclusions.snapshot()]);
3991
+ this.config.set("inputBypasses", [...this.bypasses.snapshot()]);
3992
+ }
3993
+ /** The session is over: drop every bypass, one line per input. */
3994
+ endInputBypasses(why) {
3995
+ const ended = this.bypasses.clear();
3996
+ if (ended.length === 0) return;
3997
+ this.config.set("inputBypasses", []);
3998
+ for (const entry of ended) this.ctx.logger.info("alarm input bypass ended with the arm session — watched again", {
3999
+ tags: { deviceId: entry.deviceId },
4000
+ meta: {
4001
+ panelDeviceId: this.id,
4002
+ ruleId: entry.ruleId,
4003
+ rule: entry.ruleName,
4004
+ why,
4005
+ dropped: entry.dropped
4006
+ }
4007
+ });
3746
4008
  }
3747
4009
  restore() {
3748
4010
  this.exclusions.restore(this.config.get("exclusions"));
4011
+ const unreadable = this.bypasses.restore(this.config.get("inputBypasses"));
4012
+ if (unreadable > 0) this.ctx.logger.info("alarm input bypasses: persisted entries this release cannot read were dropped", {
4013
+ tags: { deviceId: this.id },
4014
+ meta: { dropped: unreadable }
4015
+ });
3749
4016
  const restored = machineFromPersisted(this.config.get("machine"));
3750
4017
  if (restored === null) return;
3751
4018
  this.machine = restored;
@@ -3799,6 +4066,9 @@ function ncAlarmPanelPort(panel) {
3799
4066
  excludedDevices: () => panel.excludedDevices(),
3800
4067
  exclusionPhases: () => panel.exclusionPhases(),
3801
4068
  forgetExcluded: (deviceId) => panel.forgetExcluded(deviceId),
4069
+ bypassInput: (input) => panel.bypassInput(input),
4070
+ inputBypasses: () => panel.inputBypasses(),
4071
+ pruneInputBypasses: (keep) => panel.pruneInputBypasses(keep),
3802
4072
  currentState: () => panel.currentState(),
3803
4073
  currentTarget: () => panel.currentTarget(),
3804
4074
  armWith: (mode) => panel.armWith(mode),
@@ -5374,6 +5644,53 @@ async function notificationLinkEndpoints(api, logger) {
5374
5644
  });
5375
5645
  }
5376
5646
  //#endregion
5647
+ //#region src/notification-center/tracks-at-instant.ts
5648
+ /**
5649
+ * How many persisted rows the wiring reads per instant. A scene holds a handful
5650
+ * of subjects; the read is newest-first from the instant, so this bounds a
5651
+ * busy street without missing the tracks that were on screen.
5652
+ */
5653
+ var NC_TRACKS_AT_INSTANT_LIMIT = 25;
5654
+ /**
5655
+ * Every source track with a position within `toleranceMs` of `timestamp`, its
5656
+ * nearest box normalised, deduplicated by track id (a live copy and a
5657
+ * persisted copy of one track are one track). Pure.
5658
+ */
5659
+ function tracksAtInstant(sources, timestamp, toleranceMs, fallbackDims) {
5660
+ const out = /* @__PURE__ */ new Map();
5661
+ for (const track of sources) {
5662
+ if (out.has(track.trackId)) continue;
5663
+ if (track.firstSeen > timestamp + toleranceMs || track.lastSeen < timestamp - toleranceMs) continue;
5664
+ const w = track.frameWidth ?? fallbackDims?.w ?? 0;
5665
+ const h = track.frameHeight ?? fallbackDims?.h ?? 0;
5666
+ if (w <= 0 || h <= 0) continue;
5667
+ let best = null;
5668
+ let bestDelta = Number.POSITIVE_INFINITY;
5669
+ for (const p of track.positions) {
5670
+ const delta = Math.abs(p.timestamp - timestamp);
5671
+ if (delta < bestDelta) {
5672
+ bestDelta = delta;
5673
+ best = p;
5674
+ }
5675
+ }
5676
+ if (best === null || bestDelta > toleranceMs) continue;
5677
+ const label = track.subLabel ?? track.label;
5678
+ out.set(track.trackId, {
5679
+ trackId: track.trackId,
5680
+ className: track.className,
5681
+ ...label !== void 0 && label !== track.className ? { label } : {},
5682
+ ...best.score !== void 0 ? { confidence: best.score } : {},
5683
+ bbox: {
5684
+ x: best.bbox.x / w,
5685
+ y: best.bbox.y / h,
5686
+ w: best.bbox.w / w,
5687
+ h: best.bbox.h / h
5688
+ }
5689
+ });
5690
+ }
5691
+ return [...out.values()];
5692
+ }
5693
+ //#endregion
5377
5694
  //#region src/notification-center/artifact-read-plane.ts
5378
5695
  /**
5379
5696
  * Parse a single-range `Range` header against a known length.
@@ -23075,7 +23392,7 @@ function referenceOf(boxes) {
23075
23392
  function displaced(ref, b) {
23076
23393
  return Math.hypot(b.x + b.w / 2 - ref.x, b.y + b.h / 2 - ref.y) > ref.span;
23077
23394
  }
23078
- function judge(ref, run, b) {
23395
+ function judge$1(ref, run, b) {
23079
23396
  const next = displaced(ref, b) ? run + 1 : 0;
23080
23397
  return {
23081
23398
  early: [],
@@ -23095,7 +23412,7 @@ function judgeAll(boxes) {
23095
23412
  };
23096
23413
  for (const b of boxes) {
23097
23414
  if (probe.moved) return probe;
23098
- probe = judge(ref, probe.run, b);
23415
+ probe = judge$1(ref, probe.run, b);
23099
23416
  }
23100
23417
  return probe;
23101
23418
  }
@@ -23110,7 +23427,7 @@ function isMeasurableBox(box) {
23110
23427
  function stepMovedProbe(probe, box) {
23111
23428
  if (probe.moved || !isMeasurableBox(box)) return probe;
23112
23429
  if (probe.reference !== null) {
23113
- const next = judge(probe.reference, probe.run, box);
23430
+ const next = judge$1(probe.reference, probe.run, box);
23114
23431
  return next.run === probe.run && !next.moved ? probe : next;
23115
23432
  }
23116
23433
  const early = [...probe.early, box];
@@ -26221,6 +26538,11 @@ var MAX_ARM_BUTTONS = 3;
26221
26538
  * | `alarm-arm-refused` | **Arma comunque**, bound to the row's refusal |
26222
26539
  * | `alarm-sensor-bypass` | nothing — it reports, there is nothing to undo |
26223
26540
  *
26541
+ * `alarm-triggered` and `alarm-pending` rows a RULE caused also carry **Bypass
26542
+ * this input**, bound to the row's (rule, input) pair: that one input stops
26543
+ * pressing the panel until the next disarm. It never cancels the countdown or
26544
+ * the siren already running — Disarm beside it does that.
26545
+ *
26224
26546
  * The `alarm-arming` row is the one that earns its keep: that notification
26225
26547
  * arrives at the START of the exit delay, so its Disarm is the only alarm
26226
26548
  * button an operator can press to cancel something rather than to undo it.
@@ -26257,7 +26579,7 @@ function buildAlarmButtons(input) {
26257
26579
  };
26258
26580
  });
26259
26581
  const actionId = "alarm-disarm";
26260
- return [{
26582
+ const disarm = {
26261
26583
  id: actionId,
26262
26584
  label: input.labels.disarm,
26263
26585
  url: input.mintUrl({
@@ -26267,6 +26589,19 @@ function buildAlarmButtons(input) {
26267
26589
  icon: "disarm",
26268
26590
  destructive: true,
26269
26591
  mode: "background"
26592
+ };
26593
+ const bypassable = input.bypassable;
26594
+ if (bypassable === void 0 || input.kind !== "alarm-triggered" && input.kind !== "alarm-pending") return [disarm];
26595
+ const bypassId = "alarm-bypass-input";
26596
+ return [disarm, {
26597
+ id: bypassId,
26598
+ label: input.labels.bypassInput,
26599
+ url: input.mintUrl({
26600
+ actionId: bypassId,
26601
+ action: "bypass-input",
26602
+ ...bypassable
26603
+ }),
26604
+ mode: "background"
26270
26605
  }];
26271
26606
  }
26272
26607
  /**
@@ -26501,6 +26836,14 @@ function buildAlarmLive(input) {
26501
26836
  deviceId: e.deviceId,
26502
26837
  name: input.name(e.deviceId),
26503
26838
  phase: e.phase
26839
+ })),
26840
+ bypassedInputs: input.bypasses.map((b) => ({
26841
+ ruleId: b.ruleId,
26842
+ ruleName: input.ruleName(b.ruleId) ?? b.ruleName,
26843
+ deviceId: b.deviceId,
26844
+ name: input.name(b.deviceId),
26845
+ since: b.at,
26846
+ dropped: b.dropped
26504
26847
  }))
26505
26848
  };
26506
26849
  }
@@ -28726,78 +29069,31 @@ function isCoolingDown(rule, lastFiredAt, now) {
28726
29069
  return now - lastFiredAt < rule.throttle.cooldownSec * 1e3;
28727
29070
  }
28728
29071
  /**
28729
- * The media policy EVERY sensor rule attaches: one whole-scene photograph,
28730
- * never a selector. `attach: 'keyFrame'` is the existing "clean full scene
28731
- * frame" ladder (`attachmentKindPreference`) — the closest existing concept
28732
- * to a plain snapshot, as opposed to `'best'`/`'best-matching'`, which chase a
28733
- * SUBJECT crop a sensor subject (a doorbell press, a contact change) has
28734
- * none of to chase. No zone crop, no gif, no clip, no profile — a sensor
28735
- * subject has no zone condition to crop to and no reason to animate.
28736
- */
28737
- var NC_SENSOR_MEDIA_POLICY = { attach: "keyFrame" };
28738
- /**
28739
- * The media policy actually used to build a delivery, for a rule of this
28740
- * KIND. A sensor rule's STORED `media` is never read at evaluation — the
28741
- * editor offers no media control for one any more, and `media.attach` /
28742
- * `.gif` / `.clip` / `.profile` on an existing sensor rule (authored before
28743
- * this ruling, from the viewer or the API) are IGNORED here, never refused at
28744
- * load. See {@link mediaPolicyMatchesSensorDefault} for the sampled-log gate.
29072
+ * The media policy a delivery is built from: the rule's own, with its picture
29073
+ * mode RESOLVED (`resolveNcMediaImage`) — a sensor rule included. Until D897 a
29074
+ * sensor rule's stored media was ignored for a fixed one-snapshot policy; it
29075
+ * now goes through the same selector and the same render stage as a detection
29076
+ * rule, its source frame being the linked camera's.
28745
29077
  */
28746
29078
  function effectiveMediaPolicy(rule, kind) {
28747
- return kind === "sensor" ? NC_SENSOR_MEDIA_POLICY : rule.media;
28748
- }
28749
- /**
28750
- * True when a stored media policy already reads as the sensor default — the
28751
- * gate the boot/evaluation path uses to decide whether an existing sensor
28752
- * rule's stored choice is being silently overridden (and so is worth a
28753
- * sampled log line) or already matches and there is nothing to say.
28754
- */
28755
- function mediaPolicyMatchesSensorDefault(media) {
28756
- return media.attach === NC_SENSOR_MEDIA_POLICY.attach && media.frame === void 0 && media.zoneCrop !== true && media.gif !== true && media.clip !== true && media.profile === void 0;
29079
+ return {
29080
+ ...rule.media,
29081
+ image: require_clip_model_registry.resolveNcMediaImage(rule.media, kind)
29082
+ };
28757
29083
  }
28758
- /** `effectiveMediaPolicy`, resolving the kind itself — the call-site shortcut
28759
- * every production caller uses; `effectiveMediaPolicy` stays the unit under
28760
- * test because `ruleKindOf` needs no fixture of its own there. */
29084
+ /** `effectiveMediaPolicy`, resolving the kind itself — the call-site shortcut. */
28761
29085
  function effectiveMediaPolicyForRule(rule) {
28762
29086
  return effectiveMediaPolicy(rule, require_clip_model_registry.ruleKindOf(rule));
28763
29087
  }
28764
- /**
28765
- * Ordered media-kind preference for one attachment, per policy and owner.
28766
- * `best` favours the tight subject crop; `keyFrame` the clean scene frame
28767
- * (small variant first — the degrade engine enforces per-backend byte
28768
- * caps, but there is no reason to ship a native 4K frame by default).
28769
- */
28770
- function attachmentKindPreference(policy, ownerKind) {
28771
- if (policy === "keyFrame") return ownerKind === "track" ? [
28772
- "keyFrameSmall",
28773
- "keyFrame",
28774
- SCENE_TWIN_KIND,
28775
- ROLLING_SCENE_TWIN_KIND,
28776
- "fullFrameBoxed",
28777
- "firstFrame",
28778
- "lastFrame",
28779
- "snapshot"
28780
- ] : [
28781
- "fullFrameBoxed",
28782
- "fullFrame",
28783
- "crop"
28784
- ];
28785
- return ownerKind === "track" ? [
28786
- "thumbnail",
28787
- "thumbnailSmall",
28788
- "crop",
28789
- "keyFrameSmall",
28790
- "firstFrame"
28791
- ] : [
28792
- "crop",
28793
- "thumbnail",
28794
- "fullFrameBoxed"
28795
- ];
29088
+ /** True when a rule ships no still at all. */
29089
+ function ruleShipsNoStill(rule) {
29090
+ return effectiveMediaPolicyForRule(rule).image === "none";
28796
29091
  }
28797
29092
  /**
28798
29093
  * The media kinds whose stored bytes carry a BURNT-IN detection box.
28799
29094
  *
28800
- * This is the family boundary for {@link framePreference}, and it exists as one
29095
+ * This is the family boundary for {@link cleanSourcePreference} (D897; the retired
29096
+ * `framePreference` before it), and it exists as one
28801
29097
  * exported constant because the alternative was tried and failed in production:
28802
29098
  * the `boxed` ladder listed `snapshot` / `lastFrame` / `firstFrame` as *"the
28803
29099
  * kinds that actually carry a burnt-in box today"* while, eleven lines below,
@@ -28813,7 +29109,7 @@ function attachmentKindPreference(policy, ownerKind) {
28813
29109
  *
28814
29110
  * ## Why `full` still ends with three of these kinds (D654)
28815
29111
  *
28816
- * `framePreference('full', 'track')` used to reach these kinds as soon as the
29112
+ * The retired `framePreference('full', 'track')` used to reach these kinds as soon as the
28817
29113
  * key frame was out of band, so a rule asking for the clean scene was answered
28818
29114
  * with an annotated one: of 38 `frame:'full'` deliveries sha256-matched back to
28819
29115
  * stored media, **19 (50%) were `firstFrame` / `snapshot` / `lastFrame`**.
@@ -28876,90 +29172,36 @@ function isAnnotatedSceneKind(kind) {
28876
29172
  return ANNOTATED_SCENE_KINDS.includes(kind);
28877
29173
  }
28878
29174
  /**
28879
- * The kind ladder for an EXPLICIT frame choice. Strict by design — each option
28880
- * stays inside its own family, because a rule that asked for the clean scene
28881
- * and received the boxed one (or vice versa) is not "degrading gracefully", it
28882
- * is answering a different question.
28883
- *
28884
- * Two exceptions, both degrading to the SAME scene and never to a subject
28885
- * crop, which shows something else entirely: `boxed` with no annotated frame
28886
- * stored falls back to the CLEAN scene; `full` with no clean frame stored falls
28887
- * back, last, to the annotated tile of the trigger instant — reported by the
28888
- * dispatcher on every delivery that reaches it (D654).
28889
- *
28890
- * **`boxed` used to decide nothing.** Its first rung was `fullFrameBoxed`, a
28891
- * kind RETIRED for new tracks on 2026-07-21 (`event-media-dispatcher.ts`), and
28892
- * the rest of the ladder was the clean `keyFrame` family — so an authorable
28893
- * option degraded, silently and always, to exactly what `full` returns. A knob
28894
- * that decides nothing is worse than no knob: the operator set it and believed
28895
- * they had asked for something.
28896
- *
28897
- * The annotated frames were there the whole time under different names.
28898
- * `firstFrame` / `snapshot` / `lastFrame` are written by `boxedTimelineFrame`
28899
- * — the track's OWN box burned onto the native full frame — so `boxed` now asks
28900
- * for them, newest-moment-first-per-family, before degrading to the clean
28901
- * scene. No new capture path, no new kind, and the option answers its own
28902
- * question again.
28903
- *
28904
- * The strictness is about FAMILY, and the family boundary is
28905
- * {@link ANNOTATED_SCENE_KINDS} — ONE declaration, consumed by both ladders, so
28906
- * `boxed` and `full` can never again hold different opinions about the same
28907
- * kind. They did, and it shipped: see that constant.
28908
- */
28909
- function framePreference(frame, ownerKind) {
28910
- if (frame === "cropped") return ownerKind === "track" ? [
28911
- "thumbnail",
28912
- "thumbnailSmall",
28913
- "crop"
28914
- ] : [
28915
- "crop",
28916
- "thumbnail",
28917
- "thumbnailSmall"
28918
- ];
28919
- if (frame === "boxed") return [
28920
- ...ANNOTATED_SCENE_KINDS,
28921
- ...CLEAN_SCENE_KINDS,
28922
- SCENE_TWIN_KIND,
28923
- ROLLING_SCENE_TWIN_KIND
28924
- ];
29175
+ * The SOURCE ladder of every notification picture (D897): the clean whole-scene
29176
+ * kinds of an owner, best first, then — LAST — the annotated tiles, for a
29177
+ * track that holds no clean frame at all (written before the D654 twin, or a
29178
+ * twin write that failed). The picture is rendered from what this picks, by
29179
+ * mode (`notification-image.ts`), so a rule no longer chooses a stored KIND:
29180
+ * `crop` cuts the subject out of the clean frame, `scene-boxes` draws on it.
29181
+ *
29182
+ * `keyFrame` leads because it is the native best-detection frame — the crop
29183
+ * keeps its resolution; the scene modes downscale it once at render. The twins
29184
+ * (`sceneFrame` at a boxed tile's instant, `lastSceneFrame` at the rolling
29185
+ * one) are what MOMENT finds near the trigger when the best shot is later.
29186
+ * A still-shelf photograph holds `keyFrameSmall` / `keyFrame` / `fullFrame`,
29187
+ * so a sound or a linked-camera still resolves through the same ladder.
29188
+ */
29189
+ function cleanSourcePreference(ownerKind) {
28925
29190
  return ownerKind === "track" ? [
28926
29191
  "keyFrame",
28927
29192
  "keyFrameSmall",
28928
29193
  SCENE_TWIN_KIND,
28929
29194
  ROLLING_SCENE_TWIN_KIND,
29195
+ "fullFrame",
28930
29196
  "firstFrame",
28931
29197
  "lastFrame",
28932
- "snapshot"
29198
+ "snapshot",
29199
+ "fullFrameBoxed"
28933
29200
  ] : [
28934
29201
  "fullFrame",
28935
29202
  "keyFrame",
28936
- "keyFrameSmall"
28937
- ];
28938
- }
28939
- /**
28940
- * Derive the `best-matching` media signal from a matched rule's condition
28941
- * summary ({@link NcEvaluation.matchedOn}). Identity takes priority over plate
28942
- * (D-3 ordering: a face rule that ALSO plate-matched attaches the face crop).
28943
- */
28944
- function matchSignal(matchedOn) {
28945
- if (matchedOn === void 0) return null;
28946
- if (matchedOn.includes("identities")) return "face";
28947
- if (matchedOn.includes("plates")) return "plate";
28948
- return null;
28949
- }
28950
- /**
28951
- * Ordered media-kind preference for a `best-matching` attachment. The
28952
- * signal-specific crop (`faceCrop` / `plateCrop`, both event-owned) leads;
28953
- * then the plain `best` subject ladder, then the `keyFrame` clean-scene
28954
- * ladder — so a missing specific crop degrades to best → keyFrame → none
28955
- * (the dispatcher returns null when nothing resolves) without ever blocking
28956
- * the send. A `null` signal is exactly the `best` ladder.
28957
- */
28958
- function bestMatchingKindPreference(signal, ownerKind) {
28959
- return [
28960
- ...signal === "face" ? ["faceCrop"] : signal === "plate" ? ["plateCrop"] : [],
28961
- ...attachmentKindPreference("best", ownerKind),
28962
- ...attachmentKindPreference("keyFrame", ownerKind)
29203
+ "keyFrameSmall",
29204
+ "fullFrameBoxed"
28963
29205
  ];
28964
29206
  }
28965
29207
  //#endregion
@@ -31354,7 +31596,8 @@ var NcGroupMemberFateSchema = require_clip_model_registry.union([require_clip_mo
31354
31596
  "close-failed",
31355
31597
  "alarm-entry-delay-started",
31356
31598
  "alarm-entry-delay",
31357
- "alarm-sensor-excluded"
31599
+ "alarm-sensor-excluded",
31600
+ "alarm-input-bypassed"
31358
31601
  ]), require_clip_model_registry.templateLiteral(["failed-condition:", require_clip_model_registry.string()])]);
31359
31602
  /** Read cap, so a client cannot ask for the whole ring in one call. */
31360
31603
  var MAX_QUERY_LIMIT = 200;
@@ -31666,6 +31909,8 @@ var NcPruneRecordSchema = require_clip_model_registry.object({
31666
31909
  });
31667
31910
  /** Rules × deleted subjects — small; a generous ceiling. */
31668
31911
  var LOAD_LIMIT$1 = 1e4;
31912
+ /** Page size of the load's records query. */
31913
+ var LOAD_PAGE = 1e3;
31669
31914
  function keyOf$1(row) {
31670
31915
  return `${row.kind}:${row.ruleId}:${row.field}:${row.subject}`;
31671
31916
  }
@@ -31691,18 +31936,23 @@ NC_DEVICE_PRUNES_SPEC.fromRecord, NC_DEVICE_PRUNES_SPEC.toValue, NC_DEVICE_PRUNE
31691
31936
  var KEPT_OUTCOMES = new Set(["kept-disabled", "kept"]);
31692
31937
  var NcPruneLedger = class {
31693
31938
  ledger;
31694
- store;
31939
+ /**
31940
+ * The SAME handle the ledger writes through — the records kind wherever
31941
+ * `ctx.records` exists. The load reads it, never the legacy table: no boot
31942
+ * declares that table any more, so a direct read of it fails forever.
31943
+ */
31944
+ kind;
31695
31945
  logger;
31696
31946
  loaded = false;
31697
31947
  /** Bumped on every {@link record} write — a load ignores keys written after it began. */
31698
31948
  writes = 0;
31699
31949
  writtenAt = /* @__PURE__ */ new Map();
31700
31950
  constructor(deps) {
31701
- this.store = deps.store;
31702
31951
  this.logger = deps.logger;
31952
+ this.kind = resolveDurableKind(NC_DEVICE_PRUNES_SPEC, ncDurableRecordKinds["device-prunes"], deps);
31703
31953
  this.ledger = new DurableLedger({
31704
31954
  spec: NC_DEVICE_PRUNES_SPEC,
31705
- kind: resolveDurableKind(NC_DEVICE_PRUNES_SPEC, ncDurableRecordKinds["device-prunes"], deps),
31955
+ kind: this.kind,
31706
31956
  logger: deps.logger
31707
31957
  });
31708
31958
  }
@@ -31718,15 +31968,7 @@ var NcPruneLedger = class {
31718
31968
  const startedAt = this.writes;
31719
31969
  const writtenSince = (key) => (this.writtenAt.get(key) ?? 0) > startedAt;
31720
31970
  try {
31721
- const records = await this.store.query.query({
31722
- collection: NC_DEVICE_PRUNES_COLLECTION,
31723
- filter: { limit: LOAD_LIMIT$1 }
31724
- });
31725
- const next = /* @__PURE__ */ new Map();
31726
- for (const record of records) {
31727
- const row = NC_DEVICE_PRUNES_SPEC.fromRecord(record.id, record.data);
31728
- if (row !== null) next.set(keyOf$1(row), row);
31729
- }
31971
+ const next = await this.readAll();
31730
31972
  for (const key of heldBefore) if (!next.has(key) && !writtenSince(key)) this.ledger.evict(key);
31731
31973
  for (const [key, row] of next) if (!writtenSince(key)) this.ledger.stage(row);
31732
31974
  this.loaded = true;
@@ -31738,6 +31980,26 @@ var NcPruneLedger = class {
31738
31980
  } });
31739
31981
  }
31740
31982
  }
31983
+ /** Every row the kind holds, paged. THROWS on a failed page — `load` is the guard. */
31984
+ async readAll() {
31985
+ const next = /* @__PURE__ */ new Map();
31986
+ let seen = 0;
31987
+ let cursor;
31988
+ while (seen < LOAD_LIMIT$1) {
31989
+ const page = await this.kind.query({
31990
+ limit: Math.min(LOAD_PAGE, LOAD_LIMIT$1 - seen),
31991
+ ...cursor === void 0 ? {} : { cursor }
31992
+ });
31993
+ seen += page.records.length;
31994
+ for (const record of page.records) {
31995
+ const row = NC_DEVICE_PRUNES_SPEC.fromRecord(record.id, record.data);
31996
+ if (row !== null) next.set(keyOf$1(row), row);
31997
+ }
31998
+ if (page.nextCursor === null || page.records.length === 0) break;
31999
+ cursor = page.nextCursor;
32000
+ }
32001
+ return next;
32002
+ }
31741
32003
  /** Has a load ever SUCCEEDED? The pruner acts on nothing until it has. */
31742
32004
  isLoaded() {
31743
32005
  return this.loaded;
@@ -32141,6 +32403,7 @@ var en_default = {
32141
32403
  },
32142
32404
  "button.alarm.disarm": "Disarm",
32143
32405
  "button.alarm.armAnyway": "Arm anyway",
32406
+ "button.alarm.bypassInput": "Bypass until disarm",
32144
32407
  "button.alarm.arm.home": "Arm home",
32145
32408
  "button.alarm.arm.away": "Arm away",
32146
32409
  "button.alarm.arm.night": "Arm night",
@@ -32527,6 +32790,7 @@ var it_default = {
32527
32790
  },
32528
32791
  "button.alarm.disarm": "Disarma",
32529
32792
  "button.alarm.armAnyway": "Arma comunque",
32793
+ "button.alarm.bypassInput": "Escludi fino al disinserimento",
32530
32794
  "button.alarm.arm.home": "Arma casa",
32531
32795
  "button.alarm.arm.away": "Arma fuori",
32532
32796
  "button.alarm.arm.night": "Arma notte",
@@ -33995,6 +34259,65 @@ function sameConfirm(a, b) {
33995
34259
  return a?.enabled === true === (b?.enabled === true) && a?.expect?.op === b?.expect?.op && a?.expect?.count === b?.expect?.count;
33996
34260
  }
33997
34261
  //#endregion
34262
+ //#region src/notification-center/media-guard.ts
34263
+ /**
34264
+ * The hub's half of D897: a rule's picture is ONE mode (`media.image`) plus
34265
+ * `showTriggerZone`, and both are judged against the rule they are saved on.
34266
+ *
34267
+ * - `showTriggerZone` draws the zone that FIRED the rule. A rule whose trigger
34268
+ * is not a zone (no `zones` condition, no occupancy zone) has nothing to
34269
+ * draw, so the flag is refused rather than stored as a knob that does
34270
+ * nothing.
34271
+ * - `image` must be a mode the rule's kind can honour
34272
+ * (`imageModesForKind`): a sound owns no region of the frame, so it is never
34273
+ * offered `crop`.
34274
+ *
34275
+ * The predicate is `ncMediaPolicyError` (`@camstack/types`), read by the admin
34276
+ * editor too. It runs on both save routes — the cap provider
34277
+ * (`notificationRules.createRule/updateRule`) and the rule bridge
34278
+ * (`nc.createRule/updateRule`) — beside the other guards.
34279
+ *
34280
+ * Only media the CALLER sent is judged: a create's `media`, an update's
34281
+ * `media`, against the MERGED delivery and conditions. A stored rule is never
34282
+ * judged on load — `normalizeNcMediaPolicy` drops a flag it cannot draw there,
34283
+ * because a refusal at load would cost the rule its notifications.
34284
+ *
34285
+ * Not in Zod for the reason `rule-family-guard.ts` gives: a refine would also
34286
+ * run when the store LOADS a rule.
34287
+ */
34288
+ var NcMediaPolicyRefusal = class extends Error {
34289
+ reason;
34290
+ constructor(reason) {
34291
+ super(`rule refused: ${reason}`);
34292
+ this.name = "NcMediaPolicyRefusal";
34293
+ this.reason = reason;
34294
+ }
34295
+ };
34296
+ function judge(subject, ctx) {
34297
+ if (subject.media === void 0) return;
34298
+ const reason = require_clip_model_registry.ncMediaPolicyError(subject.media, subject);
34299
+ if (reason === null) return;
34300
+ ctx.logger.warn("nc rule refused: media policy the rule cannot honour", { meta: {
34301
+ ...ctx.ruleId !== void 0 ? { ruleId: ctx.ruleId } : {},
34302
+ by: ctx.by,
34303
+ reason
34304
+ } });
34305
+ throw new NcMediaPolicyRefusal(reason);
34306
+ }
34307
+ /** The create path of both routes. */
34308
+ function guardRuleMedia(rule, ctx) {
34309
+ judge(rule, ctx);
34310
+ }
34311
+ /** The update path of both routes: the patch's media on the MERGED rule. */
34312
+ function guardPatchMedia(existing, patch, ctx) {
34313
+ if (existing === null || patch.media === void 0) return;
34314
+ judge({
34315
+ delivery: patch.delivery ?? existing.delivery,
34316
+ conditions: patch.conditions ?? existing.conditions,
34317
+ media: patch.media
34318
+ }, ctx);
34319
+ }
34320
+ //#endregion
33998
34321
  //#region src/notification-center/occupancy-watcher.ts
33999
34322
  /** Sentinel key segments for the "no zone" (whole-frame) and "no class" scopes. */
34000
34323
  var FRAME_SCOPE = "@frame";
@@ -36279,6 +36602,29 @@ var NcAlarmStatusSchema = require_clip_model_registry.object({
36279
36602
  deviceId: require_clip_model_registry.number().int().nullable(),
36280
36603
  live: require_clip_model_registry.NcAlarmConfigSchema.shape.live.unwrap().nullable()
36281
36604
  });
36605
+ /** `nc.bypassAlarmInput` — one (rule, input) pair, until the next disarm. */
36606
+ var NcAlarmBypassInputRequestSchema = require_clip_model_registry.object({
36607
+ ruleId: require_clip_model_registry.string().min(1),
36608
+ /** The input: the sensor whose match pressed the panel, else the camera. */
36609
+ deviceId: require_clip_model_registry.number().int()
36610
+ });
36611
+ var NcAlarmBypassInputResultSchema = require_clip_model_registry.object({
36612
+ outcome: require_clip_model_registry._enum([
36613
+ "bypassed",
36614
+ "already-bypassed",
36615
+ "refused"
36616
+ ]),
36617
+ /**
36618
+ * Why a `refused` did nothing: the panel is disarmed, the rule is not an
36619
+ * alarm rule with this input, or the per-arm bound is reached. Null otherwise.
36620
+ */
36621
+ reason: require_clip_model_registry._enum([
36622
+ "disarmed",
36623
+ "stale",
36624
+ "full",
36625
+ "not-an-input"
36626
+ ]).nullable()
36627
+ });
36282
36628
  /**
36283
36629
  * The action catalog — the tRPC contract Group B (the viewer client) consumes
36284
36630
  * against `addonId: 'pipeline-analytics'`. Every entry that depends on the
@@ -36407,6 +36753,17 @@ var ncActions = require_clip_model_registry.defineCustomActions({
36407
36753
  caller: "required"
36408
36754
  }),
36409
36755
  /**
36756
+ * "Bypass this input until disarm" from the app — the same panel path the
36757
+ * trigger notification's button takes, for the same principals who may
36758
+ * disarm here (admin). The live view (`nc.getAlarmStatus`) then lists it
36759
+ * under `bypassedInputs` until the next disarm.
36760
+ */
36761
+ "nc.bypassAlarmInput": require_clip_model_registry.customAction(NcAlarmBypassInputRequestSchema, NcAlarmBypassInputResultSchema, {
36762
+ kind: "mutation",
36763
+ auth: "admin",
36764
+ caller: "required"
36765
+ }),
36766
+ /**
36410
36767
  * What the deleted-device / deleted-target prune did to the rules
36411
36768
  * (`device-prune-runner.ts`) — the source of the banner both UIs show on a
36412
36769
  * rule it switched off ("Disabled on <date>: its last device (#id) was
@@ -37087,6 +37444,10 @@ function makeNcActionHandlers(deps) {
37087
37444
  logger: deps.logger,
37088
37445
  by: c.userId
37089
37446
  });
37447
+ guardRuleMedia(parsed, {
37448
+ logger: deps.logger,
37449
+ by: c.userId
37450
+ });
37090
37451
  guardRuleDevicesExclude(parsed, {
37091
37452
  logger: deps.logger,
37092
37453
  by: c.userId
@@ -37139,6 +37500,11 @@ function makeNcActionHandlers(deps) {
37139
37500
  by: c.userId,
37140
37501
  ruleId: input.ruleId
37141
37502
  });
37503
+ guardPatchMedia(existing, patch, {
37504
+ logger: deps.logger,
37505
+ by: c.userId,
37506
+ ruleId: input.ruleId
37507
+ });
37142
37508
  guardPatchDevicesExclude(existing, patch, {
37143
37509
  logger: deps.logger,
37144
37510
  by: c.userId,
@@ -37244,6 +37610,10 @@ function makeNcActionHandlers(deps) {
37244
37610
  await requireAlarm("nc.disarmAlarm").disarm(c.userId);
37245
37611
  return { success: true };
37246
37612
  },
37613
+ "nc.bypassAlarmInput": async (input, caller) => {
37614
+ const c = requireAdmin(caller);
37615
+ return requireAlarm("nc.bypassAlarmInput").bypassInput(input, c.userId);
37616
+ },
37247
37617
  "nc.listPrunes": async (_input, caller) => {
37248
37618
  requireAdmin(caller);
37249
37619
  const list = deps.listPrunes;
@@ -40251,7 +40621,7 @@ var GLYPH_ADVANCE = .56;
40251
40621
  var LABEL_USABLE_FRACTION = .94;
40252
40622
  /** `<`, `>`, `&`, `'`, `"` in a camera name or an operator caption would each
40253
40623
  * turn the overlay into a document librsvg refuses. */
40254
- function escapeXml$2(s) {
40624
+ function escapeXml$3(s) {
40255
40625
  return s.replace(/[<>&'"]/g, (ch) => {
40256
40626
  switch (ch) {
40257
40627
  case "<": return "&lt;";
@@ -40419,7 +40789,7 @@ function tileLabelSvg(width, height, label) {
40419
40789
  const baseline = height - Math.round((bandHeight - fitted.fontPx) / 2) - Math.round(fitted.fontPx * .18);
40420
40790
  return Buffer.from(`<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
40421
40791
  <rect x="0" y="${height - bandHeight}" width="${width}" height="${bandHeight}" fill="black" fill-opacity="0.55"/>
40422
- <text x="${padding}" y="${baseline}" font-size="${fitted.fontPx}" font-family="sans-serif" font-weight="bold" fill="white">${escapeXml$2(fitted.text)}</text>
40792
+ <text x="${padding}" y="${baseline}" font-size="${fitted.fontPx}" font-family="sans-serif" font-weight="bold" fill="white">${escapeXml$3(fitted.text)}</text>
40423
40793
  </svg>`);
40424
40794
  }
40425
40795
  /** The mosaic's own title band. */
@@ -40428,7 +40798,7 @@ function titleSvg(width, height, caption) {
40428
40798
  const baseline = Math.round(height / 2 + fitted.fontPx * .36);
40429
40799
  return Buffer.from(`<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
40430
40800
  <rect x="0" y="0" width="${width}" height="${height}" fill="#111111"/>
40431
- <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>
40801
+ <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$3(fitted.text)}</text>
40432
40802
  </svg>`);
40433
40803
  }
40434
40804
  /**
@@ -40851,11 +41221,11 @@ function withTimeout$3(promise, ms, what) {
40851
41221
  * {@link NC_GROUPED_MOSAIC_MAX_TILES}; the sentence still counts every member;
40852
41222
  * - each tile is resolved through the rule's own ladder on a DERIVED entry
40853
41223
  * ({@link groupedTileEntry}) that carries the member's own subject, zones and
40854
- * `matchedOn`, forced to `cropped` (N full scenes would be the same picture N times)
40855
- * and never zone-cropped.
41224
+ * `matchedOn`, rendered as `crop` (N full scenes would be the same picture N times)
41225
+ * and never with a zone drawn.
40856
41226
  * - a member whose owner is a PHOTOGRAPH held in RAM (a sensor's linked-camera still or a
40857
41227
  * still-shelf capture — an alarm incident's sensor member, D705) owns no subject crop:
40858
- * its tile is the whole photograph (`full`), or it would never land;
41228
+ * its tile is the whole photograph (`clean`), or it would never land;
40859
41229
  * - a tile is about its member ALONE: the row's system event (an alarm's `alarmSource`)
40860
41230
  * is dropped, or the member's object read would be scoped to the source's generation.
40861
41231
  *
@@ -40896,16 +41266,18 @@ function ownsSubjectCrop(ownerId) {
40896
41266
  /**
40897
41267
  * A NEW entry whose media read is about ONE member. Never mutates `entry`.
40898
41268
  *
40899
- * Drops the primary's `mediaZoneIds` (no zone crop on a tile), its `triggerZoneIds` and
40900
- * `matchedOn` (the member's own replace them, or none), and forces `mediaFrame: 'cropped'` (`full` for a RAM photograph — see the header).
41269
+ * Drops the primary's zone overlay (no zone on a tile — `mediaShowTriggerZone`, and a
41270
+ * legacy row's `mediaZoneIds`), its `triggerZoneIds` and `matchedOn` (the member's own
41271
+ * replace them, or none), and renders the tile as `crop` (`clean` for a RAM photograph —
41272
+ * see the header) through the same image stage as every still (D897).
40901
41273
  */
40902
41274
  function groupedTileEntry(entry, m) {
40903
- const { mediaZoneIds: _zoneCrop, triggerZoneIds: _primaryZones, matchedOn: _primaryMatch, ...payload } = entry.payload;
41275
+ const { mediaZoneIds: _zoneCrop, mediaShowTriggerZone: _zoneOverlay, mediaFrame: _legacyFrame, triggerZoneIds: _primaryZones, matchedOn: _primaryMatch, ...payload } = entry.payload;
40904
41276
  return {
40905
41277
  ...entry,
40906
41278
  payload: {
40907
41279
  ...payload,
40908
- mediaFrame: ownsSubjectCrop(m.trackId) ? "cropped" : "full",
41280
+ mediaImage: ownsSubjectCrop(m.trackId) ? "crop" : "clean",
40909
41281
  subject: memberSubject(entry.payload.subject, m, entry.deviceId),
40910
41282
  ...m.triggerZoneIds !== void 0 ? { triggerZoneIds: m.triggerZoneIds } : {},
40911
41283
  ...m.matchedOn !== void 0 ? { matchedOn: m.matchedOn } : {}
@@ -41338,8 +41710,9 @@ function zoneOverlap(point, polygons) {
41338
41710
  }
41339
41711
  return best;
41340
41712
  }
41341
- /** The trail point closest to `timestamp`, or null when none is close enough. */
41342
- function trailAt(trail, timestamp) {
41713
+ /** The trail point closest to `timestamp`, or null when none is within `maxMs`
41714
+ * (default {@link TRAIL_MATCH_MAX_MS}). */
41715
+ function trailAt(trail, timestamp, maxMs = TRAIL_MATCH_MAX_MS) {
41343
41716
  let best = null;
41344
41717
  let bestDelta = Number.POSITIVE_INFINITY;
41345
41718
  for (const point of trail) {
@@ -41349,7 +41722,7 @@ function trailAt(trail, timestamp) {
41349
41722
  best = point;
41350
41723
  }
41351
41724
  }
41352
- return best !== null && bestDelta <= TRAIL_MATCH_MAX_MS ? best : null;
41725
+ return best !== null && bestDelta <= maxMs ? best : null;
41353
41726
  }
41354
41727
  /**
41355
41728
  * The candidates that show the subject inside the triggering zone, in the order
@@ -41367,6 +41740,407 @@ function selectTriggerZoneCandidates(input) {
41367
41740
  return strict === true ? [] : candidates;
41368
41741
  }
41369
41742
  //#endregion
41743
+ //#region src/notification-center/notification-image.ts
41744
+ /** How long a rendered picture is reused for the same record (N targets, retries). */
41745
+ var NC_IMAGE_MEMO_TTL_MS = 15 * 6e4;
41746
+ /** At most this many rendered pictures are held. */
41747
+ var NC_IMAGE_MEMO_MAX = 128;
41748
+ /** How far a trail point may sit from the source frame's instant and still be its box. */
41749
+ var NC_IMAGE_BOX_MATCH_MAX_MS = 750;
41750
+ /** A scene picture is downscaled to at most this width (a phone shows it). */
41751
+ var NC_IMAGE_SCENE_MAX_WIDTH = 1920;
41752
+ /** A crop is downscaled to at most this long edge. */
41753
+ var NC_IMAGE_CROP_MAX_EDGE = 1280;
41754
+ /** Padding around the subject box of a crop, as a fraction of the box's side. */
41755
+ var NC_IMAGE_CROP_PAD = .25;
41756
+ /** The subject's outline. */
41757
+ var NC_IMAGE_SUBJECT_COLOR = "#ffb300";
41758
+ /** Every other track's outline. */
41759
+ var NC_IMAGE_OTHER_COLOR = "#f2f2f2";
41760
+ /** The trigger zone's outline. */
41761
+ var NC_IMAGE_ZONE_COLOR = "#29b6f6";
41762
+ /** Two tracks whose boxes overlap this much at one instant are the same subject (a fused id). */
41763
+ var SAME_SUBJECT_IOU = .8;
41764
+ var JPEG_QUALITY = 85;
41765
+ /** The caption of a box: class (or label) and confidence — "dog 87%". Language-free. */
41766
+ function imageLabelText(name, confidence) {
41767
+ if (confidence === void 0 || !Number.isFinite(confidence)) return name;
41768
+ const pct = Math.round(Math.min(1, Math.max(0, confidence)) * 100);
41769
+ return `${name} ${String(pct)}%`;
41770
+ }
41771
+ /** Stroke widths for an output of this size: the subject's is the thick one. */
41772
+ function overlayStrokes(width, height) {
41773
+ const subject = Math.max(4, Math.round(Math.min(width, height) / 110));
41774
+ return {
41775
+ subject,
41776
+ other: Math.max(2, Math.round(subject / 2.5))
41777
+ };
41778
+ }
41779
+ function escapeXml$2(text) {
41780
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
41781
+ }
41782
+ function clampPx(value, max) {
41783
+ return Math.max(0, Math.min(Math.round(value), max));
41784
+ }
41785
+ /** Scale a normalised box to an output of `width` × `height`, clamped inside it. */
41786
+ function toPixels(box, width, height) {
41787
+ const x = clampPx(box.x * width, width - 1);
41788
+ const y = clampPx(box.y * height, height - 1);
41789
+ return {
41790
+ x,
41791
+ y,
41792
+ w: Math.max(1, Math.min(Math.round(box.w * width), width - x)),
41793
+ h: Math.max(1, Math.min(Math.round(box.h * height), height - y))
41794
+ };
41795
+ }
41796
+ /**
41797
+ * The SVG drawn over a scene picture of `width` × `height`. The subject is
41798
+ * drawn LAST so a crowd never hides it. Captions sit on a dark plate above
41799
+ * the box (inside it when the box touches the top edge), white text.
41800
+ */
41801
+ function notificationOverlaySvg(input) {
41802
+ const { width, height } = input;
41803
+ const strokes = overlayStrokes(width, height);
41804
+ const fontSize = Math.max(14, Math.round(height / 36));
41805
+ const parts = [];
41806
+ for (const zone of input.zones) {
41807
+ if (zone.length < 3) continue;
41808
+ const points = zone.map((p) => `${String(clampPx(p.x * width, width))},${String(clampPx(p.y * height, height))}`).join(" ");
41809
+ parts.push(`<polygon data-role="zone" points="${points}" fill="${NC_IMAGE_ZONE_COLOR}" fill-opacity="0.18" stroke="${NC_IMAGE_ZONE_COLOR}" stroke-width="${String(strokes.other)}" stroke-dasharray="${String(strokes.subject * 2)},${String(strokes.subject)}"/>`);
41810
+ }
41811
+ const ordered = [...input.boxes.filter((b) => b.role === "other"), ...input.boxes.filter((b) => b.role === "subject")];
41812
+ for (const box of ordered) {
41813
+ const subject = box.role === "subject";
41814
+ const color = subject ? NC_IMAGE_SUBJECT_COLOR : NC_IMAGE_OTHER_COLOR;
41815
+ const stroke = subject ? strokes.subject : strokes.other;
41816
+ parts.push(`<rect data-role="${box.role}" x="${String(box.x)}" y="${String(box.y)}" width="${String(box.w)}" height="${String(box.h)}" fill="none" stroke="${color}" stroke-width="${String(stroke)}"/>`);
41817
+ if (box.label.length === 0) continue;
41818
+ const plateH = Math.round(fontSize * 1.35);
41819
+ const plateW = Math.round(box.label.length * fontSize * .62 + fontSize * .8);
41820
+ const plateY = box.y - plateH >= 0 ? box.y - plateH : box.y;
41821
+ const plateX = Math.min(box.x, Math.max(0, width - plateW));
41822
+ parts.push(`<rect data-role="${box.role}-plate" x="${String(plateX)}" y="${String(plateY)}" width="${String(plateW)}" height="${String(plateH)}" fill="#000000" fill-opacity="0.72" stroke="${color}" stroke-width="${String(subject ? 2 : 1)}"/>`, `<text data-role="${box.role}-label" x="${String(plateX + Math.round(fontSize * .4))}" y="${String(plateY + Math.round(plateH * .75))}" font-family="DejaVu Sans, Helvetica, Arial, sans-serif" font-size="${String(fontSize)}" font-weight="${subject ? "700" : "400"}" fill="#ffffff">${escapeXml$2(box.label)}</text>`);
41823
+ }
41824
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${String(width)}" height="${String(height)}">${parts.join("")}</svg>`;
41825
+ }
41826
+ /** The pixel rectangle a `crop` cuts from a `width` × `height` frame, or null when too small. */
41827
+ function subjectCropRect(box, width, height) {
41828
+ const px = toPixels(box, width, height);
41829
+ const padX = Math.max(16, Math.round(px.w * NC_IMAGE_CROP_PAD));
41830
+ const padY = Math.max(16, Math.round(px.h * NC_IMAGE_CROP_PAD));
41831
+ const left = Math.max(0, px.x - padX);
41832
+ const top = Math.max(0, px.y - padY);
41833
+ const right = Math.min(width, px.x + px.w + padX);
41834
+ const bottom = Math.min(height, px.y + px.h + padY);
41835
+ if (right - left < 16 || bottom - top < 16) return null;
41836
+ return {
41837
+ left,
41838
+ top,
41839
+ width: right - left,
41840
+ height: bottom - top
41841
+ };
41842
+ }
41843
+ function iou$4(a, b) {
41844
+ const x1 = Math.max(a.x, b.x);
41845
+ const y1 = Math.max(a.y, b.y);
41846
+ const x2 = Math.min(a.x + a.w, b.x + b.w);
41847
+ const y2 = Math.min(a.y + a.h, b.y + b.h);
41848
+ const inter = Math.max(0, x2 - x1) * Math.max(0, y2 - y1);
41849
+ const union = a.w * a.h + b.w * b.h - inter;
41850
+ return union <= 0 ? 0 : inter / union;
41851
+ }
41852
+ /**
41853
+ * The tracks to draw thin: every track at the instant except the subject —
41854
+ * the same id, or a box that is the subject's (a fused id reads under its
41855
+ * survivor's name).
41856
+ */
41857
+ function otherTracks(tracks, subjectTrackId, subjectBox) {
41858
+ return tracks.filter((t) => t.trackId !== subjectTrackId && (subjectBox === null || iou$4(t.bbox, subjectBox) < SAME_SUBJECT_IOU));
41859
+ }
41860
+ /**
41861
+ * What picture a ROW asks for. A row written since D897 carries `mediaImage`;
41862
+ * an older row (still in the outbox across the upgrade) is read through what
41863
+ * its legacy fields meant — the same mapping the rule migration applies.
41864
+ */
41865
+ function payloadImageMode(payload) {
41866
+ if (payload.media === "none") return "none";
41867
+ if (payload.mediaImage !== void 0) return payload.mediaImage;
41868
+ if (payload.mediaZoneIds !== void 0 && payload.mediaZoneIds.length > 0) return "clean";
41869
+ if (payload.mediaFrame === "full") return "clean";
41870
+ if (payload.mediaFrame === "boxed") return "scene-boxes";
41871
+ if (payload.mediaFrame === "cropped") return "crop";
41872
+ if (payload.media === "keyFrame") return "clean";
41873
+ return "crop";
41874
+ }
41875
+ /** The zone ids a row's picture outlines: the zones that fired it, when it asked. */
41876
+ function payloadZoneOverlayIds(payload) {
41877
+ if (payload.mediaShowTriggerZone === true) return payload.triggerZoneIds ?? [];
41878
+ if (payload.mediaImage === void 0) return payload.mediaZoneIds ?? [];
41879
+ return [];
41880
+ }
41881
+ /**
41882
+ * Which camera the primary still SHOWS, or `composite` when it is a composed
41883
+ * mosaic of several. A sensor row of a linked set names its cameras primary
41884
+ * first; in `each` mode the primary's still is that first camera's alone.
41885
+ */
41886
+ function primaryImageCamera(payload) {
41887
+ const linked = payload.subject.linkedCameraIds;
41888
+ if (linked === void 0 || linked.length === 0) return payload.subject.deviceId;
41889
+ if (linked.length > 1 && payload.subject.extraStills === void 0) return "composite";
41890
+ return linked[0] ?? payload.subject.deviceId;
41891
+ }
41892
+ /** True when the row's subject is a tracked subject (not a RAM photograph owner). */
41893
+ function hasTrackedSubject(trackId) {
41894
+ return trackId !== void 0 && !isLinkedStillId(trackId) && !isStillShelfId(trackId);
41895
+ }
41896
+ function toArrayBufferBytes(buf) {
41897
+ const out = new Uint8Array(buf.byteLength);
41898
+ out.set(buf);
41899
+ return out;
41900
+ }
41901
+ /**
41902
+ * Render one picture from a clean JPEG. A `crop` with no box, and every
41903
+ * `clean`, are the scene with nothing drawn. Throws on undecodable bytes; the
41904
+ * stage turns that into the source shipped as it is.
41905
+ */
41906
+ async function renderNotificationImage(req) {
41907
+ const { default: sharp$18 } = await import("sharp");
41908
+ const meta = await sharp$18(Buffer.from(req.bytes)).metadata();
41909
+ const srcW = meta.width ?? 0;
41910
+ const srcH = meta.height ?? 0;
41911
+ if (srcW === 0 || srcH === 0) throw new Error("source frame has no dimensions");
41912
+ if (req.mode === "crop" && req.subject !== null) {
41913
+ const rect = subjectCropRect(req.subject, srcW, srcH);
41914
+ if (rect !== null) return {
41915
+ bytes: toArrayBufferBytes(await sharp$18(Buffer.from(req.bytes)).extract(rect).resize({
41916
+ width: NC_IMAGE_CROP_MAX_EDGE,
41917
+ height: NC_IMAGE_CROP_MAX_EDGE,
41918
+ fit: "inside",
41919
+ withoutEnlargement: true
41920
+ }).jpeg({ quality: JPEG_QUALITY }).toBuffer()),
41921
+ name: "crop.jpg",
41922
+ rendered: "crop"
41923
+ };
41924
+ }
41925
+ const scale = Math.min(1, NC_IMAGE_SCENE_MAX_WIDTH / srcW);
41926
+ const outW = Math.max(1, Math.round(srcW * scale));
41927
+ const outH = Math.max(1, Math.round(srcH * scale));
41928
+ const boxes = req.mode === "scene-boxes" ? [...req.others.map((o) => ({
41929
+ ...toPixels(o, outW, outH),
41930
+ role: "other",
41931
+ label: o.label
41932
+ })), ...req.subject !== null ? [{
41933
+ ...toPixels(req.subject, outW, outH),
41934
+ role: "subject",
41935
+ label: req.subject.label
41936
+ }] : []] : [];
41937
+ const zones = req.mode === "crop" ? [] : req.zones;
41938
+ let pipeline = sharp$18(Buffer.from(req.bytes)).resize({
41939
+ width: outW,
41940
+ height: outH,
41941
+ fit: "fill"
41942
+ });
41943
+ if (boxes.length > 0 || zones.length > 0) {
41944
+ const svg = notificationOverlaySvg({
41945
+ width: outW,
41946
+ height: outH,
41947
+ boxes,
41948
+ zones
41949
+ });
41950
+ pipeline = pipeline.composite([{
41951
+ input: Buffer.from(svg),
41952
+ top: 0,
41953
+ left: 0
41954
+ }]);
41955
+ }
41956
+ const out = await pipeline.jpeg({ quality: JPEG_QUALITY }).toBuffer();
41957
+ const rendered = req.mode === "scene-boxes" && boxes.length > 0 ? "scene-boxes" : "clean";
41958
+ return {
41959
+ bytes: toArrayBufferBytes(out),
41960
+ name: `${rendered}.jpg`,
41961
+ rendered
41962
+ };
41963
+ }
41964
+ var NcNotificationImageStage = class {
41965
+ deps;
41966
+ memo = /* @__PURE__ */ new Map();
41967
+ render;
41968
+ constructor(deps) {
41969
+ this.deps = deps;
41970
+ this.render = deps.render ?? renderNotificationImage;
41971
+ }
41972
+ /** Rendered pictures currently held (spec + diagnostics). */
41973
+ size() {
41974
+ return this.memo.size;
41975
+ }
41976
+ /**
41977
+ * The picture for this row from this source, rendered once per record +
41978
+ * subject + mode + zone flag + camera and reused for every target and retry.
41979
+ * Null when the row ships no still.
41980
+ */
41981
+ async notificationImage(input) {
41982
+ const payload = input.entry.payload;
41983
+ const mode = payloadImageMode(payload);
41984
+ if (mode === "none") return null;
41985
+ const zoneIds = input.withSubject ? payloadZoneOverlayIds(payload) : [];
41986
+ const key = [
41987
+ input.entry.recordId,
41988
+ payload.subject.trackId ?? "",
41989
+ mode,
41990
+ zoneIds.length > 0 ? "z" : "-",
41991
+ String(input.camera)
41992
+ ].join("|");
41993
+ const now = this.deps.now();
41994
+ const hit = this.memo.get(key);
41995
+ if (hit !== void 0 && now - hit.at <= NC_IMAGE_MEMO_TTL_MS) {
41996
+ this.memo.delete(key);
41997
+ this.memo.set(key, hit);
41998
+ return hit.value;
41999
+ }
42000
+ const value = this.compute(input, mode, zoneIds);
42001
+ this.remember(key, {
42002
+ at: now,
42003
+ value
42004
+ });
42005
+ value.then((image) => {
42006
+ if (image.reason === "render-failed") this.memo.delete(key);
42007
+ }, () => this.memo.delete(key));
42008
+ return value;
42009
+ }
42010
+ remember(key, entry) {
42011
+ const now = this.deps.now();
42012
+ for (const [k, v] of this.memo) if (now - v.at > NC_IMAGE_MEMO_TTL_MS) this.memo.delete(k);
42013
+ while (this.memo.size >= NC_IMAGE_MEMO_MAX) {
42014
+ const oldest = this.memo.keys().next();
42015
+ if (oldest.done === true) break;
42016
+ this.memo.delete(oldest.value);
42017
+ }
42018
+ this.memo.set(key, entry);
42019
+ }
42020
+ async compute(input, mode, zoneIds) {
42021
+ const { entry, source, camera } = input;
42022
+ const payload = entry.payload;
42023
+ if (source.annotated) return this.asStored(input, mode, "annotated-source");
42024
+ if (camera === "composite") return mode === "clean" ? this.asStored(input, mode) : this.asStored(input, mode, "composite-source");
42025
+ const subjectTrackId = input.withSubject ? payload.subject.trackId : void 0;
42026
+ const subjectBox = hasTrackedSubject(subjectTrackId) ? await this.subjectBoxAt(camera, subjectTrackId, source.timestamp) : null;
42027
+ const subject = subjectBox !== null ? {
42028
+ ...subjectBox,
42029
+ label: imageLabelText(payload.subject.label ?? payload.subject.className, payload.subject.confidence)
42030
+ } : null;
42031
+ const others = mode === "scene-boxes" ? otherTracks(await this.tracksAt(camera, source.timestamp), subjectTrackId, subjectBox).map((t) => ({
42032
+ ...t.bbox,
42033
+ label: imageLabelText(t.label ?? t.className, t.confidence)
42034
+ })) : [];
42035
+ const zones = mode === "crop" || zoneIds.length === 0 ? [] : await this.zonePolygons(camera, zoneIds);
42036
+ try {
42037
+ const image = await this.render({
42038
+ mode,
42039
+ bytes: source.bytes,
42040
+ subject,
42041
+ others,
42042
+ zones
42043
+ });
42044
+ const reason = image.rendered === mode ? void 0 : mode === "crop" ? "no-subject-box" : "no-tracks-at-instant";
42045
+ if (reason !== void 0) this.reportFallback(input, mode, reason);
42046
+ return reason !== void 0 ? {
42047
+ ...image,
42048
+ reason
42049
+ } : image;
42050
+ } catch (err) {
42051
+ this.deps.logger.warn("notification picture render failed — shipping the source frame", {
42052
+ tags: { deviceId: camera },
42053
+ meta: {
42054
+ ruleId: entry.ruleId,
42055
+ recordId: entry.recordId,
42056
+ asked: mode,
42057
+ kind: source.kind,
42058
+ error: String(err)
42059
+ }
42060
+ });
42061
+ return {
42062
+ bytes: source.bytes,
42063
+ name: `${source.kind}.jpg`,
42064
+ rendered: "clean",
42065
+ reason: "render-failed"
42066
+ };
42067
+ }
42068
+ }
42069
+ asStored(input, mode, reason) {
42070
+ if (reason !== void 0) this.reportFallback(input, mode, reason);
42071
+ return {
42072
+ bytes: input.source.bytes,
42073
+ name: `${input.source.kind}.jpg`,
42074
+ rendered: input.source.annotated ? "scene-boxes" : "clean",
42075
+ ...reason !== void 0 ? { reason } : {}
42076
+ };
42077
+ }
42078
+ /** ONE line per rendered record (the memo makes it so): what was asked, what ships, why. */
42079
+ reportFallback(input, asked, reason) {
42080
+ const camera = input.camera === "composite" ? input.entry.payload.subject.deviceId : input.camera;
42081
+ this.deps.logger.info("notification picture shipped without the mode the rule asked for", {
42082
+ tags: { deviceId: camera },
42083
+ meta: {
42084
+ ruleId: input.entry.ruleId,
42085
+ recordId: input.entry.recordId,
42086
+ ...input.entry.payload.subject.trackId !== void 0 ? { trackId: input.entry.payload.subject.trackId } : {},
42087
+ asked,
42088
+ reason,
42089
+ kind: input.source.kind,
42090
+ offsetMs: input.source.timestamp - input.entry.payload.subject.timestamp
42091
+ }
42092
+ });
42093
+ }
42094
+ async subjectBoxAt(camera, trackId, timestamp) {
42095
+ const read = this.deps.getSubjectTrail;
42096
+ if (read === void 0) return null;
42097
+ try {
42098
+ return trailAt(await read(camera, trackId), timestamp, NC_IMAGE_BOX_MATCH_MAX_MS)?.bbox ?? null;
42099
+ } catch (err) {
42100
+ this.deps.logger.debug("subject trail read failed — no subject box on the picture", {
42101
+ tags: { deviceId: camera },
42102
+ meta: {
42103
+ trackId,
42104
+ error: String(err)
42105
+ }
42106
+ });
42107
+ return null;
42108
+ }
42109
+ }
42110
+ async tracksAt(camera, timestamp) {
42111
+ const read = this.deps.getTracksAt;
42112
+ if (read === void 0) return [];
42113
+ try {
42114
+ return await read(camera, timestamp, NC_IMAGE_BOX_MATCH_MAX_MS);
42115
+ } catch (err) {
42116
+ this.deps.logger.debug("tracks-at-instant read failed — no other boxes on the picture", {
42117
+ tags: { deviceId: camera },
42118
+ meta: {
42119
+ timestamp,
42120
+ error: String(err)
42121
+ }
42122
+ });
42123
+ return [];
42124
+ }
42125
+ }
42126
+ async zonePolygons(camera, zoneIds) {
42127
+ const read = this.deps.getZonePolygons;
42128
+ if (read === void 0) return [];
42129
+ try {
42130
+ return await read(camera, zoneIds);
42131
+ } catch (err) {
42132
+ this.deps.logger.debug("zone polygon read failed — the trigger zone is not drawn", {
42133
+ tags: { deviceId: camera },
42134
+ meta: {
42135
+ zoneIds: zoneIds.join(","),
42136
+ error: String(err)
42137
+ }
42138
+ });
42139
+ return [];
42140
+ }
42141
+ }
42142
+ };
42143
+ //#endregion
41370
42144
  //#region src/notification-center/dispatcher.ts
41371
42145
  /**
41372
42146
  * NcDispatcher — turns a durable outbox entry into ONE canonical
@@ -41509,12 +42283,6 @@ var NC_STILL_WAIT_INTERVAL_MS = 500;
41509
42283
  * is the instant; the ladder then runs among the frames that are about it.
41510
42284
  */
41511
42285
  var NC_TRIGGER_STILL_MAX_AGE_MS = TRAIL_MATCH_MAX_MS;
41512
- /** Bound on the once-per-record memo of the annotated-fallback line. */
41513
- var NC_ANNOTATED_FALLBACK_MEMO_MAX = 5e3;
41514
- function cleanSceneAsk(policy, frame) {
41515
- if (frame !== void 0) return frame === "full" ? "full" : null;
41516
- return policy === "keyFrame" ? "keyFrame" : null;
41517
- }
41518
42286
  /**
41519
42287
  * Past this age the poll is pointless and only adds latency: the seconds in
41520
42288
  * which this subject's media was being written are long gone, so a row that has
@@ -41548,10 +42316,8 @@ var NcDispatcher = class {
41548
42316
  now;
41549
42317
  sleep;
41550
42318
  targetCacheTtlMs;
41551
- /** Records whose annotated-fallback line was already said — see
41552
- * {@link reportAnnotatedFallback}. Bounded by
41553
- * {@link NC_ANNOTATED_FALLBACK_MEMO_MAX}. */
41554
- reportedAnnotatedFallbacks = /* @__PURE__ */ new Set();
42319
+ /** The ONE render stage of every notification picture (D897). */
42320
+ imageStage;
41555
42321
  texts;
41556
42322
  /** One recorded cut per moment, and the recorder-busy breaker (D686). */
41557
42323
  recordedCuts = new NcRecordedCutMemo();
@@ -41561,6 +42327,13 @@ var NcDispatcher = class {
41561
42327
  this.sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
41562
42328
  this.targetCacheTtlMs = deps.targetCacheTtlMs ?? DEFAULT_TARGET_CACHE_TTL_MS;
41563
42329
  this.texts = deps.texts ?? new NcTextCatalog();
42330
+ this.imageStage = new NcNotificationImageStage({
42331
+ logger: deps.logger,
42332
+ now: this.now,
42333
+ ...deps.getSubjectTrail !== void 0 ? { getSubjectTrail: deps.getSubjectTrail } : {},
42334
+ ...deps.getTracksAt !== void 0 ? { getTracksAt: deps.getTracksAt } : {},
42335
+ ...deps.getZonePolygons !== void 0 ? { getZonePolygons: deps.getZonePolygons } : {}
42336
+ });
41564
42337
  }
41565
42338
  /**
41566
42339
  * Drop held recorded cuts past their TTL (re-review R4). Driven by the
@@ -42634,16 +43407,14 @@ var NcDispatcher = class {
42634
43407
  * analyst to judge ({@link NcBuiltNotification.grouped}). */
42635
43408
  async resolveAttachments(entry) {
42636
43409
  const out = [];
42637
- const zoneIdsWanted = entry.payload.mediaZoneIds;
42638
- const stillOverride = zoneIdsWanted !== void 0 && zoneIdsWanted.length > 0 ? "keyFrame" : void 0;
42639
43410
  const groupView = readOutboxGroup(entry.payload.group);
42640
43411
  const groupMembers = groupedTileMembers(groupView);
42641
43412
  const groupTiles = entry.payload.media === "none" ? null : groupMembers;
42642
43413
  const memberTrackIds = (groupView?.members ?? []).map((m) => m.trackId);
42643
- const firstLook = groupTiles !== null ? null : await this.resolveAttachment(entry, stillOverride, canStillGainMedia(entry) ? "trigger-only" : "any");
43414
+ const firstLook = groupTiles !== null ? null : await this.resolveAttachment(entry, canStillGainMedia(entry) ? "trigger-only" : "any");
42644
43415
  const footagePending = this.renderFootage(entry);
42645
43416
  const mosaic = groupView !== void 0 && groupTiles !== null ? await this.groupedMosaic(entry, groupView, groupTiles) : null;
42646
- const still = mosaic !== null ? mosaic.attachment : firstLook !== null || entry.payload.media === "none" ? firstLook : await this.waitForStill(entry, stillOverride);
43417
+ const still = mosaic !== null ? mosaic.attachment : firstLook !== null || entry.payload.media === "none" ? firstLook : await this.waitForStill(entry);
42647
43418
  const grouped = groupView !== void 0 && groupMembers !== null ? {
42648
43419
  windowKey: groupView.key,
42649
43420
  members: groupView.members.length,
@@ -42651,17 +43422,8 @@ var NcDispatcher = class {
42651
43422
  } : null;
42652
43423
  const footage = await footagePending;
42653
43424
  const extras = await this.linkedExtraStills(entry);
42654
- if (still !== null) {
42655
- const zoneIds = zoneIdsWanted;
42656
- if (groupTiles === null && zoneIds !== void 0 && zoneIds.length > 0) {
42657
- const cropped = await this.zoneCrop(entry.payload.subject.deviceId, zoneIds, still.bytes);
42658
- out.push(cropped !== null ? {
42659
- ...still,
42660
- bytes: cropped,
42661
- name: "zone.jpg"
42662
- } : still);
42663
- } else out.push(still);
42664
- } else if (entry.payload.media !== "none" && extras.length > 0) this.deps.logger.info("no still of the primary camera could be resolved — the other linked cameras' stills ship without it", {
43425
+ if (still !== null) out.push(still);
43426
+ else if (entry.payload.media !== "none" && extras.length > 0) this.deps.logger.info("no still of the primary camera could be resolved — the other linked cameras' stills ship without it", {
42665
43427
  tags: { deviceId: entry.payload.subject.deviceId },
42666
43428
  meta: {
42667
43429
  recordKind: entry.recordKind,
@@ -42677,7 +43439,7 @@ var NcDispatcher = class {
42677
43439
  recordKind: entry.recordKind,
42678
43440
  recordId: entry.recordId,
42679
43441
  policy: entry.payload.media,
42680
- ...entry.payload.mediaFrame !== void 0 ? { frame: entry.payload.mediaFrame } : {},
43442
+ ...entry.payload.mediaImage !== void 0 ? { image: entry.payload.mediaImage } : {},
42681
43443
  ...memberTrackIds.length > 1 ? { memberTrackIds } : {},
42682
43444
  owners: [...entry.payload.subject.eventId !== void 0 ? [`object:${entry.payload.subject.eventId}`] : [], ...entry.payload.subject.trackId !== void 0 ? [`track:${entry.payload.subject.trackId}`] : []]
42683
43445
  }
@@ -42705,7 +43467,7 @@ var NcDispatcher = class {
42705
43467
  for (const { ownerId, cameraId } of subject.extraStills ?? []) {
42706
43468
  const file = await this.linkedStillFile(entry, ownerId, cameraId);
42707
43469
  const bytes = file === void 0 ? null : decodeStillBytes(file.base64);
42708
- if (bytes === null) {
43470
+ if (file === void 0 || bytes === null) {
42709
43471
  this.deps.logger.info("linked camera picture missing at send — skipped", {
42710
43472
  tags: { deviceId: cameraId },
42711
43473
  meta: {
@@ -42716,9 +43478,21 @@ var NcDispatcher = class {
42716
43478
  });
42717
43479
  continue;
42718
43480
  }
43481
+ const image = await this.imageStage.notificationImage({
43482
+ entry,
43483
+ source: {
43484
+ bytes,
43485
+ kind: file.kind,
43486
+ timestamp: file.timestamp,
43487
+ annotated: isAnnotatedSceneKind(file.kind)
43488
+ },
43489
+ camera: cameraId,
43490
+ withSubject: false
43491
+ });
43492
+ if (image === null) continue;
42719
43493
  out.push({
42720
43494
  mediaType: "image",
42721
- bytes,
43495
+ bytes: image.bytes,
42722
43496
  mime: "image/jpeg",
42723
43497
  name: `camera-${String(cameraId)}.jpg`
42724
43498
  });
@@ -43175,14 +43949,14 @@ var NcDispatcher = class {
43175
43949
  * Bounded and then abandoned — a text-only notification about a person at the
43176
43950
  * door beats a punctual one nobody received.
43177
43951
  */
43178
- async waitForStill(entry, policyOverride) {
43952
+ async waitForStill(entry) {
43179
43953
  const subject = entry.payload.subject;
43180
- if (this.now() - subject.timestamp > NC_STILL_WAIT_MAX_AGE_MS) return this.resolveAttachment(entry, policyOverride, "any");
43954
+ if (this.now() - subject.timestamp > NC_STILL_WAIT_MAX_AGE_MS) return this.resolveAttachment(entry, "any");
43181
43955
  if (subject.eventId === void 0 && subject.trackId === void 0) return null;
43182
43956
  const attempts = Math.floor(NC_STILL_WAIT_MS / NC_STILL_WAIT_INTERVAL_MS);
43183
43957
  for (let i = 0; i < attempts; i += 1) {
43184
43958
  await this.sleep(NC_STILL_WAIT_INTERVAL_MS);
43185
- const still = await this.resolveAttachment(entry, policyOverride, "trigger-only");
43959
+ const still = await this.resolveAttachment(entry, "trigger-only");
43186
43960
  if (still !== null) {
43187
43961
  this.deps.logger.info("the still landed during the bounded wait", {
43188
43962
  tags: { deviceId: entry.payload.subject.deviceId },
@@ -43196,55 +43970,7 @@ var NcDispatcher = class {
43196
43970
  return still;
43197
43971
  }
43198
43972
  }
43199
- return this.resolveAttachment(entry, policyOverride, "any");
43200
- }
43201
- /** Crop a JPEG to the padded bbox of the given zones (normalized polygons →
43202
- * pixel rect via sharp metadata). Null on any failure — caller falls back
43203
- * to the uncropped still. */
43204
- async zoneCrop(deviceId, zoneIds, jpeg) {
43205
- try {
43206
- const points = (await this.deps.getZonePolygons?.(deviceId, zoneIds) ?? []).flat();
43207
- if (points.length === 0) return null;
43208
- const { default: sharp$18 } = await import("sharp");
43209
- const img = sharp$18(Buffer.from(jpeg));
43210
- const meta = await img.metadata();
43211
- const W = meta.width ?? 0;
43212
- const H = meta.height ?? 0;
43213
- if (W === 0 || H === 0) return null;
43214
- let minX = 1;
43215
- let minY = 1;
43216
- let maxX = 0;
43217
- let maxY = 0;
43218
- for (const p of points) {
43219
- if (p.x < minX) minX = p.x;
43220
- if (p.y < minY) minY = p.y;
43221
- if (p.x > maxX) maxX = p.x;
43222
- if (p.y > maxY) maxY = p.y;
43223
- }
43224
- if (maxX <= minX || maxY <= minY) return null;
43225
- const padX = (maxX - minX) * .1;
43226
- const padY = (maxY - minY) * .1;
43227
- const left = Math.max(0, Math.floor((minX - padX) * W));
43228
- const top = Math.max(0, Math.floor((minY - padY) * H));
43229
- const width = Math.min(W - left, Math.ceil((maxX - minX + 2 * padX) * W));
43230
- const height = Math.min(H - top, Math.ceil((maxY - minY + 2 * padY) * H));
43231
- if (width < 16 || height < 16) return null;
43232
- const outBuf = await img.extract({
43233
- left,
43234
- top,
43235
- width,
43236
- height
43237
- }).jpeg({ quality: 82 }).toBuffer();
43238
- const bytes = new Uint8Array(outBuf.byteLength);
43239
- bytes.set(outBuf);
43240
- return bytes;
43241
- } catch (err) {
43242
- this.deps.logger.debug("zone crop failed — attaching uncropped still", { meta: {
43243
- deviceId,
43244
- error: String(err)
43245
- } });
43246
- return null;
43247
- }
43973
+ return this.resolveAttachment(entry, "any");
43248
43974
  }
43249
43975
  /**
43250
43976
  * The grouped MOSAIC (D692 §6.2): one tile per member, at most
@@ -43376,7 +44102,7 @@ var NcDispatcher = class {
43376
44102
  const tileEntry = entries[index];
43377
44103
  const m = tiles[index];
43378
44104
  if (tileEntry === void 0 || m === void 0) return;
43379
- const still = await this.resolveAttachment(tileEntry, void 0, freshness);
44105
+ const still = await this.resolveAttachment(tileEntry, freshness);
43380
44106
  if (still !== null) found.set(index, {
43381
44107
  trackId: m.trackId,
43382
44108
  className: m.className,
@@ -43508,53 +44234,32 @@ var NcDispatcher = class {
43508
44234
  return ordered;
43509
44235
  }
43510
44236
  /**
43511
- * A rule that asked for the CLEAN scene was answered by an annotated frame.
44237
+ * The row's still: the CLEAN source frame of the trigger moment, rendered by
44238
+ * the row's picture mode (D897, `notification-image.ts`).
43512
44239
  *
43513
- * The documented last rung of the `full` / `keyFrame` ladders (D654): the
43514
- * clean twin is written beside every boxed tile, so reaching an annotated
43515
- * kind means this track holds no clean frame at all — written before the
43516
- * twin existed, or a twin write that failed. The picture ships (it is of the
43517
- * right moment), but never silently as "full frame": this is the line that
43518
- * answers "why does my full-frame notification have a box on it".
44240
+ * The source ladder is the same for every mode — `cleanSourcePreference`,
44241
+ * the track owner first (the scene frames live there), then the object
44242
+ * event's — and MOMENT then PLACE filter it exactly as before. What the
44243
+ * rule asked to SEE is decided once, by the render, never by which stored
44244
+ * kind happened to land.
43519
44245
  */
43520
- reportAnnotatedFallback(entry, asked, kind, ownerId) {
43521
- if (asked === null || !isAnnotatedSceneKind(kind)) return;
43522
- if (this.reportedAnnotatedFallbacks.has(entry.recordId)) return;
43523
- if (this.reportedAnnotatedFallbacks.size >= NC_ANNOTATED_FALLBACK_MEMO_MAX) this.reportedAnnotatedFallbacks.clear();
43524
- this.reportedAnnotatedFallbacks.add(entry.recordId);
43525
- this.deps.logger.warn("clean scene asked — only an annotated frame is stored, shipping it", {
43526
- tags: { deviceId: entry.payload.subject.deviceId },
43527
- meta: {
43528
- ruleId: entry.ruleId,
43529
- recordId: entry.recordId,
43530
- ownerId,
43531
- asked,
43532
- kind
43533
- }
43534
- });
43535
- }
43536
- async resolveAttachment(entry, policyOverride, freshness = "any") {
43537
- const policy = policyOverride ?? entry.payload.media;
43538
- if (policy === "none") return null;
43539
- const frame = policyOverride === void 0 ? entry.payload.mediaFrame : void 0;
44246
+ async resolveAttachment(entry, freshness = "any") {
44247
+ if (entry.payload.media === "none") return null;
43540
44248
  const subject = entry.payload.subject;
43541
- const signal = policy === "best-matching" ? matchSignal(entry.payload.matchedOn) : null;
43542
44249
  const owners = [];
43543
- if (subject.eventId !== void 0) owners.push({
43544
- kind: "object",
43545
- id: subject.eventId
43546
- });
43547
44250
  if (subject.trackId !== void 0) owners.push({
43548
44251
  kind: "track",
43549
44252
  id: subject.trackId
43550
44253
  });
43551
- if (policy === "keyFrame" || frame === "full" || frame === "boxed") owners.reverse();
44254
+ if (subject.eventId !== void 0) owners.push({
44255
+ kind: "object",
44256
+ id: subject.eventId
44257
+ });
43552
44258
  for (const owner of owners) try {
43553
44259
  const files = owner.kind === "object" ? await this.deps.getMediaForOwner(owner.kind, owner.id, eventMediaScopeOf(subject)) : await this.deps.getMediaForOwner(owner.kind, owner.id);
43554
44260
  if (files.length === 0) continue;
43555
- const preference = frame !== void 0 ? framePreference(frame, owner.kind) : policy === "best-matching" ? bestMatchingKindPreference(signal, owner.kind) : attachmentKindPreference(policy, owner.kind);
43556
44261
  const ordered = [];
43557
- for (const kind of preference) for (const file of files) if (file.kind === kind) ordered.push(file);
44262
+ for (const kind of cleanSourcePreference(owner.kind)) for (const file of files) if (file.kind === kind) ordered.push(file);
43558
44263
  const inBand = this.narrowToTriggerInstant(entry, ordered, freshness);
43559
44264
  if (inBand.length === 0) continue;
43560
44265
  const shortlist = await this.narrowToTriggerZone(entry, inBand, freshness);
@@ -43562,12 +44267,23 @@ var NcDispatcher = class {
43562
44267
  for (const file of shortlist) {
43563
44268
  const bytes = decodeStillBytes(file.base64);
43564
44269
  if (bytes === null) continue;
43565
- this.reportAnnotatedFallback(entry, cleanSceneAsk(entry.payload.media, entry.payload.mediaFrame), file.kind, owner.id);
44270
+ const image = await this.imageStage.notificationImage({
44271
+ entry,
44272
+ source: {
44273
+ bytes,
44274
+ kind: file.kind,
44275
+ timestamp: file.timestamp,
44276
+ annotated: isAnnotatedSceneKind(file.kind)
44277
+ },
44278
+ camera: primaryImageCamera(entry.payload),
44279
+ withSubject: true
44280
+ });
44281
+ if (image === null) return null;
43566
44282
  return {
43567
44283
  mediaType: "image",
43568
- bytes,
44284
+ bytes: image.bytes,
43569
44285
  mime: "image/jpeg",
43570
- name: `${file.kind}.jpg`
44286
+ name: image.name
43571
44287
  };
43572
44288
  }
43573
44289
  } catch (err) {
@@ -48371,7 +49087,20 @@ function bodyToRule(id, raw, onProjection) {
48371
49087
  });
48372
49088
  if (!parsed.success) return null;
48373
49089
  if (projection.kind === "projected") onProjection?.(id, projection);
48374
- return parsed.data;
49090
+ return withNormalizedMedia(parsed.data);
49091
+ }
49092
+ /**
49093
+ * D897: the rule with its media policy in the stored shape — `image` set, the
49094
+ * legacy `attach` / `frame` / `zoneCrop` folded into it. Applied on LOAD (a read
49095
+ * projection: a rule nobody saved since keeps its row as written) and on every
49096
+ * WRITE, so the first save of a rule converges its row and an editor that still
49097
+ * sends the legacy triple is folded on the way in.
49098
+ */
49099
+ function withNormalizedMedia(rule) {
49100
+ return {
49101
+ ...rule,
49102
+ media: require_clip_model_registry.normalizeNcMediaPolicy(rule.media, rule)
49103
+ };
48375
49104
  }
48376
49105
  function isLegacyDeps$2(deps) {
48377
49106
  return "store" in deps;
@@ -48648,14 +49377,14 @@ var NcRuleStore = class NcRuleStore {
48648
49377
  /** Create a new rule. `createdBy` is the SERVER-injected caller userId. */
48649
49378
  async create(input, createdBy) {
48650
49379
  const now = this.now();
48651
- const rule = {
49380
+ const rule = withNormalizedMedia({
48652
49381
  ...input,
48653
49382
  id: this.newId(),
48654
49383
  createdBy,
48655
49384
  createdAt: now,
48656
49385
  updatedAt: now,
48657
49386
  disabledTargetIds: []
48658
- };
49387
+ });
48659
49388
  await this.write(rule);
48660
49389
  return rule;
48661
49390
  }
@@ -48693,7 +49422,7 @@ var NcRuleStore = class NcRuleStore {
48693
49422
  createdAt: existing.createdAt,
48694
49423
  updatedAt: this.now()
48695
49424
  };
48696
- const updated = require_clip_model_registry.NcRuleSchema.parse(candidate);
49425
+ const updated = withNormalizedMedia(require_clip_model_registry.NcRuleSchema.parse(candidate));
48697
49426
  await this.write(updated);
48698
49427
  return updated;
48699
49428
  }
@@ -55254,19 +55983,20 @@ var NC_ALARM_ANNOUNCE_RULE_ID = "nc-alarm-announce";
55254
55983
  * every row a rule addresses AND by the alarm's own announcement row when it is
55255
55984
  * the COMBINED siren (the triggering rule's pictures on the alarm's words).
55256
55985
  *
55257
- * `effectiveMediaPolicyForRule` is what actually resolves it: a SENSOR rule's
55258
- * stored `media` is ignored here, in favour of the one-snapshot default
55259
- * (operator ruling, 2026-10-01) — the rule still LOADS with whatever it has
55260
- * stored, and `buildEntries` is where the sampled log fires when that stored
55261
- * choice is about to be overridden.
55986
+ * `effectiveMediaPolicyForRule` resolves the picture MODE (D897) — a sensor
55987
+ * rule's included: it goes through the same selector and render stage as a
55988
+ * detection rule. `triggerZoneIds` are the zones of the rule that owns the
55989
+ * media (the SOURCE rule of a combined alarm row), and a zone overlay is
55990
+ * frozen only when the rule asked for one AND fired on a zone.
55262
55991
  */
55263
55992
  function mediaPolicyFields(mediaRule, cropZoneIds) {
55264
55993
  const media = effectiveMediaPolicyForRule(mediaRule);
55994
+ const image = media.image ?? "crop";
55265
55995
  return {
55266
- media: media.attach,
55267
- ...media.zoneCrop === true && cropZoneIds !== void 0 ? { mediaZoneIds: [...cropZoneIds] } : {},
55996
+ media: image === "none" ? "none" : "best",
55997
+ mediaImage: image,
55998
+ ...media.showTriggerZone === true && cropZoneIds !== void 0 && cropZoneIds.length > 0 ? { mediaShowTriggerZone: true } : {},
55268
55999
  ...cropZoneIds !== void 0 ? { triggerZoneIds: [...cropZoneIds] } : {},
55269
- ...media.frame !== void 0 ? { mediaFrame: media.frame } : {},
55270
56000
  ...media.profile !== void 0 ? { mediaProfile: media.profile } : {},
55271
56001
  ...media.gif === true ? { mediaGif: true } : {},
55272
56002
  ...media.clip === true ? { mediaClip: true } : {},
@@ -55306,6 +56036,36 @@ function alarmButtonKind(kind) {
55306
56036
  default: return null;
55307
56037
  }
55308
56038
  }
56039
+ /**
56040
+ * Is a bypassed (rule, input) pair still an input of that rule? The rule must
56041
+ * exist, be enabled, still press the panel, and — when it names devices —
56042
+ * still name the input or its camera, and not exclude the input. A false
56043
+ * "no" re-arms the input (protection restored), which is the safe direction.
56044
+ */
56045
+ function alarmRuleStillHasInput(rule, entry) {
56046
+ if (rule === null || !rule.enabled || !require_clip_model_registry.ruleAutoTriggersAlarm(rule)) return false;
56047
+ if ((rule.conditions.devicesExclude ?? []).includes(entry.deviceId)) return false;
56048
+ const scope = rule.conditions.devices;
56049
+ if (scope === void 0 || scope.length === 0) return true;
56050
+ return scope.includes(entry.deviceId) || scope.includes(entry.cameraId);
56051
+ }
56052
+ /**
56053
+ * The (rule, input) pair an `alarm-triggered` / `alarm-pending` row is about,
56054
+ * from the source it FROZE (`alarmSource`). Undefined on a manual trigger.
56055
+ */
56056
+ function rowBypassableInput(systemEvent) {
56057
+ const kind = systemEvent?.kind;
56058
+ if (kind !== "alarm-triggered" && kind !== "alarm-pending") return void 0;
56059
+ const source = systemEvent?.alarmSource;
56060
+ if (source === void 0) return void 0;
56061
+ return {
56062
+ ruleId: source.ruleId,
56063
+ ruleName: source.ruleName,
56064
+ inputDeviceId: intrusionDeviceOf(source),
56065
+ cameraId: source.deviceId,
56066
+ notedAt: source.timestamp
56067
+ };
56068
+ }
55309
56069
  /** The refusal id an `alarm-arm-refused` row froze (`incomingFromAlarmTransition`). */
55310
56070
  function rowRefusalId(systemEvent) {
55311
56071
  const value = systemEvent?.data["refusalId"];
@@ -55321,6 +56081,12 @@ var ARM_ANYWAY_REJECTION_COPY = {
55321
56081
  used: "Already used",
55322
56082
  "unknown-refusal": "This refusal is no longer current — arm again"
55323
56083
  };
56084
+ /** What a pressed Bypass that did nothing tells the person holding the phone. */
56085
+ var BYPASS_INPUT_REFUSAL_COPY = {
56086
+ disarmed: "The alarm is disarmed — nothing to bypass",
56087
+ stale: "The alarm was re-armed since this notification — nothing was bypassed",
56088
+ full: "Too many inputs are bypassed already — disarm to reset them"
56089
+ };
55324
56090
  /**
55325
56091
  * What the sustained-state alerts run on when nothing wired the settings in.
55326
56092
  *
@@ -55706,9 +56472,6 @@ function renderAudioScope(devices, excluded) {
55706
56472
  if (devices.length === 0) return excluded.length === 0 ? "@all" : `@all-except:${excluded.join("+")}`;
55707
56473
  return devices.filter((id) => !excluded.includes(id)).join("+");
55708
56474
  }
55709
- /** {@link NotificationCenter.reportedSensorMediaIgnored} cap, same shape as
55710
- * the dispatcher's own annotated-fallback memo. */
55711
- var NC_SENSOR_MEDIA_IGNORED_MEMO_MAX = 5e3;
55712
56475
  /**
55713
56476
  * One KEPT member as the payload freezes it (§6.1): its latest name, its own first-matched
55714
56477
  * event identity (D694 — the event's STORED timestamp, never a clock), its qualifying
@@ -55769,9 +56532,6 @@ var NotificationCenter = class {
55769
56532
  dispatcher;
55770
56533
  deps;
55771
56534
  now;
55772
- /** Rule ids whose stored (non-default) media policy has already been
55773
- * reported as ignored at evaluation — ONE line per rule, not per fire. */
55774
- reportedSensorMediaIgnored = /* @__PURE__ */ new Set();
55775
56535
  /** Samples the device-compat guard's "accepted unchecked" WARN per rule. */
55776
56536
  deviceCompatWarnSampler = new NcDeviceCompatWarnSampler();
55777
56537
  /**
@@ -56141,6 +56901,7 @@ var NotificationCenter = class {
56141
56901
  targetId: input.targetId
56142
56902
  }
56143
56903
  });
56904
+ const bypassable = rowBypassableInput(input.systemEvent);
56144
56905
  const alarmButtons = alarmKind === null ? [] : buildAlarmButtons({
56145
56906
  kind: alarmKind,
56146
56907
  panel: panel === null ? null : {
@@ -56148,10 +56909,12 @@ var NotificationCenter = class {
56148
56909
  availableModes: panel.availableModes()
56149
56910
  },
56150
56911
  ...refusalId !== void 0 ? { refusalId } : {},
56912
+ ...bypassable !== void 0 ? { bypassable } : {},
56151
56913
  labels: {
56152
56914
  disarm: this.texts.text({ key: "button.alarm.disarm" }),
56153
56915
  arm: (mode) => this.texts.text({ key: `button.alarm.arm.${mode}` }),
56154
- armAnyway: this.texts.text({ key: "button.alarm.armAnyway" })
56916
+ armAnyway: this.texts.text({ key: "button.alarm.armAnyway" }),
56917
+ bypassInput: this.texts.text({ key: "button.alarm.bypassInput" })
56155
56918
  },
56156
56919
  mintUrl: (button) => {
56157
56920
  const id = (0, node_crypto.randomUUID)();
@@ -56188,6 +56951,16 @@ var NotificationCenter = class {
56188
56951
  action: "disarm",
56189
56952
  expiresAt
56190
56953
  };
56954
+ case "bypass-input": return {
56955
+ ...common,
56956
+ action: "bypass-input",
56957
+ ruleId: button.ruleId,
56958
+ ruleName: button.ruleName,
56959
+ inputDeviceId: button.inputDeviceId,
56960
+ cameraId: button.cameraId,
56961
+ notedAt: button.notedAt,
56962
+ expiresAt
56963
+ };
56191
56964
  }
56192
56965
  }
56193
56966
  });
@@ -56375,6 +57148,7 @@ var NotificationCenter = class {
56375
57148
  if (panel === null) throw new Error("this node has no alarm panel");
56376
57149
  if (panel.deviceId !== grant.panelDeviceId) throw new Error("this button was minted for a different alarm panel");
56377
57150
  if (grant.action === "arm-anyway") return this.applyGrantedArmAnyway(panel, grant);
57151
+ if (grant.action === "bypass-input") return this.applyGrantedBypassInput(panel, grant);
56378
57152
  if (this.deps.actuate === void 0) throw new Error("no actuator is wired on this node");
56379
57153
  await this.actuateWithAttribution({
56380
57154
  deviceId: panel.deviceId,
@@ -56411,6 +57185,32 @@ var NotificationCenter = class {
56411
57185
  return;
56412
57186
  }
56413
57187
  }
57188
+ /**
57189
+ * "Bypass this input until disarm", from a trigger notification — the (rule,
57190
+ * input) pair the grant froze, on the panel, in-process. The token is the
57191
+ * whole of the authority (D47), exactly as for the Disarm beside it; the
57192
+ * panel refuses a disarmed alarm and an arm session that is already over.
57193
+ */
57194
+ async applyGrantedBypassInput(panel, grant) {
57195
+ const outcome = panel.bypassInput({
57196
+ ruleId: grant.ruleId,
57197
+ ruleName: grant.ruleName,
57198
+ deviceId: grant.inputDeviceId,
57199
+ cameraId: grant.cameraId,
57200
+ notedAt: grant.notedAt
57201
+ });
57202
+ if (outcome.outcome === "refused") throw new NcActionRefusedError(BYPASS_INPUT_REFUSAL_COPY[outcome.reason]);
57203
+ this.logger.info("alarm input bypassed from a notification button", {
57204
+ tags: { deviceId: grant.inputDeviceId },
57205
+ meta: {
57206
+ panelDeviceId: panel.deviceId,
57207
+ ruleId: grant.ruleId,
57208
+ rule: grant.ruleName,
57209
+ outcome: outcome.outcome,
57210
+ targetId: grant.targetId ?? null
57211
+ }
57212
+ });
57213
+ }
56414
57214
  /** The registry the action plane claims against. */
56415
57215
  get actionTokenRegistry() {
56416
57216
  return this.actionTokens;
@@ -57818,7 +58618,7 @@ var NotificationCenter = class {
57818
58618
  const shown = this.unmutedCameras(cameraIds);
57819
58619
  const involved = [sensorId, ...rowShown];
57820
58620
  if (!this.rules.listEnabled("device-event").some((rule) => {
57821
- if (effectiveMediaPolicyForRule(rule).attach === "none") return false;
58621
+ if (ruleShipsNoStill(rule)) return false;
57822
58622
  if (!deviceEventRuleMayFireOnKind(rule, sensorKind, sensorRaised)) return false;
57823
58623
  const excluded = rule.conditions.devicesExclude;
57824
58624
  if (excluded !== void 0 && deviceScopeExcludes(excluded, {
@@ -57962,7 +58762,7 @@ var NotificationCenter = class {
57962
58762
  const shelf = this.stills;
57963
58763
  if (shelf === null || !this.evaluationActive) return void 0;
57964
58764
  for (const rule of this.rules.listEnabled("immediate")) {
57965
- if (rule.media.attach === "none") continue;
58765
+ if (ruleShipsNoStill(rule)) continue;
57966
58766
  if (!(mode === "condition" ? rule.conditions.audio !== void 0 : rule.conditions.audio === void 0 && referencesAudioClass(rule.conditions.classes))) continue;
57967
58767
  const devices = rule.conditions.devices;
57968
58768
  if (devices !== void 0 && devices.length > 0 && !devices.includes(deviceId)) continue;
@@ -58134,7 +58934,7 @@ var NotificationCenter = class {
58134
58934
  ruleWantsOccupancyStill(edge) {
58135
58935
  const subject = occupancySubjectFromEdge(edge);
58136
58936
  for (const rule of this.rules.listEnabled("device-event")) {
58137
- if (effectiveMediaPolicyForRule(rule).attach === "none") continue;
58937
+ if (ruleShipsNoStill(rule)) continue;
58138
58938
  const occ = rule.conditions.occupancy;
58139
58939
  if (occ === void 0 || !matchesOccupancy(occ, subject)) continue;
58140
58940
  const devices = rule.conditions.devices;
@@ -58430,6 +59230,10 @@ var NotificationCenter = class {
58430
59230
  logger: this.logger,
58431
59231
  by: caller.userId
58432
59232
  });
59233
+ guardRuleMedia(rule, {
59234
+ logger: this.logger,
59235
+ by: caller.userId
59236
+ });
58433
59237
  guardRuleDevicesExclude(rule, {
58434
59238
  logger: this.logger,
58435
59239
  by: caller.userId
@@ -58477,6 +59281,11 @@ var NotificationCenter = class {
58477
59281
  by: caller.userId,
58478
59282
  ruleId
58479
59283
  });
59284
+ guardPatchMedia(existing, patch, {
59285
+ logger: this.logger,
59286
+ by: caller.userId,
59287
+ ruleId
59288
+ });
58480
59289
  guardPatchDevicesExclude(existing, patch, {
58481
59290
  logger: this.logger,
58482
59291
  by: caller.userId,
@@ -58873,6 +59682,49 @@ var NotificationCenter = class {
58873
59682
  meta: { userId }
58874
59683
  });
58875
59684
  },
59685
+ bypassInput: async ({ ruleId, deviceId }, userId) => {
59686
+ const panel = requirePanel();
59687
+ const rule = this.rules.get(ruleId) ?? null;
59688
+ if (rule === null || !alarmRuleStillHasInput(rule, {
59689
+ deviceId,
59690
+ cameraId: deviceId
59691
+ })) {
59692
+ this.logger.info("alarm input bypass refused — not an input of an alarm rule", {
59693
+ tags: { deviceId },
59694
+ meta: {
59695
+ userId,
59696
+ ruleId,
59697
+ panelDeviceId: panel.deviceId
59698
+ }
59699
+ });
59700
+ return {
59701
+ outcome: "refused",
59702
+ reason: "not-an-input"
59703
+ };
59704
+ }
59705
+ const outcome = panel.bypassInput({
59706
+ ruleId,
59707
+ ruleName: rule.name,
59708
+ deviceId,
59709
+ cameraId: deviceId
59710
+ });
59711
+ this.logger.info("alarm input bypass requested through the bridge", {
59712
+ tags: { deviceId },
59713
+ meta: {
59714
+ userId,
59715
+ ruleId,
59716
+ rule: rule.name,
59717
+ outcome: outcome.outcome
59718
+ }
59719
+ });
59720
+ return outcome.outcome === "refused" ? {
59721
+ outcome: "refused",
59722
+ reason: outcome.reason
59723
+ } : {
59724
+ outcome: outcome.outcome,
59725
+ reason: null
59726
+ };
59727
+ },
58876
59728
  getConfig: () => this.alarmConfig(),
58877
59729
  getSensors: (input) => this.alarmSensors(input),
58878
59730
  setConfig: (patch) => this.setAlarmConfig(patch)
@@ -58949,6 +59801,8 @@ var NotificationCenter = class {
58949
59801
  openings,
58950
59802
  nonBlocking: panel.settings().nonBlocking,
58951
59803
  exclusions: panel.exclusionPhases(),
59804
+ bypasses: panel.inputBypasses(),
59805
+ ruleName: (ruleId) => this.rules.get(ruleId)?.name,
58952
59806
  name: this.deviceNameFromDirectory()
58953
59807
  });
58954
59808
  }
@@ -59265,7 +60119,7 @@ var NotificationCenter = class {
59265
60119
  const subject = incoming.subject;
59266
60120
  const alarmSource = subject.systemEvent?.alarmSource;
59267
60121
  const sourceRule = alarmSource !== void 0 ? this.rules.get(alarmSource.ruleId) : null;
59268
- const media = sourceRule !== null ? mediaPolicyFields(sourceRule, void 0) : { media: "none" };
60122
+ const media = sourceRule !== null ? mediaPolicyFields(sourceRule, ruleCropZoneIds(sourceRule)) : { media: "none" };
59269
60123
  const mediaDeviceId = alarmSource?.deviceId ?? panel.deviceId;
59270
60124
  const entries = targets.map((targetId) => ({
59271
60125
  ruleId: NC_ALARM_ANNOUNCE_RULE_ID,
@@ -60105,7 +60959,7 @@ var NotificationCenter = class {
60105
60959
  buttons: unresolvable.join(", ")
60106
60960
  }
60107
60961
  });
60108
- if (!noOwnerReported && kind === "device-event" && subject.trackId === void 0 && subject.linkedStills === void 0 && effectiveMediaPolicyForRule(rule).attach !== "none") {
60962
+ if (!noOwnerReported && kind === "device-event" && subject.trackId === void 0 && subject.linkedStills === void 0 && !ruleShipsNoStill(rule)) {
60109
60963
  noOwnerReported = true;
60110
60964
  const linked = subject.linkedCameraIds;
60111
60965
  const meta = {
@@ -60138,7 +60992,7 @@ var NotificationCenter = class {
60138
60992
  meta
60139
60993
  });
60140
60994
  }
60141
- if (!noOwnerReported && (kind === "audio-window" || kind === "audio-event") && subject.trackId === void 0 && rule.media.attach !== "none") {
60995
+ if (!noOwnerReported && (kind === "audio-window" || kind === "audio-event") && subject.trackId === void 0 && !ruleShipsNoStill(rule)) {
60142
60996
  noOwnerReported = true;
60143
60997
  this.logger.warn("audio match has no still owner — this notification ships text-only", {
60144
60998
  tags: { deviceId: subject.deviceId },
@@ -60226,8 +61080,17 @@ var NotificationCenter = class {
60226
61080
  * Materialise legacy inline `onTrigger` sequences into the shared store and
60227
61081
  * rewrite each rule's actions to bindings + `buttons.sequenceId`. Runs on RAW
60228
61082
  * rows before {@link NcRuleStore.load} so Zod never skips them.
61083
+ *
61084
+ * A rewrite of the LEGACY rules table only. A records-backed centre has no
61085
+ * such table to read (no boot declares it; the adoption mapper read it once,
61086
+ * after every earlier boot had already run this rewrite on it), so it skips —
61087
+ * reading it anyway failed on every boot ("is not declared").
60229
61088
  */
60230
61089
  async migrateLegacyRuleActions() {
61090
+ if (this.deps.records !== void 0) {
61091
+ this.logger.debug("legacy actions migration not run — rules live on records", { meta: { collection: NC_RULES_COLLECTION } });
61092
+ return;
61093
+ }
60231
61094
  let rows;
60232
61095
  try {
60233
61096
  rows = await this.deps.store.query.query({
@@ -60505,24 +61368,6 @@ var NotificationCenter = class {
60505
61368
  }
60506
61369
  });
60507
61370
  }
60508
- /**
60509
- * ONE line per rule id, the first time a SENSOR rule's stored media policy
60510
- * is about to be overridden by {@link effectiveMediaPolicyForRule}'s
60511
- * one-snapshot default (operator ruling, 2026-10-01). Never refuses the
60512
- * rule — it still loads and still fires — this only says the stored choice
60513
- * is not the one being used.
60514
- */
60515
- warnSensorMediaIgnoredOnce(rule) {
60516
- if (mediaPolicyMatchesSensorDefault(rule.media)) return;
60517
- if (this.reportedSensorMediaIgnored.has(rule.id)) return;
60518
- if (this.reportedSensorMediaIgnored.size >= NC_SENSOR_MEDIA_IGNORED_MEMO_MAX) this.reportedSensorMediaIgnored.clear();
60519
- this.reportedSensorMediaIgnored.add(rule.id);
60520
- this.logger.info("sensor rule carries a media choice — ignored at evaluation, one snapshot is always attached", { meta: {
60521
- ruleId: rule.id,
60522
- rule: rule.name,
60523
- storedMedia: rule.media
60524
- } });
60525
- }
60526
61371
  buildEntries(rule, subject, kind, matchedOn, userTargets = [], origin = "pipeline", cooldown, extras) {
60527
61372
  const hasEventMedia = kind === "object-event" || kind === "package-event";
60528
61373
  const isTrackScoped = kind === "object-event" || kind === "track-end";
@@ -60530,11 +61375,10 @@ var NotificationCenter = class {
60530
61375
  const alarmSource = subject.systemEvent?.alarmSource;
60531
61376
  const mediaRule = alarmSource !== void 0 ? this.rules.get(alarmSource.ruleId) ?? rule : rule;
60532
61377
  const noMedia = isSystemEvent && alarmSource === void 0;
60533
- if (require_clip_model_registry.ruleKindOf(mediaRule) === "sensor") this.warnSensorMediaIgnoredOnce(mediaRule);
60534
61378
  const direct = new Set(rule.targets.map((t) => t.targetId));
60535
61379
  const renderedZones = notificationZones(rule, subject);
60536
61380
  const confirm = frozenConfirmPolicy(rule, isSystemEvent);
60537
- const cropZoneIds = ruleCropZoneIds(rule);
61381
+ const cropZoneIds = ruleCropZoneIds(mediaRule);
60538
61382
  const allTargets = [...rule.targets, ...userTargets.filter((t) => !direct.has(t.targetId))];
60539
61383
  const linkedOwners = !noMedia && subject.linkedStills !== void 0 ? linkedMediaOwners(subject.linkedStills, linkedCameraMode(mediaRule.media)) : void 0;
60540
61384
  const groupV2 = extras?.groupV2 ?? (subject.systemEvent !== void 0 && !noMedia ? alarmIncidentGroup(subject.systemEvent, subject.timestamp) : void 0);
@@ -60896,6 +61740,11 @@ var NotificationCenter = class {
60896
61740
  * read cannot answer for, or that reads unknown, changes nothing (D49).
60897
61741
  * Never throws into the tick.
60898
61742
  */
61743
+ pruneAlarmInputBypasses() {
61744
+ const panel = this.alarmPanel;
61745
+ if (panel === null || panel.inputBypasses().length === 0) return;
61746
+ panel.pruneInputBypasses((entry) => alarmRuleStillHasInput(this.rules.get(entry.ruleId) ?? null, entry));
61747
+ }
60899
61748
  async observeAlarmOpenings() {
60900
61749
  const panel = this.alarmPanel;
60901
61750
  const read = this.deps.readDeviceOpenings;
@@ -61180,6 +62029,7 @@ var NotificationCenter = class {
61180
62029
  await this.zoneOwners.refresh();
61181
62030
  this.sceneStates.refresh();
61182
62031
  await this.observeAlarmOpenings();
62032
+ this.pruneAlarmInputBypasses();
61183
62033
  await this.occupancyStore.pruneExcept(this.activeOccupancyKeys());
61184
62034
  if (!this.pruneLedger.isLoaded()) await this.pruneLedger.load();
61185
62035
  await this.pruner.reconcileDevices();
@@ -98789,6 +99639,14 @@ function planeRunsStationary(source) {
98789
99639
  //#endregion
98790
99640
  //#region src/pipeline-analytics/pipeline/suppressed-births.ts
98791
99641
  /**
99642
+ * How many rejected ids a device remembers AFTER the tracker stopped carrying
99643
+ * them. An id's `leaving` / zone-exit events fire for a track that is absent
99644
+ * from the frame by definition — the frame it is lost or a few frames later —
99645
+ * so the event path must still recognise it once `retain` dropped it. Track ids
99646
+ * are never reused; the bound only caps memory on a phantom-heavy camera.
99647
+ */
99648
+ var RETIRED_REJECTIONS_PER_DEVICE = 256;
99649
+ /**
98792
99650
  * Memory of births the confirmation gate rejected, per device.
98793
99651
  *
98794
99652
  * The gate runs AFTER the TrackStore upsert, and it has to: the upsert's
@@ -98810,6 +99668,8 @@ function planeRunsStationary(source) {
98810
99668
  */
98811
99669
  var SuppressedBirthRegistry = class {
98812
99670
  byDevice = /* @__PURE__ */ new Map();
99671
+ /** Rejected ids the tracker no longer carries, oldest first (bounded). */
99672
+ retired = /* @__PURE__ */ new Map();
98813
99673
  /** Rejected ids whose per-frame skip has already been reported once. */
98814
99674
  skipReported = /* @__PURE__ */ new Map();
98815
99675
  /** Record that this birth was rejected; later frames must not re-upsert it. */
@@ -98825,6 +99685,14 @@ var SuppressedBirthRegistry = class {
98825
99685
  return this.byDevice.get(deviceKey)?.has(trackId) === true;
98826
99686
  }
98827
99687
  /**
99688
+ * True when an event naming this id must be dropped: the birth was rejected,
99689
+ * whether the tracker still carries the id or already let it go. A rejected
99690
+ * birth is a track that never existed — it has no `left`, no zone exit.
99691
+ */
99692
+ suppressesEvents(deviceKey, trackId) {
99693
+ return this.isRejected(deviceKey, trackId) || this.retired.get(deviceKey)?.has(trackId) === true;
99694
+ }
99695
+ /**
98828
99696
  * True the FIRST time a rejected id's frame work is skipped (so the host logs
98829
99697
  * one line per id, not one per frame); false afterwards.
98830
99698
  */
@@ -98842,7 +99710,9 @@ var SuppressedBirthRegistry = class {
98842
99710
  * Forget every rejected id the tracker is no longer carrying.
98843
99711
  *
98844
99712
  * Called once per frame with the ids present THIS frame. An id absent from
98845
- * that set can never be upserted again, so holding it serves nothing.
99713
+ * that set can never be upserted again, so it leaves the live set — but it
99714
+ * moves to the bounded retired memory, because its `leaving` / zone-exit
99715
+ * events fire exactly when it is absent (`suppressesEvents`).
98846
99716
  */
98847
99717
  retain(deviceKey, currentTrackIds) {
98848
99718
  const reported = this.skipReported.get(deviceKey);
@@ -98852,9 +99722,26 @@ var SuppressedBirthRegistry = class {
98852
99722
  }
98853
99723
  const ids = this.byDevice.get(deviceKey);
98854
99724
  if (!ids) return;
98855
- for (const id of ids) if (!currentTrackIds.has(id)) ids.delete(id);
99725
+ for (const id of ids) {
99726
+ if (currentTrackIds.has(id)) continue;
99727
+ ids.delete(id);
99728
+ this.retire(deviceKey, id);
99729
+ }
98856
99730
  if (ids.size === 0) this.byDevice.delete(deviceKey);
98857
99731
  }
99732
+ retire(deviceKey, trackId) {
99733
+ let ids = this.retired.get(deviceKey);
99734
+ if (!ids) {
99735
+ ids = /* @__PURE__ */ new Set();
99736
+ this.retired.set(deviceKey, ids);
99737
+ }
99738
+ ids.add(trackId);
99739
+ while (ids.size > RETIRED_REJECTIONS_PER_DEVICE) {
99740
+ const oldest = ids.values().next();
99741
+ if (oldest.done === true) break;
99742
+ ids.delete(oldest.value);
99743
+ }
99744
+ }
98858
99745
  /** Rejected ids currently held for a device — diagnostics and tests. */
98859
99746
  size(deviceKey) {
98860
99747
  return this.byDevice.get(deviceKey)?.size ?? 0;
@@ -98863,6 +99750,7 @@ var SuppressedBirthRegistry = class {
98863
99750
  clearDevice(deviceKey) {
98864
99751
  this.byDevice.delete(deviceKey);
98865
99752
  this.skipReported.delete(deviceKey);
99753
+ this.retired.delete(deviceKey);
98866
99754
  }
98867
99755
  };
98868
99756
  //#endregion
@@ -107133,6 +108021,10 @@ Object.freeze({
107133
108021
  "resolution": "auto",
107134
108022
  "maxPoints": 1e3
107135
108023
  },
108024
+ "queryMany": {
108025
+ "resolution": "auto",
108026
+ "maxPoints": 1e3
108027
+ },
107136
108028
  "queryNode": {
107137
108029
  "resolution": "auto",
107138
108030
  "maxPoints": 1e3
@@ -107191,7 +108083,7 @@ Object.freeze({
107191
108083
  "createRule": { "rule": { "__nested__": {
107192
108084
  "enabled": true,
107193
108085
  "conditions": {},
107194
- "media": { "attach": "best" },
108086
+ "media": {},
107195
108087
  "throttle": {
107196
108088
  "cooldownSec": 60,
107197
108089
  "scope": "rule-device"
@@ -107202,7 +108094,7 @@ Object.freeze({
107202
108094
  "previewStateSummary": { "rule": { "__nested__": {
107203
108095
  "enabled": true,
107204
108096
  "conditions": {},
107205
- "media": { "attach": "best" },
108097
+ "media": {},
107206
108098
  "throttle": {
107207
108099
  "cooldownSec": 60,
107208
108100
  "scope": "rule-device"
@@ -107213,7 +108105,7 @@ Object.freeze({
107213
108105
  "rule": { "__nested__": {
107214
108106
  "enabled": true,
107215
108107
  "conditions": {},
107216
- "media": { "attach": "best" },
108108
+ "media": {},
107217
108109
  "throttle": {
107218
108110
  "cooldownSec": 60,
107219
108111
  "scope": "rule-device"
@@ -107406,7 +108298,6 @@ Object.freeze({
107406
108298
  ],
107407
108299
  "load-contribution": ["list"],
107408
108300
  "log-channels": ["list"],
107409
- "log-destination": ["query"],
107410
108301
  "login-method": ["getLoginMethods"],
107411
108302
  "mqtt-broker": ["listBrokers"],
107412
108303
  "network-access": ["listEndpoints"],
@@ -107428,12 +108319,19 @@ Object.freeze({
107428
108319
  "user-passkeys": ["listPasskeys"],
107429
108320
  "videoclips": ["listClips", "listSources"]
107430
108321
  });
107431
- Object.freeze({ "videoclips": [
107432
- "dialClipStream",
107433
- "getPlaybackOptions",
107434
- "offerClipBytes",
107435
- "readClipBytes"
107436
- ] });
108322
+ Object.freeze({
108323
+ "log-destination": [
108324
+ "cancelRead",
108325
+ "describeRead",
108326
+ "read"
108327
+ ],
108328
+ "videoclips": [
108329
+ "dialClipStream",
108330
+ "getPlaybackOptions",
108331
+ "offerClipBytes",
108332
+ "readClipBytes"
108333
+ ]
108334
+ });
107437
108335
  /** cap name -> the numeric state field whose strict advance IS the cap's one-shot event. */
107438
108336
  var CAP_ADVANCE_FIELDS = Object.freeze({ "doorbell": "lastPressedAt" });
107439
108337
  Object.freeze({
@@ -117387,6 +118285,15 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
117387
118285
  }
117388
118286
  }));
117389
118287
  },
118288
+ getTracksAt: async (deviceId, timestamp, toleranceMs) => {
118289
+ const live = stores.trackStore.getActive(deviceId);
118290
+ const persisted = await stores.trackStore.queryRecent({
118291
+ deviceIds: [deviceId],
118292
+ since: timestamp - toleranceMs,
118293
+ limit: NC_TRACKS_AT_INSTANT_LIMIT
118294
+ });
118295
+ return tracksAtInstant([...live, ...persisted.tracks], timestamp, toleranceMs, this.lastFrameDimsByDevice.get(deviceId));
118296
+ },
117390
118297
  getDeviceName,
117391
118298
  getDeviceLocation,
117392
118299
  buildActions: (input) => this.notificationCenter?.mintButtons(input) ?? Promise.resolve([])
@@ -119752,12 +120659,13 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
119752
120659
  zones: computeStationaryEntryZones(v, liveZones, trk.zoneMembershipMinOverlap),
119753
120660
  standing: true
119754
120661
  }));
120662
+ const occupancyTracked = result.tracked.filter((t) => !this.suppressedBirths.isRejected(key, t.trackId));
119755
120663
  this.zoneAnalytics?.recordFrame({
119756
120664
  deviceId,
119757
120665
  timestamp: result.timestamp,
119758
120666
  frameWidth: result.frameWidth,
119759
120667
  frameHeight: result.frameHeight,
119760
- tracked: stationaryAsTracked.length > 0 ? [...result.tracked, ...stationaryAsTracked] : result.tracked,
120668
+ tracked: stationaryAsTracked.length > 0 ? [...occupancyTracked, ...stationaryAsTracked] : occupancyTracked,
119761
120669
  zones: liveZones,
119762
120670
  ...stationaryViews.length > 0 ? { stationaryObjects: stationaryViews } : {}
119763
120671
  });
@@ -120114,12 +121022,14 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
120114
121022
  }
120115
121023
  });
120116
121024
  }
120117
- if (rejectedThisFrame.size > 0) result = {
121025
+ const keptObjectEvents = this.dropSuppressedBirthEvents(deviceId, key, result.objectEvents);
121026
+ const keptAppearanceEvents = this.dropSuppressedBirthEvents(deviceId, key, result.appearanceEvents);
121027
+ if (rejectedThisFrame.size > 0 || keptObjectEvents !== result.objectEvents || keptAppearanceEvents !== result.appearanceEvents) result = {
120118
121028
  ...result,
120119
121029
  tracked: result.tracked.filter((t) => !rejectedThisFrame.has(t.trackId)),
120120
121030
  rawTrackedDetections: result.rawTrackedDetections.filter((rt) => !rejectedThisFrame.has(rt.trackId)),
120121
- objectEvents: result.objectEvents.filter((e) => e.trackId === void 0 || !rejectedThisFrame.has(e.trackId)),
120122
- appearanceEvents: result.appearanceEvents.filter((e) => e.trackId === void 0 || !rejectedThisFrame.has(e.trackId))
121031
+ objectEvents: keptObjectEvents,
121032
+ appearanceEvents: keptAppearanceEvents
120123
121033
  };
120124
121034
  if (currentTrackIds.size > 0) this.lastTrackActivityMs.set(deviceId, Date.now());
120125
121035
  const stationarySettings = this.stationarySettingsFromCache(deviceId);
@@ -120579,6 +121489,35 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
120579
121489
  * invented id would put `event:NaN` in a media key. A branch that accepts
120580
121490
  * work and produces nothing must log it.
120581
121491
  */
121492
+ /**
121493
+ * The events of `drafts` whose track's birth the confirmation gate rejected,
121494
+ * removed — the SAME array when none is, so a caller can tell. A rejected
121495
+ * birth is a track that never existed: it has no event row, no media and no
121496
+ * notification, on any frame. Each drop is logged (bounded by event rate):
121497
+ * an accepted event that silently vanishes reads as "never happened" (D391).
121498
+ */
121499
+ dropSuppressedBirthEvents(deviceId, key, drafts) {
121500
+ const isSuppressed = (e) => e.trackId !== void 0 && this.suppressedBirths.suppressesEvents(key, e.trackId);
121501
+ if (!drafts.some(isSuppressed)) return drafts;
121502
+ const kept = [];
121503
+ for (const e of drafts) {
121504
+ if (!isSuppressed(e)) {
121505
+ kept.push(e);
121506
+ continue;
121507
+ }
121508
+ this.ctx.logger.info("object event dropped — the track birth was suppressed", {
121509
+ tags: { deviceId },
121510
+ meta: {
121511
+ trackId: e.trackId,
121512
+ className: e.className,
121513
+ state: e.state,
121514
+ zones: e.zones,
121515
+ reason: "birth-suppressed"
121516
+ }
121517
+ });
121518
+ }
121519
+ return kept;
121520
+ }
120582
121521
  async persistObjectEvents(deviceId, drafts) {
120583
121522
  if (drafts.length === 0) return [];
120584
121523
  const store = this.eventStore;