@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.
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-Bs6oboqZ.js");
5
+ const require_dist = require("../dist-CFOT1Yoz.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_dist.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -3390,19 +3390,26 @@ function cosineSimilarity(a, b) {
3390
3390
  }
3391
3391
  /**
3392
3392
  * Select the references a live crop is scored against for a given condition —
3393
- * **exact condition match, with NO fallback.**
3394
- *
3395
- * There used to be a silent fallback to every reference when the current
3396
- * condition had none. For a named-states monitor that was a defensible "better
3397
- * than blind". For a latched alarm it is a false positive every single night:
3398
- * the day reference is compared against an IR frame, the cosine collapses, the
3399
- * scene latches `diverged`, and the operator is told the bin was taken at
3400
- * 21:40. Cross-condition cosines are not comparable, so the honest answer to "I
3401
- * have never seen this scene in this light" is *I don't know* — never a guess
3402
- * in the direction that raises an alarm.
3403
- */
3404
- function selectConditionRefs(references, currentCondition) {
3405
- return references.filter((r) => r.condition === currentCondition);
3393
+ * **exact condition match, and by default NO fallback.**
3394
+ *
3395
+ * There used to be an unconditional silent fallback to every reference when the
3396
+ * current condition had none. For a named-states monitor that was a defensible
3397
+ * "better than blind". For a latched alarm it is a false positive every single
3398
+ * night: the day reference is compared against an IR frame, the cosine
3399
+ * collapses, the scene latches `diverged`, and the operator is told the bin was
3400
+ * taken at 21:40. Cross-condition cosines are not comparable, so the honest
3401
+ * answer to "I have never seen this scene in this light" is *I don't know* —
3402
+ * never a guess in the direction that raises an alarm.
3403
+ *
3404
+ * The fallback survives only as an explicit per-scene opt-in, and even then it
3405
+ * is a LAST resort: when the exact condition has references of its own they
3406
+ * win outright, so opting in never widens a comparison the operator already
3407
+ * covered properly.
3408
+ */
3409
+ function selectConditionRefs(references, currentCondition, crossConditionFallback) {
3410
+ const exact = references.filter((r) => r.condition === currentCondition);
3411
+ if (exact.length > 0 || !crossConditionFallback) return exact;
3412
+ return references;
3406
3413
  }
3407
3414
  /** A reference is comparable only when its `modelId` equals the live encoder's
3408
3415
  * model AND its dimension equals the encoder's embedding dim (face-matcher
@@ -3418,11 +3425,12 @@ function comparable(r, encoderModelId, encoderDim) {
3418
3425
  * Returns `{ bestStateId: null, bestScore: null }` when NOTHING was comparable
3419
3426
  * — see `ScoreResult.bestScore`.
3420
3427
  */
3421
- function scoreStates(states, liveEmbedding, currentCondition, encoderModelId, encoderDim) {
3428
+ function scoreStates(states, liveEmbedding, currentCondition, encoderModelId, encoderDim, options) {
3422
3429
  let bestStateId = null;
3423
3430
  let bestScore = null;
3431
+ const crossConditionFallback = options?.crossConditionFallback === true;
3424
3432
  for (const s of states) {
3425
- const refs = selectConditionRefs(s.references, currentCondition);
3433
+ const refs = selectConditionRefs(s.references, currentCondition, crossConditionFallback);
3426
3434
  let stateScore = null;
3427
3435
  for (const r of refs) {
3428
3436
  if (!comparable(r, encoderModelId, encoderDim)) continue;
@@ -3608,7 +3616,8 @@ var SceneEngine = class {
3608
3616
  lastCountedAt: 0,
3609
3617
  lastAvailability: "ok",
3610
3618
  lastVerdict: currentStateId === null ? "unknown" : "matched",
3611
- lastUnavailable: null
3619
+ lastUnavailable: null,
3620
+ suspendedCondition: null
3612
3621
  });
3613
3622
  }
3614
3623
  /** Force an out-of-cycle check for a single monitor. */
@@ -3631,7 +3640,8 @@ var SceneEngine = class {
3631
3640
  lastCountedAt: 0,
3632
3641
  lastAvailability: monitor.availability,
3633
3642
  lastVerdict: monitor.verdict,
3634
- lastUnavailable: monitor.unavailable
3643
+ lastUnavailable: monitor.unavailable,
3644
+ suspendedCondition: monitor.suspendedCondition
3635
3645
  };
3636
3646
  rt.lastCheckAt = now;
3637
3647
  this.runtime.set(k, rt);
@@ -3682,6 +3692,7 @@ var SceneEngine = class {
3682
3692
  availability: "unavailable",
3683
3693
  unavailableReason: llmResult.reason,
3684
3694
  coveredConditions: [],
3695
+ suspendedCondition: null,
3685
3696
  transitioned: false,
3686
3697
  checkedAt: now
3687
3698
  });
@@ -3735,12 +3746,20 @@ var SceneEngine = class {
3735
3746
  if (monitor.check.mode !== "similarity") return;
3736
3747
  const { embedding } = await this.deps.encoder.encode(crop, cropWidth, cropHeight);
3737
3748
  const info = await this.encoderInfo();
3738
- const { bestStateId, bestScore } = scoreStates(monitor.states, embedding, condition, info.modelId, info.embeddingDim);
3749
+ const { bestStateId, bestScore } = scoreStates(monitor.states, embedding, condition, info.modelId, info.embeddingDim, { crossConditionFallback: monitor.onUncoveredCondition === "judge-anyway" });
3739
3750
  const covered = [...coveredConditions(monitor.states, info.modelId, info.embeddingDim)];
3740
3751
  if (bestScore === null) {
3741
3752
  const cause = unknownCause(monitor, condition);
3742
3753
  rt.lastAvailability = "ok";
3743
3754
  this.runtime.set(k, rt);
3755
+ if (cause === "no-reference-for-condition" && covered.length > 0 && monitor.onUncoveredCondition === "skip") {
3756
+ await this.reportSuspended(deviceId, monitor, rt, {
3757
+ condition,
3758
+ covered,
3759
+ now
3760
+ });
3761
+ return;
3762
+ }
3744
3763
  await this.reportUnknown(deviceId, monitor, rt, {
3745
3764
  condition,
3746
3765
  cause,
@@ -3808,9 +3827,11 @@ var SceneEngine = class {
3808
3827
  rt.lastAvailability = "ok";
3809
3828
  this.runtime.set(k, rt);
3810
3829
  const verdict = projectVerdict(step.currentStateId, monitor.baselineStateId);
3811
- const changed = step.transitioned || verdict !== rt.lastVerdict || rt.lastAvailability !== monitor.availability || rt.lastUnavailable !== null;
3830
+ const resumed = rt.suspendedCondition !== null;
3831
+ const changed = step.transitioned || verdict !== rt.lastVerdict || rt.lastAvailability !== monitor.availability || rt.lastUnavailable !== null || resumed;
3812
3832
  rt.lastVerdict = verdict;
3813
3833
  rt.lastUnavailable = null;
3834
+ rt.suspendedCondition = null;
3814
3835
  this.runtime.set(k, rt);
3815
3836
  if (changed) await this.deps.applyEngineResult(deviceId, monitor.id, {
3816
3837
  currentStateId: step.currentStateId,
@@ -3822,20 +3843,61 @@ var SceneEngine = class {
3822
3843
  availability: "ok",
3823
3844
  unavailableReason: null,
3824
3845
  coveredConditions: covered,
3846
+ suspendedCondition: null,
3825
3847
  transitioned: step.transitioned,
3826
3848
  checkedAt: now
3827
3849
  });
3828
3850
  }
3829
3851
  /**
3852
+ * Announce that this scene is SITTING OUT the current light — once per
3853
+ * transition into it, never once per tick. At a 60 s cadence a per-tick line
3854
+ * would be ~480 lines a night per scene, which is how a log stops being read
3855
+ * at all.
3856
+ *
3857
+ * `info`, not `warn`: nothing is wrong. The operator chose not to capture
3858
+ * this light, or has not got round to it, and either way the scene is doing
3859
+ * exactly what it was configured to do. A branch that drops work still has to
3860
+ * SAY so — silence reads as "never happened" — but saying it at warning level
3861
+ * would train the operator to ignore the one channel that reports real
3862
+ * faults.
3863
+ */
3864
+ async reportSuspended(deviceId, monitor, rt, input) {
3865
+ if (rt.suspendedCondition === input.condition) return;
3866
+ rt.suspendedCondition = input.condition;
3867
+ this.deps.logger.info("scene checks paused — nothing captured in this light", {
3868
+ tags: { deviceId },
3869
+ meta: {
3870
+ monitorId: monitor.id,
3871
+ condition: input.condition,
3872
+ covered: input.covered.join(",")
3873
+ }
3874
+ });
3875
+ await this.deps.applyEngineResult(deviceId, monitor.id, {
3876
+ currentStateId: rt.hysteresis.currentStateId,
3877
+ previousStateId: rt.hysteresis.currentStateId,
3878
+ lastConfidence: null,
3879
+ currentCondition: input.condition,
3880
+ verdict: rt.lastVerdict,
3881
+ unavailable: rt.lastUnavailable,
3882
+ availability: "ok",
3883
+ unavailableReason: null,
3884
+ coveredConditions: input.covered,
3885
+ suspendedCondition: input.condition,
3886
+ transitioned: false,
3887
+ checkedAt: input.now
3888
+ });
3889
+ }
3890
+ /**
3830
3891
  * Push an `unknown` verdict — once per transition, not once per poll. The
3831
3892
  * held state is deliberately NOT destroyed: a scene that cannot judge is a
3832
3893
  * scene that has not observed anything, and an observation it could not make
3833
3894
  * must never spend or clear hysteresis credit in either direction.
3834
3895
  */
3835
3896
  async reportUnknown(deviceId, monitor, rt, input) {
3836
- if (rt.lastVerdict === "unknown" && rt.lastUnavailable === input.cause) return;
3897
+ if (rt.lastVerdict === "unknown" && rt.lastUnavailable === input.cause && rt.suspendedCondition === null) return;
3837
3898
  rt.lastVerdict = "unknown";
3838
3899
  rt.lastUnavailable = input.cause;
3900
+ rt.suspendedCondition = null;
3839
3901
  this.deps.logger.warn("scene cannot judge — reporting unknown", {
3840
3902
  tags: { deviceId },
3841
3903
  meta: {
@@ -3855,6 +3917,7 @@ var SceneEngine = class {
3855
3917
  availability: "ok",
3856
3918
  unavailableReason: `no comparable reference for "${input.condition}"`,
3857
3919
  coveredConditions: input.covered,
3920
+ suspendedCondition: null,
3858
3921
  transitioned: false,
3859
3922
  checkedAt: input.now
3860
3923
  });
@@ -4577,6 +4640,8 @@ var SceneMonitorProvider = class {
4577
4640
  minObservationSpacingSec: 120,
4578
4641
  anchorThreshold: require_dist.SCENE_DEFAULT_ANCHOR_THRESHOLD,
4579
4642
  autoRestore: false,
4643
+ onUncoveredCondition: require_dist.SCENE_DEFAULT_UNCOVERED_POLICY,
4644
+ suspendedCondition: null,
4580
4645
  unavailable: "no-reference-for-condition",
4581
4646
  coveredConditions: []
4582
4647
  };
@@ -4606,7 +4671,9 @@ var SceneMonitorProvider = class {
4606
4671
  this.refresh(input.deviceId);
4607
4672
  }
4608
4673
  async deleteScene(input) {
4674
+ const doomed = this.deps.store.getScene(input.deviceId, input.monitorId);
4609
4675
  await this.deps.store.deleteScene(input.deviceId, input.monitorId);
4676
+ await this.dropOrphanedMedia(doomed?.monitor.states.flatMap((s) => s.references) ?? []);
4610
4677
  this.deps.logger.info("scene deleted", {
4611
4678
  tags: { deviceId: input.deviceId },
4612
4679
  meta: { monitorId: input.monitorId }
@@ -4629,7 +4696,9 @@ var SceneMonitorProvider = class {
4629
4696
  const stateId = input.stateId ?? monitor.states[0]?.id ?? (0, node_crypto.randomUUID)();
4630
4697
  const existing = monitor.states.find((s) => s.id === stateId);
4631
4698
  const label = input.label ?? existing?.label ?? monitor.label;
4632
- const references = boundReferences([...existing?.references ?? [], reference], condition);
4699
+ const kept = [...existing?.references ?? [], reference];
4700
+ const references = boundReferences(kept, condition);
4701
+ await this.dropOrphanedMedia(kept.filter((r) => !references.includes(r)));
4633
4702
  const state = {
4634
4703
  id: stateId,
4635
4704
  label,
@@ -4663,6 +4732,7 @@ var SceneMonitorProvider = class {
4663
4732
  }
4664
4733
  async deleteReference(input) {
4665
4734
  const monitor = this.requireScene(input.deviceId, input.monitorId);
4735
+ const removed = monitor.states.find((s) => s.id === input.stateId)?.references[input.index];
4666
4736
  const states = monitor.states.map((s) => {
4667
4737
  if (s.id !== input.stateId) return s;
4668
4738
  const references = s.references.filter((_, i) => i !== input.index);
@@ -4676,6 +4746,17 @@ var SceneMonitorProvider = class {
4676
4746
  ...monitor,
4677
4747
  states
4678
4748
  }, this.now());
4749
+ await this.dropOrphanedMedia(removed === void 0 ? [] : [removed]);
4750
+ this.deps.logger.info("scene reference deleted", {
4751
+ tags: { deviceId: input.deviceId },
4752
+ meta: {
4753
+ monitorId: input.monitorId,
4754
+ stateId: input.stateId,
4755
+ index: input.index,
4756
+ condition: removed?.condition ?? "(none)",
4757
+ remaining: states.reduce((n, s) => n + s.references.length, 0)
4758
+ }
4759
+ });
4679
4760
  this.refresh(input.deviceId);
4680
4761
  }
4681
4762
  async recheckNow(input) {
@@ -4760,6 +4841,15 @@ var SceneMonitorProvider = class {
4760
4841
  const row = this.deps.store.getScene(deviceId, monitorId);
4761
4842
  if (row === void 0) return;
4762
4843
  const monitor = row.monitor;
4844
+ if (result.suspendedCondition !== null) {
4845
+ await this.deps.store.putScene(deviceId, {
4846
+ ...monitor,
4847
+ suspendedCondition: result.suspendedCondition,
4848
+ coveredConditions: [...result.coveredConditions]
4849
+ }, result.checkedAt);
4850
+ this.refresh(deviceId);
4851
+ return;
4852
+ }
4763
4853
  const previous = this.deps.store.getLatch(deviceId, monitorId);
4764
4854
  const wasLatched = previous?.latched ?? monitor.latched;
4765
4855
  const wasVerdict = previous?.verdict ?? monitor.verdict;
@@ -4786,6 +4876,7 @@ var SceneMonitorProvider = class {
4786
4876
  const merged = {
4787
4877
  ...monitor,
4788
4878
  coveredConditions: [...result.coveredConditions],
4879
+ suspendedCondition: null,
4789
4880
  availability: result.availability,
4790
4881
  unavailableReason: result.unavailableReason
4791
4882
  };
@@ -4889,8 +4980,29 @@ var SceneMonitorProvider = class {
4889
4980
  updatedAt: now
4890
4981
  };
4891
4982
  }
4983
+ /**
4984
+ * Drop the pictures of references that are no longer stored anywhere.
4985
+ *
4986
+ * Best-effort per blob and never throwing: the reference row is already gone,
4987
+ * and refusing the operator's delete because a JPEG would not unlink would be
4988
+ * failing the request over the follower rather than the fact.
4989
+ */
4990
+ async dropOrphanedMedia(references) {
4991
+ const drop = this.deps.dropMedia;
4992
+ if (drop === void 0) return;
4993
+ for (const r of references) {
4994
+ if (r.thumbnailMediaId === void 0) continue;
4995
+ await drop(r.thumbnailMediaId).catch((err) => {
4996
+ this.deps.logger.debug("scene reference thumbnail delete failed", { meta: {
4997
+ mediaId: r.thumbnailMediaId,
4998
+ error: String(err)
4999
+ } });
5000
+ });
5001
+ }
5002
+ }
4892
5003
  /** Snapshot → ROI crop + whole-frame anchor → two embeddings + a thumbnail. */
4893
5004
  async encodeReference(deviceId, monitor, condition) {
5005
+ const capturedAt = this.now();
4894
5006
  const snap = await this.deps.getSnapshot(deviceId, true);
4895
5007
  if (snap === null) throw new Error("no snapshot available for this camera");
4896
5008
  const encoded = Buffer.from(snap.base64, "base64");
@@ -4908,15 +5020,15 @@ var SceneMonitorProvider = class {
4908
5020
  });
4909
5021
  const thumbnailMediaId = await this.deps.putMedia?.({
4910
5022
  deviceId,
4911
- ownerId: `scene-${monitor.id}-${condition}`,
5023
+ ownerId: `scene-${monitor.id}-${condition}-${capturedAt}`,
4912
5024
  data: roi.crop,
4913
- timestamp: this.now()
5025
+ timestamp: capturedAt
4914
5026
  }).catch(() => void 0);
4915
5027
  return {
4916
5028
  embedding,
4917
5029
  modelId: info.modelId,
4918
5030
  condition,
4919
- capturedAt: this.now(),
5031
+ capturedAt,
4920
5032
  ...thumbnailMediaId !== void 0 ? { thumbnailMediaId } : {},
4921
5033
  ...anchor !== null ? { anchorEmbedding: anchor } : {}
4922
5034
  };
@@ -5412,6 +5524,256 @@ function observeLabel(deviceId, spec, sample, now) {
5412
5524
  spec
5413
5525
  };
5414
5526
  }
5527
+ /** JPEG quality for the downscaled full frame — matches the crop path. */
5528
+ var FULL_FRAME_QUALITY = 80;
5529
+ /**
5530
+ * Downscale an already-encoded JPEG full frame to FIT WITHIN
5531
+ * {@link FULL_FRAME_MAX_WIDTH}×{@link FULL_FRAME_MAX_HEIGHT}, preserving aspect
5532
+ * ratio (`fit: 'inside'`) and never enlarging a source already smaller than the
5533
+ * box. Re-encodes as JPEG. Used before persisting a synthetic sensor/control
5534
+ * track's whole-scene snapshot so a raw native-resolution frame (a 4K bedroom
5535
+ * at night) is never stored or served — the privacy fix moved to CAPTURE time.
5536
+ */
5537
+ async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
5538
+ return (0, sharp.default)(Buffer.from(jpeg)).resize(maxWidth, maxHeight, {
5539
+ fit: "inside",
5540
+ withoutEnlargement: true
5541
+ }).jpeg({ quality: FULL_FRAME_QUALITY }).toBuffer();
5542
+ }
5543
+ //#endregion
5544
+ //#region src/notification-center/audio-still.ts
5545
+ /**
5546
+ * The audio still shelf — the PHOTOGRAPH a sound rule's notification carries.
5547
+ *
5548
+ * ## Why a sound needs one at all
5549
+ *
5550
+ * The attachment ladder resolves media by OWNER, and every other trigger has
5551
+ * one: an object or package event owns its crops, a closed track owns its best
5552
+ * shot, a doorbell press owns the marker track the same press projected
5553
+ * (`sensor-marker-projector.ts`), an occupancy edge names one of the objects it
5554
+ * counted (`chooseOccupancyMediaOwner`). An audio match owns nothing. Nothing
5555
+ * was boxed, nothing was tracked, and — deliberately — nothing is persisted at
5556
+ * all: a confirmed window is a claim about sound that has already stopped, and
5557
+ * `event-intake.ts` states why replaying it later would be wrong.
5558
+ *
5559
+ * So the only honest picture is a PHOTOGRAPH of the camera taken at the moment
5560
+ * of the match. Not of the sound — of what the camera could see while it was
5561
+ * heard.
5562
+ *
5563
+ * ## Three properties, and each one is a decision
5564
+ *
5565
+ * **It is not a record.** The bytes live here, in RAM, under an owner id and a
5566
+ * TTL that covers the outbox's whole retry horizon — and nowhere else. The
5567
+ * alternative was the doorbell's: materialise a synthetic marker track through
5568
+ * `SyntheticTrackMaterializer` and let the notification name it. That would put
5569
+ * a durable Track on the camera's timeline for every confirmed window, feeding
5570
+ * the digest's `listTracks`, retention, and the audio-marker feature's own
5571
+ * operator ceilings (`audio-marker-projector.ts` exists precisely to bound how
5572
+ * many audio markers a camera may emit). A notification must not manufacture
5573
+ * timeline history as a side effect of wanting a picture.
5574
+ *
5575
+ * **The capture STARTS immediately and is never awaited.** A sound is transient
5576
+ * — a scream is over before a snapshot round-trip completes — so the fetch is
5577
+ * kicked off at the confirmation, before the rule evaluation runs, and the
5578
+ * owner id is minted synchronously so the outbox row can name it. The bytes
5579
+ * land while the row waits in the queue, and the dispatcher's existing bounded
5580
+ * still-wait (or the pause its own footage render already costs) picks them up.
5581
+ * A camera that never answers costs the picture and never the notification.
5582
+ *
5583
+ * **Two confirmations seconds apart share ONE capture.** A barking dog confirms
5584
+ * repeatedly and a label-mode rule has no re-arm timer at all (D157) — the
5585
+ * rule's cooldown is its only brake, and the cooldown is applied AFTER this.
5586
+ * Without a reuse window this would photograph a camera at whatever rate the
5587
+ * sound happens to occur. Each confirmation still gets its OWN owner id, so two
5588
+ * outbox rows are never mistaken for one subject; they merely point at the same
5589
+ * frame, which is the truth — the scene did not change in ten seconds.
5590
+ *
5591
+ * Nothing here is silent: a capture that lands and a camera that refuses each
5592
+ * emit one line carrying `tags: { deviceId }`, because "why did 617 get a photo
5593
+ * and 615 not" is the only form that question is ever asked in.
5594
+ */
5595
+ /**
5596
+ * The owner-id namespace. It is what routes a lookup here instead of to the
5597
+ * media store, and it is the reason the dispatcher needs no audio branch —
5598
+ * `getMediaForOwner` answers for both under one signature.
5599
+ */
5600
+ var NC_AUDIO_STILL_PREFIX = "nc-audio-still:";
5601
+ /** True for an owner id this shelf minted. */
5602
+ function isAudioStillId(id) {
5603
+ return id.startsWith(NC_AUDIO_STILL_PREFIX);
5604
+ }
5605
+ /**
5606
+ * How long the bytes are held.
5607
+ *
5608
+ * The outbox retries 8 times with a 5 s → 300 s backoff, which tops out around
5609
+ * ten minutes; fifteen covers that with room for a slow drain. Past it the row
5610
+ * ships text-only, which is the correct degradation for a photograph of a scene
5611
+ * that is a quarter of an hour stale anyway.
5612
+ */
5613
+ var NC_AUDIO_STILL_TTL_MS = 15 * 6e4;
5614
+ /** A hanging snapshot cap must not pin a capture slot forever. */
5615
+ var SNAPSHOT_TIMEOUT_MS = 8e3;
5616
+ /**
5617
+ * Hard bound on held captures. Reached only if every camera on the hub confirms
5618
+ * an audio rule inside one TTL; the oldest is dropped first, which costs a
5619
+ * fifteen-minute-old picture nobody is waiting for.
5620
+ */
5621
+ var MAX_CAPTURES = 64;
5622
+ var NcAudioStillShelf = class {
5623
+ deps;
5624
+ /** captureId → the photograph. */
5625
+ captures = /* @__PURE__ */ new Map();
5626
+ /** ownerId → captureId. Several owners may name one capture (the reuse window). */
5627
+ owners = /* @__PURE__ */ new Map();
5628
+ /** deviceId → its newest capture, for the reuse window. */
5629
+ newest = /* @__PURE__ */ new Map();
5630
+ constructor(deps) {
5631
+ this.deps = deps;
5632
+ }
5633
+ /**
5634
+ * Photograph `deviceId` for a match at `atMs`, and return the owner id the
5635
+ * subject should name. SYNCHRONOUS by contract: the caller is on the audio
5636
+ * confirmation path and the outbox row is built from what this returns.
5637
+ */
5638
+ capture(deviceId, atMs) {
5639
+ const now = this.deps.now();
5640
+ this.prune(now);
5641
+ const ownerId = `${NC_AUDIO_STILL_PREFIX}${(0, node_crypto.randomUUID)()}`;
5642
+ const recent = this.newest.get(deviceId);
5643
+ if (recent !== void 0 && now - recent.at < 1e4 && this.captures.has(recent.captureId)) {
5644
+ this.owners.set(ownerId, recent.captureId);
5645
+ return ownerId;
5646
+ }
5647
+ const captureId = (0, node_crypto.randomUUID)();
5648
+ this.captures.set(captureId, {
5649
+ deviceId,
5650
+ startedAt: now,
5651
+ expiresAt: now + NC_AUDIO_STILL_TTL_MS,
5652
+ files: []
5653
+ });
5654
+ this.newest.set(deviceId, {
5655
+ captureId,
5656
+ at: now
5657
+ });
5658
+ this.owners.set(ownerId, captureId);
5659
+ this.fetch(captureId, deviceId, atMs);
5660
+ return ownerId;
5661
+ }
5662
+ /**
5663
+ * The media an owner id holds, or `undefined` when this shelf never minted
5664
+ * it. An EMPTY array is a different answer: the capture exists and has not
5665
+ * landed (or never will), which is exactly the case the dispatcher's bounded
5666
+ * wait and its `no still could be resolved` line are for.
5667
+ */
5668
+ get(ownerId) {
5669
+ const captureId = this.owners.get(ownerId);
5670
+ if (captureId === void 0) return void 0;
5671
+ return this.captures.get(captureId)?.files ?? [];
5672
+ }
5673
+ /** Drop expired captures and the owners that named them. */
5674
+ prune(now) {
5675
+ for (const [captureId, held] of this.captures) {
5676
+ if (held.expiresAt > now) continue;
5677
+ this.captures.delete(captureId);
5678
+ if (this.newest.get(held.deviceId)?.captureId === captureId) this.newest.delete(held.deviceId);
5679
+ }
5680
+ while (this.captures.size > MAX_CAPTURES) {
5681
+ const oldest = this.captures.keys().next();
5682
+ if (oldest.done === true) break;
5683
+ const held = this.captures.get(oldest.value);
5684
+ this.captures.delete(oldest.value);
5685
+ if (held !== void 0 && this.newest.get(held.deviceId)?.captureId === oldest.value) this.newest.delete(held.deviceId);
5686
+ }
5687
+ for (const [ownerId, captureId] of this.owners) if (!this.captures.has(captureId)) this.owners.delete(ownerId);
5688
+ }
5689
+ /** Drop everything (shutdown). */
5690
+ clear() {
5691
+ this.captures.clear();
5692
+ this.owners.clear();
5693
+ this.newest.clear();
5694
+ }
5695
+ async fetch(captureId, deviceId, atMs) {
5696
+ try {
5697
+ const shot = await withTimeout$2(this.deps.getSnapshot(deviceId), SNAPSHOT_TIMEOUT_MS);
5698
+ if (shot === null || shot.base64.length === 0) {
5699
+ this.reportMiss(deviceId, "the camera returned no snapshot");
5700
+ return;
5701
+ }
5702
+ const raw = Buffer.from(shot.base64, "base64");
5703
+ let data = raw;
5704
+ try {
5705
+ data = await downscaleFullFrameJpeg(raw, 960, 540);
5706
+ } catch {}
5707
+ const held = this.captures.get(captureId);
5708
+ if (held === void 0) return;
5709
+ this.captures.set(captureId, {
5710
+ ...held,
5711
+ files: audioStillMedia(data, atMs)
5712
+ });
5713
+ this.deps.logger.info("audio still captured", {
5714
+ tags: { deviceId },
5715
+ meta: {
5716
+ bytes: data.byteLength,
5717
+ tookMs: this.deps.now() - held.startedAt
5718
+ }
5719
+ });
5720
+ } catch (err) {
5721
+ this.reportMiss(deviceId, err instanceof Error ? err.message : String(err));
5722
+ }
5723
+ }
5724
+ /**
5725
+ * A branch that drops work says so. This one costs the operator the picture
5726
+ * on a notification they DID receive, so it is a warn and it carries the
5727
+ * camera — the only key the question is ever asked with.
5728
+ */
5729
+ reportMiss(deviceId, reason) {
5730
+ this.deps.logger.warn("the camera did not answer the audio still — this notification ships text-only", {
5731
+ tags: { deviceId },
5732
+ meta: { reason }
5733
+ });
5734
+ }
5735
+ };
5736
+ /**
5737
+ * The photograph, in the three kinds the CLEAN-SCENE ladders ask for.
5738
+ *
5739
+ * `keyFrameSmall` leads because it is the first rung of both `attach: 'best'`
5740
+ * and `attach: 'keyFrame'` on a track owner; `keyFrame` and `fullFrame` answer
5741
+ * `frame: 'full'` and `frame: 'boxed'`'s honest degrade. There is deliberately
5742
+ * no `crop` / `thumbnail`: nothing was boxed, so `frame: 'cropped'` resolves
5743
+ * NOTHING and the notification ships text-only with the ordinary line saying
5744
+ * so. Inventing a centre crop would answer a different question from the one
5745
+ * the operator asked.
5746
+ */
5747
+ function audioStillMedia(data, timestamp) {
5748
+ const base64 = data.toString("base64");
5749
+ const file = (kind) => ({
5750
+ key: `${NC_AUDIO_STILL_PREFIX}${kind}`,
5751
+ kind,
5752
+ base64,
5753
+ sizeBytes: data.byteLength,
5754
+ timestamp
5755
+ });
5756
+ return [
5757
+ file("keyFrameSmall"),
5758
+ file("keyFrame"),
5759
+ file("fullFrame")
5760
+ ];
5761
+ }
5762
+ /** Reject if `promise` does not settle within `ms`. */
5763
+ function withTimeout$2(promise, ms) {
5764
+ return new Promise((resolve, reject) => {
5765
+ const timer = setTimeout(() => {
5766
+ reject(/* @__PURE__ */ new Error(`snapshot cap timed out after ${String(ms)}ms`));
5767
+ }, ms);
5768
+ promise.then((value) => {
5769
+ clearTimeout(timer);
5770
+ resolve(value);
5771
+ }, (err) => {
5772
+ clearTimeout(timer);
5773
+ reject(err instanceof Error ? err : new Error(String(err)));
5774
+ });
5775
+ });
5776
+ }
5415
5777
  //#endregion
5416
5778
  //#region src/shared/llm-vision/prompt-hygiene.ts
5417
5779
  /**
@@ -6022,6 +6384,298 @@ var NcDeviceStateCache = class {
6022
6384
  }
6023
6385
  };
6024
6386
  //#endregion
6387
+ //#region src/shared/frame/box-drawer.ts
6388
+ var DEFAULT_COLOR = require_dist.DEFAULT_EVENT_COLOR;
6389
+ var DEFAULT_QUALITY = 80;
6390
+ var STROKE_WIDTH = 3;
6391
+ function escapeXml$2(s) {
6392
+ return s.replace(/[<>&'"]/g, (ch) => {
6393
+ switch (ch) {
6394
+ case "<": return "&lt;";
6395
+ case ">": return "&gt;";
6396
+ case "&": return "&amp;";
6397
+ case "'": return "&apos;";
6398
+ default: return "&quot;";
6399
+ }
6400
+ });
6401
+ }
6402
+ /** Clamp a pixel box to the frame so the rect always stays inside [0,W]×[0,H]. */
6403
+ function clampBox(b, frameWidth, frameHeight) {
6404
+ const x = Math.max(0, Math.min(Math.round(b.x), frameWidth - 1));
6405
+ const y = Math.max(0, Math.min(Math.round(b.y), frameHeight - 1));
6406
+ return {
6407
+ x,
6408
+ y,
6409
+ w: Math.max(1, Math.min(Math.round(b.w), frameWidth - x)),
6410
+ h: Math.max(1, Math.min(Math.round(b.h), frameHeight - y))
6411
+ };
6412
+ }
6413
+ /**
6414
+ * Draw bounding boxes over a raw RGB frame and JPEG-encode it.
6415
+ *
6416
+ * The frame is composited with an SVG overlay (one `<rect>` per box, plus an
6417
+ * optional caption) at full resolution, then optionally downscaled — so the
6418
+ * box stays crisp relative to the scene. Boxes are clamped to frame bounds to
6419
+ * avoid sharp `extract`/region errors on slightly-out-of-range detections.
6420
+ */
6421
+ async function drawBoxedFrame(frameData, frameWidth, frameHeight, boxes, opts = {}) {
6422
+ const quality = opts.quality ?? DEFAULT_QUALITY;
6423
+ let base = frameData;
6424
+ let baseIsRaw = true;
6425
+ if (boxes.length > 0) {
6426
+ const fontSize = Math.max(12, Math.round(frameHeight / 30));
6427
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}">${boxes.map((b) => {
6428
+ const c = clampBox(b, frameWidth, frameHeight);
6429
+ const color = b.color ?? DEFAULT_COLOR;
6430
+ const rect = `<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${c.h}" fill="none" stroke="${color}" stroke-width="${STROKE_WIDTH}"/>`;
6431
+ if (!b.label) return rect;
6432
+ const ty = Math.max(fontSize, c.y - 4);
6433
+ 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>`;
6434
+ }).join("")}</svg>`;
6435
+ base = await (0, sharp.default)(frameData, { raw: {
6436
+ width: frameWidth,
6437
+ height: frameHeight,
6438
+ channels: 3
6439
+ } }).composite([{
6440
+ input: Buffer.from(svg),
6441
+ top: 0,
6442
+ left: 0
6443
+ }]).png().toBuffer();
6444
+ baseIsRaw = false;
6445
+ }
6446
+ let pipeline = baseIsRaw ? (0, sharp.default)(base, { raw: {
6447
+ width: frameWidth,
6448
+ height: frameHeight,
6449
+ channels: 3
6450
+ } }) : (0, sharp.default)(base);
6451
+ if (opts.maxWidth !== void 0) pipeline = pipeline.resize({
6452
+ width: opts.maxWidth,
6453
+ withoutEnlargement: true
6454
+ });
6455
+ return pipeline.jpeg({ quality }).toBuffer();
6456
+ }
6457
+ /** JPEG quality of the composed artefact — the mosaic's number, same reasons. */
6458
+ var GROUP_FRAME_QUALITY = 82;
6459
+ /**
6460
+ * Draw the members onto the frame. Returns `null` when there is nothing to
6461
+ * draw, or when anything at all goes wrong — the caller then ships the
6462
+ * unannotated frame unchanged.
6463
+ */
6464
+ async function composeGroupFrame(input) {
6465
+ const freshnessMs = input.freshnessMs ?? 3e3;
6466
+ const fresh = input.boxes.filter((b) => Math.abs(input.at - b.observedAt) <= freshnessMs);
6467
+ const withheld = input.boxes.length - fresh.length;
6468
+ if (fresh.length < 2) {
6469
+ if (withheld > 0) input.logger.info("group frame not drawn — too few members with fresh geometry", {
6470
+ tags: { deviceId: input.deviceId },
6471
+ meta: {
6472
+ groupKey: input.groupKey,
6473
+ members: input.boxes.length,
6474
+ fresh: fresh.length,
6475
+ withheld
6476
+ }
6477
+ });
6478
+ return null;
6479
+ }
6480
+ try {
6481
+ const decoded = await (0, sharp.default)(input.jpeg).raw().toBuffer({ resolveWithObject: true });
6482
+ const { width, height, channels } = decoded.info;
6483
+ if (!(width > 0 && height > 0) || channels !== 3) {
6484
+ input.logger.warn("group frame not drawn — unexpected raster shape", {
6485
+ tags: { deviceId: input.deviceId },
6486
+ meta: {
6487
+ groupKey: input.groupKey,
6488
+ width,
6489
+ height,
6490
+ channels
6491
+ }
6492
+ });
6493
+ return null;
6494
+ }
6495
+ const boxes = fresh.map((b) => ({
6496
+ x: b.bbox.x * width,
6497
+ y: b.bbox.y * height,
6498
+ w: b.bbox.w * width,
6499
+ h: b.bbox.h * height,
6500
+ ...b.label !== void 0 && b.label.length > 0 ? { label: b.label } : {}
6501
+ }));
6502
+ const bytes = await drawBoxedFrame(decoded.data, width, height, boxes, { quality: GROUP_FRAME_QUALITY });
6503
+ input.logger.debug("group frame composed", {
6504
+ tags: { deviceId: input.deviceId },
6505
+ meta: {
6506
+ groupKey: input.groupKey,
6507
+ drawn: boxes.length,
6508
+ withheld,
6509
+ width,
6510
+ height
6511
+ }
6512
+ });
6513
+ return {
6514
+ bytes,
6515
+ drawn: boxes.length,
6516
+ withheld
6517
+ };
6518
+ } catch (err) {
6519
+ input.logger.warn("group frame compose failed — shipping the unannotated frame", {
6520
+ tags: { deviceId: input.deviceId },
6521
+ meta: {
6522
+ groupKey: input.groupKey,
6523
+ error: err instanceof Error ? err.message : String(err)
6524
+ }
6525
+ });
6526
+ return null;
6527
+ }
6528
+ }
6529
+ //#endregion
6530
+ //#region src/notification-center/text-compose.ts
6531
+ /** One normalisation, used on both sides of every class lookup — a pipeline
6532
+ * that emitted `Person` must find the key written `class.person`. */
6533
+ function normaliseClass(className) {
6534
+ return className.trim().toLowerCase();
6535
+ }
6536
+ /**
6537
+ * The noun for a class, in the count's plural category.
6538
+ *
6539
+ * A class no locale names falls through to `class.other`, which prints the RAW
6540
+ * name: a custom model's class must appear in the sentence rather than vanish
6541
+ * out of it, and a missing translation is a smaller defect than a missing
6542
+ * detection.
6543
+ */
6544
+ function ncClassNoun(texts, className, count) {
6545
+ const key = `class.${normaliseClass(className)}`;
6546
+ if (texts.has(key)) return texts.text({
6547
+ key,
6548
+ count,
6549
+ vars: { name: className }
6550
+ });
6551
+ return texts.text({
6552
+ key: "class.other",
6553
+ count,
6554
+ vars: { name: className }
6555
+ });
6556
+ }
6557
+ /** `person (John)` / `persona (John)` — the thing the notification is about. */
6558
+ function ncSubjectText(texts, input) {
6559
+ const classNoun = ncClassNoun(texts, input.className, 1);
6560
+ if (input.label === void 0) return texts.text({
6561
+ key: "detection.subject",
6562
+ vars: { classNoun }
6563
+ });
6564
+ return texts.text({
6565
+ key: subjectLabelKey(input.labelKind),
6566
+ vars: {
6567
+ classNoun,
6568
+ label: input.label
6569
+ }
6570
+ });
6571
+ }
6572
+ /**
6573
+ * Which labelled-subject key a recognition gets. A kind the catalog does not
6574
+ * name falls back to the generic key rather than printing nothing — the same
6575
+ * direction `ncClassNoun` degrades in, and for the same reason.
6576
+ */
6577
+ function subjectLabelKey(kind) {
6578
+ if (kind === "identity") return "detection.subject.identity";
6579
+ if (kind === "plate") return "detection.subject.plate";
6580
+ return "detection.subject.labelled";
6581
+ }
6582
+ /**
6583
+ * ` in gate, drive` — INCLUDING its leading space, and empty when there are no
6584
+ * zones.
6585
+ *
6586
+ * The space belongs to the fragment rather than to the body key because the
6587
+ * alternative is a body that ends in a trailing space on every zone-less
6588
+ * notification. A translator moving the clause moves the space with it.
6589
+ */
6590
+ function ncInZonesText(texts, zoneLabels) {
6591
+ if (zoneLabels.length === 0) return "";
6592
+ return texts.text({
6593
+ key: "detection.inZones",
6594
+ vars: { zones: zoneLabels.join(", ") }
6595
+ });
6596
+ }
6597
+ /** The human-readable edge polarity behind `{{op}}` and the occupancy body. */
6598
+ function ncOccupancyOp(texts, occupied) {
6599
+ return texts.text({ key: occupied ? "occupancy.op.occupied" : "occupancy.op.free" });
6600
+ }
6601
+ /**
6602
+ * `1 persona, 2 veicoli e 3 animali` — the language's own conjunction, its own
6603
+ * plural rules, and its own nouns.
6604
+ *
6605
+ * Zero-count classes are dropped: a selected class the window never contained
6606
+ * is a legitimate `0` in `{{count_vehicle}}`, but "0 veicoli" inside a summary
6607
+ * sentence is a line nobody reads. Order is the caller's (busiest first) and is
6608
+ * preserved, so the same night always produces the same sentence.
6609
+ */
6610
+ function ncDetectionSummary(texts, counts) {
6611
+ const parts = counts.filter((entry) => entry.count > 0).map((entry) => `${entry.count} ${ncClassNoun(texts, entry.className, entry.count)}`);
6612
+ return texts.join(parts);
6613
+ }
6614
+ //#endregion
6615
+ //#region src/notification-center/group/nc-group-text.ts
6616
+ var DEFAULT_MAX_NAMES = 2;
6617
+ /**
6618
+ * `N altre persone` — the remainder phrase for one class, in its own gender.
6619
+ *
6620
+ * Mirrors {@link ncClassNoun} byte for byte, including the fallback: a class no
6621
+ * locale names falls through to `group.others.other`, which prints the raw class
6622
+ * name rather than vanishing. A custom model's class must appear in the sentence.
6623
+ */
6624
+ function ncGroupOthers(texts, className, count) {
6625
+ const classNoun = ncClassNoun(texts, className, count);
6626
+ const key = `group.others.${className.trim().toLowerCase()}`;
6627
+ if (texts.has(key)) return texts.text({
6628
+ key,
6629
+ count,
6630
+ vars: {
6631
+ count: `${count}`,
6632
+ classNoun
6633
+ }
6634
+ });
6635
+ return texts.text({
6636
+ key: "group.others.other",
6637
+ count,
6638
+ vars: {
6639
+ count: `${count}`,
6640
+ classNoun
6641
+ }
6642
+ });
6643
+ }
6644
+ /** Counts per class, in first-seen order so the same burst always reads the same. */
6645
+ function countByClass(members) {
6646
+ const counts = /* @__PURE__ */ new Map();
6647
+ for (const m of members) counts.set(m.className, (counts.get(m.className) ?? 0) + 1);
6648
+ return [...counts].map(([className, count]) => ({
6649
+ className,
6650
+ count
6651
+ }));
6652
+ }
6653
+ /**
6654
+ * The finished `{{subject}}` phrase for a group.
6655
+ *
6656
+ * Pure: a catalog in, a string out. No clock, no store, no I/O — like every
6657
+ * other composer in `text-compose.ts`, and for the same reason.
6658
+ */
6659
+ function ncGroupSubjectText(texts, input) {
6660
+ const members = input.members;
6661
+ const only = members.length === 1 ? members[0] : void 0;
6662
+ if (only !== void 0) return ncSubjectText(texts, {
6663
+ className: only.className,
6664
+ ...only.label !== void 0 ? { label: only.label } : {},
6665
+ ...only.labelKind !== void 0 ? { labelKind: only.labelKind } : {}
6666
+ });
6667
+ if (members.length === 0) return "";
6668
+ const maxNames = input.maxNames !== void 0 && input.maxNames >= 0 ? input.maxNames : DEFAULT_MAX_NAMES;
6669
+ const named = members.filter((m) => m.label !== void 0 && m.label.length > 0);
6670
+ const names = named.slice(0, maxNames).map((m) => m.label ?? "");
6671
+ if (names.length === 0) return ncDetectionSummary(texts, countByClass(members));
6672
+ const spoken = new Set(named.slice(0, maxNames));
6673
+ const remainder = members.filter((m) => !spoken.has(m));
6674
+ if (remainder.length === 0) return texts.join(names);
6675
+ const others = countByClass(remainder).map((c) => ncGroupOthers(texts, c.className, c.count));
6676
+ return texts.join([...names, ...others]);
6677
+ }
6678
+ //#endregion
6025
6679
  //#region src/notification-center/render-template.ts
6026
6680
  /**
6027
6681
  * The ONE `{{var}}` renderer of the notification centre.
@@ -6745,10 +7399,17 @@ function subjectFromSensorEvent(ev, markerTrackId) {
6745
7399
  * `minConfidence` condition composes naturally). A level-path audio event (no
6746
7400
  * `classification`) yields NO class ⇒ it can never satisfy the audio opt-in
6747
7401
  * gate, so only classified audio ever notifies (documented boundary). Audio has
6748
- * no zones / bbox / label / track, so the object/track-specific conditions all
6749
- * fail closed (see the {@link evaluateRule} audio gate + the matchers below).
7402
+ * no zones / bbox / label, so the object-specific conditions all fail closed
7403
+ * (see the {@link evaluateRule} audio gate + the matchers below).
7404
+ *
7405
+ * `stillOwnerId` is the audio still shelf's owner id — the photograph of the
7406
+ * camera taken at the match (`audio-still.ts`). It rides `trackId` because that
7407
+ * is the field the attachment ladder reads, exactly as a doorbell press rides
7408
+ * its marker track's id; nothing else about the subject is track-scoped, and
7409
+ * `buildEntries` keys an audio row on its record id, never on this. Absent when
7410
+ * no rule that could fire wants a still.
6750
7411
  */
6751
- function subjectFromAudioEvent(ev) {
7412
+ function subjectFromAudioEvent(ev, stillOwnerId) {
6752
7413
  const macro = ev.classification?.className;
6753
7414
  return {
6754
7415
  kind: "audio-event",
@@ -6758,6 +7419,7 @@ function subjectFromAudioEvent(ev) {
6758
7419
  classNames: macro !== void 0 ? [`audio-${macro}`] : [],
6759
7420
  ...ev.classification?.score !== void 0 ? { confidence: ev.classification.score } : {},
6760
7421
  zones: [],
7422
+ ...stillOwnerId !== void 0 ? { trackId: stillOwnerId } : {},
6761
7423
  source: "pipeline"
6762
7424
  };
6763
7425
  }
@@ -6778,8 +7440,12 @@ function subjectFromAudioEvent(ev) {
6778
7440
  * neither is a score, and lending one to `minConfidence` would let a detection
6779
7441
  * condition silently re-judge an audio rule on a number that means something
6780
7442
  * else.
7443
+ *
7444
+ * `stillOwnerId` — see {@link subjectFromAudioEvent}. A sound owns no frame, so
7445
+ * the only picture it can carry is a photograph of its camera taken at the
7446
+ * confirmation, held on the still shelf under this id.
6781
7447
  */
6782
- function subjectFromAudioWindow(hit) {
7448
+ function subjectFromAudioWindow(hit, stillOwnerId) {
6783
7449
  const spec = hit.spec;
6784
7450
  return {
6785
7451
  kind: "audio-window",
@@ -6788,6 +7454,7 @@ function subjectFromAudioWindow(hit) {
6788
7454
  timestamp: hit.timestamp,
6789
7455
  classNames: hit.labels.map((l) => `audio-${l}`),
6790
7456
  zones: [],
7457
+ ...stillOwnerId !== void 0 ? { trackId: stillOwnerId } : {},
6791
7458
  source: "audio",
6792
7459
  audioWindow: audioSubjectFor(hit, spec)
6793
7460
  };
@@ -8010,298 +8677,6 @@ var NcTextCatalog = class {
8010
8677
  }
8011
8678
  };
8012
8679
  //#endregion
8013
- //#region src/shared/frame/box-drawer.ts
8014
- var DEFAULT_COLOR = require_dist.DEFAULT_EVENT_COLOR;
8015
- var DEFAULT_QUALITY = 80;
8016
- var STROKE_WIDTH = 3;
8017
- function escapeXml$2(s) {
8018
- return s.replace(/[<>&'"]/g, (ch) => {
8019
- switch (ch) {
8020
- case "<": return "&lt;";
8021
- case ">": return "&gt;";
8022
- case "&": return "&amp;";
8023
- case "'": return "&apos;";
8024
- default: return "&quot;";
8025
- }
8026
- });
8027
- }
8028
- /** Clamp a pixel box to the frame so the rect always stays inside [0,W]×[0,H]. */
8029
- function clampBox(b, frameWidth, frameHeight) {
8030
- const x = Math.max(0, Math.min(Math.round(b.x), frameWidth - 1));
8031
- const y = Math.max(0, Math.min(Math.round(b.y), frameHeight - 1));
8032
- return {
8033
- x,
8034
- y,
8035
- w: Math.max(1, Math.min(Math.round(b.w), frameWidth - x)),
8036
- h: Math.max(1, Math.min(Math.round(b.h), frameHeight - y))
8037
- };
8038
- }
8039
- /**
8040
- * Draw bounding boxes over a raw RGB frame and JPEG-encode it.
8041
- *
8042
- * The frame is composited with an SVG overlay (one `<rect>` per box, plus an
8043
- * optional caption) at full resolution, then optionally downscaled — so the
8044
- * box stays crisp relative to the scene. Boxes are clamped to frame bounds to
8045
- * avoid sharp `extract`/region errors on slightly-out-of-range detections.
8046
- */
8047
- async function drawBoxedFrame(frameData, frameWidth, frameHeight, boxes, opts = {}) {
8048
- const quality = opts.quality ?? DEFAULT_QUALITY;
8049
- let base = frameData;
8050
- let baseIsRaw = true;
8051
- if (boxes.length > 0) {
8052
- const fontSize = Math.max(12, Math.round(frameHeight / 30));
8053
- const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}">${boxes.map((b) => {
8054
- const c = clampBox(b, frameWidth, frameHeight);
8055
- const color = b.color ?? DEFAULT_COLOR;
8056
- const rect = `<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${c.h}" fill="none" stroke="${color}" stroke-width="${STROKE_WIDTH}"/>`;
8057
- if (!b.label) return rect;
8058
- const ty = Math.max(fontSize, c.y - 4);
8059
- 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>`;
8060
- }).join("")}</svg>`;
8061
- base = await (0, sharp.default)(frameData, { raw: {
8062
- width: frameWidth,
8063
- height: frameHeight,
8064
- channels: 3
8065
- } }).composite([{
8066
- input: Buffer.from(svg),
8067
- top: 0,
8068
- left: 0
8069
- }]).png().toBuffer();
8070
- baseIsRaw = false;
8071
- }
8072
- let pipeline = baseIsRaw ? (0, sharp.default)(base, { raw: {
8073
- width: frameWidth,
8074
- height: frameHeight,
8075
- channels: 3
8076
- } }) : (0, sharp.default)(base);
8077
- if (opts.maxWidth !== void 0) pipeline = pipeline.resize({
8078
- width: opts.maxWidth,
8079
- withoutEnlargement: true
8080
- });
8081
- return pipeline.jpeg({ quality }).toBuffer();
8082
- }
8083
- /** JPEG quality of the composed artefact — the mosaic's number, same reasons. */
8084
- var GROUP_FRAME_QUALITY = 82;
8085
- /**
8086
- * Draw the members onto the frame. Returns `null` when there is nothing to
8087
- * draw, or when anything at all goes wrong — the caller then ships the
8088
- * unannotated frame unchanged.
8089
- */
8090
- async function composeGroupFrame(input) {
8091
- const freshnessMs = input.freshnessMs ?? 3e3;
8092
- const fresh = input.boxes.filter((b) => Math.abs(input.at - b.observedAt) <= freshnessMs);
8093
- const withheld = input.boxes.length - fresh.length;
8094
- if (fresh.length < 2) {
8095
- if (withheld > 0) input.logger.info("group frame not drawn — too few members with fresh geometry", {
8096
- tags: { deviceId: input.deviceId },
8097
- meta: {
8098
- groupKey: input.groupKey,
8099
- members: input.boxes.length,
8100
- fresh: fresh.length,
8101
- withheld
8102
- }
8103
- });
8104
- return null;
8105
- }
8106
- try {
8107
- const decoded = await (0, sharp.default)(input.jpeg).raw().toBuffer({ resolveWithObject: true });
8108
- const { width, height, channels } = decoded.info;
8109
- if (!(width > 0 && height > 0) || channels !== 3) {
8110
- input.logger.warn("group frame not drawn — unexpected raster shape", {
8111
- tags: { deviceId: input.deviceId },
8112
- meta: {
8113
- groupKey: input.groupKey,
8114
- width,
8115
- height,
8116
- channels
8117
- }
8118
- });
8119
- return null;
8120
- }
8121
- const boxes = fresh.map((b) => ({
8122
- x: b.bbox.x * width,
8123
- y: b.bbox.y * height,
8124
- w: b.bbox.w * width,
8125
- h: b.bbox.h * height,
8126
- ...b.label !== void 0 && b.label.length > 0 ? { label: b.label } : {}
8127
- }));
8128
- const bytes = await drawBoxedFrame(decoded.data, width, height, boxes, { quality: GROUP_FRAME_QUALITY });
8129
- input.logger.debug("group frame composed", {
8130
- tags: { deviceId: input.deviceId },
8131
- meta: {
8132
- groupKey: input.groupKey,
8133
- drawn: boxes.length,
8134
- withheld,
8135
- width,
8136
- height
8137
- }
8138
- });
8139
- return {
8140
- bytes,
8141
- drawn: boxes.length,
8142
- withheld
8143
- };
8144
- } catch (err) {
8145
- input.logger.warn("group frame compose failed — shipping the unannotated frame", {
8146
- tags: { deviceId: input.deviceId },
8147
- meta: {
8148
- groupKey: input.groupKey,
8149
- error: err instanceof Error ? err.message : String(err)
8150
- }
8151
- });
8152
- return null;
8153
- }
8154
- }
8155
- //#endregion
8156
- //#region src/notification-center/text-compose.ts
8157
- /** One normalisation, used on both sides of every class lookup — a pipeline
8158
- * that emitted `Person` must find the key written `class.person`. */
8159
- function normaliseClass(className) {
8160
- return className.trim().toLowerCase();
8161
- }
8162
- /**
8163
- * The noun for a class, in the count's plural category.
8164
- *
8165
- * A class no locale names falls through to `class.other`, which prints the RAW
8166
- * name: a custom model's class must appear in the sentence rather than vanish
8167
- * out of it, and a missing translation is a smaller defect than a missing
8168
- * detection.
8169
- */
8170
- function ncClassNoun(texts, className, count) {
8171
- const key = `class.${normaliseClass(className)}`;
8172
- if (texts.has(key)) return texts.text({
8173
- key,
8174
- count,
8175
- vars: { name: className }
8176
- });
8177
- return texts.text({
8178
- key: "class.other",
8179
- count,
8180
- vars: { name: className }
8181
- });
8182
- }
8183
- /** `person (John)` / `persona (John)` — the thing the notification is about. */
8184
- function ncSubjectText(texts, input) {
8185
- const classNoun = ncClassNoun(texts, input.className, 1);
8186
- if (input.label === void 0) return texts.text({
8187
- key: "detection.subject",
8188
- vars: { classNoun }
8189
- });
8190
- return texts.text({
8191
- key: subjectLabelKey(input.labelKind),
8192
- vars: {
8193
- classNoun,
8194
- label: input.label
8195
- }
8196
- });
8197
- }
8198
- /**
8199
- * Which labelled-subject key a recognition gets. A kind the catalog does not
8200
- * name falls back to the generic key rather than printing nothing — the same
8201
- * direction `ncClassNoun` degrades in, and for the same reason.
8202
- */
8203
- function subjectLabelKey(kind) {
8204
- if (kind === "identity") return "detection.subject.identity";
8205
- if (kind === "plate") return "detection.subject.plate";
8206
- return "detection.subject.labelled";
8207
- }
8208
- /**
8209
- * ` in gate, drive` — INCLUDING its leading space, and empty when there are no
8210
- * zones.
8211
- *
8212
- * The space belongs to the fragment rather than to the body key because the
8213
- * alternative is a body that ends in a trailing space on every zone-less
8214
- * notification. A translator moving the clause moves the space with it.
8215
- */
8216
- function ncInZonesText(texts, zoneLabels) {
8217
- if (zoneLabels.length === 0) return "";
8218
- return texts.text({
8219
- key: "detection.inZones",
8220
- vars: { zones: zoneLabels.join(", ") }
8221
- });
8222
- }
8223
- /** The human-readable edge polarity behind `{{op}}` and the occupancy body. */
8224
- function ncOccupancyOp(texts, occupied) {
8225
- return texts.text({ key: occupied ? "occupancy.op.occupied" : "occupancy.op.free" });
8226
- }
8227
- /**
8228
- * `1 persona, 2 veicoli e 3 animali` — the language's own conjunction, its own
8229
- * plural rules, and its own nouns.
8230
- *
8231
- * Zero-count classes are dropped: a selected class the window never contained
8232
- * is a legitimate `0` in `{{count_vehicle}}`, but "0 veicoli" inside a summary
8233
- * sentence is a line nobody reads. Order is the caller's (busiest first) and is
8234
- * preserved, so the same night always produces the same sentence.
8235
- */
8236
- function ncDetectionSummary(texts, counts) {
8237
- const parts = counts.filter((entry) => entry.count > 0).map((entry) => `${entry.count} ${ncClassNoun(texts, entry.className, entry.count)}`);
8238
- return texts.join(parts);
8239
- }
8240
- //#endregion
8241
- //#region src/notification-center/group/nc-group-text.ts
8242
- var DEFAULT_MAX_NAMES = 2;
8243
- /**
8244
- * `N altre persone` — the remainder phrase for one class, in its own gender.
8245
- *
8246
- * Mirrors {@link ncClassNoun} byte for byte, including the fallback: a class no
8247
- * locale names falls through to `group.others.other`, which prints the raw class
8248
- * name rather than vanishing. A custom model's class must appear in the sentence.
8249
- */
8250
- function ncGroupOthers(texts, className, count) {
8251
- const classNoun = ncClassNoun(texts, className, count);
8252
- const key = `group.others.${className.trim().toLowerCase()}`;
8253
- if (texts.has(key)) return texts.text({
8254
- key,
8255
- count,
8256
- vars: {
8257
- count: `${count}`,
8258
- classNoun
8259
- }
8260
- });
8261
- return texts.text({
8262
- key: "group.others.other",
8263
- count,
8264
- vars: {
8265
- count: `${count}`,
8266
- classNoun
8267
- }
8268
- });
8269
- }
8270
- /** Counts per class, in first-seen order so the same burst always reads the same. */
8271
- function countByClass(members) {
8272
- const counts = /* @__PURE__ */ new Map();
8273
- for (const m of members) counts.set(m.className, (counts.get(m.className) ?? 0) + 1);
8274
- return [...counts].map(([className, count]) => ({
8275
- className,
8276
- count
8277
- }));
8278
- }
8279
- /**
8280
- * The finished `{{subject}}` phrase for a group.
8281
- *
8282
- * Pure: a catalog in, a string out. No clock, no store, no I/O — like every
8283
- * other composer in `text-compose.ts`, and for the same reason.
8284
- */
8285
- function ncGroupSubjectText(texts, input) {
8286
- const members = input.members;
8287
- const only = members.length === 1 ? members[0] : void 0;
8288
- if (only !== void 0) return ncSubjectText(texts, {
8289
- className: only.className,
8290
- ...only.label !== void 0 ? { label: only.label } : {},
8291
- ...only.labelKind !== void 0 ? { labelKind: only.labelKind } : {}
8292
- });
8293
- if (members.length === 0) return "";
8294
- const maxNames = input.maxNames !== void 0 && input.maxNames >= 0 ? input.maxNames : DEFAULT_MAX_NAMES;
8295
- const named = members.filter((m) => m.label !== void 0 && m.label.length > 0);
8296
- const names = named.slice(0, maxNames).map((m) => m.label ?? "");
8297
- if (names.length === 0) return ncDetectionSummary(texts, countByClass(members));
8298
- const spoken = new Set(named.slice(0, maxNames));
8299
- const remainder = members.filter((m) => !spoken.has(m));
8300
- if (remainder.length === 0) return texts.join(names);
8301
- const others = countByClass(remainder).map((c) => ncGroupOthers(texts, c.className, c.count));
8302
- return texts.join([...names, ...others]);
8303
- }
8304
- //#endregion
8305
8680
  //#region src/notification-center/dispatcher.ts
8306
8681
  var DEFAULT_TARGET_CACHE_TTL_MS = 6e4;
8307
8682
  /**
@@ -8864,7 +9239,7 @@ var NcDispatcher = class {
8864
9239
  const stillOverride = zoneIdsWanted !== void 0 && zoneIdsWanted.length > 0 ? "keyFrame" : void 0;
8865
9240
  const firstLook = await this.resolveAttachment(entry, stillOverride);
8866
9241
  const footage = await this.renderFootage(entry);
8867
- const still = firstLook !== null || entry.payload.media === "none" ? firstLook : footage.attempted ? await this.resolveAttachment(entry, stillOverride) : await this.waitForStill(entry, stillOverride);
9242
+ 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);
8868
9243
  if (still !== null) {
8869
9244
  const zoneIds = zoneIdsWanted;
8870
9245
  if (zoneIds !== void 0 && zoneIds.length > 0) {
@@ -9352,17 +9727,24 @@ function incomingFromSensorEvent(event, markerTrackId) {
9352
9727
  })
9353
9728
  };
9354
9729
  }
9355
- /** Audio-event persist (classified episode). */
9356
- function incomingFromAudioEvent(event) {
9730
+ /**
9731
+ * Audio-event persist (classified episode).
9732
+ *
9733
+ * `stillOwnerId` is the audio still shelf's owner id for the photograph taken
9734
+ * at this episode (`audio-still.ts`). Absent when no rule that could fire asked
9735
+ * for a picture — the same shape `incomingFromSensorEvent` carries its marker.
9736
+ */
9737
+ function incomingFromAudioEvent(event, stillOwnerId) {
9357
9738
  return {
9358
- subject: subjectFromAudioEvent(event),
9739
+ subject: subjectFromAudioEvent(event, stillOwnerId),
9359
9740
  kind: "audio-event",
9360
9741
  origin: "pipeline",
9361
9742
  log: () => ({
9362
9743
  tags: { deviceId: event.deviceId },
9363
9744
  meta: {
9364
9745
  audioEventId: event.id,
9365
- class: event.classification?.className
9746
+ class: event.classification?.className,
9747
+ ...stillOwnerId !== void 0 ? { stillOwner: stillOwnerId } : {}
9366
9748
  }
9367
9749
  })
9368
9750
  };
@@ -9392,15 +9774,20 @@ function incomingFromPackageEvent(event, phase) {
9392
9774
  * and a crash in the confirm→outbox window drops the notification rather than
9393
9775
  * replaying it. Deliberate: a stale match is a claim about a noise that has
9394
9776
  * already stopped.
9777
+ *
9778
+ * `stillOwnerId` — see {@link incomingFromAudioEvent}.
9395
9779
  */
9396
- function incomingFromAudioWindow(hit) {
9780
+ function incomingFromAudioWindow(hit, stillOwnerId) {
9397
9781
  return {
9398
- subject: subjectFromAudioWindow(hit),
9782
+ subject: subjectFromAudioWindow(hit, stillOwnerId),
9399
9783
  kind: "audio-window",
9400
9784
  origin: "pipeline",
9401
9785
  log: () => ({
9402
9786
  tags: { deviceId: hit.deviceId },
9403
- meta: audioHitMeta(hit)
9787
+ meta: {
9788
+ ...audioHitMeta(hit),
9789
+ ...stillOwnerId !== void 0 ? { stillOwner: stillOwnerId } : {}
9790
+ }
9404
9791
  })
9405
9792
  };
9406
9793
  }
@@ -13341,6 +13728,29 @@ function escapeXml$1(s) {
13341
13728
  }
13342
13729
  });
13343
13730
  }
13731
+ /**
13732
+ * The single decision about drawn text — and the home of the DEFAULT.
13733
+ *
13734
+ * `undefined` means `none`, not `all`: a rule stored before this option existed
13735
+ * has no `captions` key, and the operator's request is that such a rule deliver
13736
+ * pictures. The schema materialises the same default on read
13737
+ * (`NcSummaryRuleInputSchema`), so this arm only fires for a caller that never
13738
+ * had a rule — the test bridge and the spec. Both agreeing is the point.
13739
+ */
13740
+ function mosaicTextPlan(captions) {
13741
+ if (captions === "all") return {
13742
+ tileLabels: true,
13743
+ titleBand: true
13744
+ };
13745
+ if (captions === "tiles") return {
13746
+ tileLabels: true,
13747
+ titleBand: false
13748
+ };
13749
+ return {
13750
+ tileLabels: false,
13751
+ titleBand: false
13752
+ };
13753
+ }
13344
13754
  /** Grid shape for `n` tiles: as square as possible, rows never exceeding cols
13345
13755
  * by more than one — a 3×3 for nine, a 3×2 for five, never a 1×9 strip. */
13346
13756
  function mosaicGrid(n) {
@@ -13447,22 +13857,25 @@ function titleSvg(width, height, caption) {
13447
13857
  * treats that as "no mosaic" and still delivers the counts.
13448
13858
  */
13449
13859
  async function renderMosaic(request, logger) {
13860
+ const plan = mosaicTextPlan(request.captions);
13450
13861
  const decoded = [];
13451
13862
  const probeLayout = mosaicLayout({
13452
13863
  tileCount: Math.max(1, request.tiles.length),
13453
13864
  ...request.maxWidth !== void 0 ? { maxWidth: request.maxWidth } : {}
13454
13865
  });
13455
13866
  for (const tile of request.tiles) try {
13456
- const resized = await (0, sharp.default)(tile.jpeg).resize({
13867
+ const label = plan.tileLabels ? tile.label?.trim() ?? "" : "";
13868
+ const fitted = (0, sharp.default)(tile.jpeg).resize({
13457
13869
  width: probeLayout.tileWidth,
13458
13870
  height: probeLayout.tileHeight,
13459
13871
  fit: "contain",
13460
13872
  background: MOSAIC_BACKGROUND
13461
- }).composite([{
13462
- input: tileLabelSvg(probeLayout.tileWidth, probeLayout.tileHeight, tile.label),
13873
+ });
13874
+ const resized = await (label.length === 0 ? fitted : fitted.composite([{
13875
+ input: tileLabelSvg(probeLayout.tileWidth, probeLayout.tileHeight, label),
13463
13876
  top: 0,
13464
13877
  left: 0
13465
- }]).jpeg({ quality: 82 }).toBuffer();
13878
+ }])).jpeg({ quality: 82 }).toBuffer();
13466
13879
  decoded.push({
13467
13880
  tile,
13468
13881
  resized
@@ -13477,7 +13890,7 @@ async function renderMosaic(request, logger) {
13477
13890
  });
13478
13891
  }
13479
13892
  const droppedTiles = request.tiles.length - decoded.length;
13480
- const caption = request.caption?.trim() ?? "";
13893
+ const caption = plan.titleBand ? request.caption?.trim() ?? "" : "";
13481
13894
  const layout = mosaicLayout({
13482
13895
  tileCount: Math.max(1, decoded.length),
13483
13896
  ...request.maxWidth !== void 0 ? { maxWidth: request.maxWidth } : {},
@@ -14346,18 +14759,20 @@ var NcSummaryProducer = class {
14346
14759
  };
14347
14760
  let rendered;
14348
14761
  try {
14762
+ const plan = mosaicTextPlan(rule.captions);
14349
14763
  rendered = await renderMosaic({
14350
14764
  tiles: tiles.map(({ candidate, jpeg }) => ({
14351
14765
  jpeg,
14352
- label: `${names.get(candidate.deviceId) ?? candidate.deviceId} · ${clockOf$2(candidate.firstSeen)} · ${candidate.className}`,
14766
+ ...plan.tileLabels ? { label: `${names.get(candidate.deviceId) ?? candidate.deviceId} · ${clockOf$2(candidate.firstSeen)} · ${candidate.className}` } : {},
14353
14767
  deviceId: candidate.deviceId,
14354
14768
  trackId: candidate.trackId
14355
14769
  })),
14356
- caption: summaryCaptionText({
14770
+ captions: rule.captions,
14771
+ ...plan.titleBand ? { caption: summaryCaptionText({
14357
14772
  ...rule.captionText !== void 0 ? { captionText: rule.captionText } : {},
14358
14773
  ruleName: rule.name,
14359
14774
  window
14360
- })
14775
+ }) } : {}
14361
14776
  }, this.deps.logger);
14362
14777
  } catch (err) {
14363
14778
  this.deps.logger.warn("summary mosaic could not be composed — delivering the counts alone", { meta: {
@@ -14621,6 +15036,34 @@ var NcSummaryRankSchema = require_dist._enum([
14621
15036
  "dwell"
14622
15037
  ]);
14623
15038
  /**
15039
+ * How much TEXT is burnt into the mosaic — and the answer is NONE by default.
15040
+ *
15041
+ * `none` is the shipped default and, crucially, the reading of an ABSENT field:
15042
+ * `.default()` on the Input schema materialises it, and the persisted schema
15043
+ * extends Input, so a rule authored before this option existed parses as `none`
15044
+ * on the next read. No migration, no backfill, no rewrite of a stored rule.
15045
+ *
15046
+ * The operator asked for "solo immagini". Three reasons it is the right
15047
+ * default rather than a preference:
15048
+ *
15049
+ * - a caption is PERMANENT in the artefact, exactly like a detection box, and
15050
+ * a mosaic is re-read days later;
15051
+ * - the per-tile strip covers 14% of the tile from the bottom edge up, which
15052
+ * is where feet, wheels and parcels are;
15053
+ * - nothing downstream needs it. The AI pass is given the RAW per-track JPEGs
15054
+ * and gets camera, class and time as PROMPT TEXT — it never reads the
15055
+ * mosaic, so the pixels carry no fact the judge would lose.
15056
+ *
15057
+ * `tiles` and `all` keep the old behaviour reachable for an operator who wants
15058
+ * it; `all` is byte-for-byte what every rule produced before this field.
15059
+ */
15060
+ var NcSummaryCaptionsSchema = require_dist._enum([
15061
+ "none",
15062
+ "tiles",
15063
+ "all"
15064
+ ]);
15065
+ var NC_SUMMARY_DEFAULT_CAPTIONS = "none";
15066
+ /**
14624
15067
  * The AI section — DECLARED IN P1, WIRED IN P2.
14625
15068
  *
14626
15069
  * Nothing in this addon reads it today. It is here so the rule shape does not
@@ -14654,6 +15097,8 @@ var NcSummaryRuleInputSchema = require_dist.object({
14654
15097
  barrierSeconds: BarrierSecondsField.default(120),
14655
15098
  targets: TargetsField,
14656
15099
  template: NcSummaryTemplateSchema.optional(),
15100
+ /** Burned onto the mosaic's title band — and ONLY when `captions` is `all`. */
15101
+ captions: NcSummaryCaptionsSchema.default(NC_SUMMARY_DEFAULT_CAPTIONS),
14657
15102
  /** Burned onto the mosaic's title band. `''` is reachable and means "no
14658
15103
  * caption" — which is why this is not `.min(1)`. */
14659
15104
  captionText: require_dist.string().max(200).optional(),
@@ -14683,6 +15128,7 @@ var NcSummaryRulePatchSchema = require_dist.object({
14683
15128
  barrierSeconds: BarrierSecondsField.optional(),
14684
15129
  targets: TargetsField.optional(),
14685
15130
  template: NcSummaryTemplateSchema.nullable().optional(),
15131
+ captions: NcSummaryCaptionsSchema.optional(),
14686
15132
  captionText: require_dist.string().max(200).optional(),
14687
15133
  deliverEmpty: require_dist.boolean().optional(),
14688
15134
  priority: PriorityField.optional(),
@@ -15217,14 +15663,19 @@ function isSyntheticId(id) {
15217
15663
  * snapshot that produced it lists the members being counted and the edge
15218
15664
  * names one (`chooseOccupancyMediaOwner`), so the tester stands in for that
15219
15665
  * member exactly as it stands in for the marker.
15666
+ * - and, since 2026-08-15, a `trackId` for both AUDIO kinds: a sound owns no
15667
+ * frame, so production photographs the camera at the match and holds the
15668
+ * bytes on the still shelf under an owner id the subject names
15669
+ * (`audio-still.ts`). The tester's RAM-held frame stands in for exactly that
15670
+ * photograph, and — unlike every other kind here — it is taken the same way
15671
+ * production takes it, at the same moment, from the same cap.
15220
15672
  *
15221
- * An audio episode still freezes neither. It HAS a marker projection, but its
15222
- * id does not reach the notification subject yet, so it remains honestly
15223
- * unattachable — a tester that invented a picture for it would be advertising a
15224
- * feature that does not exist.
15673
+ * Only `system-event` remains: an infrastructure event is about no camera at
15674
+ * all, so there is nothing to photograph and a tester that invented a picture
15675
+ * for it would be advertising a feature that does not exist.
15225
15676
  */
15226
15677
  function triggerCanCarryStill(kind) {
15227
- return kind === "object-event" || kind === "package-event" || kind === "track-end" || kind === "device-event" || kind === "occupancy-event";
15678
+ return kind !== "system-event";
15228
15679
  }
15229
15680
  /** The detection provenance production stamps on a subject of this kind. */
15230
15681
  function syntheticSource(kind) {
@@ -15262,7 +15713,7 @@ function audioSubjectOf(input) {
15262
15713
  function buildSyntheticEvent(input, recordId, now) {
15263
15714
  const kind = input.trigger;
15264
15715
  const timestamp = input.timestamp ?? now;
15265
- const carriesTrackId = kind === "object-event" || kind === "track-end" || kind === "device-event" || kind === "occupancy-event";
15716
+ const carriesTrackId = kind === "object-event" || kind === "track-end" || kind === "device-event" || kind === "occupancy-event" || kind === "audio-event" || kind === "audio-window";
15266
15717
  return {
15267
15718
  subject: {
15268
15719
  kind,
@@ -17842,6 +18293,12 @@ var NotificationCenter = class NotificationCenter {
17842
18293
  * is the one thing the synthetic producer promises not to do.
17843
18294
  */
17844
18295
  syntheticMedia = /* @__PURE__ */ new Map();
18296
+ /**
18297
+ * The photograph a SOUND rule's notification carries — see `audio-still.ts`.
18298
+ * Null when this wiring has no snapshot cap, which leaves every audio
18299
+ * notification text-only rather than broken.
18300
+ */
18301
+ audioStills;
17845
18302
  /** Per-device rate limit for the "matched NO rule" report — see `reportNoMatch`. */
17846
18303
  lastNoMatchReportAt = /* @__PURE__ */ new Map();
17847
18304
  noMatchSuppressed = /* @__PURE__ */ new Map();
@@ -18133,6 +18590,12 @@ var NotificationCenter = class NotificationCenter {
18133
18590
  this.deps = deps;
18134
18591
  this.logger = deps.logger;
18135
18592
  this.now = deps.now ?? (() => Date.now());
18593
+ const getSnapshot = deps.getSnapshot;
18594
+ this.audioStills = getSnapshot !== void 0 ? new NcAudioStillShelf({
18595
+ getSnapshot,
18596
+ logger: this.logger.child("audio-still"),
18597
+ now: this.now
18598
+ }) : null;
18136
18599
  this.rules = new NcRuleStore({
18137
18600
  store: deps.store,
18138
18601
  logger: this.logger.child("rules"),
@@ -18210,6 +18673,8 @@ var NotificationCenter = class NotificationCenter {
18210
18673
  getMediaForOwner: async (ownerKind, ownerId) => {
18211
18674
  const held = isSyntheticId(ownerId) ? this.syntheticMedia.get(ownerId) : void 0;
18212
18675
  if (held !== void 0) return held.files;
18676
+ const audio = isAudioStillId(ownerId) ? this.audioStills?.get(ownerId) : void 0;
18677
+ if (audio !== void 0) return audio;
18213
18678
  return deps.dispatcher.getMediaForOwner(ownerKind, ownerId);
18214
18679
  },
18215
18680
  ...deps.now !== void 0 ? { now: deps.now } : {},
@@ -18414,6 +18879,7 @@ var NotificationCenter = class NotificationCenter {
18414
18879
  }
18415
18880
  this.timelapseScheduler?.stop();
18416
18881
  this.summaryProducer?.stop();
18882
+ this.audioStills?.clear();
18417
18883
  this.evaluationActive = false;
18418
18884
  }
18419
18885
  /**
@@ -18743,7 +19209,35 @@ var NotificationCenter = class NotificationCenter {
18743
19209
  * durable guarantee begins at this hook, matching the device-event boundary.
18744
19210
  */
18745
19211
  onAudioEventPersisted(event) {
18746
- this.consumeEvent(incomingFromAudioEvent(event));
19212
+ this.consumeEvent(incomingFromAudioEvent(event, this.audioStillOwner(event.deviceId, event.timestamp, "legacy")));
19213
+ }
19214
+ /**
19215
+ * Start photographing `deviceId` NOW and return the owner id the audio
19216
+ * subject should name — or `undefined` when nothing would use it.
19217
+ *
19218
+ * **The capture is not awaited.** A sound is transient and a notification
19219
+ * about a scream must not wait on a camera; the bytes land while the outbox
19220
+ * row queues, and the dispatcher's ordinary bounded still-wait picks them up.
19221
+ * That is also why this runs BEFORE the cooldown and the mute are consulted:
19222
+ * by the time a rule's window has been judged the moment is gone.
19223
+ *
19224
+ * **The gate is the RULE's media policy, not the trigger.** Photographing a
19225
+ * camera on every confirmed window for rules that ship text anyway would be a
19226
+ * snapshot at whatever rate the sound happens to occur, for nothing. The rule
19227
+ * cache is an in-memory mirror, so this is not a fallible read (D49) — a
19228
+ * failed one would look exactly like "no rule wants a picture", which is the
19229
+ * direction that DESTROYS the media.
19230
+ */
19231
+ audioStillOwner(deviceId, atMs, mode) {
19232
+ const shelf = this.audioStills;
19233
+ if (shelf === null || !this.evaluationActive) return void 0;
19234
+ for (const rule of this.rules.listEnabled("immediate")) {
19235
+ if (rule.media.attach === "none") continue;
19236
+ if (!(mode === "condition" ? rule.conditions.audio !== void 0 : rule.conditions.audio === void 0 && referencesAudioClass(rule.conditions.classes))) continue;
19237
+ const devices = rule.conditions.devices;
19238
+ if (devices !== void 0 && devices.length > 0 && !devices.includes(deviceId)) continue;
19239
+ return shelf.capture(deviceId, atMs);
19240
+ }
18747
19241
  }
18748
19242
  /**
18749
19243
  * Called at the package object-event persist site (`PackageDropDetector` —
@@ -18841,7 +19335,7 @@ var NotificationCenter = class NotificationCenter {
18841
19335
  tags: { deviceId },
18842
19336
  meta: audioHitMeta(hit)
18843
19337
  });
18844
- this.consumeEvent(incomingFromAudioWindow(hit));
19338
+ this.consumeEvent(incomingFromAudioWindow(hit, this.audioStillOwner(deviceId, hit.timestamp, "condition")));
18845
19339
  }
18846
19340
  }
18847
19341
  /**
@@ -19658,6 +20152,19 @@ var NotificationCenter = class NotificationCenter {
19658
20152
  }
19659
20153
  });
19660
20154
  }
20155
+ if (!noOwnerReported && (kind === "audio-window" || kind === "audio-event") && subject.trackId === void 0 && rule.media.attach !== "none") {
20156
+ noOwnerReported = true;
20157
+ this.logger.warn("audio match has no still owner — this notification ships text-only", {
20158
+ tags: { deviceId: subject.deviceId },
20159
+ meta: {
20160
+ recordId: subject.recordId,
20161
+ kind,
20162
+ ruleId: rule.id,
20163
+ rule: rule.name,
20164
+ snapshotWired: this.audioStills !== null
20165
+ }
20166
+ });
20167
+ }
19661
20168
  const userTargets = await this.resolveUserTargets(rule, subject.deviceId);
19662
20169
  const entries = this.buildEntries(rule, subject, kind, evaluation.matchedOn, userTargets, origin, {
19663
20170
  key,
@@ -19814,6 +20321,7 @@ var NotificationCenter = class NotificationCenter {
19814
20321
  admitToGroup(rule, subject, now) {
19815
20322
  const idleSec = rule.groupIdleSec;
19816
20323
  if (idleSec === void 0 || idleSec <= 0) return { outcome: "disabled" };
20324
+ if (subject.kind === "audio-window" || subject.kind === "audio-event") return { outcome: "disabled" };
19817
20325
  const trackId = subject.trackId;
19818
20326
  if (trackId === void 0) return { outcome: "disabled" };
19819
20327
  const member = {
@@ -23466,88 +23974,6 @@ var embeddingActions = require_dist.defineCustomActions({
23466
23974
  "embedding.wipe": require_dist.customAction(NoInputSchema, WipeResultSchema, { auth: "admin" })
23467
23975
  });
23468
23976
  //#endregion
23469
- //#region src/pipeline-analytics/retention/orphan-audit-actions.ts
23470
- /**
23471
- * The operator button for the orphan audit.
23472
- *
23473
- * The audit engine, its five bounds and its resumable cursor already existed
23474
- * and had run in `report` mode for weeks; what it had no way to be was
23475
- * *asked*. Its only trigger was a 6-hourly timer reading a durable setting, so
23476
- * draining a standing backlog meant flipping a persistent mode, waiting up to
23477
- * six hours, and remembering to flip it back — with nothing returned to look
23478
- * at. On 2026-08-14 the backlog was 74,700 media rows / 18.9 GB plus 23,296
23479
- * object events and 4,543 CLIP vectors (D161), and that is not a thing to
23480
- * drain by leaving a switch on.
23481
- *
23482
- * Three properties this action does NOT leave to the caller's care:
23483
- *
23484
- * 1. **Dry run is the default.** `mode` omitted means `report`: it counts,
23485
- * logs and returns, and deletes nothing — regardless of what the durable
23486
- * `orphanAuditMode` setting says. `reclaim` is a word that has to be
23487
- * typed. The engine's own default is the same for the same reason.
23488
- * 2. **It is paced.** `pageSize` bounds one read, `maxRowsPerScope` bounds
23489
- * one pass, `maxReclaimPerScope` bounds the damage, and the engine yields
23490
- * the thread between pages — `better-sqlite3` is synchronous and shares
23491
- * hub-main with every live frame.
23492
- * 3. **It answers.** The report comes back per collection with rows AND
23493
- * bytes, so "did it do anything" is not a question anyone has to infer
23494
- * from a log line.
23495
- */
23496
- /**
23497
- * `report` counts; `reclaim` deletes.
23498
- *
23499
- * `.default('report')` is load-bearing, not decoration: the settings form
23500
- * sends `{}` for a button with no fields, and the safe interpretation of an
23501
- * unstated intent here is the one that destroys nothing.
23502
- */
23503
- var OrphanAuditActionModeSchema = require_dist._enum(["report", "reclaim"]).default("report");
23504
- var OrphanAuditActionInputSchema = require_dist.object({
23505
- mode: OrphanAuditActionModeSchema,
23506
- /**
23507
- * Start every scope from the top instead of resuming its persisted cursor.
23508
- *
23509
- * What draining a backlog wants, and never the default — the cursor exists
23510
- * so a 6-hourly pass makes progress instead of re-walking the same head.
23511
- */
23512
- restart: require_dist.boolean().optional(),
23513
- /** Rows read per statement. Smaller = more yields = a gentler pass. */
23514
- pageSize: require_dist.number().int().min(50).max(5e3).optional(),
23515
- /** Rows EXAMINED per collection in this pass — the pacing bound. */
23516
- maxRowsPerScope: require_dist.number().int().min(1).max(2e5).optional(),
23517
- /** Rows DELETED per collection in this pass — the blast-radius bound. */
23518
- maxReclaimPerScope: require_dist.number().int().min(1).max(1e5).optional(),
23519
- /**
23520
- * Minutes of immunity for a recent row. Protective ONLY — raising it can
23521
- * exclusively save rows, never select them, which is what keeps this off
23522
- * the one-age-keyed-sweep guard's books.
23523
- */
23524
- graceMinutes: require_dist.number().int().min(1).max(10080).optional()
23525
- });
23526
- var OrphanAuditScopeReportSchema = require_dist.object({
23527
- collection: require_dist.string(),
23528
- examined: require_dist.number(),
23529
- orphans: require_dist.number(),
23530
- reclaimed: require_dist.number(),
23531
- bytesReclaimed: require_dist.number(),
23532
- exempt: require_dist.number(),
23533
- withinGrace: require_dist.number(),
23534
- unresolvable: require_dist.number(),
23535
- stoppedBy: require_dist.string(),
23536
- /** Where the NEXT pass resumes. `0` = the scope was walked to the end. */
23537
- nextCursor: require_dist.number()
23538
- });
23539
- var OrphanAuditActionResultSchema = require_dist.object({
23540
- /** `false` when a pass was already in flight — nothing was done. */
23541
- ran: require_dist.boolean(),
23542
- mode: require_dist._enum(["report", "reclaim"]),
23543
- scopes: require_dist.array(OrphanAuditScopeReportSchema),
23544
- totalOrphans: require_dist.number(),
23545
- totalReclaimed: require_dist.number(),
23546
- totalBytesReclaimed: require_dist.number(),
23547
- durationMs: require_dist.number()
23548
- });
23549
- var orphanAuditActions = require_dist.defineCustomActions({ "retention.orphanAudit": require_dist.customAction(OrphanAuditActionInputSchema, OrphanAuditActionResultSchema, { auth: "admin" }) });
23550
- //#endregion
23551
23977
  //#region src/pipeline-analytics/map-with-concurrency.ts
23552
23978
  /**
23553
23979
  * Apply `mapper` to every item with at most `limit` in flight at once.
@@ -27864,6 +28290,88 @@ var PlateGalleryProvider = class {
27864
28290
  }
27865
28291
  };
27866
28292
  //#endregion
28293
+ //#region src/pipeline-analytics/retention/orphan-audit-actions.ts
28294
+ /**
28295
+ * The operator button for the orphan audit.
28296
+ *
28297
+ * The audit engine, its five bounds and its resumable cursor already existed
28298
+ * and had run in `report` mode for weeks; what it had no way to be was
28299
+ * *asked*. Its only trigger was a 6-hourly timer reading a durable setting, so
28300
+ * draining a standing backlog meant flipping a persistent mode, waiting up to
28301
+ * six hours, and remembering to flip it back — with nothing returned to look
28302
+ * at. On 2026-08-14 the backlog was 74,700 media rows / 18.9 GB plus 23,296
28303
+ * object events and 4,543 CLIP vectors (D161), and that is not a thing to
28304
+ * drain by leaving a switch on.
28305
+ *
28306
+ * Three properties this action does NOT leave to the caller's care:
28307
+ *
28308
+ * 1. **Dry run is the default.** `mode` omitted means `report`: it counts,
28309
+ * logs and returns, and deletes nothing — regardless of what the durable
28310
+ * `orphanAuditMode` setting says. `reclaim` is a word that has to be
28311
+ * typed. The engine's own default is the same for the same reason.
28312
+ * 2. **It is paced.** `pageSize` bounds one read, `maxRowsPerScope` bounds
28313
+ * one pass, `maxReclaimPerScope` bounds the damage, and the engine yields
28314
+ * the thread between pages — `better-sqlite3` is synchronous and shares
28315
+ * hub-main with every live frame.
28316
+ * 3. **It answers.** The report comes back per collection with rows AND
28317
+ * bytes, so "did it do anything" is not a question anyone has to infer
28318
+ * from a log line.
28319
+ */
28320
+ /**
28321
+ * `report` counts; `reclaim` deletes.
28322
+ *
28323
+ * `.default('report')` is load-bearing, not decoration: the settings form
28324
+ * sends `{}` for a button with no fields, and the safe interpretation of an
28325
+ * unstated intent here is the one that destroys nothing.
28326
+ */
28327
+ var OrphanAuditActionModeSchema = require_dist._enum(["report", "reclaim"]).default("report");
28328
+ var OrphanAuditActionInputSchema = require_dist.object({
28329
+ mode: OrphanAuditActionModeSchema,
28330
+ /**
28331
+ * Start every scope from the top instead of resuming its persisted cursor.
28332
+ *
28333
+ * What draining a backlog wants, and never the default — the cursor exists
28334
+ * so a 6-hourly pass makes progress instead of re-walking the same head.
28335
+ */
28336
+ restart: require_dist.boolean().optional(),
28337
+ /** Rows read per statement. Smaller = more yields = a gentler pass. */
28338
+ pageSize: require_dist.number().int().min(50).max(5e3).optional(),
28339
+ /** Rows EXAMINED per collection in this pass — the pacing bound. */
28340
+ maxRowsPerScope: require_dist.number().int().min(1).max(2e5).optional(),
28341
+ /** Rows DELETED per collection in this pass — the blast-radius bound. */
28342
+ maxReclaimPerScope: require_dist.number().int().min(1).max(1e5).optional(),
28343
+ /**
28344
+ * Minutes of immunity for a recent row. Protective ONLY — raising it can
28345
+ * exclusively save rows, never select them, which is what keeps this off
28346
+ * the one-age-keyed-sweep guard's books.
28347
+ */
28348
+ graceMinutes: require_dist.number().int().min(1).max(10080).optional()
28349
+ });
28350
+ var OrphanAuditScopeReportSchema = require_dist.object({
28351
+ collection: require_dist.string(),
28352
+ examined: require_dist.number(),
28353
+ orphans: require_dist.number(),
28354
+ reclaimed: require_dist.number(),
28355
+ bytesReclaimed: require_dist.number(),
28356
+ exempt: require_dist.number(),
28357
+ withinGrace: require_dist.number(),
28358
+ unresolvable: require_dist.number(),
28359
+ stoppedBy: require_dist.string(),
28360
+ /** Where the NEXT pass resumes. `0` = the scope was walked to the end. */
28361
+ nextCursor: require_dist.number()
28362
+ });
28363
+ var OrphanAuditActionResultSchema = require_dist.object({
28364
+ /** `false` when a pass was already in flight — nothing was done. */
28365
+ ran: require_dist.boolean(),
28366
+ mode: require_dist._enum(["report", "reclaim"]),
28367
+ scopes: require_dist.array(OrphanAuditScopeReportSchema),
28368
+ totalOrphans: require_dist.number(),
28369
+ totalReclaimed: require_dist.number(),
28370
+ totalBytesReclaimed: require_dist.number(),
28371
+ durationMs: require_dist.number()
28372
+ });
28373
+ var orphanAuditActions = require_dist.defineCustomActions({ "retention.orphanAudit": require_dist.customAction(OrphanAuditActionInputSchema, OrphanAuditActionResultSchema, { auth: "admin" }) });
28374
+ //#endregion
27867
28375
  //#region src/pipeline-analytics/videoclips-provider.ts
27868
28376
  var SOURCE = "analytics";
27869
28377
  function clipIdFor(eventId, startMs, endMs) {
@@ -33607,13 +34115,25 @@ var StoragePressureTracker = class {
33607
34115
  };
33608
34116
  //#endregion
33609
34117
  //#region src/pipeline-analytics/store/media-store.ts
34118
+ /**
34119
+ * `scene` is its own owner kind rather than borrowing `event`.
34120
+ *
34121
+ * Scene reference thumbnails were written as `ownerKind: 'event'` with an
34122
+ * `ownerId` of `scene-<monitorId>-…`, which resolves through
34123
+ * `resolveMediaOwner` to `{ kind: 'event', eventId: 'scene-…' }` — an event row
34124
+ * that has never existed. The orphan audit therefore classified every
34125
+ * operator-captured reference picture as collectable. It is the same class of
34126
+ * mistake `identity` and `vehicle` are exempt for: a blob the OPERATOR curated,
34127
+ * which nothing else can regenerate and no cascade owns.
34128
+ */
33610
34129
  var OWNER_KINDS = [
33611
34130
  "event",
33612
34131
  "track",
33613
34132
  "face",
33614
34133
  "identity",
33615
34134
  "plate",
33616
- "vehicle"
34135
+ "vehicle",
34136
+ "scene"
33617
34137
  ];
33618
34138
  /**
33619
34139
  * Narrow an arbitrary string to an `OwnerKind`.
@@ -42395,7 +42915,11 @@ function auditableCollections() {
42395
42915
  * exemption lives here now, and the orphan collector — the only sweep that
42396
42916
  * walks media it was not handed by name — reads it.
42397
42917
  */
42398
- var RETENTION_EXEMPT_OWNER_KINDS = new Set(["identity", "vehicle"]);
42918
+ var RETENTION_EXEMPT_OWNER_KINDS = new Set([
42919
+ "identity",
42920
+ "vehicle",
42921
+ "scene"
42922
+ ]);
42399
42923
  function isRetentionExemptOwnerKind(kind) {
42400
42924
  return RETENTION_EXEMPT_OWNER_KINDS.has(kind);
42401
42925
  }
@@ -45334,22 +45858,6 @@ async function projectSensorMarkers(deps, data, timestamp) {
45334
45858
  }
45335
45859
  return landed;
45336
45860
  }
45337
- /** JPEG quality for the downscaled full frame — matches the crop path. */
45338
- var FULL_FRAME_QUALITY = 80;
45339
- /**
45340
- * Downscale an already-encoded JPEG full frame to FIT WITHIN
45341
- * {@link FULL_FRAME_MAX_WIDTH}×{@link FULL_FRAME_MAX_HEIGHT}, preserving aspect
45342
- * ratio (`fit: 'inside'`) and never enlarging a source already smaller than the
45343
- * box. Re-encodes as JPEG. Used before persisting a synthetic sensor/control
45344
- * track's whole-scene snapshot so a raw native-resolution frame (a 4K bedroom
45345
- * at night) is never stored or served — the privacy fix moved to CAPTURE time.
45346
- */
45347
- async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
45348
- return (0, sharp.default)(Buffer.from(jpeg)).resize(maxWidth, maxHeight, {
45349
- fit: "inside",
45350
- withoutEnlargement: true
45351
- }).jpeg({ quality: FULL_FRAME_QUALITY }).toBuffer();
45352
- }
45353
45861
  //#endregion
45354
45862
  //#region src/pipeline-analytics/services/synthetic-track.ts
45355
45863
  /**
@@ -49894,6 +50402,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
49894
50402
  addonId: device.addonId
49895
50403
  };
49896
50404
  },
50405
+ getSnapshot: (deviceId) => api.snapshot.getSnapshot.query({ deviceId }),
49897
50406
  identityIdsByName: async () => {
49898
50407
  const byName = /* @__PURE__ */ new Map();
49899
50408
  const identities = await stores.identityStore.listIdentities();
@@ -50770,13 +51279,16 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
50770
51279
  if (media === null) throw new Error("media store not ready");
50771
51280
  return media.putReplacing({
50772
51281
  deviceId,
50773
- ownerKind: "event",
51282
+ ownerKind: "scene",
50774
51283
  ownerId,
50775
51284
  kind: "thumbnailSmall",
50776
51285
  timestamp,
50777
51286
  data
50778
51287
  });
50779
51288
  },
51289
+ dropMedia: async (mediaId) => {
51290
+ await this.mediaStore?.deleteByKey(mediaId);
51291
+ },
50780
51292
  mirrorSlice: (deviceId, status) => {
50781
51293
  this.mirrorSceneSlice(deviceId, status);
50782
51294
  },