@camstack/addon-post-analysis 1.2.77 → 1.2.78

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { $ as isDetectionMacroClass, At as unknown, B as alarmPanelCapability, C as NcSnoozeSchema, Ct as discriminatedUnion, D as OpsLogEntrySchema, Dt as partialRecord, E as NcTaxonomySchema, Et as object, F as TimelapseRuleInputSchema, G as cosineSimilarity$1, H as audioMetricsCapability, I as TimelapseRulePatchSchema, J as deriveRecordingMode, K as customAction, L as TimelapseRuleSchema, M as SCENE_DIVERGED, N as SceneMonitorSchema, O as RECORDING_EXPORT_MAX_READ_BYTES, Ot as record, P as TIMELAPSE_DENSE_FLOOR_SEC, R as TrackSourceSchema, S as NcSnoozeInputSchema, St as boolean, T as NcSystemEventKindSchema, Tt as number, U as audioModeOf, V as assertTimelapseCadences, W as buildEventKindDescriptor, X as encodeVectorBase64, Z as faceGalleryCapability, _ as NcRuleInputSchema, _t as isDeviceScopedCap, at as readDeviceStateFrom, b as NcRuleTargetSchema, bt as _enum, c as EVENT_PAD_MS, ct as subKindsOf, d as NC_ALARM_SYSTEM_EVENT_KINDS, dt as zoneAnalyticsCapability, et as isScheduleActive, f as NC_CONDITION_CATALOG, ft as errMsg, g as NcConditionDescriptorSchema, gt as hydrateSchema, h as NC_TAXONOMY, ht as createEvent, i as DETECTION_MACRO_CLASSES, it as plateGalleryCapability, j as SCENE_DEFAULT_ANCHOR_THRESHOLD, jt as EventCategory, k as RetrainStatusSchema, kt as string, l as LabelAttributionSchema, lt as vectorDimFromBase64, mt as DeviceType, n as DEFAULT_EVENT_COLOR, nt as notificationRulesCapability, o as DeclaredDevices, ot as readTimelapseGeneratedAt, p as NC_DEFAULT_SNOOZE_MINUTES, pt as BaseAddon, q as defineCustomActions, rt as pipelineAnalyticsCapability, s as EVENT_KIND_BY_CAP, st as sceneMonitorCapability, t as BaseDevice, tt as kebabToCamel, u as MACRO_LABELS, ut as videoclipsCapability, v as NcRulePatchSchema, vt as nodePin, w as NcSnoozeSuppressedSchema, wt as literal, x as NcScheduleSchema, xt as array, y as NcRuleSchema, yt as sleep, z as addonWidgetsSourceCapability } from "../dist-CxeFHSSV.mjs";
1
+ import { At as string, B as addonWidgetsSourceCapability, C as NcSnoozeSchema, Ct as boolean, D as OpsLogEntrySchema, Dt as object, E as NcTaxonomySchema, Et as number, F as TIMELAPSE_DENSE_FLOOR_SEC, G as buildEventKindDescriptor, H as assertTimelapseCadences, I as TimelapseRuleInputSchema, J as defineCustomActions, K as cosineSimilarity$1, L as TimelapseRulePatchSchema, M as SCENE_DEFAULT_UNCOVERED_POLICY, Mt as EventCategory, N as SCENE_DIVERGED, O as RECORDING_EXPORT_MAX_READ_BYTES, Ot as partialRecord, P as SceneMonitorSchema, Q as faceGalleryCapability, R as TimelapseRuleSchema, S as NcSnoozeInputSchema, St as array, T as NcSystemEventKindSchema, Tt as literal, U as audioMetricsCapability, V as alarmPanelCapability, W as audioModeOf, Y as deriveRecordingMode, Z as encodeVectorBase64, _ as NcRuleInputSchema, _t as hydrateSchema, at as plateGalleryCapability, b as NcRuleTargetSchema, bt as sleep, c as EVENT_PAD_MS, ct as sceneMonitorCapability, d as NC_ALARM_SYSTEM_EVENT_KINDS, dt as videoclipsCapability, et as isDetectionMacroClass, f as NC_CONDITION_CATALOG, ft as zoneAnalyticsCapability, g as NcConditionDescriptorSchema, gt as createEvent, h as NC_TAXONOMY, ht as DeviceType, i as DETECTION_MACRO_CLASSES, it as pipelineAnalyticsCapability, j as SCENE_DEFAULT_ANCHOR_THRESHOLD, jt as unknown, k as RetrainStatusSchema, kt as record, l as LabelAttributionSchema, lt as subKindsOf, mt as BaseAddon, n as DEFAULT_EVENT_COLOR, nt as kebabToCamel, o as DeclaredDevices, ot as readDeviceStateFrom, p as NC_DEFAULT_SNOOZE_MINUTES, pt as errMsg, q as customAction, rt as notificationRulesCapability, s as EVENT_KIND_BY_CAP, st as readTimelapseGeneratedAt, t as BaseDevice, tt as isScheduleActive, u as MACRO_LABELS, ut as vectorDimFromBase64, v as NcRulePatchSchema, vt as isDeviceScopedCap, w as NcSnoozeSuppressedSchema, wt as discriminatedUnion, x as NcScheduleSchema, xt as _enum, y as NcRuleSchema, yt as nodePin, z as TrackSourceSchema } from "../dist-CA2KdMY9.mjs";
2
2
  import { t as __exportAll } from "../embedding-encoder/index.mjs";
3
3
  import * as fs from "node:fs";
4
4
  import { promises } from "node:fs";
@@ -3384,19 +3384,26 @@ function cosineSimilarity(a, b) {
3384
3384
  }
3385
3385
  /**
3386
3386
  * Select the references a live crop is scored against for a given condition —
3387
- * **exact condition match, with NO fallback.**
3388
- *
3389
- * There used to be a silent fallback to every reference when the current
3390
- * condition had none. For a named-states monitor that was a defensible "better
3391
- * than blind". For a latched alarm it is a false positive every single night:
3392
- * the day reference is compared against an IR frame, the cosine collapses, the
3393
- * scene latches `diverged`, and the operator is told the bin was taken at
3394
- * 21:40. Cross-condition cosines are not comparable, so the honest answer to "I
3395
- * have never seen this scene in this light" is *I don't know* — never a guess
3396
- * in the direction that raises an alarm.
3397
- */
3398
- function selectConditionRefs(references, currentCondition) {
3399
- return references.filter((r) => r.condition === currentCondition);
3387
+ * **exact condition match, and by default NO fallback.**
3388
+ *
3389
+ * There used to be an unconditional silent fallback to every reference when the
3390
+ * current condition had none. For a named-states monitor that was a defensible
3391
+ * "better than blind". For a latched alarm it is a false positive every single
3392
+ * night: the day reference is compared against an IR frame, the cosine
3393
+ * collapses, the scene latches `diverged`, and the operator is told the bin was
3394
+ * taken at 21:40. Cross-condition cosines are not comparable, so the honest
3395
+ * answer to "I have never seen this scene in this light" is *I don't know* —
3396
+ * never a guess in the direction that raises an alarm.
3397
+ *
3398
+ * The fallback survives only as an explicit per-scene opt-in, and even then it
3399
+ * is a LAST resort: when the exact condition has references of its own they
3400
+ * win outright, so opting in never widens a comparison the operator already
3401
+ * covered properly.
3402
+ */
3403
+ function selectConditionRefs(references, currentCondition, crossConditionFallback) {
3404
+ const exact = references.filter((r) => r.condition === currentCondition);
3405
+ if (exact.length > 0 || !crossConditionFallback) return exact;
3406
+ return references;
3400
3407
  }
3401
3408
  /** A reference is comparable only when its `modelId` equals the live encoder's
3402
3409
  * model AND its dimension equals the encoder's embedding dim (face-matcher
@@ -3412,11 +3419,12 @@ function comparable(r, encoderModelId, encoderDim) {
3412
3419
  * Returns `{ bestStateId: null, bestScore: null }` when NOTHING was comparable
3413
3420
  * — see `ScoreResult.bestScore`.
3414
3421
  */
3415
- function scoreStates(states, liveEmbedding, currentCondition, encoderModelId, encoderDim) {
3422
+ function scoreStates(states, liveEmbedding, currentCondition, encoderModelId, encoderDim, options) {
3416
3423
  let bestStateId = null;
3417
3424
  let bestScore = null;
3425
+ const crossConditionFallback = options?.crossConditionFallback === true;
3418
3426
  for (const s of states) {
3419
- const refs = selectConditionRefs(s.references, currentCondition);
3427
+ const refs = selectConditionRefs(s.references, currentCondition, crossConditionFallback);
3420
3428
  let stateScore = null;
3421
3429
  for (const r of refs) {
3422
3430
  if (!comparable(r, encoderModelId, encoderDim)) continue;
@@ -3602,7 +3610,8 @@ var SceneEngine = class {
3602
3610
  lastCountedAt: 0,
3603
3611
  lastAvailability: "ok",
3604
3612
  lastVerdict: currentStateId === null ? "unknown" : "matched",
3605
- lastUnavailable: null
3613
+ lastUnavailable: null,
3614
+ suspendedCondition: null
3606
3615
  });
3607
3616
  }
3608
3617
  /** Force an out-of-cycle check for a single monitor. */
@@ -3625,7 +3634,8 @@ var SceneEngine = class {
3625
3634
  lastCountedAt: 0,
3626
3635
  lastAvailability: monitor.availability,
3627
3636
  lastVerdict: monitor.verdict,
3628
- lastUnavailable: monitor.unavailable
3637
+ lastUnavailable: monitor.unavailable,
3638
+ suspendedCondition: monitor.suspendedCondition
3629
3639
  };
3630
3640
  rt.lastCheckAt = now;
3631
3641
  this.runtime.set(k, rt);
@@ -3676,6 +3686,7 @@ var SceneEngine = class {
3676
3686
  availability: "unavailable",
3677
3687
  unavailableReason: llmResult.reason,
3678
3688
  coveredConditions: [],
3689
+ suspendedCondition: null,
3679
3690
  transitioned: false,
3680
3691
  checkedAt: now
3681
3692
  });
@@ -3729,12 +3740,20 @@ var SceneEngine = class {
3729
3740
  if (monitor.check.mode !== "similarity") return;
3730
3741
  const { embedding } = await this.deps.encoder.encode(crop, cropWidth, cropHeight);
3731
3742
  const info = await this.encoderInfo();
3732
- const { bestStateId, bestScore } = scoreStates(monitor.states, embedding, condition, info.modelId, info.embeddingDim);
3743
+ const { bestStateId, bestScore } = scoreStates(monitor.states, embedding, condition, info.modelId, info.embeddingDim, { crossConditionFallback: monitor.onUncoveredCondition === "judge-anyway" });
3733
3744
  const covered = [...coveredConditions(monitor.states, info.modelId, info.embeddingDim)];
3734
3745
  if (bestScore === null) {
3735
3746
  const cause = unknownCause(monitor, condition);
3736
3747
  rt.lastAvailability = "ok";
3737
3748
  this.runtime.set(k, rt);
3749
+ if (cause === "no-reference-for-condition" && covered.length > 0 && monitor.onUncoveredCondition === "skip") {
3750
+ await this.reportSuspended(deviceId, monitor, rt, {
3751
+ condition,
3752
+ covered,
3753
+ now
3754
+ });
3755
+ return;
3756
+ }
3738
3757
  await this.reportUnknown(deviceId, monitor, rt, {
3739
3758
  condition,
3740
3759
  cause,
@@ -3802,9 +3821,11 @@ var SceneEngine = class {
3802
3821
  rt.lastAvailability = "ok";
3803
3822
  this.runtime.set(k, rt);
3804
3823
  const verdict = projectVerdict(step.currentStateId, monitor.baselineStateId);
3805
- const changed = step.transitioned || verdict !== rt.lastVerdict || rt.lastAvailability !== monitor.availability || rt.lastUnavailable !== null;
3824
+ const resumed = rt.suspendedCondition !== null;
3825
+ const changed = step.transitioned || verdict !== rt.lastVerdict || rt.lastAvailability !== monitor.availability || rt.lastUnavailable !== null || resumed;
3806
3826
  rt.lastVerdict = verdict;
3807
3827
  rt.lastUnavailable = null;
3828
+ rt.suspendedCondition = null;
3808
3829
  this.runtime.set(k, rt);
3809
3830
  if (changed) await this.deps.applyEngineResult(deviceId, monitor.id, {
3810
3831
  currentStateId: step.currentStateId,
@@ -3816,20 +3837,61 @@ var SceneEngine = class {
3816
3837
  availability: "ok",
3817
3838
  unavailableReason: null,
3818
3839
  coveredConditions: covered,
3840
+ suspendedCondition: null,
3819
3841
  transitioned: step.transitioned,
3820
3842
  checkedAt: now
3821
3843
  });
3822
3844
  }
3823
3845
  /**
3846
+ * Announce that this scene is SITTING OUT the current light — once per
3847
+ * transition into it, never once per tick. At a 60 s cadence a per-tick line
3848
+ * would be ~480 lines a night per scene, which is how a log stops being read
3849
+ * at all.
3850
+ *
3851
+ * `info`, not `warn`: nothing is wrong. The operator chose not to capture
3852
+ * this light, or has not got round to it, and either way the scene is doing
3853
+ * exactly what it was configured to do. A branch that drops work still has to
3854
+ * SAY so — silence reads as "never happened" — but saying it at warning level
3855
+ * would train the operator to ignore the one channel that reports real
3856
+ * faults.
3857
+ */
3858
+ async reportSuspended(deviceId, monitor, rt, input) {
3859
+ if (rt.suspendedCondition === input.condition) return;
3860
+ rt.suspendedCondition = input.condition;
3861
+ this.deps.logger.info("scene checks paused — nothing captured in this light", {
3862
+ tags: { deviceId },
3863
+ meta: {
3864
+ monitorId: monitor.id,
3865
+ condition: input.condition,
3866
+ covered: input.covered.join(",")
3867
+ }
3868
+ });
3869
+ await this.deps.applyEngineResult(deviceId, monitor.id, {
3870
+ currentStateId: rt.hysteresis.currentStateId,
3871
+ previousStateId: rt.hysteresis.currentStateId,
3872
+ lastConfidence: null,
3873
+ currentCondition: input.condition,
3874
+ verdict: rt.lastVerdict,
3875
+ unavailable: rt.lastUnavailable,
3876
+ availability: "ok",
3877
+ unavailableReason: null,
3878
+ coveredConditions: input.covered,
3879
+ suspendedCondition: input.condition,
3880
+ transitioned: false,
3881
+ checkedAt: input.now
3882
+ });
3883
+ }
3884
+ /**
3824
3885
  * Push an `unknown` verdict — once per transition, not once per poll. The
3825
3886
  * held state is deliberately NOT destroyed: a scene that cannot judge is a
3826
3887
  * scene that has not observed anything, and an observation it could not make
3827
3888
  * must never spend or clear hysteresis credit in either direction.
3828
3889
  */
3829
3890
  async reportUnknown(deviceId, monitor, rt, input) {
3830
- if (rt.lastVerdict === "unknown" && rt.lastUnavailable === input.cause) return;
3891
+ if (rt.lastVerdict === "unknown" && rt.lastUnavailable === input.cause && rt.suspendedCondition === null) return;
3831
3892
  rt.lastVerdict = "unknown";
3832
3893
  rt.lastUnavailable = input.cause;
3894
+ rt.suspendedCondition = null;
3833
3895
  this.deps.logger.warn("scene cannot judge — reporting unknown", {
3834
3896
  tags: { deviceId },
3835
3897
  meta: {
@@ -3849,6 +3911,7 @@ var SceneEngine = class {
3849
3911
  availability: "ok",
3850
3912
  unavailableReason: `no comparable reference for "${input.condition}"`,
3851
3913
  coveredConditions: input.covered,
3914
+ suspendedCondition: null,
3852
3915
  transitioned: false,
3853
3916
  checkedAt: input.now
3854
3917
  });
@@ -4571,6 +4634,8 @@ var SceneMonitorProvider = class {
4571
4634
  minObservationSpacingSec: 120,
4572
4635
  anchorThreshold: SCENE_DEFAULT_ANCHOR_THRESHOLD,
4573
4636
  autoRestore: false,
4637
+ onUncoveredCondition: SCENE_DEFAULT_UNCOVERED_POLICY,
4638
+ suspendedCondition: null,
4574
4639
  unavailable: "no-reference-for-condition",
4575
4640
  coveredConditions: []
4576
4641
  };
@@ -4600,7 +4665,9 @@ var SceneMonitorProvider = class {
4600
4665
  this.refresh(input.deviceId);
4601
4666
  }
4602
4667
  async deleteScene(input) {
4668
+ const doomed = this.deps.store.getScene(input.deviceId, input.monitorId);
4603
4669
  await this.deps.store.deleteScene(input.deviceId, input.monitorId);
4670
+ await this.dropOrphanedMedia(doomed?.monitor.states.flatMap((s) => s.references) ?? []);
4604
4671
  this.deps.logger.info("scene deleted", {
4605
4672
  tags: { deviceId: input.deviceId },
4606
4673
  meta: { monitorId: input.monitorId }
@@ -4623,7 +4690,9 @@ var SceneMonitorProvider = class {
4623
4690
  const stateId = input.stateId ?? monitor.states[0]?.id ?? randomUUID();
4624
4691
  const existing = monitor.states.find((s) => s.id === stateId);
4625
4692
  const label = input.label ?? existing?.label ?? monitor.label;
4626
- const references = boundReferences([...existing?.references ?? [], reference], condition);
4693
+ const kept = [...existing?.references ?? [], reference];
4694
+ const references = boundReferences(kept, condition);
4695
+ await this.dropOrphanedMedia(kept.filter((r) => !references.includes(r)));
4627
4696
  const state = {
4628
4697
  id: stateId,
4629
4698
  label,
@@ -4657,6 +4726,7 @@ var SceneMonitorProvider = class {
4657
4726
  }
4658
4727
  async deleteReference(input) {
4659
4728
  const monitor = this.requireScene(input.deviceId, input.monitorId);
4729
+ const removed = monitor.states.find((s) => s.id === input.stateId)?.references[input.index];
4660
4730
  const states = monitor.states.map((s) => {
4661
4731
  if (s.id !== input.stateId) return s;
4662
4732
  const references = s.references.filter((_, i) => i !== input.index);
@@ -4670,6 +4740,17 @@ var SceneMonitorProvider = class {
4670
4740
  ...monitor,
4671
4741
  states
4672
4742
  }, this.now());
4743
+ await this.dropOrphanedMedia(removed === void 0 ? [] : [removed]);
4744
+ this.deps.logger.info("scene reference deleted", {
4745
+ tags: { deviceId: input.deviceId },
4746
+ meta: {
4747
+ monitorId: input.monitorId,
4748
+ stateId: input.stateId,
4749
+ index: input.index,
4750
+ condition: removed?.condition ?? "(none)",
4751
+ remaining: states.reduce((n, s) => n + s.references.length, 0)
4752
+ }
4753
+ });
4673
4754
  this.refresh(input.deviceId);
4674
4755
  }
4675
4756
  async recheckNow(input) {
@@ -4754,6 +4835,15 @@ var SceneMonitorProvider = class {
4754
4835
  const row = this.deps.store.getScene(deviceId, monitorId);
4755
4836
  if (row === void 0) return;
4756
4837
  const monitor = row.monitor;
4838
+ if (result.suspendedCondition !== null) {
4839
+ await this.deps.store.putScene(deviceId, {
4840
+ ...monitor,
4841
+ suspendedCondition: result.suspendedCondition,
4842
+ coveredConditions: [...result.coveredConditions]
4843
+ }, result.checkedAt);
4844
+ this.refresh(deviceId);
4845
+ return;
4846
+ }
4757
4847
  const previous = this.deps.store.getLatch(deviceId, monitorId);
4758
4848
  const wasLatched = previous?.latched ?? monitor.latched;
4759
4849
  const wasVerdict = previous?.verdict ?? monitor.verdict;
@@ -4780,6 +4870,7 @@ var SceneMonitorProvider = class {
4780
4870
  const merged = {
4781
4871
  ...monitor,
4782
4872
  coveredConditions: [...result.coveredConditions],
4873
+ suspendedCondition: null,
4783
4874
  availability: result.availability,
4784
4875
  unavailableReason: result.unavailableReason
4785
4876
  };
@@ -4883,8 +4974,29 @@ var SceneMonitorProvider = class {
4883
4974
  updatedAt: now
4884
4975
  };
4885
4976
  }
4977
+ /**
4978
+ * Drop the pictures of references that are no longer stored anywhere.
4979
+ *
4980
+ * Best-effort per blob and never throwing: the reference row is already gone,
4981
+ * and refusing the operator's delete because a JPEG would not unlink would be
4982
+ * failing the request over the follower rather than the fact.
4983
+ */
4984
+ async dropOrphanedMedia(references) {
4985
+ const drop = this.deps.dropMedia;
4986
+ if (drop === void 0) return;
4987
+ for (const r of references) {
4988
+ if (r.thumbnailMediaId === void 0) continue;
4989
+ await drop(r.thumbnailMediaId).catch((err) => {
4990
+ this.deps.logger.debug("scene reference thumbnail delete failed", { meta: {
4991
+ mediaId: r.thumbnailMediaId,
4992
+ error: String(err)
4993
+ } });
4994
+ });
4995
+ }
4996
+ }
4886
4997
  /** Snapshot → ROI crop + whole-frame anchor → two embeddings + a thumbnail. */
4887
4998
  async encodeReference(deviceId, monitor, condition) {
4999
+ const capturedAt = this.now();
4888
5000
  const snap = await this.deps.getSnapshot(deviceId, true);
4889
5001
  if (snap === null) throw new Error("no snapshot available for this camera");
4890
5002
  const encoded = Buffer.from(snap.base64, "base64");
@@ -4902,15 +5014,15 @@ var SceneMonitorProvider = class {
4902
5014
  });
4903
5015
  const thumbnailMediaId = await this.deps.putMedia?.({
4904
5016
  deviceId,
4905
- ownerId: `scene-${monitor.id}-${condition}`,
5017
+ ownerId: `scene-${monitor.id}-${condition}-${capturedAt}`,
4906
5018
  data: roi.crop,
4907
- timestamp: this.now()
5019
+ timestamp: capturedAt
4908
5020
  }).catch(() => void 0);
4909
5021
  return {
4910
5022
  embedding,
4911
5023
  modelId: info.modelId,
4912
5024
  condition,
4913
- capturedAt: this.now(),
5025
+ capturedAt,
4914
5026
  ...thumbnailMediaId !== void 0 ? { thumbnailMediaId } : {},
4915
5027
  ...anchor !== null ? { anchorEmbedding: anchor } : {}
4916
5028
  };
@@ -5406,6 +5518,256 @@ function observeLabel(deviceId, spec, sample, now) {
5406
5518
  spec
5407
5519
  };
5408
5520
  }
5521
+ /** JPEG quality for the downscaled full frame — matches the crop path. */
5522
+ var FULL_FRAME_QUALITY = 80;
5523
+ /**
5524
+ * Downscale an already-encoded JPEG full frame to FIT WITHIN
5525
+ * {@link FULL_FRAME_MAX_WIDTH}×{@link FULL_FRAME_MAX_HEIGHT}, preserving aspect
5526
+ * ratio (`fit: 'inside'`) and never enlarging a source already smaller than the
5527
+ * box. Re-encodes as JPEG. Used before persisting a synthetic sensor/control
5528
+ * track's whole-scene snapshot so a raw native-resolution frame (a 4K bedroom
5529
+ * at night) is never stored or served — the privacy fix moved to CAPTURE time.
5530
+ */
5531
+ async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
5532
+ return sharp(Buffer.from(jpeg)).resize(maxWidth, maxHeight, {
5533
+ fit: "inside",
5534
+ withoutEnlargement: true
5535
+ }).jpeg({ quality: FULL_FRAME_QUALITY }).toBuffer();
5536
+ }
5537
+ //#endregion
5538
+ //#region src/notification-center/audio-still.ts
5539
+ /**
5540
+ * The audio still shelf — the PHOTOGRAPH a sound rule's notification carries.
5541
+ *
5542
+ * ## Why a sound needs one at all
5543
+ *
5544
+ * The attachment ladder resolves media by OWNER, and every other trigger has
5545
+ * one: an object or package event owns its crops, a closed track owns its best
5546
+ * shot, a doorbell press owns the marker track the same press projected
5547
+ * (`sensor-marker-projector.ts`), an occupancy edge names one of the objects it
5548
+ * counted (`chooseOccupancyMediaOwner`). An audio match owns nothing. Nothing
5549
+ * was boxed, nothing was tracked, and — deliberately — nothing is persisted at
5550
+ * all: a confirmed window is a claim about sound that has already stopped, and
5551
+ * `event-intake.ts` states why replaying it later would be wrong.
5552
+ *
5553
+ * So the only honest picture is a PHOTOGRAPH of the camera taken at the moment
5554
+ * of the match. Not of the sound — of what the camera could see while it was
5555
+ * heard.
5556
+ *
5557
+ * ## Three properties, and each one is a decision
5558
+ *
5559
+ * **It is not a record.** The bytes live here, in RAM, under an owner id and a
5560
+ * TTL that covers the outbox's whole retry horizon — and nowhere else. The
5561
+ * alternative was the doorbell's: materialise a synthetic marker track through
5562
+ * `SyntheticTrackMaterializer` and let the notification name it. That would put
5563
+ * a durable Track on the camera's timeline for every confirmed window, feeding
5564
+ * the digest's `listTracks`, retention, and the audio-marker feature's own
5565
+ * operator ceilings (`audio-marker-projector.ts` exists precisely to bound how
5566
+ * many audio markers a camera may emit). A notification must not manufacture
5567
+ * timeline history as a side effect of wanting a picture.
5568
+ *
5569
+ * **The capture STARTS immediately and is never awaited.** A sound is transient
5570
+ * — a scream is over before a snapshot round-trip completes — so the fetch is
5571
+ * kicked off at the confirmation, before the rule evaluation runs, and the
5572
+ * owner id is minted synchronously so the outbox row can name it. The bytes
5573
+ * land while the row waits in the queue, and the dispatcher's existing bounded
5574
+ * still-wait (or the pause its own footage render already costs) picks them up.
5575
+ * A camera that never answers costs the picture and never the notification.
5576
+ *
5577
+ * **Two confirmations seconds apart share ONE capture.** A barking dog confirms
5578
+ * repeatedly and a label-mode rule has no re-arm timer at all (D157) — the
5579
+ * rule's cooldown is its only brake, and the cooldown is applied AFTER this.
5580
+ * Without a reuse window this would photograph a camera at whatever rate the
5581
+ * sound happens to occur. Each confirmation still gets its OWN owner id, so two
5582
+ * outbox rows are never mistaken for one subject; they merely point at the same
5583
+ * frame, which is the truth — the scene did not change in ten seconds.
5584
+ *
5585
+ * Nothing here is silent: a capture that lands and a camera that refuses each
5586
+ * emit one line carrying `tags: { deviceId }`, because "why did 617 get a photo
5587
+ * and 615 not" is the only form that question is ever asked in.
5588
+ */
5589
+ /**
5590
+ * The owner-id namespace. It is what routes a lookup here instead of to the
5591
+ * media store, and it is the reason the dispatcher needs no audio branch —
5592
+ * `getMediaForOwner` answers for both under one signature.
5593
+ */
5594
+ var NC_AUDIO_STILL_PREFIX = "nc-audio-still:";
5595
+ /** True for an owner id this shelf minted. */
5596
+ function isAudioStillId(id) {
5597
+ return id.startsWith(NC_AUDIO_STILL_PREFIX);
5598
+ }
5599
+ /**
5600
+ * How long the bytes are held.
5601
+ *
5602
+ * The outbox retries 8 times with a 5 s → 300 s backoff, which tops out around
5603
+ * ten minutes; fifteen covers that with room for a slow drain. Past it the row
5604
+ * ships text-only, which is the correct degradation for a photograph of a scene
5605
+ * that is a quarter of an hour stale anyway.
5606
+ */
5607
+ var NC_AUDIO_STILL_TTL_MS = 15 * 6e4;
5608
+ /** A hanging snapshot cap must not pin a capture slot forever. */
5609
+ var SNAPSHOT_TIMEOUT_MS = 8e3;
5610
+ /**
5611
+ * Hard bound on held captures. Reached only if every camera on the hub confirms
5612
+ * an audio rule inside one TTL; the oldest is dropped first, which costs a
5613
+ * fifteen-minute-old picture nobody is waiting for.
5614
+ */
5615
+ var MAX_CAPTURES = 64;
5616
+ var NcAudioStillShelf = class {
5617
+ deps;
5618
+ /** captureId → the photograph. */
5619
+ captures = /* @__PURE__ */ new Map();
5620
+ /** ownerId → captureId. Several owners may name one capture (the reuse window). */
5621
+ owners = /* @__PURE__ */ new Map();
5622
+ /** deviceId → its newest capture, for the reuse window. */
5623
+ newest = /* @__PURE__ */ new Map();
5624
+ constructor(deps) {
5625
+ this.deps = deps;
5626
+ }
5627
+ /**
5628
+ * Photograph `deviceId` for a match at `atMs`, and return the owner id the
5629
+ * subject should name. SYNCHRONOUS by contract: the caller is on the audio
5630
+ * confirmation path and the outbox row is built from what this returns.
5631
+ */
5632
+ capture(deviceId, atMs) {
5633
+ const now = this.deps.now();
5634
+ this.prune(now);
5635
+ const ownerId = `${NC_AUDIO_STILL_PREFIX}${randomUUID()}`;
5636
+ const recent = this.newest.get(deviceId);
5637
+ if (recent !== void 0 && now - recent.at < 1e4 && this.captures.has(recent.captureId)) {
5638
+ this.owners.set(ownerId, recent.captureId);
5639
+ return ownerId;
5640
+ }
5641
+ const captureId = randomUUID();
5642
+ this.captures.set(captureId, {
5643
+ deviceId,
5644
+ startedAt: now,
5645
+ expiresAt: now + NC_AUDIO_STILL_TTL_MS,
5646
+ files: []
5647
+ });
5648
+ this.newest.set(deviceId, {
5649
+ captureId,
5650
+ at: now
5651
+ });
5652
+ this.owners.set(ownerId, captureId);
5653
+ this.fetch(captureId, deviceId, atMs);
5654
+ return ownerId;
5655
+ }
5656
+ /**
5657
+ * The media an owner id holds, or `undefined` when this shelf never minted
5658
+ * it. An EMPTY array is a different answer: the capture exists and has not
5659
+ * landed (or never will), which is exactly the case the dispatcher's bounded
5660
+ * wait and its `no still could be resolved` line are for.
5661
+ */
5662
+ get(ownerId) {
5663
+ const captureId = this.owners.get(ownerId);
5664
+ if (captureId === void 0) return void 0;
5665
+ return this.captures.get(captureId)?.files ?? [];
5666
+ }
5667
+ /** Drop expired captures and the owners that named them. */
5668
+ prune(now) {
5669
+ for (const [captureId, held] of this.captures) {
5670
+ if (held.expiresAt > now) continue;
5671
+ this.captures.delete(captureId);
5672
+ if (this.newest.get(held.deviceId)?.captureId === captureId) this.newest.delete(held.deviceId);
5673
+ }
5674
+ while (this.captures.size > MAX_CAPTURES) {
5675
+ const oldest = this.captures.keys().next();
5676
+ if (oldest.done === true) break;
5677
+ const held = this.captures.get(oldest.value);
5678
+ this.captures.delete(oldest.value);
5679
+ if (held !== void 0 && this.newest.get(held.deviceId)?.captureId === oldest.value) this.newest.delete(held.deviceId);
5680
+ }
5681
+ for (const [ownerId, captureId] of this.owners) if (!this.captures.has(captureId)) this.owners.delete(ownerId);
5682
+ }
5683
+ /** Drop everything (shutdown). */
5684
+ clear() {
5685
+ this.captures.clear();
5686
+ this.owners.clear();
5687
+ this.newest.clear();
5688
+ }
5689
+ async fetch(captureId, deviceId, atMs) {
5690
+ try {
5691
+ const shot = await withTimeout$2(this.deps.getSnapshot(deviceId), SNAPSHOT_TIMEOUT_MS);
5692
+ if (shot === null || shot.base64.length === 0) {
5693
+ this.reportMiss(deviceId, "the camera returned no snapshot");
5694
+ return;
5695
+ }
5696
+ const raw = Buffer.from(shot.base64, "base64");
5697
+ let data = raw;
5698
+ try {
5699
+ data = await downscaleFullFrameJpeg(raw, 960, 540);
5700
+ } catch {}
5701
+ const held = this.captures.get(captureId);
5702
+ if (held === void 0) return;
5703
+ this.captures.set(captureId, {
5704
+ ...held,
5705
+ files: audioStillMedia(data, atMs)
5706
+ });
5707
+ this.deps.logger.info("audio still captured", {
5708
+ tags: { deviceId },
5709
+ meta: {
5710
+ bytes: data.byteLength,
5711
+ tookMs: this.deps.now() - held.startedAt
5712
+ }
5713
+ });
5714
+ } catch (err) {
5715
+ this.reportMiss(deviceId, err instanceof Error ? err.message : String(err));
5716
+ }
5717
+ }
5718
+ /**
5719
+ * A branch that drops work says so. This one costs the operator the picture
5720
+ * on a notification they DID receive, so it is a warn and it carries the
5721
+ * camera — the only key the question is ever asked with.
5722
+ */
5723
+ reportMiss(deviceId, reason) {
5724
+ this.deps.logger.warn("the camera did not answer the audio still — this notification ships text-only", {
5725
+ tags: { deviceId },
5726
+ meta: { reason }
5727
+ });
5728
+ }
5729
+ };
5730
+ /**
5731
+ * The photograph, in the three kinds the CLEAN-SCENE ladders ask for.
5732
+ *
5733
+ * `keyFrameSmall` leads because it is the first rung of both `attach: 'best'`
5734
+ * and `attach: 'keyFrame'` on a track owner; `keyFrame` and `fullFrame` answer
5735
+ * `frame: 'full'` and `frame: 'boxed'`'s honest degrade. There is deliberately
5736
+ * no `crop` / `thumbnail`: nothing was boxed, so `frame: 'cropped'` resolves
5737
+ * NOTHING and the notification ships text-only with the ordinary line saying
5738
+ * so. Inventing a centre crop would answer a different question from the one
5739
+ * the operator asked.
5740
+ */
5741
+ function audioStillMedia(data, timestamp) {
5742
+ const base64 = data.toString("base64");
5743
+ const file = (kind) => ({
5744
+ key: `${NC_AUDIO_STILL_PREFIX}${kind}`,
5745
+ kind,
5746
+ base64,
5747
+ sizeBytes: data.byteLength,
5748
+ timestamp
5749
+ });
5750
+ return [
5751
+ file("keyFrameSmall"),
5752
+ file("keyFrame"),
5753
+ file("fullFrame")
5754
+ ];
5755
+ }
5756
+ /** Reject if `promise` does not settle within `ms`. */
5757
+ function withTimeout$2(promise, ms) {
5758
+ return new Promise((resolve, reject) => {
5759
+ const timer = setTimeout(() => {
5760
+ reject(/* @__PURE__ */ new Error(`snapshot cap timed out after ${String(ms)}ms`));
5761
+ }, ms);
5762
+ promise.then((value) => {
5763
+ clearTimeout(timer);
5764
+ resolve(value);
5765
+ }, (err) => {
5766
+ clearTimeout(timer);
5767
+ reject(err instanceof Error ? err : new Error(String(err)));
5768
+ });
5769
+ });
5770
+ }
5409
5771
  //#endregion
5410
5772
  //#region src/shared/llm-vision/prompt-hygiene.ts
5411
5773
  /**
@@ -6016,6 +6378,298 @@ var NcDeviceStateCache = class {
6016
6378
  }
6017
6379
  };
6018
6380
  //#endregion
6381
+ //#region src/shared/frame/box-drawer.ts
6382
+ var DEFAULT_COLOR = DEFAULT_EVENT_COLOR;
6383
+ var DEFAULT_QUALITY = 80;
6384
+ var STROKE_WIDTH = 3;
6385
+ function escapeXml$2(s) {
6386
+ return s.replace(/[<>&'"]/g, (ch) => {
6387
+ switch (ch) {
6388
+ case "<": return "&lt;";
6389
+ case ">": return "&gt;";
6390
+ case "&": return "&amp;";
6391
+ case "'": return "&apos;";
6392
+ default: return "&quot;";
6393
+ }
6394
+ });
6395
+ }
6396
+ /** Clamp a pixel box to the frame so the rect always stays inside [0,W]×[0,H]. */
6397
+ function clampBox(b, frameWidth, frameHeight) {
6398
+ const x = Math.max(0, Math.min(Math.round(b.x), frameWidth - 1));
6399
+ const y = Math.max(0, Math.min(Math.round(b.y), frameHeight - 1));
6400
+ return {
6401
+ x,
6402
+ y,
6403
+ w: Math.max(1, Math.min(Math.round(b.w), frameWidth - x)),
6404
+ h: Math.max(1, Math.min(Math.round(b.h), frameHeight - y))
6405
+ };
6406
+ }
6407
+ /**
6408
+ * Draw bounding boxes over a raw RGB frame and JPEG-encode it.
6409
+ *
6410
+ * The frame is composited with an SVG overlay (one `<rect>` per box, plus an
6411
+ * optional caption) at full resolution, then optionally downscaled — so the
6412
+ * box stays crisp relative to the scene. Boxes are clamped to frame bounds to
6413
+ * avoid sharp `extract`/region errors on slightly-out-of-range detections.
6414
+ */
6415
+ async function drawBoxedFrame(frameData, frameWidth, frameHeight, boxes, opts = {}) {
6416
+ const quality = opts.quality ?? DEFAULT_QUALITY;
6417
+ let base = frameData;
6418
+ let baseIsRaw = true;
6419
+ if (boxes.length > 0) {
6420
+ const fontSize = Math.max(12, Math.round(frameHeight / 30));
6421
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}">${boxes.map((b) => {
6422
+ const c = clampBox(b, frameWidth, frameHeight);
6423
+ const color = b.color ?? DEFAULT_COLOR;
6424
+ const rect = `<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${c.h}" fill="none" stroke="${color}" stroke-width="${STROKE_WIDTH}"/>`;
6425
+ if (!b.label) return rect;
6426
+ const ty = Math.max(fontSize, c.y - 4);
6427
+ return rect + `<text x="${c.x}" y="${ty}" font-family="sans-serif" font-size="${fontSize}" fill="${color}" stroke="black" stroke-width="0.5">${escapeXml$2(b.label)}</text>`;
6428
+ }).join("")}</svg>`;
6429
+ base = await sharp(frameData, { raw: {
6430
+ width: frameWidth,
6431
+ height: frameHeight,
6432
+ channels: 3
6433
+ } }).composite([{
6434
+ input: Buffer.from(svg),
6435
+ top: 0,
6436
+ left: 0
6437
+ }]).png().toBuffer();
6438
+ baseIsRaw = false;
6439
+ }
6440
+ let pipeline = baseIsRaw ? sharp(base, { raw: {
6441
+ width: frameWidth,
6442
+ height: frameHeight,
6443
+ channels: 3
6444
+ } }) : sharp(base);
6445
+ if (opts.maxWidth !== void 0) pipeline = pipeline.resize({
6446
+ width: opts.maxWidth,
6447
+ withoutEnlargement: true
6448
+ });
6449
+ return pipeline.jpeg({ quality }).toBuffer();
6450
+ }
6451
+ /** JPEG quality of the composed artefact — the mosaic's number, same reasons. */
6452
+ var GROUP_FRAME_QUALITY = 82;
6453
+ /**
6454
+ * Draw the members onto the frame. Returns `null` when there is nothing to
6455
+ * draw, or when anything at all goes wrong — the caller then ships the
6456
+ * unannotated frame unchanged.
6457
+ */
6458
+ async function composeGroupFrame(input) {
6459
+ const freshnessMs = input.freshnessMs ?? 3e3;
6460
+ const fresh = input.boxes.filter((b) => Math.abs(input.at - b.observedAt) <= freshnessMs);
6461
+ const withheld = input.boxes.length - fresh.length;
6462
+ if (fresh.length < 2) {
6463
+ if (withheld > 0) input.logger.info("group frame not drawn — too few members with fresh geometry", {
6464
+ tags: { deviceId: input.deviceId },
6465
+ meta: {
6466
+ groupKey: input.groupKey,
6467
+ members: input.boxes.length,
6468
+ fresh: fresh.length,
6469
+ withheld
6470
+ }
6471
+ });
6472
+ return null;
6473
+ }
6474
+ try {
6475
+ const decoded = await sharp(input.jpeg).raw().toBuffer({ resolveWithObject: true });
6476
+ const { width, height, channels } = decoded.info;
6477
+ if (!(width > 0 && height > 0) || channels !== 3) {
6478
+ input.logger.warn("group frame not drawn — unexpected raster shape", {
6479
+ tags: { deviceId: input.deviceId },
6480
+ meta: {
6481
+ groupKey: input.groupKey,
6482
+ width,
6483
+ height,
6484
+ channels
6485
+ }
6486
+ });
6487
+ return null;
6488
+ }
6489
+ const boxes = fresh.map((b) => ({
6490
+ x: b.bbox.x * width,
6491
+ y: b.bbox.y * height,
6492
+ w: b.bbox.w * width,
6493
+ h: b.bbox.h * height,
6494
+ ...b.label !== void 0 && b.label.length > 0 ? { label: b.label } : {}
6495
+ }));
6496
+ const bytes = await drawBoxedFrame(decoded.data, width, height, boxes, { quality: GROUP_FRAME_QUALITY });
6497
+ input.logger.debug("group frame composed", {
6498
+ tags: { deviceId: input.deviceId },
6499
+ meta: {
6500
+ groupKey: input.groupKey,
6501
+ drawn: boxes.length,
6502
+ withheld,
6503
+ width,
6504
+ height
6505
+ }
6506
+ });
6507
+ return {
6508
+ bytes,
6509
+ drawn: boxes.length,
6510
+ withheld
6511
+ };
6512
+ } catch (err) {
6513
+ input.logger.warn("group frame compose failed — shipping the unannotated frame", {
6514
+ tags: { deviceId: input.deviceId },
6515
+ meta: {
6516
+ groupKey: input.groupKey,
6517
+ error: err instanceof Error ? err.message : String(err)
6518
+ }
6519
+ });
6520
+ return null;
6521
+ }
6522
+ }
6523
+ //#endregion
6524
+ //#region src/notification-center/text-compose.ts
6525
+ /** One normalisation, used on both sides of every class lookup — a pipeline
6526
+ * that emitted `Person` must find the key written `class.person`. */
6527
+ function normaliseClass(className) {
6528
+ return className.trim().toLowerCase();
6529
+ }
6530
+ /**
6531
+ * The noun for a class, in the count's plural category.
6532
+ *
6533
+ * A class no locale names falls through to `class.other`, which prints the RAW
6534
+ * name: a custom model's class must appear in the sentence rather than vanish
6535
+ * out of it, and a missing translation is a smaller defect than a missing
6536
+ * detection.
6537
+ */
6538
+ function ncClassNoun(texts, className, count) {
6539
+ const key = `class.${normaliseClass(className)}`;
6540
+ if (texts.has(key)) return texts.text({
6541
+ key,
6542
+ count,
6543
+ vars: { name: className }
6544
+ });
6545
+ return texts.text({
6546
+ key: "class.other",
6547
+ count,
6548
+ vars: { name: className }
6549
+ });
6550
+ }
6551
+ /** `person (John)` / `persona (John)` — the thing the notification is about. */
6552
+ function ncSubjectText(texts, input) {
6553
+ const classNoun = ncClassNoun(texts, input.className, 1);
6554
+ if (input.label === void 0) return texts.text({
6555
+ key: "detection.subject",
6556
+ vars: { classNoun }
6557
+ });
6558
+ return texts.text({
6559
+ key: subjectLabelKey(input.labelKind),
6560
+ vars: {
6561
+ classNoun,
6562
+ label: input.label
6563
+ }
6564
+ });
6565
+ }
6566
+ /**
6567
+ * Which labelled-subject key a recognition gets. A kind the catalog does not
6568
+ * name falls back to the generic key rather than printing nothing — the same
6569
+ * direction `ncClassNoun` degrades in, and for the same reason.
6570
+ */
6571
+ function subjectLabelKey(kind) {
6572
+ if (kind === "identity") return "detection.subject.identity";
6573
+ if (kind === "plate") return "detection.subject.plate";
6574
+ return "detection.subject.labelled";
6575
+ }
6576
+ /**
6577
+ * ` in gate, drive` — INCLUDING its leading space, and empty when there are no
6578
+ * zones.
6579
+ *
6580
+ * The space belongs to the fragment rather than to the body key because the
6581
+ * alternative is a body that ends in a trailing space on every zone-less
6582
+ * notification. A translator moving the clause moves the space with it.
6583
+ */
6584
+ function ncInZonesText(texts, zoneLabels) {
6585
+ if (zoneLabels.length === 0) return "";
6586
+ return texts.text({
6587
+ key: "detection.inZones",
6588
+ vars: { zones: zoneLabels.join(", ") }
6589
+ });
6590
+ }
6591
+ /** The human-readable edge polarity behind `{{op}}` and the occupancy body. */
6592
+ function ncOccupancyOp(texts, occupied) {
6593
+ return texts.text({ key: occupied ? "occupancy.op.occupied" : "occupancy.op.free" });
6594
+ }
6595
+ /**
6596
+ * `1 persona, 2 veicoli e 3 animali` — the language's own conjunction, its own
6597
+ * plural rules, and its own nouns.
6598
+ *
6599
+ * Zero-count classes are dropped: a selected class the window never contained
6600
+ * is a legitimate `0` in `{{count_vehicle}}`, but "0 veicoli" inside a summary
6601
+ * sentence is a line nobody reads. Order is the caller's (busiest first) and is
6602
+ * preserved, so the same night always produces the same sentence.
6603
+ */
6604
+ function ncDetectionSummary(texts, counts) {
6605
+ const parts = counts.filter((entry) => entry.count > 0).map((entry) => `${entry.count} ${ncClassNoun(texts, entry.className, entry.count)}`);
6606
+ return texts.join(parts);
6607
+ }
6608
+ //#endregion
6609
+ //#region src/notification-center/group/nc-group-text.ts
6610
+ var DEFAULT_MAX_NAMES = 2;
6611
+ /**
6612
+ * `N altre persone` — the remainder phrase for one class, in its own gender.
6613
+ *
6614
+ * Mirrors {@link ncClassNoun} byte for byte, including the fallback: a class no
6615
+ * locale names falls through to `group.others.other`, which prints the raw class
6616
+ * name rather than vanishing. A custom model's class must appear in the sentence.
6617
+ */
6618
+ function ncGroupOthers(texts, className, count) {
6619
+ const classNoun = ncClassNoun(texts, className, count);
6620
+ const key = `group.others.${className.trim().toLowerCase()}`;
6621
+ if (texts.has(key)) return texts.text({
6622
+ key,
6623
+ count,
6624
+ vars: {
6625
+ count: `${count}`,
6626
+ classNoun
6627
+ }
6628
+ });
6629
+ return texts.text({
6630
+ key: "group.others.other",
6631
+ count,
6632
+ vars: {
6633
+ count: `${count}`,
6634
+ classNoun
6635
+ }
6636
+ });
6637
+ }
6638
+ /** Counts per class, in first-seen order so the same burst always reads the same. */
6639
+ function countByClass(members) {
6640
+ const counts = /* @__PURE__ */ new Map();
6641
+ for (const m of members) counts.set(m.className, (counts.get(m.className) ?? 0) + 1);
6642
+ return [...counts].map(([className, count]) => ({
6643
+ className,
6644
+ count
6645
+ }));
6646
+ }
6647
+ /**
6648
+ * The finished `{{subject}}` phrase for a group.
6649
+ *
6650
+ * Pure: a catalog in, a string out. No clock, no store, no I/O — like every
6651
+ * other composer in `text-compose.ts`, and for the same reason.
6652
+ */
6653
+ function ncGroupSubjectText(texts, input) {
6654
+ const members = input.members;
6655
+ const only = members.length === 1 ? members[0] : void 0;
6656
+ if (only !== void 0) return ncSubjectText(texts, {
6657
+ className: only.className,
6658
+ ...only.label !== void 0 ? { label: only.label } : {},
6659
+ ...only.labelKind !== void 0 ? { labelKind: only.labelKind } : {}
6660
+ });
6661
+ if (members.length === 0) return "";
6662
+ const maxNames = input.maxNames !== void 0 && input.maxNames >= 0 ? input.maxNames : DEFAULT_MAX_NAMES;
6663
+ const named = members.filter((m) => m.label !== void 0 && m.label.length > 0);
6664
+ const names = named.slice(0, maxNames).map((m) => m.label ?? "");
6665
+ if (names.length === 0) return ncDetectionSummary(texts, countByClass(members));
6666
+ const spoken = new Set(named.slice(0, maxNames));
6667
+ const remainder = members.filter((m) => !spoken.has(m));
6668
+ if (remainder.length === 0) return texts.join(names);
6669
+ const others = countByClass(remainder).map((c) => ncGroupOthers(texts, c.className, c.count));
6670
+ return texts.join([...names, ...others]);
6671
+ }
6672
+ //#endregion
6019
6673
  //#region src/notification-center/render-template.ts
6020
6674
  /**
6021
6675
  * The ONE `{{var}}` renderer of the notification centre.
@@ -6739,10 +7393,17 @@ function subjectFromSensorEvent(ev, markerTrackId) {
6739
7393
  * `minConfidence` condition composes naturally). A level-path audio event (no
6740
7394
  * `classification`) yields NO class ⇒ it can never satisfy the audio opt-in
6741
7395
  * gate, so only classified audio ever notifies (documented boundary). Audio has
6742
- * no zones / bbox / label / track, so the object/track-specific conditions all
6743
- * fail closed (see the {@link evaluateRule} audio gate + the matchers below).
7396
+ * no zones / bbox / label, so the object-specific conditions all fail closed
7397
+ * (see the {@link evaluateRule} audio gate + the matchers below).
7398
+ *
7399
+ * `stillOwnerId` is the audio still shelf's owner id — the photograph of the
7400
+ * camera taken at the match (`audio-still.ts`). It rides `trackId` because that
7401
+ * is the field the attachment ladder reads, exactly as a doorbell press rides
7402
+ * its marker track's id; nothing else about the subject is track-scoped, and
7403
+ * `buildEntries` keys an audio row on its record id, never on this. Absent when
7404
+ * no rule that could fire wants a still.
6744
7405
  */
6745
- function subjectFromAudioEvent(ev) {
7406
+ function subjectFromAudioEvent(ev, stillOwnerId) {
6746
7407
  const macro = ev.classification?.className;
6747
7408
  return {
6748
7409
  kind: "audio-event",
@@ -6752,6 +7413,7 @@ function subjectFromAudioEvent(ev) {
6752
7413
  classNames: macro !== void 0 ? [`audio-${macro}`] : [],
6753
7414
  ...ev.classification?.score !== void 0 ? { confidence: ev.classification.score } : {},
6754
7415
  zones: [],
7416
+ ...stillOwnerId !== void 0 ? { trackId: stillOwnerId } : {},
6755
7417
  source: "pipeline"
6756
7418
  };
6757
7419
  }
@@ -6772,8 +7434,12 @@ function subjectFromAudioEvent(ev) {
6772
7434
  * neither is a score, and lending one to `minConfidence` would let a detection
6773
7435
  * condition silently re-judge an audio rule on a number that means something
6774
7436
  * else.
7437
+ *
7438
+ * `stillOwnerId` — see {@link subjectFromAudioEvent}. A sound owns no frame, so
7439
+ * the only picture it can carry is a photograph of its camera taken at the
7440
+ * confirmation, held on the still shelf under this id.
6775
7441
  */
6776
- function subjectFromAudioWindow(hit) {
7442
+ function subjectFromAudioWindow(hit, stillOwnerId) {
6777
7443
  const spec = hit.spec;
6778
7444
  return {
6779
7445
  kind: "audio-window",
@@ -6782,6 +7448,7 @@ function subjectFromAudioWindow(hit) {
6782
7448
  timestamp: hit.timestamp,
6783
7449
  classNames: hit.labels.map((l) => `audio-${l}`),
6784
7450
  zones: [],
7451
+ ...stillOwnerId !== void 0 ? { trackId: stillOwnerId } : {},
6785
7452
  source: "audio",
6786
7453
  audioWindow: audioSubjectFor(hit, spec)
6787
7454
  };
@@ -8004,298 +8671,6 @@ var NcTextCatalog = class {
8004
8671
  }
8005
8672
  };
8006
8673
  //#endregion
8007
- //#region src/shared/frame/box-drawer.ts
8008
- var DEFAULT_COLOR = DEFAULT_EVENT_COLOR;
8009
- var DEFAULT_QUALITY = 80;
8010
- var STROKE_WIDTH = 3;
8011
- function escapeXml$2(s) {
8012
- return s.replace(/[<>&'"]/g, (ch) => {
8013
- switch (ch) {
8014
- case "<": return "&lt;";
8015
- case ">": return "&gt;";
8016
- case "&": return "&amp;";
8017
- case "'": return "&apos;";
8018
- default: return "&quot;";
8019
- }
8020
- });
8021
- }
8022
- /** Clamp a pixel box to the frame so the rect always stays inside [0,W]×[0,H]. */
8023
- function clampBox(b, frameWidth, frameHeight) {
8024
- const x = Math.max(0, Math.min(Math.round(b.x), frameWidth - 1));
8025
- const y = Math.max(0, Math.min(Math.round(b.y), frameHeight - 1));
8026
- return {
8027
- x,
8028
- y,
8029
- w: Math.max(1, Math.min(Math.round(b.w), frameWidth - x)),
8030
- h: Math.max(1, Math.min(Math.round(b.h), frameHeight - y))
8031
- };
8032
- }
8033
- /**
8034
- * Draw bounding boxes over a raw RGB frame and JPEG-encode it.
8035
- *
8036
- * The frame is composited with an SVG overlay (one `<rect>` per box, plus an
8037
- * optional caption) at full resolution, then optionally downscaled — so the
8038
- * box stays crisp relative to the scene. Boxes are clamped to frame bounds to
8039
- * avoid sharp `extract`/region errors on slightly-out-of-range detections.
8040
- */
8041
- async function drawBoxedFrame(frameData, frameWidth, frameHeight, boxes, opts = {}) {
8042
- const quality = opts.quality ?? DEFAULT_QUALITY;
8043
- let base = frameData;
8044
- let baseIsRaw = true;
8045
- if (boxes.length > 0) {
8046
- const fontSize = Math.max(12, Math.round(frameHeight / 30));
8047
- const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}">${boxes.map((b) => {
8048
- const c = clampBox(b, frameWidth, frameHeight);
8049
- const color = b.color ?? DEFAULT_COLOR;
8050
- const rect = `<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${c.h}" fill="none" stroke="${color}" stroke-width="${STROKE_WIDTH}"/>`;
8051
- if (!b.label) return rect;
8052
- const ty = Math.max(fontSize, c.y - 4);
8053
- return rect + `<text x="${c.x}" y="${ty}" font-family="sans-serif" font-size="${fontSize}" fill="${color}" stroke="black" stroke-width="0.5">${escapeXml$2(b.label)}</text>`;
8054
- }).join("")}</svg>`;
8055
- base = await sharp(frameData, { raw: {
8056
- width: frameWidth,
8057
- height: frameHeight,
8058
- channels: 3
8059
- } }).composite([{
8060
- input: Buffer.from(svg),
8061
- top: 0,
8062
- left: 0
8063
- }]).png().toBuffer();
8064
- baseIsRaw = false;
8065
- }
8066
- let pipeline = baseIsRaw ? sharp(base, { raw: {
8067
- width: frameWidth,
8068
- height: frameHeight,
8069
- channels: 3
8070
- } }) : sharp(base);
8071
- if (opts.maxWidth !== void 0) pipeline = pipeline.resize({
8072
- width: opts.maxWidth,
8073
- withoutEnlargement: true
8074
- });
8075
- return pipeline.jpeg({ quality }).toBuffer();
8076
- }
8077
- /** JPEG quality of the composed artefact — the mosaic's number, same reasons. */
8078
- var GROUP_FRAME_QUALITY = 82;
8079
- /**
8080
- * Draw the members onto the frame. Returns `null` when there is nothing to
8081
- * draw, or when anything at all goes wrong — the caller then ships the
8082
- * unannotated frame unchanged.
8083
- */
8084
- async function composeGroupFrame(input) {
8085
- const freshnessMs = input.freshnessMs ?? 3e3;
8086
- const fresh = input.boxes.filter((b) => Math.abs(input.at - b.observedAt) <= freshnessMs);
8087
- const withheld = input.boxes.length - fresh.length;
8088
- if (fresh.length < 2) {
8089
- if (withheld > 0) input.logger.info("group frame not drawn — too few members with fresh geometry", {
8090
- tags: { deviceId: input.deviceId },
8091
- meta: {
8092
- groupKey: input.groupKey,
8093
- members: input.boxes.length,
8094
- fresh: fresh.length,
8095
- withheld
8096
- }
8097
- });
8098
- return null;
8099
- }
8100
- try {
8101
- const decoded = await sharp(input.jpeg).raw().toBuffer({ resolveWithObject: true });
8102
- const { width, height, channels } = decoded.info;
8103
- if (!(width > 0 && height > 0) || channels !== 3) {
8104
- input.logger.warn("group frame not drawn — unexpected raster shape", {
8105
- tags: { deviceId: input.deviceId },
8106
- meta: {
8107
- groupKey: input.groupKey,
8108
- width,
8109
- height,
8110
- channels
8111
- }
8112
- });
8113
- return null;
8114
- }
8115
- const boxes = fresh.map((b) => ({
8116
- x: b.bbox.x * width,
8117
- y: b.bbox.y * height,
8118
- w: b.bbox.w * width,
8119
- h: b.bbox.h * height,
8120
- ...b.label !== void 0 && b.label.length > 0 ? { label: b.label } : {}
8121
- }));
8122
- const bytes = await drawBoxedFrame(decoded.data, width, height, boxes, { quality: GROUP_FRAME_QUALITY });
8123
- input.logger.debug("group frame composed", {
8124
- tags: { deviceId: input.deviceId },
8125
- meta: {
8126
- groupKey: input.groupKey,
8127
- drawn: boxes.length,
8128
- withheld,
8129
- width,
8130
- height
8131
- }
8132
- });
8133
- return {
8134
- bytes,
8135
- drawn: boxes.length,
8136
- withheld
8137
- };
8138
- } catch (err) {
8139
- input.logger.warn("group frame compose failed — shipping the unannotated frame", {
8140
- tags: { deviceId: input.deviceId },
8141
- meta: {
8142
- groupKey: input.groupKey,
8143
- error: err instanceof Error ? err.message : String(err)
8144
- }
8145
- });
8146
- return null;
8147
- }
8148
- }
8149
- //#endregion
8150
- //#region src/notification-center/text-compose.ts
8151
- /** One normalisation, used on both sides of every class lookup — a pipeline
8152
- * that emitted `Person` must find the key written `class.person`. */
8153
- function normaliseClass(className) {
8154
- return className.trim().toLowerCase();
8155
- }
8156
- /**
8157
- * The noun for a class, in the count's plural category.
8158
- *
8159
- * A class no locale names falls through to `class.other`, which prints the RAW
8160
- * name: a custom model's class must appear in the sentence rather than vanish
8161
- * out of it, and a missing translation is a smaller defect than a missing
8162
- * detection.
8163
- */
8164
- function ncClassNoun(texts, className, count) {
8165
- const key = `class.${normaliseClass(className)}`;
8166
- if (texts.has(key)) return texts.text({
8167
- key,
8168
- count,
8169
- vars: { name: className }
8170
- });
8171
- return texts.text({
8172
- key: "class.other",
8173
- count,
8174
- vars: { name: className }
8175
- });
8176
- }
8177
- /** `person (John)` / `persona (John)` — the thing the notification is about. */
8178
- function ncSubjectText(texts, input) {
8179
- const classNoun = ncClassNoun(texts, input.className, 1);
8180
- if (input.label === void 0) return texts.text({
8181
- key: "detection.subject",
8182
- vars: { classNoun }
8183
- });
8184
- return texts.text({
8185
- key: subjectLabelKey(input.labelKind),
8186
- vars: {
8187
- classNoun,
8188
- label: input.label
8189
- }
8190
- });
8191
- }
8192
- /**
8193
- * Which labelled-subject key a recognition gets. A kind the catalog does not
8194
- * name falls back to the generic key rather than printing nothing — the same
8195
- * direction `ncClassNoun` degrades in, and for the same reason.
8196
- */
8197
- function subjectLabelKey(kind) {
8198
- if (kind === "identity") return "detection.subject.identity";
8199
- if (kind === "plate") return "detection.subject.plate";
8200
- return "detection.subject.labelled";
8201
- }
8202
- /**
8203
- * ` in gate, drive` — INCLUDING its leading space, and empty when there are no
8204
- * zones.
8205
- *
8206
- * The space belongs to the fragment rather than to the body key because the
8207
- * alternative is a body that ends in a trailing space on every zone-less
8208
- * notification. A translator moving the clause moves the space with it.
8209
- */
8210
- function ncInZonesText(texts, zoneLabels) {
8211
- if (zoneLabels.length === 0) return "";
8212
- return texts.text({
8213
- key: "detection.inZones",
8214
- vars: { zones: zoneLabels.join(", ") }
8215
- });
8216
- }
8217
- /** The human-readable edge polarity behind `{{op}}` and the occupancy body. */
8218
- function ncOccupancyOp(texts, occupied) {
8219
- return texts.text({ key: occupied ? "occupancy.op.occupied" : "occupancy.op.free" });
8220
- }
8221
- /**
8222
- * `1 persona, 2 veicoli e 3 animali` — the language's own conjunction, its own
8223
- * plural rules, and its own nouns.
8224
- *
8225
- * Zero-count classes are dropped: a selected class the window never contained
8226
- * is a legitimate `0` in `{{count_vehicle}}`, but "0 veicoli" inside a summary
8227
- * sentence is a line nobody reads. Order is the caller's (busiest first) and is
8228
- * preserved, so the same night always produces the same sentence.
8229
- */
8230
- function ncDetectionSummary(texts, counts) {
8231
- const parts = counts.filter((entry) => entry.count > 0).map((entry) => `${entry.count} ${ncClassNoun(texts, entry.className, entry.count)}`);
8232
- return texts.join(parts);
8233
- }
8234
- //#endregion
8235
- //#region src/notification-center/group/nc-group-text.ts
8236
- var DEFAULT_MAX_NAMES = 2;
8237
- /**
8238
- * `N altre persone` — the remainder phrase for one class, in its own gender.
8239
- *
8240
- * Mirrors {@link ncClassNoun} byte for byte, including the fallback: a class no
8241
- * locale names falls through to `group.others.other`, which prints the raw class
8242
- * name rather than vanishing. A custom model's class must appear in the sentence.
8243
- */
8244
- function ncGroupOthers(texts, className, count) {
8245
- const classNoun = ncClassNoun(texts, className, count);
8246
- const key = `group.others.${className.trim().toLowerCase()}`;
8247
- if (texts.has(key)) return texts.text({
8248
- key,
8249
- count,
8250
- vars: {
8251
- count: `${count}`,
8252
- classNoun
8253
- }
8254
- });
8255
- return texts.text({
8256
- key: "group.others.other",
8257
- count,
8258
- vars: {
8259
- count: `${count}`,
8260
- classNoun
8261
- }
8262
- });
8263
- }
8264
- /** Counts per class, in first-seen order so the same burst always reads the same. */
8265
- function countByClass(members) {
8266
- const counts = /* @__PURE__ */ new Map();
8267
- for (const m of members) counts.set(m.className, (counts.get(m.className) ?? 0) + 1);
8268
- return [...counts].map(([className, count]) => ({
8269
- className,
8270
- count
8271
- }));
8272
- }
8273
- /**
8274
- * The finished `{{subject}}` phrase for a group.
8275
- *
8276
- * Pure: a catalog in, a string out. No clock, no store, no I/O — like every
8277
- * other composer in `text-compose.ts`, and for the same reason.
8278
- */
8279
- function ncGroupSubjectText(texts, input) {
8280
- const members = input.members;
8281
- const only = members.length === 1 ? members[0] : void 0;
8282
- if (only !== void 0) return ncSubjectText(texts, {
8283
- className: only.className,
8284
- ...only.label !== void 0 ? { label: only.label } : {},
8285
- ...only.labelKind !== void 0 ? { labelKind: only.labelKind } : {}
8286
- });
8287
- if (members.length === 0) return "";
8288
- const maxNames = input.maxNames !== void 0 && input.maxNames >= 0 ? input.maxNames : DEFAULT_MAX_NAMES;
8289
- const named = members.filter((m) => m.label !== void 0 && m.label.length > 0);
8290
- const names = named.slice(0, maxNames).map((m) => m.label ?? "");
8291
- if (names.length === 0) return ncDetectionSummary(texts, countByClass(members));
8292
- const spoken = new Set(named.slice(0, maxNames));
8293
- const remainder = members.filter((m) => !spoken.has(m));
8294
- if (remainder.length === 0) return texts.join(names);
8295
- const others = countByClass(remainder).map((c) => ncGroupOthers(texts, c.className, c.count));
8296
- return texts.join([...names, ...others]);
8297
- }
8298
- //#endregion
8299
8674
  //#region src/notification-center/dispatcher.ts
8300
8675
  var DEFAULT_TARGET_CACHE_TTL_MS = 6e4;
8301
8676
  /**
@@ -8858,7 +9233,7 @@ var NcDispatcher = class {
8858
9233
  const stillOverride = zoneIdsWanted !== void 0 && zoneIdsWanted.length > 0 ? "keyFrame" : void 0;
8859
9234
  const firstLook = await this.resolveAttachment(entry, stillOverride);
8860
9235
  const footage = await this.renderFootage(entry);
8861
- const still = firstLook !== null || entry.payload.media === "none" ? firstLook : footage.attempted ? await this.resolveAttachment(entry, stillOverride) : await this.waitForStill(entry, stillOverride);
9236
+ const still = firstLook !== null || entry.payload.media === "none" ? firstLook : footage.attempted ? await this.resolveAttachment(entry, stillOverride) ?? await this.waitForStill(entry, stillOverride) : await this.waitForStill(entry, stillOverride);
8862
9237
  if (still !== null) {
8863
9238
  const zoneIds = zoneIdsWanted;
8864
9239
  if (zoneIds !== void 0 && zoneIds.length > 0) {
@@ -9346,17 +9721,24 @@ function incomingFromSensorEvent(event, markerTrackId) {
9346
9721
  })
9347
9722
  };
9348
9723
  }
9349
- /** Audio-event persist (classified episode). */
9350
- function incomingFromAudioEvent(event) {
9724
+ /**
9725
+ * Audio-event persist (classified episode).
9726
+ *
9727
+ * `stillOwnerId` is the audio still shelf's owner id for the photograph taken
9728
+ * at this episode (`audio-still.ts`). Absent when no rule that could fire asked
9729
+ * for a picture — the same shape `incomingFromSensorEvent` carries its marker.
9730
+ */
9731
+ function incomingFromAudioEvent(event, stillOwnerId) {
9351
9732
  return {
9352
- subject: subjectFromAudioEvent(event),
9733
+ subject: subjectFromAudioEvent(event, stillOwnerId),
9353
9734
  kind: "audio-event",
9354
9735
  origin: "pipeline",
9355
9736
  log: () => ({
9356
9737
  tags: { deviceId: event.deviceId },
9357
9738
  meta: {
9358
9739
  audioEventId: event.id,
9359
- class: event.classification?.className
9740
+ class: event.classification?.className,
9741
+ ...stillOwnerId !== void 0 ? { stillOwner: stillOwnerId } : {}
9360
9742
  }
9361
9743
  })
9362
9744
  };
@@ -9386,15 +9768,20 @@ function incomingFromPackageEvent(event, phase) {
9386
9768
  * and a crash in the confirm→outbox window drops the notification rather than
9387
9769
  * replaying it. Deliberate: a stale match is a claim about a noise that has
9388
9770
  * already stopped.
9771
+ *
9772
+ * `stillOwnerId` — see {@link incomingFromAudioEvent}.
9389
9773
  */
9390
- function incomingFromAudioWindow(hit) {
9774
+ function incomingFromAudioWindow(hit, stillOwnerId) {
9391
9775
  return {
9392
- subject: subjectFromAudioWindow(hit),
9776
+ subject: subjectFromAudioWindow(hit, stillOwnerId),
9393
9777
  kind: "audio-window",
9394
9778
  origin: "pipeline",
9395
9779
  log: () => ({
9396
9780
  tags: { deviceId: hit.deviceId },
9397
- meta: audioHitMeta(hit)
9781
+ meta: {
9782
+ ...audioHitMeta(hit),
9783
+ ...stillOwnerId !== void 0 ? { stillOwner: stillOwnerId } : {}
9784
+ }
9398
9785
  })
9399
9786
  };
9400
9787
  }
@@ -13335,6 +13722,29 @@ function escapeXml$1(s) {
13335
13722
  }
13336
13723
  });
13337
13724
  }
13725
+ /**
13726
+ * The single decision about drawn text — and the home of the DEFAULT.
13727
+ *
13728
+ * `undefined` means `none`, not `all`: a rule stored before this option existed
13729
+ * has no `captions` key, and the operator's request is that such a rule deliver
13730
+ * pictures. The schema materialises the same default on read
13731
+ * (`NcSummaryRuleInputSchema`), so this arm only fires for a caller that never
13732
+ * had a rule — the test bridge and the spec. Both agreeing is the point.
13733
+ */
13734
+ function mosaicTextPlan(captions) {
13735
+ if (captions === "all") return {
13736
+ tileLabels: true,
13737
+ titleBand: true
13738
+ };
13739
+ if (captions === "tiles") return {
13740
+ tileLabels: true,
13741
+ titleBand: false
13742
+ };
13743
+ return {
13744
+ tileLabels: false,
13745
+ titleBand: false
13746
+ };
13747
+ }
13338
13748
  /** Grid shape for `n` tiles: as square as possible, rows never exceeding cols
13339
13749
  * by more than one — a 3×3 for nine, a 3×2 for five, never a 1×9 strip. */
13340
13750
  function mosaicGrid(n) {
@@ -13441,22 +13851,25 @@ function titleSvg(width, height, caption) {
13441
13851
  * treats that as "no mosaic" and still delivers the counts.
13442
13852
  */
13443
13853
  async function renderMosaic(request, logger) {
13854
+ const plan = mosaicTextPlan(request.captions);
13444
13855
  const decoded = [];
13445
13856
  const probeLayout = mosaicLayout({
13446
13857
  tileCount: Math.max(1, request.tiles.length),
13447
13858
  ...request.maxWidth !== void 0 ? { maxWidth: request.maxWidth } : {}
13448
13859
  });
13449
13860
  for (const tile of request.tiles) try {
13450
- const resized = await sharp(tile.jpeg).resize({
13861
+ const label = plan.tileLabels ? tile.label?.trim() ?? "" : "";
13862
+ const fitted = sharp(tile.jpeg).resize({
13451
13863
  width: probeLayout.tileWidth,
13452
13864
  height: probeLayout.tileHeight,
13453
13865
  fit: "contain",
13454
13866
  background: MOSAIC_BACKGROUND
13455
- }).composite([{
13456
- input: tileLabelSvg(probeLayout.tileWidth, probeLayout.tileHeight, tile.label),
13867
+ });
13868
+ const resized = await (label.length === 0 ? fitted : fitted.composite([{
13869
+ input: tileLabelSvg(probeLayout.tileWidth, probeLayout.tileHeight, label),
13457
13870
  top: 0,
13458
13871
  left: 0
13459
- }]).jpeg({ quality: 82 }).toBuffer();
13872
+ }])).jpeg({ quality: 82 }).toBuffer();
13460
13873
  decoded.push({
13461
13874
  tile,
13462
13875
  resized
@@ -13471,7 +13884,7 @@ async function renderMosaic(request, logger) {
13471
13884
  });
13472
13885
  }
13473
13886
  const droppedTiles = request.tiles.length - decoded.length;
13474
- const caption = request.caption?.trim() ?? "";
13887
+ const caption = plan.titleBand ? request.caption?.trim() ?? "" : "";
13475
13888
  const layout = mosaicLayout({
13476
13889
  tileCount: Math.max(1, decoded.length),
13477
13890
  ...request.maxWidth !== void 0 ? { maxWidth: request.maxWidth } : {},
@@ -14340,18 +14753,20 @@ var NcSummaryProducer = class {
14340
14753
  };
14341
14754
  let rendered;
14342
14755
  try {
14756
+ const plan = mosaicTextPlan(rule.captions);
14343
14757
  rendered = await renderMosaic({
14344
14758
  tiles: tiles.map(({ candidate, jpeg }) => ({
14345
14759
  jpeg,
14346
- label: `${names.get(candidate.deviceId) ?? candidate.deviceId} · ${clockOf$2(candidate.firstSeen)} · ${candidate.className}`,
14760
+ ...plan.tileLabels ? { label: `${names.get(candidate.deviceId) ?? candidate.deviceId} · ${clockOf$2(candidate.firstSeen)} · ${candidate.className}` } : {},
14347
14761
  deviceId: candidate.deviceId,
14348
14762
  trackId: candidate.trackId
14349
14763
  })),
14350
- caption: summaryCaptionText({
14764
+ captions: rule.captions,
14765
+ ...plan.titleBand ? { caption: summaryCaptionText({
14351
14766
  ...rule.captionText !== void 0 ? { captionText: rule.captionText } : {},
14352
14767
  ruleName: rule.name,
14353
14768
  window
14354
- })
14769
+ }) } : {}
14355
14770
  }, this.deps.logger);
14356
14771
  } catch (err) {
14357
14772
  this.deps.logger.warn("summary mosaic could not be composed — delivering the counts alone", { meta: {
@@ -14615,6 +15030,34 @@ var NcSummaryRankSchema = _enum([
14615
15030
  "dwell"
14616
15031
  ]);
14617
15032
  /**
15033
+ * How much TEXT is burnt into the mosaic — and the answer is NONE by default.
15034
+ *
15035
+ * `none` is the shipped default and, crucially, the reading of an ABSENT field:
15036
+ * `.default()` on the Input schema materialises it, and the persisted schema
15037
+ * extends Input, so a rule authored before this option existed parses as `none`
15038
+ * on the next read. No migration, no backfill, no rewrite of a stored rule.
15039
+ *
15040
+ * The operator asked for "solo immagini". Three reasons it is the right
15041
+ * default rather than a preference:
15042
+ *
15043
+ * - a caption is PERMANENT in the artefact, exactly like a detection box, and
15044
+ * a mosaic is re-read days later;
15045
+ * - the per-tile strip covers 14% of the tile from the bottom edge up, which
15046
+ * is where feet, wheels and parcels are;
15047
+ * - nothing downstream needs it. The AI pass is given the RAW per-track JPEGs
15048
+ * and gets camera, class and time as PROMPT TEXT — it never reads the
15049
+ * mosaic, so the pixels carry no fact the judge would lose.
15050
+ *
15051
+ * `tiles` and `all` keep the old behaviour reachable for an operator who wants
15052
+ * it; `all` is byte-for-byte what every rule produced before this field.
15053
+ */
15054
+ var NcSummaryCaptionsSchema = _enum([
15055
+ "none",
15056
+ "tiles",
15057
+ "all"
15058
+ ]);
15059
+ var NC_SUMMARY_DEFAULT_CAPTIONS = "none";
15060
+ /**
14618
15061
  * The AI section — DECLARED IN P1, WIRED IN P2.
14619
15062
  *
14620
15063
  * Nothing in this addon reads it today. It is here so the rule shape does not
@@ -14648,6 +15091,8 @@ var NcSummaryRuleInputSchema = object({
14648
15091
  barrierSeconds: BarrierSecondsField.default(120),
14649
15092
  targets: TargetsField,
14650
15093
  template: NcSummaryTemplateSchema.optional(),
15094
+ /** Burned onto the mosaic's title band — and ONLY when `captions` is `all`. */
15095
+ captions: NcSummaryCaptionsSchema.default(NC_SUMMARY_DEFAULT_CAPTIONS),
14651
15096
  /** Burned onto the mosaic's title band. `''` is reachable and means "no
14652
15097
  * caption" — which is why this is not `.min(1)`. */
14653
15098
  captionText: string().max(200).optional(),
@@ -14677,6 +15122,7 @@ var NcSummaryRulePatchSchema = object({
14677
15122
  barrierSeconds: BarrierSecondsField.optional(),
14678
15123
  targets: TargetsField.optional(),
14679
15124
  template: NcSummaryTemplateSchema.nullable().optional(),
15125
+ captions: NcSummaryCaptionsSchema.optional(),
14680
15126
  captionText: string().max(200).optional(),
14681
15127
  deliverEmpty: boolean().optional(),
14682
15128
  priority: PriorityField.optional(),
@@ -15196,14 +15642,19 @@ function isSyntheticId(id) {
15196
15642
  * snapshot that produced it lists the members being counted and the edge
15197
15643
  * names one (`chooseOccupancyMediaOwner`), so the tester stands in for that
15198
15644
  * member exactly as it stands in for the marker.
15645
+ * - and, since 2026-08-15, a `trackId` for both AUDIO kinds: a sound owns no
15646
+ * frame, so production photographs the camera at the match and holds the
15647
+ * bytes on the still shelf under an owner id the subject names
15648
+ * (`audio-still.ts`). The tester's RAM-held frame stands in for exactly that
15649
+ * photograph, and — unlike every other kind here — it is taken the same way
15650
+ * production takes it, at the same moment, from the same cap.
15199
15651
  *
15200
- * An audio episode still freezes neither. It HAS a marker projection, but its
15201
- * id does not reach the notification subject yet, so it remains honestly
15202
- * unattachable — a tester that invented a picture for it would be advertising a
15203
- * feature that does not exist.
15652
+ * Only `system-event` remains: an infrastructure event is about no camera at
15653
+ * all, so there is nothing to photograph and a tester that invented a picture
15654
+ * for it would be advertising a feature that does not exist.
15204
15655
  */
15205
15656
  function triggerCanCarryStill(kind) {
15206
- return kind === "object-event" || kind === "package-event" || kind === "track-end" || kind === "device-event" || kind === "occupancy-event";
15657
+ return kind !== "system-event";
15207
15658
  }
15208
15659
  /** The detection provenance production stamps on a subject of this kind. */
15209
15660
  function syntheticSource(kind) {
@@ -15241,7 +15692,7 @@ function audioSubjectOf(input) {
15241
15692
  function buildSyntheticEvent(input, recordId, now) {
15242
15693
  const kind = input.trigger;
15243
15694
  const timestamp = input.timestamp ?? now;
15244
- const carriesTrackId = kind === "object-event" || kind === "track-end" || kind === "device-event" || kind === "occupancy-event";
15695
+ const carriesTrackId = kind === "object-event" || kind === "track-end" || kind === "device-event" || kind === "occupancy-event" || kind === "audio-event" || kind === "audio-window";
15245
15696
  return {
15246
15697
  subject: {
15247
15698
  kind,
@@ -17821,6 +18272,12 @@ var NotificationCenter = class NotificationCenter {
17821
18272
  * is the one thing the synthetic producer promises not to do.
17822
18273
  */
17823
18274
  syntheticMedia = /* @__PURE__ */ new Map();
18275
+ /**
18276
+ * The photograph a SOUND rule's notification carries — see `audio-still.ts`.
18277
+ * Null when this wiring has no snapshot cap, which leaves every audio
18278
+ * notification text-only rather than broken.
18279
+ */
18280
+ audioStills;
17824
18281
  /** Per-device rate limit for the "matched NO rule" report — see `reportNoMatch`. */
17825
18282
  lastNoMatchReportAt = /* @__PURE__ */ new Map();
17826
18283
  noMatchSuppressed = /* @__PURE__ */ new Map();
@@ -18112,6 +18569,12 @@ var NotificationCenter = class NotificationCenter {
18112
18569
  this.deps = deps;
18113
18570
  this.logger = deps.logger;
18114
18571
  this.now = deps.now ?? (() => Date.now());
18572
+ const getSnapshot = deps.getSnapshot;
18573
+ this.audioStills = getSnapshot !== void 0 ? new NcAudioStillShelf({
18574
+ getSnapshot,
18575
+ logger: this.logger.child("audio-still"),
18576
+ now: this.now
18577
+ }) : null;
18115
18578
  this.rules = new NcRuleStore({
18116
18579
  store: deps.store,
18117
18580
  logger: this.logger.child("rules"),
@@ -18189,6 +18652,8 @@ var NotificationCenter = class NotificationCenter {
18189
18652
  getMediaForOwner: async (ownerKind, ownerId) => {
18190
18653
  const held = isSyntheticId(ownerId) ? this.syntheticMedia.get(ownerId) : void 0;
18191
18654
  if (held !== void 0) return held.files;
18655
+ const audio = isAudioStillId(ownerId) ? this.audioStills?.get(ownerId) : void 0;
18656
+ if (audio !== void 0) return audio;
18192
18657
  return deps.dispatcher.getMediaForOwner(ownerKind, ownerId);
18193
18658
  },
18194
18659
  ...deps.now !== void 0 ? { now: deps.now } : {},
@@ -18393,6 +18858,7 @@ var NotificationCenter = class NotificationCenter {
18393
18858
  }
18394
18859
  this.timelapseScheduler?.stop();
18395
18860
  this.summaryProducer?.stop();
18861
+ this.audioStills?.clear();
18396
18862
  this.evaluationActive = false;
18397
18863
  }
18398
18864
  /**
@@ -18722,7 +19188,35 @@ var NotificationCenter = class NotificationCenter {
18722
19188
  * durable guarantee begins at this hook, matching the device-event boundary.
18723
19189
  */
18724
19190
  onAudioEventPersisted(event) {
18725
- this.consumeEvent(incomingFromAudioEvent(event));
19191
+ this.consumeEvent(incomingFromAudioEvent(event, this.audioStillOwner(event.deviceId, event.timestamp, "legacy")));
19192
+ }
19193
+ /**
19194
+ * Start photographing `deviceId` NOW and return the owner id the audio
19195
+ * subject should name — or `undefined` when nothing would use it.
19196
+ *
19197
+ * **The capture is not awaited.** A sound is transient and a notification
19198
+ * about a scream must not wait on a camera; the bytes land while the outbox
19199
+ * row queues, and the dispatcher's ordinary bounded still-wait picks them up.
19200
+ * That is also why this runs BEFORE the cooldown and the mute are consulted:
19201
+ * by the time a rule's window has been judged the moment is gone.
19202
+ *
19203
+ * **The gate is the RULE's media policy, not the trigger.** Photographing a
19204
+ * camera on every confirmed window for rules that ship text anyway would be a
19205
+ * snapshot at whatever rate the sound happens to occur, for nothing. The rule
19206
+ * cache is an in-memory mirror, so this is not a fallible read (D49) — a
19207
+ * failed one would look exactly like "no rule wants a picture", which is the
19208
+ * direction that DESTROYS the media.
19209
+ */
19210
+ audioStillOwner(deviceId, atMs, mode) {
19211
+ const shelf = this.audioStills;
19212
+ if (shelf === null || !this.evaluationActive) return void 0;
19213
+ for (const rule of this.rules.listEnabled("immediate")) {
19214
+ if (rule.media.attach === "none") continue;
19215
+ if (!(mode === "condition" ? rule.conditions.audio !== void 0 : rule.conditions.audio === void 0 && referencesAudioClass(rule.conditions.classes))) continue;
19216
+ const devices = rule.conditions.devices;
19217
+ if (devices !== void 0 && devices.length > 0 && !devices.includes(deviceId)) continue;
19218
+ return shelf.capture(deviceId, atMs);
19219
+ }
18726
19220
  }
18727
19221
  /**
18728
19222
  * Called at the package object-event persist site (`PackageDropDetector` —
@@ -18820,7 +19314,7 @@ var NotificationCenter = class NotificationCenter {
18820
19314
  tags: { deviceId },
18821
19315
  meta: audioHitMeta(hit)
18822
19316
  });
18823
- this.consumeEvent(incomingFromAudioWindow(hit));
19317
+ this.consumeEvent(incomingFromAudioWindow(hit, this.audioStillOwner(deviceId, hit.timestamp, "condition")));
18824
19318
  }
18825
19319
  }
18826
19320
  /**
@@ -19637,6 +20131,19 @@ var NotificationCenter = class NotificationCenter {
19637
20131
  }
19638
20132
  });
19639
20133
  }
20134
+ if (!noOwnerReported && (kind === "audio-window" || kind === "audio-event") && subject.trackId === void 0 && rule.media.attach !== "none") {
20135
+ noOwnerReported = true;
20136
+ this.logger.warn("audio match has no still owner — this notification ships text-only", {
20137
+ tags: { deviceId: subject.deviceId },
20138
+ meta: {
20139
+ recordId: subject.recordId,
20140
+ kind,
20141
+ ruleId: rule.id,
20142
+ rule: rule.name,
20143
+ snapshotWired: this.audioStills !== null
20144
+ }
20145
+ });
20146
+ }
19640
20147
  const userTargets = await this.resolveUserTargets(rule, subject.deviceId);
19641
20148
  const entries = this.buildEntries(rule, subject, kind, evaluation.matchedOn, userTargets, origin, {
19642
20149
  key,
@@ -19793,6 +20300,7 @@ var NotificationCenter = class NotificationCenter {
19793
20300
  admitToGroup(rule, subject, now) {
19794
20301
  const idleSec = rule.groupIdleSec;
19795
20302
  if (idleSec === void 0 || idleSec <= 0) return { outcome: "disabled" };
20303
+ if (subject.kind === "audio-window" || subject.kind === "audio-event") return { outcome: "disabled" };
19796
20304
  const trackId = subject.trackId;
19797
20305
  if (trackId === void 0) return { outcome: "disabled" };
19798
20306
  const member = {
@@ -23444,51 +23952,6 @@ var embeddingActions = defineCustomActions({
23444
23952
  "embedding.rebuildStatus": customAction(NoInputSchema, RebuildStatusSchema),
23445
23953
  "embedding.wipe": customAction(NoInputSchema, WipeResultSchema, { auth: "admin" })
23446
23954
  });
23447
- var OrphanAuditActionInputSchema = object({
23448
- mode: _enum(["report", "reclaim"]).default("report"),
23449
- /**
23450
- * Start every scope from the top instead of resuming its persisted cursor.
23451
- *
23452
- * What draining a backlog wants, and never the default — the cursor exists
23453
- * so a 6-hourly pass makes progress instead of re-walking the same head.
23454
- */
23455
- restart: boolean().optional(),
23456
- /** Rows read per statement. Smaller = more yields = a gentler pass. */
23457
- pageSize: number().int().min(50).max(5e3).optional(),
23458
- /** Rows EXAMINED per collection in this pass — the pacing bound. */
23459
- maxRowsPerScope: number().int().min(1).max(2e5).optional(),
23460
- /** Rows DELETED per collection in this pass — the blast-radius bound. */
23461
- maxReclaimPerScope: number().int().min(1).max(1e5).optional(),
23462
- /**
23463
- * Minutes of immunity for a recent row. Protective ONLY — raising it can
23464
- * exclusively save rows, never select them, which is what keeps this off
23465
- * the one-age-keyed-sweep guard's books.
23466
- */
23467
- graceMinutes: number().int().min(1).max(10080).optional()
23468
- });
23469
- var OrphanAuditScopeReportSchema = object({
23470
- collection: string(),
23471
- examined: number(),
23472
- orphans: number(),
23473
- reclaimed: number(),
23474
- bytesReclaimed: number(),
23475
- exempt: number(),
23476
- withinGrace: number(),
23477
- unresolvable: number(),
23478
- stoppedBy: string(),
23479
- /** Where the NEXT pass resumes. `0` = the scope was walked to the end. */
23480
- nextCursor: number()
23481
- });
23482
- var orphanAuditActions = defineCustomActions({ "retention.orphanAudit": customAction(OrphanAuditActionInputSchema, object({
23483
- /** `false` when a pass was already in flight — nothing was done. */
23484
- ran: boolean(),
23485
- mode: _enum(["report", "reclaim"]),
23486
- scopes: array(OrphanAuditScopeReportSchema),
23487
- totalOrphans: number(),
23488
- totalReclaimed: number(),
23489
- totalBytesReclaimed: number(),
23490
- durationMs: number()
23491
- }), { auth: "admin" }) });
23492
23955
  //#endregion
23493
23956
  //#region src/pipeline-analytics/map-with-concurrency.ts
23494
23957
  /**
@@ -27805,6 +28268,51 @@ var PlateGalleryProvider = class {
27805
28268
  };
27806
28269
  }
27807
28270
  };
28271
+ var OrphanAuditActionInputSchema = object({
28272
+ mode: _enum(["report", "reclaim"]).default("report"),
28273
+ /**
28274
+ * Start every scope from the top instead of resuming its persisted cursor.
28275
+ *
28276
+ * What draining a backlog wants, and never the default — the cursor exists
28277
+ * so a 6-hourly pass makes progress instead of re-walking the same head.
28278
+ */
28279
+ restart: boolean().optional(),
28280
+ /** Rows read per statement. Smaller = more yields = a gentler pass. */
28281
+ pageSize: number().int().min(50).max(5e3).optional(),
28282
+ /** Rows EXAMINED per collection in this pass — the pacing bound. */
28283
+ maxRowsPerScope: number().int().min(1).max(2e5).optional(),
28284
+ /** Rows DELETED per collection in this pass — the blast-radius bound. */
28285
+ maxReclaimPerScope: number().int().min(1).max(1e5).optional(),
28286
+ /**
28287
+ * Minutes of immunity for a recent row. Protective ONLY — raising it can
28288
+ * exclusively save rows, never select them, which is what keeps this off
28289
+ * the one-age-keyed-sweep guard's books.
28290
+ */
28291
+ graceMinutes: number().int().min(1).max(10080).optional()
28292
+ });
28293
+ var OrphanAuditScopeReportSchema = object({
28294
+ collection: string(),
28295
+ examined: number(),
28296
+ orphans: number(),
28297
+ reclaimed: number(),
28298
+ bytesReclaimed: number(),
28299
+ exempt: number(),
28300
+ withinGrace: number(),
28301
+ unresolvable: number(),
28302
+ stoppedBy: string(),
28303
+ /** Where the NEXT pass resumes. `0` = the scope was walked to the end. */
28304
+ nextCursor: number()
28305
+ });
28306
+ var orphanAuditActions = defineCustomActions({ "retention.orphanAudit": customAction(OrphanAuditActionInputSchema, object({
28307
+ /** `false` when a pass was already in flight — nothing was done. */
28308
+ ran: boolean(),
28309
+ mode: _enum(["report", "reclaim"]),
28310
+ scopes: array(OrphanAuditScopeReportSchema),
28311
+ totalOrphans: number(),
28312
+ totalReclaimed: number(),
28313
+ totalBytesReclaimed: number(),
28314
+ durationMs: number()
28315
+ }), { auth: "admin" }) });
27808
28316
  //#endregion
27809
28317
  //#region src/pipeline-analytics/videoclips-provider.ts
27810
28318
  var SOURCE = "analytics";
@@ -33549,13 +34057,25 @@ var StoragePressureTracker = class {
33549
34057
  };
33550
34058
  //#endregion
33551
34059
  //#region src/pipeline-analytics/store/media-store.ts
34060
+ /**
34061
+ * `scene` is its own owner kind rather than borrowing `event`.
34062
+ *
34063
+ * Scene reference thumbnails were written as `ownerKind: 'event'` with an
34064
+ * `ownerId` of `scene-<monitorId>-…`, which resolves through
34065
+ * `resolveMediaOwner` to `{ kind: 'event', eventId: 'scene-…' }` — an event row
34066
+ * that has never existed. The orphan audit therefore classified every
34067
+ * operator-captured reference picture as collectable. It is the same class of
34068
+ * mistake `identity` and `vehicle` are exempt for: a blob the OPERATOR curated,
34069
+ * which nothing else can regenerate and no cascade owns.
34070
+ */
33552
34071
  var OWNER_KINDS = [
33553
34072
  "event",
33554
34073
  "track",
33555
34074
  "face",
33556
34075
  "identity",
33557
34076
  "plate",
33558
- "vehicle"
34077
+ "vehicle",
34078
+ "scene"
33559
34079
  ];
33560
34080
  /**
33561
34081
  * Narrow an arbitrary string to an `OwnerKind`.
@@ -42337,7 +42857,11 @@ function auditableCollections() {
42337
42857
  * exemption lives here now, and the orphan collector — the only sweep that
42338
42858
  * walks media it was not handed by name — reads it.
42339
42859
  */
42340
- var RETENTION_EXEMPT_OWNER_KINDS = new Set(["identity", "vehicle"]);
42860
+ var RETENTION_EXEMPT_OWNER_KINDS = new Set([
42861
+ "identity",
42862
+ "vehicle",
42863
+ "scene"
42864
+ ]);
42341
42865
  function isRetentionExemptOwnerKind(kind) {
42342
42866
  return RETENTION_EXEMPT_OWNER_KINDS.has(kind);
42343
42867
  }
@@ -45276,22 +45800,6 @@ async function projectSensorMarkers(deps, data, timestamp) {
45276
45800
  }
45277
45801
  return landed;
45278
45802
  }
45279
- /** JPEG quality for the downscaled full frame — matches the crop path. */
45280
- var FULL_FRAME_QUALITY = 80;
45281
- /**
45282
- * Downscale an already-encoded JPEG full frame to FIT WITHIN
45283
- * {@link FULL_FRAME_MAX_WIDTH}×{@link FULL_FRAME_MAX_HEIGHT}, preserving aspect
45284
- * ratio (`fit: 'inside'`) and never enlarging a source already smaller than the
45285
- * box. Re-encodes as JPEG. Used before persisting a synthetic sensor/control
45286
- * track's whole-scene snapshot so a raw native-resolution frame (a 4K bedroom
45287
- * at night) is never stored or served — the privacy fix moved to CAPTURE time.
45288
- */
45289
- async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
45290
- return sharp(Buffer.from(jpeg)).resize(maxWidth, maxHeight, {
45291
- fit: "inside",
45292
- withoutEnlargement: true
45293
- }).jpeg({ quality: FULL_FRAME_QUALITY }).toBuffer();
45294
- }
45295
45803
  //#endregion
45296
45804
  //#region src/pipeline-analytics/services/synthetic-track.ts
45297
45805
  /**
@@ -49836,6 +50344,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
49836
50344
  addonId: device.addonId
49837
50345
  };
49838
50346
  },
50347
+ getSnapshot: (deviceId) => api.snapshot.getSnapshot.query({ deviceId }),
49839
50348
  identityIdsByName: async () => {
49840
50349
  const byName = /* @__PURE__ */ new Map();
49841
50350
  const identities = await stores.identityStore.listIdentities();
@@ -50712,13 +51221,16 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
50712
51221
  if (media === null) throw new Error("media store not ready");
50713
51222
  return media.putReplacing({
50714
51223
  deviceId,
50715
- ownerKind: "event",
51224
+ ownerKind: "scene",
50716
51225
  ownerId,
50717
51226
  kind: "thumbnailSmall",
50718
51227
  timestamp,
50719
51228
  data
50720
51229
  });
50721
51230
  },
51231
+ dropMedia: async (mediaId) => {
51232
+ await this.mediaStore?.deleteByKey(mediaId);
51233
+ },
50722
51234
  mirrorSlice: (deviceId, status) => {
50723
51235
  this.mirrorSceneSlice(deviceId, status);
50724
51236
  },