@camstack/addon-post-analysis 1.2.95 → 1.2.96

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.
@@ -27629,9 +27629,21 @@ var RecordingLocationUsageSchema = object({
27629
27629
  * previously rendered as two identical "disks" with a nonsensical used
27630
27630
  * split — hydrate attribution across aliases is arbitrary by nature. */
27631
27631
  locationIds: array(string()).optional(),
27632
- /** Bytes of recordings stored on this PHYSICAL volume (all aliases). */
27632
+ /**
27633
+ * Filesystem root of the physical volume this row accounts — the only
27634
+ * operator-legible identity when a location has no `displayName` yet, and
27635
+ * what a "which disk is full" alert has to name. OPTIONAL for the same
27636
+ * train-skew reason as `oldestMs`: a recorder that predates the field omits
27637
+ * it, and this schema must keep validating that older provider's payload.
27638
+ */
27639
+ root: string().optional(),
27640
+ /** Bytes of recordings stored on this PHYSICAL volume (all aliases). This is
27641
+ * the footage's SHARE of the volume, not how full the volume is — the disk
27642
+ * also holds everything that is not recordings. Volume fill is
27643
+ * `(totalBytes - availableBytes) / totalBytes`. */
27633
27644
  usedBytes: number(),
27634
- /** Free bytes on the location's volume; null when capacity is unknown (remote). */
27645
+ /** Free bytes on the location's volume; null when capacity is unknown
27646
+ * (remote/unstattable — the recorder logs the statfs failure with the root). */
27635
27647
  availableBytes: number().nullable(),
27636
27648
  /** Total bytes of the location's volume; null when unknown. */
27637
27649
  totalBytes: number().nullable()
@@ -27643,7 +27655,15 @@ var RecordingStorageUsageSchema = object({
27643
27655
  nodeId: string(),
27644
27656
  totalUsedBytes: number(),
27645
27657
  devices: array(RecordingDeviceUsageSchema),
27646
- locations: array(RecordingLocationUsageSchema)
27658
+ /**
27659
+ * Per-physical-volume capacity. OPTIONAL, and consumers must render the rest
27660
+ * of the payload without it: the hub serves this method through a BAKED
27661
+ * router, so a hub whose framework train predates a change to this array
27662
+ * strips it out of an answer a newer recorder happily produced. A UI that
27663
+ * dereferences it unconditionally crashes the whole Storage page over a
27664
+ * missing disk bar.
27665
+ */
27666
+ locations: array(RecordingLocationUsageSchema).optional()
27647
27667
  });
27648
27668
  /**
27649
27669
  * The OPERATOR-ARMED half of multi-location recordings (D116).
@@ -38505,6 +38525,11 @@ Object.freeze({
38505
38525
  "network-access": "ingress",
38506
38526
  "smtp-provider": "email"
38507
38527
  });
38528
+ /** Schema defaults — an untouched sub-field must author exactly these. */
38529
+ var NC_AUDIO_DEFAULTS = {
38530
+ hitPercent: 60,
38531
+ samplingSeconds: 10
38532
+ };
38508
38533
  /**
38509
38534
  * Which mode this condition is in — the ONE place that question is answered.
38510
38535
  *
@@ -38527,6 +38552,7 @@ function audioModeOf(value) {
38527
38552
  }
38528
38553
  new Map(AUDIO_MACRO_LABELS.flatMap((macro) => macro.icon === void 0 ? [] : [[macro.id, macro.icon]]));
38529
38554
  new Set(["devices", "classes"]);
38555
+ NC_AUDIO_DEFAULTS.hitPercent, NC_AUDIO_DEFAULTS.samplingSeconds;
38530
38556
  var WEEKDAY_TO_DAY = {
38531
38557
  Sun: 0,
38532
38558
  Mon: 1,
@@ -27598,9 +27598,21 @@ var RecordingLocationUsageSchema = object({
27598
27598
  * previously rendered as two identical "disks" with a nonsensical used
27599
27599
  * split — hydrate attribution across aliases is arbitrary by nature. */
27600
27600
  locationIds: array(string()).optional(),
27601
- /** Bytes of recordings stored on this PHYSICAL volume (all aliases). */
27601
+ /**
27602
+ * Filesystem root of the physical volume this row accounts — the only
27603
+ * operator-legible identity when a location has no `displayName` yet, and
27604
+ * what a "which disk is full" alert has to name. OPTIONAL for the same
27605
+ * train-skew reason as `oldestMs`: a recorder that predates the field omits
27606
+ * it, and this schema must keep validating that older provider's payload.
27607
+ */
27608
+ root: string().optional(),
27609
+ /** Bytes of recordings stored on this PHYSICAL volume (all aliases). This is
27610
+ * the footage's SHARE of the volume, not how full the volume is — the disk
27611
+ * also holds everything that is not recordings. Volume fill is
27612
+ * `(totalBytes - availableBytes) / totalBytes`. */
27602
27613
  usedBytes: number(),
27603
- /** Free bytes on the location's volume; null when capacity is unknown (remote). */
27614
+ /** Free bytes on the location's volume; null when capacity is unknown
27615
+ * (remote/unstattable — the recorder logs the statfs failure with the root). */
27604
27616
  availableBytes: number().nullable(),
27605
27617
  /** Total bytes of the location's volume; null when unknown. */
27606
27618
  totalBytes: number().nullable()
@@ -27612,7 +27624,15 @@ var RecordingStorageUsageSchema = object({
27612
27624
  nodeId: string(),
27613
27625
  totalUsedBytes: number(),
27614
27626
  devices: array(RecordingDeviceUsageSchema),
27615
- locations: array(RecordingLocationUsageSchema)
27627
+ /**
27628
+ * Per-physical-volume capacity. OPTIONAL, and consumers must render the rest
27629
+ * of the payload without it: the hub serves this method through a BAKED
27630
+ * router, so a hub whose framework train predates a change to this array
27631
+ * strips it out of an answer a newer recorder happily produced. A UI that
27632
+ * dereferences it unconditionally crashes the whole Storage page over a
27633
+ * missing disk bar.
27634
+ */
27635
+ locations: array(RecordingLocationUsageSchema).optional()
27616
27636
  });
27617
27637
  /**
27618
27638
  * The OPERATOR-ARMED half of multi-location recordings (D116).
@@ -38474,6 +38494,11 @@ Object.freeze({
38474
38494
  "network-access": "ingress",
38475
38495
  "smtp-provider": "email"
38476
38496
  });
38497
+ /** Schema defaults — an untouched sub-field must author exactly these. */
38498
+ var NC_AUDIO_DEFAULTS = {
38499
+ hitPercent: 60,
38500
+ samplingSeconds: 10
38501
+ };
38477
38502
  /**
38478
38503
  * Which mode this condition is in — the ONE place that question is answered.
38479
38504
  *
@@ -38496,6 +38521,7 @@ function audioModeOf(value) {
38496
38521
  }
38497
38522
  new Map(AUDIO_MACRO_LABELS.flatMap((macro) => macro.icon === void 0 ? [] : [[macro.id, macro.icon]]));
38498
38523
  new Set(["devices", "classes"]);
38524
+ NC_AUDIO_DEFAULTS.hitPercent, NC_AUDIO_DEFAULTS.samplingSeconds;
38499
38525
  var WEEKDAY_TO_DAY = {
38500
38526
  Sun: 0,
38501
38527
  Mon: 1,
@@ -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-BdojoP7i.js");
5
+ const require_dist = require("../dist-BQsMSX2A.js");
6
6
  let node_fs = require("node:fs");
7
7
  let node_fs$1 = require_dist.__toESM(node_fs, 1);
8
8
  node_fs = require_dist.__toESM(node_fs);
@@ -1,4 +1,4 @@
1
- import { O as PoolMemoryWatchdog, Q as embeddingEncoderCapability, dt as resolvePoolMemoryPolicy, ot as parseProcStatus, tt as hfModelUrl, yt as BaseAddon } from "../dist-C-_TL-I1.mjs";
1
+ import { O as PoolMemoryWatchdog, Q as embeddingEncoderCapability, dt as resolvePoolMemoryPolicy, ot as parseProcStatus, tt as hfModelUrl, yt as BaseAddon } from "../dist-ecY_-nDx.mjs";
2
2
  import { createRequire } from "node:module";
3
3
  import * as fs from "node:fs";
4
4
  import "node:fs";
@@ -3,7 +3,7 @@ import "./dist-CYZr2fwk.mjs";
3
3
  var e = {
4
4
  "@camstack/sdk": {
5
5
  name: "@camstack/sdk",
6
- version: "1.2.24",
6
+ version: "1.2.25",
7
7
  scope: ["default"],
8
8
  loaded: !1,
9
9
  from: "addon_pipeline_analytics_widgets",
@@ -18,7 +18,7 @@ var e = {
18
18
  },
19
19
  "@camstack/types": {
20
20
  name: "@camstack/types",
21
- version: "1.2.88",
21
+ version: "1.2.89",
22
22
  scope: ["default"],
23
23
  loaded: !1,
24
24
  from: "addon_pipeline_analytics_widgets",
@@ -33,7 +33,7 @@ var e = {
33
33
  },
34
34
  "@camstack/ui-library": {
35
35
  name: "@camstack/ui-library",
36
- version: "1.2.60",
36
+ version: "1.2.61",
37
37
  scope: ["default"],
38
38
  loaded: !1,
39
39
  from: "addon_pipeline_analytics_widgets",
@@ -36,7 +36,7 @@ async function r() {
36
36
  }
37
37
  },
38
38
  "@camstack/types": {
39
- version: "1.2.88",
39
+ version: "1.2.89",
40
40
  scope: "default",
41
41
  shareConfig: {
42
42
  singleton: !0,
@@ -45,7 +45,7 @@ async function r() {
45
45
  }
46
46
  },
47
47
  "@camstack/sdk": {
48
- version: "1.2.24",
48
+ version: "1.2.25",
49
49
  scope: "default",
50
50
  shareConfig: {
51
51
  singleton: !0,
@@ -81,7 +81,7 @@ async function r() {
81
81
  }
82
82
  },
83
83
  "@camstack/ui-library": {
84
- version: "1.2.60",
84
+ version: "1.2.61",
85
85
  scope: "default",
86
86
  shareConfig: {
87
87
  singleton: !0,
@@ -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-BdojoP7i.js");
5
+ const require_dist = require("../dist-BQsMSX2A.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");
@@ -5562,24 +5562,39 @@ async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
5562
5562
  }).jpeg({ quality: FULL_FRAME_QUALITY }).toBuffer();
5563
5563
  }
5564
5564
  //#endregion
5565
- //#region src/notification-center/audio-still.ts
5565
+ //#region src/notification-center/still-shelf.ts
5566
5566
  /**
5567
- * The audio still shelf — the PHOTOGRAPH a sound rule's notification carries.
5567
+ * The still shelf — the PHOTOGRAPH a notification carries when nothing it can
5568
+ * name owns a frame.
5568
5569
  *
5569
- * ## Why a sound needs one at all
5570
+ * ## Why a trigger needs one at all
5570
5571
  *
5571
- * The attachment ladder resolves media by OWNER, and every other trigger has
5572
- * one: an object or package event owns its crops, a closed track owns its best
5573
- * shot, a doorbell press owns the marker track the same press projected
5572
+ * The attachment ladder resolves media by OWNER, and most triggers have one: an
5573
+ * object or package event owns its crops, a closed track owns its best shot, a
5574
+ * doorbell press owns the marker track the same press projected
5574
5575
  * (`sensor-marker-projector.ts`), an occupancy edge names one of the objects it
5575
- * counted (`chooseOccupancyMediaOwner`). An audio match owns nothing. Nothing
5576
- * was boxed, nothing was tracked, and — deliberately — nothing is persisted at
5577
- * all: a confirmed window is a claim about sound that has already stopped, and
5578
- * `event-intake.ts` states why replaying it later would be wrong.
5576
+ * counted (`chooseOccupancyMediaOwner`). Two triggers own nothing, and for the
5577
+ * same reason in both cases — the subject is an ABSENCE:
5578
+ *
5579
+ * - an **audio** match: nothing was boxed, nothing was tracked, and —
5580
+ * deliberately — nothing is persisted at all. A confirmed window is a claim
5581
+ * about sound that has already stopped, and `event-intake.ts` states why
5582
+ * replaying it later would be wrong.
5583
+ * - an **occupancy** edge whose scope is EMPTY — "posto libero". The vehicle
5584
+ * whose departure IS the news has left, so `chooseOccupancyMediaOwner` names
5585
+ * nobody and the ladder logs `no still could be resolved … owners=[]`.
5579
5586
  *
5580
5587
  * So the only honest picture is a PHOTOGRAPH of the camera taken at the moment
5581
- * of the match. Not of the sound — of what the camera could see while it was
5582
- * heard.
5588
+ * of the trigger. Not of the sound, not of the object that left — of what the
5589
+ * camera can see now that it happened. For a freed parking space that is
5590
+ * exactly the answer the operator wants: the space, empty.
5591
+ *
5592
+ * **One shelf, not two.** The mechanism is identical down to the reuse window,
5593
+ * and the only thing that differs between the two triggers is the GATE deciding
5594
+ * that a photograph is owed at all — which belongs at the trigger site, where
5595
+ * the rules are, and not here. A twin module would be a second derivation of
5596
+ * one thing. The trigger rides along as {@link NcStillTrigger} only so the logs
5597
+ * can say which absence they are about.
5583
5598
  *
5584
5599
  * ## Three properties, and each one is a decision
5585
5600
  *
@@ -5587,27 +5602,30 @@ async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
5587
5602
  * TTL that covers the outbox's whole retry horizon — and nowhere else. The
5588
5603
  * alternative was the doorbell's: materialise a synthetic marker track through
5589
5604
  * `SyntheticTrackMaterializer` and let the notification name it. That would put
5590
- * a durable Track on the camera's timeline for every confirmed window, feeding
5591
- * the digest's `listTracks`, retention, and the audio-marker feature's own
5592
- * operator ceilings (`audio-marker-projector.ts` exists precisely to bound how
5593
- * many audio markers a camera may emit). A notification must not manufacture
5594
- * timeline history as a side effect of wanting a picture.
5595
- *
5596
- * **The capture STARTS immediately and is never awaited.** A sound is transient
5597
- * — a scream is over before a snapshot round-trip completes — so the fetch is
5598
- * kicked off at the confirmation, before the rule evaluation runs, and the
5599
- * owner id is minted synchronously so the outbox row can name it. The bytes
5600
- * land while the row waits in the queue, and the dispatcher's existing bounded
5601
- * still-wait (or the pause its own footage render already costs) picks them up.
5602
- * A camera that never answers costs the picture and never the notification.
5603
- *
5604
- * **Two confirmations seconds apart share ONE capture.** A barking dog confirms
5605
+ * a durable Track on the camera's timeline for every confirmed window and every
5606
+ * emptied zone, feeding the digest's `listTracks`, retention, and the audio-
5607
+ * marker feature's own operator ceilings (`audio-marker-projector.ts` exists
5608
+ * precisely to bound how many audio markers a camera may emit). A notification
5609
+ * must not manufacture timeline history as a side effect of wanting a picture.
5610
+ *
5611
+ * **The capture STARTS immediately and is never awaited.** The subject is
5612
+ * transient — a scream is over before a snapshot round-trip completes, and a
5613
+ * freed space is about to be taken by the next car — so the fetch is kicked off
5614
+ * at the trigger, before the rule evaluation runs, and the owner id is minted
5615
+ * synchronously so the outbox row can name it. The bytes land while the row
5616
+ * waits in the queue, and the dispatcher's existing bounded still-wait (or the
5617
+ * pause its own footage render already costs) picks them up. A camera that
5618
+ * never answers costs the picture and never the notification.
5619
+ *
5620
+ * **Two triggers seconds apart share ONE capture.** A barking dog confirms
5605
5621
  * repeatedly and a label-mode rule has no re-arm timer at all (D157) — the
5606
5622
  * rule's cooldown is its only brake, and the cooldown is applied AFTER this.
5607
5623
  * Without a reuse window this would photograph a camera at whatever rate the
5608
- * sound happens to occur. Each confirmation still gets its OWN owner id, so two
5624
+ * sound happens to occur. Each trigger still gets its OWN owner id, so two
5609
5625
  * outbox rows are never mistaken for one subject; they merely point at the same
5610
- * frame, which is the truth — the scene did not change in ten seconds.
5626
+ * frame, which is the truth — the scene did not change in ten seconds. The
5627
+ * window is per CAMERA and trigger-agnostic for the same reason: a sound and an
5628
+ * emptied zone ten seconds apart are two claims about one scene.
5611
5629
  *
5612
5630
  * Nothing here is silent: a capture that lands and a camera that refuses each
5613
5631
  * emit one line carrying `tags: { deviceId }`, because "why did 617 get a photo
@@ -5615,13 +5633,13 @@ async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
5615
5633
  */
5616
5634
  /**
5617
5635
  * The owner-id namespace. It is what routes a lookup here instead of to the
5618
- * media store, and it is the reason the dispatcher needs no audio branch —
5619
- * `getMediaForOwner` answers for both under one signature.
5636
+ * media store, and it is the reason the dispatcher needs no audio or occupancy
5637
+ * branch — `getMediaForOwner` answers for all of them under one signature.
5620
5638
  */
5621
- var NC_AUDIO_STILL_PREFIX = "nc-audio-still:";
5639
+ var NC_STILL_SHELF_PREFIX = "nc-still:";
5622
5640
  /** True for an owner id this shelf minted. */
5623
- function isAudioStillId(id) {
5624
- return id.startsWith(NC_AUDIO_STILL_PREFIX);
5641
+ function isStillShelfId(id) {
5642
+ return id.startsWith(NC_STILL_SHELF_PREFIX);
5625
5643
  }
5626
5644
  /**
5627
5645
  * How long the bytes are held.
@@ -5631,16 +5649,16 @@ function isAudioStillId(id) {
5631
5649
  * ships text-only, which is the correct degradation for a photograph of a scene
5632
5650
  * that is a quarter of an hour stale anyway.
5633
5651
  */
5634
- var NC_AUDIO_STILL_TTL_MS = 15 * 6e4;
5652
+ var NC_STILL_SHELF_TTL_MS = 15 * 6e4;
5635
5653
  /** A hanging snapshot cap must not pin a capture slot forever. */
5636
5654
  var SNAPSHOT_TIMEOUT_MS = 8e3;
5637
5655
  /**
5638
- * Hard bound on held captures. Reached only if every camera on the hub confirms
5639
- * an audio rule inside one TTL; the oldest is dropped first, which costs a
5656
+ * Hard bound on held captures. Reached only if every camera on the hub triggers
5657
+ * one inside a single TTL; the oldest is dropped first, which costs a
5640
5658
  * fifteen-minute-old picture nobody is waiting for.
5641
5659
  */
5642
5660
  var MAX_CAPTURES = 64;
5643
- var NcAudioStillShelf = class {
5661
+ var NcStillShelf = class {
5644
5662
  deps;
5645
5663
  /** captureId → the photograph. */
5646
5664
  captures = /* @__PURE__ */ new Map();
@@ -5652,14 +5670,14 @@ var NcAudioStillShelf = class {
5652
5670
  this.deps = deps;
5653
5671
  }
5654
5672
  /**
5655
- * Photograph `deviceId` for a match at `atMs`, and return the owner id the
5656
- * subject should name. SYNCHRONOUS by contract: the caller is on the audio
5657
- * confirmation path and the outbox row is built from what this returns.
5673
+ * Photograph `deviceId` for a trigger at `atMs`, and return the owner id the
5674
+ * subject should name. SYNCHRONOUS by contract: the caller is on the trigger
5675
+ * path and the outbox row is built from what this returns.
5658
5676
  */
5659
- capture(deviceId, atMs) {
5677
+ capture(deviceId, atMs, trigger) {
5660
5678
  const now = this.deps.now();
5661
5679
  this.prune(now);
5662
- const ownerId = `${NC_AUDIO_STILL_PREFIX}${(0, node_crypto.randomUUID)()}`;
5680
+ const ownerId = `${NC_STILL_SHELF_PREFIX}${(0, node_crypto.randomUUID)()}`;
5663
5681
  const recent = this.newest.get(deviceId);
5664
5682
  if (recent !== void 0 && now - recent.at < 1e4 && this.captures.has(recent.captureId)) {
5665
5683
  this.owners.set(ownerId, recent.captureId);
@@ -5668,8 +5686,9 @@ var NcAudioStillShelf = class {
5668
5686
  const captureId = (0, node_crypto.randomUUID)();
5669
5687
  this.captures.set(captureId, {
5670
5688
  deviceId,
5689
+ trigger,
5671
5690
  startedAt: now,
5672
- expiresAt: now + NC_AUDIO_STILL_TTL_MS,
5691
+ expiresAt: now + NC_STILL_SHELF_TTL_MS,
5673
5692
  files: []
5674
5693
  });
5675
5694
  this.newest.set(deviceId, {
@@ -5677,7 +5696,7 @@ var NcAudioStillShelf = class {
5677
5696
  at: now
5678
5697
  });
5679
5698
  this.owners.set(ownerId, captureId);
5680
- this.fetch(captureId, deviceId, atMs);
5699
+ this.fetch(captureId, deviceId, atMs, trigger);
5681
5700
  return ownerId;
5682
5701
  }
5683
5702
  /**
@@ -5713,11 +5732,11 @@ var NcAudioStillShelf = class {
5713
5732
  this.owners.clear();
5714
5733
  this.newest.clear();
5715
5734
  }
5716
- async fetch(captureId, deviceId, atMs) {
5735
+ async fetch(captureId, deviceId, atMs, trigger) {
5717
5736
  try {
5718
5737
  const shot = await withTimeout$3(this.deps.getSnapshot(deviceId), SNAPSHOT_TIMEOUT_MS);
5719
5738
  if (shot === null || shot.base64.length === 0) {
5720
- this.reportMiss(deviceId, "the camera returned no snapshot");
5739
+ this.reportMiss(deviceId, trigger, "the camera returned no snapshot");
5721
5740
  return;
5722
5741
  }
5723
5742
  const raw = Buffer.from(shot.base64, "base64");
@@ -5729,17 +5748,18 @@ var NcAudioStillShelf = class {
5729
5748
  if (held === void 0) return;
5730
5749
  this.captures.set(captureId, {
5731
5750
  ...held,
5732
- files: audioStillMedia(data, atMs)
5751
+ files: stillShelfMedia(data, atMs)
5733
5752
  });
5734
- this.deps.logger.info("audio still captured", {
5753
+ this.deps.logger.info(`${trigger} still captured`, {
5735
5754
  tags: { deviceId },
5736
5755
  meta: {
5756
+ trigger,
5737
5757
  bytes: data.byteLength,
5738
5758
  tookMs: this.deps.now() - held.startedAt
5739
5759
  }
5740
5760
  });
5741
5761
  } catch (err) {
5742
- this.reportMiss(deviceId, err instanceof Error ? err.message : String(err));
5762
+ this.reportMiss(deviceId, trigger, err instanceof Error ? err.message : String(err));
5743
5763
  }
5744
5764
  }
5745
5765
  /**
@@ -5747,10 +5767,13 @@ var NcAudioStillShelf = class {
5747
5767
  * on a notification they DID receive, so it is a warn and it carries the
5748
5768
  * camera — the only key the question is ever asked with.
5749
5769
  */
5750
- reportMiss(deviceId, reason) {
5751
- this.deps.logger.warn("the camera did not answer the audio still — this notification ships text-only", {
5770
+ reportMiss(deviceId, trigger, reason) {
5771
+ this.deps.logger.warn(`the camera did not answer the ${trigger} still — this notification ships text-only`, {
5752
5772
  tags: { deviceId },
5753
- meta: { reason }
5773
+ meta: {
5774
+ trigger,
5775
+ reason
5776
+ }
5754
5777
  });
5755
5778
  }
5756
5779
  };
@@ -5765,10 +5788,10 @@ var NcAudioStillShelf = class {
5765
5788
  * so. Inventing a centre crop would answer a different question from the one
5766
5789
  * the operator asked.
5767
5790
  */
5768
- function audioStillMedia(data, timestamp) {
5791
+ function stillShelfMedia(data, timestamp) {
5769
5792
  const base64 = data.toString("base64");
5770
5793
  const file = (kind) => ({
5771
- key: `${NC_AUDIO_STILL_PREFIX}${kind}`,
5794
+ key: `${NC_STILL_SHELF_PREFIX}${kind}`,
5772
5795
  kind,
5773
5796
  base64,
5774
5797
  sizeBytes: data.byteLength,
@@ -7434,7 +7457,7 @@ function subjectFromSensorEvent(ev, markerTrackId) {
7434
7457
  * (see the {@link evaluateRule} audio gate + the matchers below).
7435
7458
  *
7436
7459
  * `stillOwnerId` is the audio still shelf's owner id — the photograph of the
7437
- * camera taken at the match (`audio-still.ts`). It rides `trackId` because that
7460
+ * camera taken at the match (`still-shelf.ts`). It rides `trackId` because that
7438
7461
  * is the field the attachment ladder reads, exactly as a doorbell press rides
7439
7462
  * its marker track's id; nothing else about the subject is track-scoped, and
7440
7463
  * `buildEntries` keys an audio row on its record id, never on this. Absent when
@@ -7526,10 +7549,18 @@ function audioSubjectFor(hit, spec) {
7526
7549
  * chosen at commit (`chooseOccupancyMediaOwner`) — rides on `trackId`, and that
7527
7550
  * is the whole of what gives an occupancy notification a picture: the ordinary
7528
7551
  * media-owner ladder resolves it, exactly as it resolves a doorbell's marker
7529
- * track. Absent when the scope held no member (a became-free edge) ⇒ text-only,
7530
- * by design.
7531
- */
7532
- function subjectFromOccupancyEvent(edge) {
7552
+ * track.
7553
+ *
7554
+ * `stillOwnerId` is the SECOND source of that picture and the only one an
7555
+ * emptied scope can have: with no member left to name — "posto libero", where
7556
+ * the object whose departure is the news has gone — the trigger site photographs
7557
+ * the camera and holds the bytes on the still shelf (`still-shelf.ts`), exactly
7558
+ * as an audio match does. The member track always WINS: it is a picture of the
7559
+ * counted object, and a photograph is only ever the stand-in for not having one.
7560
+ * Neither present (no snapshot cap on this node) ⇒ text-only, as before.
7561
+ */
7562
+ function subjectFromOccupancyEvent(edge, stillOwnerId) {
7563
+ const mediaOwner = edge.mediaTrackId ?? stillOwnerId;
7533
7564
  return {
7534
7565
  kind: "occupancy-event",
7535
7566
  recordId: `occ:${edge.deviceId}:${edge.zoneId ?? "@frame"}:${edge.className ?? "@all"}:t${edge.threshold}:${edge.occupied ? "occ" : "free"}:${edge.timestamp}`,
@@ -7537,17 +7568,28 @@ function subjectFromOccupancyEvent(edge) {
7537
7568
  timestamp: edge.timestamp,
7538
7569
  classNames: [edge.className ?? "occupancy"],
7539
7570
  zones: [],
7540
- ...edge.mediaTrackId !== void 0 ? { trackId: edge.mediaTrackId } : {},
7571
+ ...mediaOwner !== void 0 ? { trackId: mediaOwner } : {},
7541
7572
  source: "pipeline",
7542
- occupancy: {
7543
- ...edge.zoneId !== void 0 ? { zoneId: edge.zoneId } : {},
7544
- ...edge.zoneName !== void 0 ? { zoneName: edge.zoneName } : {},
7545
- ...edge.className !== void 0 ? { className: edge.className } : {},
7546
- count: edge.count,
7547
- previousCount: edge.previousCount,
7548
- occupied: edge.occupied,
7549
- threshold: edge.threshold
7550
- }
7573
+ occupancy: occupancySubjectFromEdge(edge)
7574
+ };
7575
+ }
7576
+ /**
7577
+ * The scope + level half of an occupancy subject, ALONE.
7578
+ *
7579
+ * Split out for one reason: the still-shelf gate must ask `matchesOccupancy`
7580
+ * whether a rule would fire on an edge BEFORE the subject that would carry the
7581
+ * answer exists (the owner id it mints is an input to that subject). Sharing
7582
+ * this keeps the gate reading the same numbers the verdict will.
7583
+ */
7584
+ function occupancySubjectFromEdge(edge) {
7585
+ return {
7586
+ ...edge.zoneId !== void 0 ? { zoneId: edge.zoneId } : {},
7587
+ ...edge.zoneName !== void 0 ? { zoneName: edge.zoneName } : {},
7588
+ ...edge.className !== void 0 ? { className: edge.className } : {},
7589
+ count: edge.count,
7590
+ previousCount: edge.previousCount,
7591
+ occupied: edge.occupied,
7592
+ threshold: edge.threshold
7551
7593
  };
7552
7594
  }
7553
7595
  /** Build the subject for a `package-event` evaluation (a persisted `package`
@@ -7674,6 +7716,13 @@ function matchesAudio(cond, s) {
7674
7716
  }
7675
7717
  return false;
7676
7718
  }
7719
+ /**
7720
+ * Exported for ONE caller: the still-shelf gate in the centre, which must know
7721
+ * — before the outbox row exists — whether any enabled rule would fire on THIS
7722
+ * edge and wants a picture for it. Sharing the predicate is what keeps that gate
7723
+ * from becoming a second, drifting opinion about which edge belongs to which
7724
+ * rule. It is pure and reads nothing.
7725
+ */
7677
7726
  function matchesOccupancy(occ, s) {
7678
7727
  if ((occ.zoneId ?? void 0) !== (s.zoneId ?? void 0)) return false;
7679
7728
  if ((occ.className ?? void 0) !== (s.className ?? void 0)) return false;
@@ -9881,7 +9930,7 @@ function incomingFromSensorEvent(event, markerTrackId) {
9881
9930
  * Audio-event persist (classified episode).
9882
9931
  *
9883
9932
  * `stillOwnerId` is the audio still shelf's owner id for the photograph taken
9884
- * at this episode (`audio-still.ts`). Absent when no rule that could fire asked
9933
+ * at this episode (`still-shelf.ts`). Absent when no rule that could fire asked
9885
9934
  * for a picture — the same shape `incomingFromSensorEvent` carries its marker.
9886
9935
  */
9887
9936
  function incomingFromAudioEvent(event, stillOwnerId) {
@@ -9962,10 +10011,16 @@ function audioHitMeta(hit) {
9962
10011
  samples: window.samples
9963
10012
  };
9964
10013
  }
9965
- /** A committed ZoneAnalytics occupancy edge. */
9966
- function incomingFromOccupancyEdge(edge) {
10014
+ /**
10015
+ * A committed ZoneAnalytics occupancy edge.
10016
+ *
10017
+ * `stillOwnerId` — see {@link subjectFromOccupancyEvent}. An edge whose scope is
10018
+ * empty owns no member track, so the only picture it can carry is a photograph
10019
+ * of its camera taken at the edge, held on the still shelf under this id.
10020
+ */
10021
+ function incomingFromOccupancyEdge(edge, stillOwnerId) {
9967
10022
  return {
9968
- subject: subjectFromOccupancyEvent(edge),
10023
+ subject: subjectFromOccupancyEvent(edge, stillOwnerId),
9969
10024
  kind: "occupancy-event",
9970
10025
  origin: "pipeline",
9971
10026
  log: () => ({
@@ -16094,7 +16149,7 @@ function isSyntheticId(id) {
16094
16149
  * - and, since 2026-08-15, a `trackId` for both AUDIO kinds: a sound owns no
16095
16150
  * frame, so production photographs the camera at the match and holds the
16096
16151
  * bytes on the still shelf under an owner id the subject names
16097
- * (`audio-still.ts`). The tester's RAM-held frame stands in for exactly that
16152
+ * (`still-shelf.ts`). The tester's RAM-held frame stands in for exactly that
16098
16153
  * photograph, and — unlike every other kind here — it is taken the same way
16099
16154
  * production takes it, at the same moment, from the same cap.
16100
16155
  *
@@ -18761,11 +18816,12 @@ var NotificationCenter = class NotificationCenter {
18761
18816
  */
18762
18817
  syntheticMedia = /* @__PURE__ */ new Map();
18763
18818
  /**
18764
- * The photograph a SOUND rule's notification carries — see `audio-still.ts`.
18765
- * Null when this wiring has no snapshot cap, which leaves every audio
18766
- * notification text-only rather than broken.
18819
+ * The photograph a notification carries when the subject it is about is an
18820
+ * ABSENCE — a sound that owns no frame, an emptied occupancy scope that owns
18821
+ * no member. See `still-shelf.ts`. Null when this wiring has no snapshot cap,
18822
+ * which leaves those notifications text-only rather than broken.
18767
18823
  */
18768
- audioStills;
18824
+ stills;
18769
18825
  /** Per-device rate limit for the "matched NO rule" report — see `reportNoMatch`. */
18770
18826
  lastNoMatchReportAt = /* @__PURE__ */ new Map();
18771
18827
  noMatchSuppressed = /* @__PURE__ */ new Map();
@@ -19062,9 +19118,9 @@ var NotificationCenter = class NotificationCenter {
19062
19118
  this.logger = deps.logger;
19063
19119
  this.now = deps.now ?? (() => Date.now());
19064
19120
  const getSnapshot = deps.getSnapshot;
19065
- this.audioStills = getSnapshot !== void 0 ? new NcAudioStillShelf({
19121
+ this.stills = getSnapshot !== void 0 ? new NcStillShelf({
19066
19122
  getSnapshot,
19067
- logger: this.logger.child("audio-still"),
19123
+ logger: this.logger.child("still-shelf"),
19068
19124
  now: this.now
19069
19125
  }) : null;
19070
19126
  this.rules = new NcRuleStore({
@@ -19154,8 +19210,8 @@ var NotificationCenter = class NotificationCenter {
19154
19210
  getMediaForOwner: async (ownerKind, ownerId) => {
19155
19211
  const held = isSyntheticId(ownerId) ? this.syntheticMedia.get(ownerId) : void 0;
19156
19212
  if (held !== void 0) return held.files;
19157
- const audio = isAudioStillId(ownerId) ? this.audioStills?.get(ownerId) : void 0;
19158
- if (audio !== void 0) return audio;
19213
+ const shelved = isStillShelfId(ownerId) ? this.stills?.get(ownerId) : void 0;
19214
+ if (shelved !== void 0) return shelved;
19159
19215
  return deps.dispatcher.getMediaForOwner(ownerKind, ownerId);
19160
19216
  },
19161
19217
  ...deps.now !== void 0 ? { now: deps.now } : {},
@@ -19361,7 +19417,7 @@ var NotificationCenter = class NotificationCenter {
19361
19417
  }
19362
19418
  this.timelapseScheduler?.stop();
19363
19419
  this.summaryProducer?.stop();
19364
- this.audioStills?.clear();
19420
+ this.stills?.clear();
19365
19421
  this.addonUpdates.dispose();
19366
19422
  this.livenessHoldoff.dispose();
19367
19423
  this.evaluationActive = false;
@@ -19713,14 +19769,14 @@ var NotificationCenter = class NotificationCenter {
19713
19769
  * direction that DESTROYS the media.
19714
19770
  */
19715
19771
  audioStillOwner(deviceId, atMs, mode) {
19716
- const shelf = this.audioStills;
19772
+ const shelf = this.stills;
19717
19773
  if (shelf === null || !this.evaluationActive) return void 0;
19718
19774
  for (const rule of this.rules.listEnabled("immediate")) {
19719
19775
  if (rule.media.attach === "none") continue;
19720
19776
  if (!(mode === "condition" ? rule.conditions.audio !== void 0 : rule.conditions.audio === void 0 && referencesAudioClass(rule.conditions.classes))) continue;
19721
19777
  const devices = rule.conditions.devices;
19722
19778
  if (devices !== void 0 && devices.length > 0 && !devices.includes(deviceId)) continue;
19723
- return shelf.capture(deviceId, atMs);
19779
+ return shelf.capture(deviceId, atMs, "audio");
19724
19780
  }
19725
19781
  }
19726
19782
  /**
@@ -19779,7 +19835,48 @@ var NotificationCenter = class NotificationCenter {
19779
19835
  * Public so a test / a future out-of-band edge source can drive it directly.
19780
19836
  */
19781
19837
  onOccupancyEdge(edge) {
19782
- this.consumeEvent(incomingFromOccupancyEdge(edge));
19838
+ this.consumeEvent(incomingFromOccupancyEdge(edge, this.occupancyStillOwner(edge)));
19839
+ }
19840
+ /**
19841
+ * Start photographing this edge's camera NOW and return the owner id its
19842
+ * subject should name — or `undefined` when nothing would use it.
19843
+ *
19844
+ * **Only an edge that owns NOBODY.** `chooseOccupancyMediaOwner` names a
19845
+ * member track whenever the scope holds one, and that picture is of the
19846
+ * counted object itself; a photograph is strictly the stand-in for not having
19847
+ * one. The case this exists for is "posto libero" (camera 617): the vehicle
19848
+ * whose departure IS the notification has left, so the scope is empty, the
19849
+ * ladder resolves nothing and the delivery logged `no still could be resolved
19850
+ * … owners=[]`. A became-free edge has no owner BY CONSTRUCTION, and a
19851
+ * became-occupied edge can lack one too (every member parked, i.e.
19852
+ * `stationary:`) — so the gate is the ABSENCE itself, not the polarity. That
19853
+ * is the honest predicate: photograph exactly the edges whose media cannot be
19854
+ * resolved, and no others.
19855
+ *
19856
+ * **The capture is not awaited**, for the audio shelf's reason and one of its
19857
+ * own: a freed space is about to be taken by the next car, and a still fetched
19858
+ * after the outbox drained would show it occupied again.
19859
+ *
19860
+ * **The gate is the RULE's media policy AND the engine's own verdict.** The
19861
+ * watcher commits both polarities of every watched key, so a hub with only a
19862
+ * became-occupied rule still produces free edges — photographing those would
19863
+ * be a snapshot no notification can ever carry. `matchesOccupancy` is the same
19864
+ * predicate `evaluateRule` will apply, shared rather than restated. The rule
19865
+ * cache is an in-memory mirror, so this is not a fallible read (D49).
19866
+ */
19867
+ occupancyStillOwner(edge) {
19868
+ const shelf = this.stills;
19869
+ if (shelf === null || !this.evaluationActive) return void 0;
19870
+ if (edge.mediaTrackId !== void 0) return void 0;
19871
+ const subject = occupancySubjectFromEdge(edge);
19872
+ for (const rule of this.rules.listEnabled("device-event")) {
19873
+ if (rule.media.attach === "none") continue;
19874
+ const occ = rule.conditions.occupancy;
19875
+ if (occ === void 0 || !matchesOccupancy(occ, subject)) continue;
19876
+ const devices = rule.conditions.devices;
19877
+ if (devices !== void 0 && devices.length > 0 && !devices.includes(edge.deviceId)) continue;
19878
+ return shelf.capture(edge.deviceId, edge.timestamp, "occupancy");
19879
+ }
19783
19880
  }
19784
19881
  /**
19785
19882
  * Feed ONE audio inference frame into the sampling-window matcher.
@@ -20746,7 +20843,7 @@ var NotificationCenter = class NotificationCenter {
20746
20843
  kind,
20747
20844
  ruleId: rule.id,
20748
20845
  rule: rule.name,
20749
- snapshotWired: this.audioStills !== null
20846
+ snapshotWired: this.stills !== null
20750
20847
  }
20751
20848
  });
20752
20849
  }
@@ -20911,6 +21008,7 @@ var NotificationCenter = class NotificationCenter {
20911
21008
  if (subject.kind === "audio-window" || subject.kind === "audio-event") return { outcome: "disabled" };
20912
21009
  const trackId = subject.trackId;
20913
21010
  if (trackId === void 0) return { outcome: "disabled" };
21011
+ if (isStillShelfId(trackId)) return { outcome: "disabled" };
20914
21012
  const member = {
20915
21013
  trackId,
20916
21014
  className: subject.classNames[0] ?? "event",
@@ -1,4 +1,4 @@
1
- import { $ as encodeVectorBase64, A as RetrainStatusSchema, At as literal, B as TrackSourceSchema, C as NcSnoozeSchema, Ct as isDeviceScopedCap, D as OpsLogEntrySchema, Dt as array, E as NcTaxonomySchema, Et as _enum, F as SceneMonitorSchema, Ft as string, G as audioMetricsCapability, H as addonWidgetsSourceCapability, I as TIMELAPSE_DENSE_FLOOR_SEC, It as unknown, J as cosineSimilarity$1, K as audioModeOf, L as TimelapseRuleInputSchema, Lt as EventCategory, M as SCENE_DEFAULT_ANCHOR_THRESHOLD, Mt as object, N as SCENE_DEFAULT_UNCOVERED_POLICY, Nt as partialRecord, Ot as boolean, P as SCENE_DIVERGED, Pt as record, R as TimelapseRulePatchSchema, S as NcSnoozeInputSchema, St as hydrateSchema, T as NcSystemEventKindSchema, Tt as sleep, U as alarmPanelCapability, V as VISIT_MERGE_GAP_MS, W as assertTimelapseCadences, X as defineCustomActions, Y as customAction, Z as deriveRecordingMode, _ as NcRuleInputSchema, _t as zoneAnalyticsCapability, at as notificationRulesCapability, b as NcRuleTargetSchema, bt as DeviceType, c as EVENT_PAD_MS, ct as plateGalleryCapability, d as NC_ALARM_SYSTEM_EVENT_KINDS, et as faceGalleryCapability, f as NC_CONDITION_CATALOG, ft as sceneMonitorCapability, g as NcConditionDescriptorSchema, gt as videoclipsCapability, h as NC_TAXONOMY, ht as vectorDimFromBase64, i as DETECTION_MACRO_CLASSES, it as kebabToCamel, jt as number, k as RECORDING_EXPORT_MAX_READ_BYTES, kt as discriminatedUnion, l as LabelAttributionSchema, lt as readDeviceStateFrom, mt as systemEventFilterApplies, n as DEFAULT_EVENT_COLOR, nt as isDetectionMacroClass, o as DeclaredDevices, p as NC_DEFAULT_SNOOZE_MINUTES, pt as subKindsOf, q as buildEventKindDescriptor, rt as isScheduleActive, s as EVENT_KIND_BY_CAP, st as pipelineAnalyticsCapability, t as BaseDevice, u as MACRO_LABELS, ut as readTimelapseGeneratedAt, v as NcRulePatchSchema, vt as errMsg, w as NcSnoozeSuppressedSchema, wt as nodePin, x as NcScheduleSchema, xt as createEvent, y as NcRuleSchema, yt as BaseAddon, z as TimelapseRuleSchema } from "../dist-C-_TL-I1.mjs";
1
+ import { $ as encodeVectorBase64, A as RetrainStatusSchema, At as literal, B as TrackSourceSchema, C as NcSnoozeSchema, Ct as isDeviceScopedCap, D as OpsLogEntrySchema, Dt as array, E as NcTaxonomySchema, Et as _enum, F as SceneMonitorSchema, Ft as string, G as audioMetricsCapability, H as addonWidgetsSourceCapability, I as TIMELAPSE_DENSE_FLOOR_SEC, It as unknown, J as cosineSimilarity$1, K as audioModeOf, L as TimelapseRuleInputSchema, Lt as EventCategory, M as SCENE_DEFAULT_ANCHOR_THRESHOLD, Mt as object, N as SCENE_DEFAULT_UNCOVERED_POLICY, Nt as partialRecord, Ot as boolean, P as SCENE_DIVERGED, Pt as record, R as TimelapseRulePatchSchema, S as NcSnoozeInputSchema, St as hydrateSchema, T as NcSystemEventKindSchema, Tt as sleep, U as alarmPanelCapability, V as VISIT_MERGE_GAP_MS, W as assertTimelapseCadences, X as defineCustomActions, Y as customAction, Z as deriveRecordingMode, _ as NcRuleInputSchema, _t as zoneAnalyticsCapability, at as notificationRulesCapability, b as NcRuleTargetSchema, bt as DeviceType, c as EVENT_PAD_MS, ct as plateGalleryCapability, d as NC_ALARM_SYSTEM_EVENT_KINDS, et as faceGalleryCapability, f as NC_CONDITION_CATALOG, ft as sceneMonitorCapability, g as NcConditionDescriptorSchema, gt as videoclipsCapability, h as NC_TAXONOMY, ht as vectorDimFromBase64, i as DETECTION_MACRO_CLASSES, it as kebabToCamel, jt as number, k as RECORDING_EXPORT_MAX_READ_BYTES, kt as discriminatedUnion, l as LabelAttributionSchema, lt as readDeviceStateFrom, mt as systemEventFilterApplies, n as DEFAULT_EVENT_COLOR, nt as isDetectionMacroClass, o as DeclaredDevices, p as NC_DEFAULT_SNOOZE_MINUTES, pt as subKindsOf, q as buildEventKindDescriptor, rt as isScheduleActive, s as EVENT_KIND_BY_CAP, st as pipelineAnalyticsCapability, t as BaseDevice, u as MACRO_LABELS, ut as readTimelapseGeneratedAt, v as NcRulePatchSchema, vt as errMsg, w as NcSnoozeSuppressedSchema, wt as nodePin, x as NcScheduleSchema, xt as createEvent, y as NcRuleSchema, yt as BaseAddon, z as TimelapseRuleSchema } from "../dist-ecY_-nDx.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";
@@ -5555,24 +5555,39 @@ async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
5555
5555
  }).jpeg({ quality: FULL_FRAME_QUALITY }).toBuffer();
5556
5556
  }
5557
5557
  //#endregion
5558
- //#region src/notification-center/audio-still.ts
5558
+ //#region src/notification-center/still-shelf.ts
5559
5559
  /**
5560
- * The audio still shelf — the PHOTOGRAPH a sound rule's notification carries.
5560
+ * The still shelf — the PHOTOGRAPH a notification carries when nothing it can
5561
+ * name owns a frame.
5561
5562
  *
5562
- * ## Why a sound needs one at all
5563
+ * ## Why a trigger needs one at all
5563
5564
  *
5564
- * The attachment ladder resolves media by OWNER, and every other trigger has
5565
- * one: an object or package event owns its crops, a closed track owns its best
5566
- * shot, a doorbell press owns the marker track the same press projected
5565
+ * The attachment ladder resolves media by OWNER, and most triggers have one: an
5566
+ * object or package event owns its crops, a closed track owns its best shot, a
5567
+ * doorbell press owns the marker track the same press projected
5567
5568
  * (`sensor-marker-projector.ts`), an occupancy edge names one of the objects it
5568
- * counted (`chooseOccupancyMediaOwner`). An audio match owns nothing. Nothing
5569
- * was boxed, nothing was tracked, and — deliberately — nothing is persisted at
5570
- * all: a confirmed window is a claim about sound that has already stopped, and
5571
- * `event-intake.ts` states why replaying it later would be wrong.
5569
+ * counted (`chooseOccupancyMediaOwner`). Two triggers own nothing, and for the
5570
+ * same reason in both cases — the subject is an ABSENCE:
5571
+ *
5572
+ * - an **audio** match: nothing was boxed, nothing was tracked, and —
5573
+ * deliberately — nothing is persisted at all. A confirmed window is a claim
5574
+ * about sound that has already stopped, and `event-intake.ts` states why
5575
+ * replaying it later would be wrong.
5576
+ * - an **occupancy** edge whose scope is EMPTY — "posto libero". The vehicle
5577
+ * whose departure IS the news has left, so `chooseOccupancyMediaOwner` names
5578
+ * nobody and the ladder logs `no still could be resolved … owners=[]`.
5572
5579
  *
5573
5580
  * So the only honest picture is a PHOTOGRAPH of the camera taken at the moment
5574
- * of the match. Not of the sound — of what the camera could see while it was
5575
- * heard.
5581
+ * of the trigger. Not of the sound, not of the object that left — of what the
5582
+ * camera can see now that it happened. For a freed parking space that is
5583
+ * exactly the answer the operator wants: the space, empty.
5584
+ *
5585
+ * **One shelf, not two.** The mechanism is identical down to the reuse window,
5586
+ * and the only thing that differs between the two triggers is the GATE deciding
5587
+ * that a photograph is owed at all — which belongs at the trigger site, where
5588
+ * the rules are, and not here. A twin module would be a second derivation of
5589
+ * one thing. The trigger rides along as {@link NcStillTrigger} only so the logs
5590
+ * can say which absence they are about.
5576
5591
  *
5577
5592
  * ## Three properties, and each one is a decision
5578
5593
  *
@@ -5580,27 +5595,30 @@ async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
5580
5595
  * TTL that covers the outbox's whole retry horizon — and nowhere else. The
5581
5596
  * alternative was the doorbell's: materialise a synthetic marker track through
5582
5597
  * `SyntheticTrackMaterializer` and let the notification name it. That would put
5583
- * a durable Track on the camera's timeline for every confirmed window, feeding
5584
- * the digest's `listTracks`, retention, and the audio-marker feature's own
5585
- * operator ceilings (`audio-marker-projector.ts` exists precisely to bound how
5586
- * many audio markers a camera may emit). A notification must not manufacture
5587
- * timeline history as a side effect of wanting a picture.
5588
- *
5589
- * **The capture STARTS immediately and is never awaited.** A sound is transient
5590
- * — a scream is over before a snapshot round-trip completes — so the fetch is
5591
- * kicked off at the confirmation, before the rule evaluation runs, and the
5592
- * owner id is minted synchronously so the outbox row can name it. The bytes
5593
- * land while the row waits in the queue, and the dispatcher's existing bounded
5594
- * still-wait (or the pause its own footage render already costs) picks them up.
5595
- * A camera that never answers costs the picture and never the notification.
5596
- *
5597
- * **Two confirmations seconds apart share ONE capture.** A barking dog confirms
5598
+ * a durable Track on the camera's timeline for every confirmed window and every
5599
+ * emptied zone, feeding the digest's `listTracks`, retention, and the audio-
5600
+ * marker feature's own operator ceilings (`audio-marker-projector.ts` exists
5601
+ * precisely to bound how many audio markers a camera may emit). A notification
5602
+ * must not manufacture timeline history as a side effect of wanting a picture.
5603
+ *
5604
+ * **The capture STARTS immediately and is never awaited.** The subject is
5605
+ * transient — a scream is over before a snapshot round-trip completes, and a
5606
+ * freed space is about to be taken by the next car — so the fetch is kicked off
5607
+ * at the trigger, before the rule evaluation runs, and the owner id is minted
5608
+ * synchronously so the outbox row can name it. The bytes land while the row
5609
+ * waits in the queue, and the dispatcher's existing bounded still-wait (or the
5610
+ * pause its own footage render already costs) picks them up. A camera that
5611
+ * never answers costs the picture and never the notification.
5612
+ *
5613
+ * **Two triggers seconds apart share ONE capture.** A barking dog confirms
5598
5614
  * repeatedly and a label-mode rule has no re-arm timer at all (D157) — the
5599
5615
  * rule's cooldown is its only brake, and the cooldown is applied AFTER this.
5600
5616
  * Without a reuse window this would photograph a camera at whatever rate the
5601
- * sound happens to occur. Each confirmation still gets its OWN owner id, so two
5617
+ * sound happens to occur. Each trigger still gets its OWN owner id, so two
5602
5618
  * outbox rows are never mistaken for one subject; they merely point at the same
5603
- * frame, which is the truth — the scene did not change in ten seconds.
5619
+ * frame, which is the truth — the scene did not change in ten seconds. The
5620
+ * window is per CAMERA and trigger-agnostic for the same reason: a sound and an
5621
+ * emptied zone ten seconds apart are two claims about one scene.
5604
5622
  *
5605
5623
  * Nothing here is silent: a capture that lands and a camera that refuses each
5606
5624
  * emit one line carrying `tags: { deviceId }`, because "why did 617 get a photo
@@ -5608,13 +5626,13 @@ async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
5608
5626
  */
5609
5627
  /**
5610
5628
  * The owner-id namespace. It is what routes a lookup here instead of to the
5611
- * media store, and it is the reason the dispatcher needs no audio branch —
5612
- * `getMediaForOwner` answers for both under one signature.
5629
+ * media store, and it is the reason the dispatcher needs no audio or occupancy
5630
+ * branch — `getMediaForOwner` answers for all of them under one signature.
5613
5631
  */
5614
- var NC_AUDIO_STILL_PREFIX = "nc-audio-still:";
5632
+ var NC_STILL_SHELF_PREFIX = "nc-still:";
5615
5633
  /** True for an owner id this shelf minted. */
5616
- function isAudioStillId(id) {
5617
- return id.startsWith(NC_AUDIO_STILL_PREFIX);
5634
+ function isStillShelfId(id) {
5635
+ return id.startsWith(NC_STILL_SHELF_PREFIX);
5618
5636
  }
5619
5637
  /**
5620
5638
  * How long the bytes are held.
@@ -5624,16 +5642,16 @@ function isAudioStillId(id) {
5624
5642
  * ships text-only, which is the correct degradation for a photograph of a scene
5625
5643
  * that is a quarter of an hour stale anyway.
5626
5644
  */
5627
- var NC_AUDIO_STILL_TTL_MS = 15 * 6e4;
5645
+ var NC_STILL_SHELF_TTL_MS = 15 * 6e4;
5628
5646
  /** A hanging snapshot cap must not pin a capture slot forever. */
5629
5647
  var SNAPSHOT_TIMEOUT_MS = 8e3;
5630
5648
  /**
5631
- * Hard bound on held captures. Reached only if every camera on the hub confirms
5632
- * an audio rule inside one TTL; the oldest is dropped first, which costs a
5649
+ * Hard bound on held captures. Reached only if every camera on the hub triggers
5650
+ * one inside a single TTL; the oldest is dropped first, which costs a
5633
5651
  * fifteen-minute-old picture nobody is waiting for.
5634
5652
  */
5635
5653
  var MAX_CAPTURES = 64;
5636
- var NcAudioStillShelf = class {
5654
+ var NcStillShelf = class {
5637
5655
  deps;
5638
5656
  /** captureId → the photograph. */
5639
5657
  captures = /* @__PURE__ */ new Map();
@@ -5645,14 +5663,14 @@ var NcAudioStillShelf = class {
5645
5663
  this.deps = deps;
5646
5664
  }
5647
5665
  /**
5648
- * Photograph `deviceId` for a match at `atMs`, and return the owner id the
5649
- * subject should name. SYNCHRONOUS by contract: the caller is on the audio
5650
- * confirmation path and the outbox row is built from what this returns.
5666
+ * Photograph `deviceId` for a trigger at `atMs`, and return the owner id the
5667
+ * subject should name. SYNCHRONOUS by contract: the caller is on the trigger
5668
+ * path and the outbox row is built from what this returns.
5651
5669
  */
5652
- capture(deviceId, atMs) {
5670
+ capture(deviceId, atMs, trigger) {
5653
5671
  const now = this.deps.now();
5654
5672
  this.prune(now);
5655
- const ownerId = `${NC_AUDIO_STILL_PREFIX}${randomUUID()}`;
5673
+ const ownerId = `${NC_STILL_SHELF_PREFIX}${randomUUID()}`;
5656
5674
  const recent = this.newest.get(deviceId);
5657
5675
  if (recent !== void 0 && now - recent.at < 1e4 && this.captures.has(recent.captureId)) {
5658
5676
  this.owners.set(ownerId, recent.captureId);
@@ -5661,8 +5679,9 @@ var NcAudioStillShelf = class {
5661
5679
  const captureId = randomUUID();
5662
5680
  this.captures.set(captureId, {
5663
5681
  deviceId,
5682
+ trigger,
5664
5683
  startedAt: now,
5665
- expiresAt: now + NC_AUDIO_STILL_TTL_MS,
5684
+ expiresAt: now + NC_STILL_SHELF_TTL_MS,
5666
5685
  files: []
5667
5686
  });
5668
5687
  this.newest.set(deviceId, {
@@ -5670,7 +5689,7 @@ var NcAudioStillShelf = class {
5670
5689
  at: now
5671
5690
  });
5672
5691
  this.owners.set(ownerId, captureId);
5673
- this.fetch(captureId, deviceId, atMs);
5692
+ this.fetch(captureId, deviceId, atMs, trigger);
5674
5693
  return ownerId;
5675
5694
  }
5676
5695
  /**
@@ -5706,11 +5725,11 @@ var NcAudioStillShelf = class {
5706
5725
  this.owners.clear();
5707
5726
  this.newest.clear();
5708
5727
  }
5709
- async fetch(captureId, deviceId, atMs) {
5728
+ async fetch(captureId, deviceId, atMs, trigger) {
5710
5729
  try {
5711
5730
  const shot = await withTimeout$3(this.deps.getSnapshot(deviceId), SNAPSHOT_TIMEOUT_MS);
5712
5731
  if (shot === null || shot.base64.length === 0) {
5713
- this.reportMiss(deviceId, "the camera returned no snapshot");
5732
+ this.reportMiss(deviceId, trigger, "the camera returned no snapshot");
5714
5733
  return;
5715
5734
  }
5716
5735
  const raw = Buffer.from(shot.base64, "base64");
@@ -5722,17 +5741,18 @@ var NcAudioStillShelf = class {
5722
5741
  if (held === void 0) return;
5723
5742
  this.captures.set(captureId, {
5724
5743
  ...held,
5725
- files: audioStillMedia(data, atMs)
5744
+ files: stillShelfMedia(data, atMs)
5726
5745
  });
5727
- this.deps.logger.info("audio still captured", {
5746
+ this.deps.logger.info(`${trigger} still captured`, {
5728
5747
  tags: { deviceId },
5729
5748
  meta: {
5749
+ trigger,
5730
5750
  bytes: data.byteLength,
5731
5751
  tookMs: this.deps.now() - held.startedAt
5732
5752
  }
5733
5753
  });
5734
5754
  } catch (err) {
5735
- this.reportMiss(deviceId, err instanceof Error ? err.message : String(err));
5755
+ this.reportMiss(deviceId, trigger, err instanceof Error ? err.message : String(err));
5736
5756
  }
5737
5757
  }
5738
5758
  /**
@@ -5740,10 +5760,13 @@ var NcAudioStillShelf = class {
5740
5760
  * on a notification they DID receive, so it is a warn and it carries the
5741
5761
  * camera — the only key the question is ever asked with.
5742
5762
  */
5743
- reportMiss(deviceId, reason) {
5744
- this.deps.logger.warn("the camera did not answer the audio still — this notification ships text-only", {
5763
+ reportMiss(deviceId, trigger, reason) {
5764
+ this.deps.logger.warn(`the camera did not answer the ${trigger} still — this notification ships text-only`, {
5745
5765
  tags: { deviceId },
5746
- meta: { reason }
5766
+ meta: {
5767
+ trigger,
5768
+ reason
5769
+ }
5747
5770
  });
5748
5771
  }
5749
5772
  };
@@ -5758,10 +5781,10 @@ var NcAudioStillShelf = class {
5758
5781
  * so. Inventing a centre crop would answer a different question from the one
5759
5782
  * the operator asked.
5760
5783
  */
5761
- function audioStillMedia(data, timestamp) {
5784
+ function stillShelfMedia(data, timestamp) {
5762
5785
  const base64 = data.toString("base64");
5763
5786
  const file = (kind) => ({
5764
- key: `${NC_AUDIO_STILL_PREFIX}${kind}`,
5787
+ key: `${NC_STILL_SHELF_PREFIX}${kind}`,
5765
5788
  kind,
5766
5789
  base64,
5767
5790
  sizeBytes: data.byteLength,
@@ -7427,7 +7450,7 @@ function subjectFromSensorEvent(ev, markerTrackId) {
7427
7450
  * (see the {@link evaluateRule} audio gate + the matchers below).
7428
7451
  *
7429
7452
  * `stillOwnerId` is the audio still shelf's owner id — the photograph of the
7430
- * camera taken at the match (`audio-still.ts`). It rides `trackId` because that
7453
+ * camera taken at the match (`still-shelf.ts`). It rides `trackId` because that
7431
7454
  * is the field the attachment ladder reads, exactly as a doorbell press rides
7432
7455
  * its marker track's id; nothing else about the subject is track-scoped, and
7433
7456
  * `buildEntries` keys an audio row on its record id, never on this. Absent when
@@ -7519,10 +7542,18 @@ function audioSubjectFor(hit, spec) {
7519
7542
  * chosen at commit (`chooseOccupancyMediaOwner`) — rides on `trackId`, and that
7520
7543
  * is the whole of what gives an occupancy notification a picture: the ordinary
7521
7544
  * media-owner ladder resolves it, exactly as it resolves a doorbell's marker
7522
- * track. Absent when the scope held no member (a became-free edge) ⇒ text-only,
7523
- * by design.
7524
- */
7525
- function subjectFromOccupancyEvent(edge) {
7545
+ * track.
7546
+ *
7547
+ * `stillOwnerId` is the SECOND source of that picture and the only one an
7548
+ * emptied scope can have: with no member left to name — "posto libero", where
7549
+ * the object whose departure is the news has gone — the trigger site photographs
7550
+ * the camera and holds the bytes on the still shelf (`still-shelf.ts`), exactly
7551
+ * as an audio match does. The member track always WINS: it is a picture of the
7552
+ * counted object, and a photograph is only ever the stand-in for not having one.
7553
+ * Neither present (no snapshot cap on this node) ⇒ text-only, as before.
7554
+ */
7555
+ function subjectFromOccupancyEvent(edge, stillOwnerId) {
7556
+ const mediaOwner = edge.mediaTrackId ?? stillOwnerId;
7526
7557
  return {
7527
7558
  kind: "occupancy-event",
7528
7559
  recordId: `occ:${edge.deviceId}:${edge.zoneId ?? "@frame"}:${edge.className ?? "@all"}:t${edge.threshold}:${edge.occupied ? "occ" : "free"}:${edge.timestamp}`,
@@ -7530,17 +7561,28 @@ function subjectFromOccupancyEvent(edge) {
7530
7561
  timestamp: edge.timestamp,
7531
7562
  classNames: [edge.className ?? "occupancy"],
7532
7563
  zones: [],
7533
- ...edge.mediaTrackId !== void 0 ? { trackId: edge.mediaTrackId } : {},
7564
+ ...mediaOwner !== void 0 ? { trackId: mediaOwner } : {},
7534
7565
  source: "pipeline",
7535
- occupancy: {
7536
- ...edge.zoneId !== void 0 ? { zoneId: edge.zoneId } : {},
7537
- ...edge.zoneName !== void 0 ? { zoneName: edge.zoneName } : {},
7538
- ...edge.className !== void 0 ? { className: edge.className } : {},
7539
- count: edge.count,
7540
- previousCount: edge.previousCount,
7541
- occupied: edge.occupied,
7542
- threshold: edge.threshold
7543
- }
7566
+ occupancy: occupancySubjectFromEdge(edge)
7567
+ };
7568
+ }
7569
+ /**
7570
+ * The scope + level half of an occupancy subject, ALONE.
7571
+ *
7572
+ * Split out for one reason: the still-shelf gate must ask `matchesOccupancy`
7573
+ * whether a rule would fire on an edge BEFORE the subject that would carry the
7574
+ * answer exists (the owner id it mints is an input to that subject). Sharing
7575
+ * this keeps the gate reading the same numbers the verdict will.
7576
+ */
7577
+ function occupancySubjectFromEdge(edge) {
7578
+ return {
7579
+ ...edge.zoneId !== void 0 ? { zoneId: edge.zoneId } : {},
7580
+ ...edge.zoneName !== void 0 ? { zoneName: edge.zoneName } : {},
7581
+ ...edge.className !== void 0 ? { className: edge.className } : {},
7582
+ count: edge.count,
7583
+ previousCount: edge.previousCount,
7584
+ occupied: edge.occupied,
7585
+ threshold: edge.threshold
7544
7586
  };
7545
7587
  }
7546
7588
  /** Build the subject for a `package-event` evaluation (a persisted `package`
@@ -7667,6 +7709,13 @@ function matchesAudio(cond, s) {
7667
7709
  }
7668
7710
  return false;
7669
7711
  }
7712
+ /**
7713
+ * Exported for ONE caller: the still-shelf gate in the centre, which must know
7714
+ * — before the outbox row exists — whether any enabled rule would fire on THIS
7715
+ * edge and wants a picture for it. Sharing the predicate is what keeps that gate
7716
+ * from becoming a second, drifting opinion about which edge belongs to which
7717
+ * rule. It is pure and reads nothing.
7718
+ */
7670
7719
  function matchesOccupancy(occ, s) {
7671
7720
  if ((occ.zoneId ?? void 0) !== (s.zoneId ?? void 0)) return false;
7672
7721
  if ((occ.className ?? void 0) !== (s.className ?? void 0)) return false;
@@ -9874,7 +9923,7 @@ function incomingFromSensorEvent(event, markerTrackId) {
9874
9923
  * Audio-event persist (classified episode).
9875
9924
  *
9876
9925
  * `stillOwnerId` is the audio still shelf's owner id for the photograph taken
9877
- * at this episode (`audio-still.ts`). Absent when no rule that could fire asked
9926
+ * at this episode (`still-shelf.ts`). Absent when no rule that could fire asked
9878
9927
  * for a picture — the same shape `incomingFromSensorEvent` carries its marker.
9879
9928
  */
9880
9929
  function incomingFromAudioEvent(event, stillOwnerId) {
@@ -9955,10 +10004,16 @@ function audioHitMeta(hit) {
9955
10004
  samples: window.samples
9956
10005
  };
9957
10006
  }
9958
- /** A committed ZoneAnalytics occupancy edge. */
9959
- function incomingFromOccupancyEdge(edge) {
10007
+ /**
10008
+ * A committed ZoneAnalytics occupancy edge.
10009
+ *
10010
+ * `stillOwnerId` — see {@link subjectFromOccupancyEvent}. An edge whose scope is
10011
+ * empty owns no member track, so the only picture it can carry is a photograph
10012
+ * of its camera taken at the edge, held on the still shelf under this id.
10013
+ */
10014
+ function incomingFromOccupancyEdge(edge, stillOwnerId) {
9960
10015
  return {
9961
- subject: subjectFromOccupancyEvent(edge),
10016
+ subject: subjectFromOccupancyEvent(edge, stillOwnerId),
9962
10017
  kind: "occupancy-event",
9963
10018
  origin: "pipeline",
9964
10019
  log: () => ({
@@ -16072,7 +16127,7 @@ function isSyntheticId(id) {
16072
16127
  * - and, since 2026-08-15, a `trackId` for both AUDIO kinds: a sound owns no
16073
16128
  * frame, so production photographs the camera at the match and holds the
16074
16129
  * bytes on the still shelf under an owner id the subject names
16075
- * (`audio-still.ts`). The tester's RAM-held frame stands in for exactly that
16130
+ * (`still-shelf.ts`). The tester's RAM-held frame stands in for exactly that
16076
16131
  * photograph, and — unlike every other kind here — it is taken the same way
16077
16132
  * production takes it, at the same moment, from the same cap.
16078
16133
  *
@@ -18739,11 +18794,12 @@ var NotificationCenter = class NotificationCenter {
18739
18794
  */
18740
18795
  syntheticMedia = /* @__PURE__ */ new Map();
18741
18796
  /**
18742
- * The photograph a SOUND rule's notification carries — see `audio-still.ts`.
18743
- * Null when this wiring has no snapshot cap, which leaves every audio
18744
- * notification text-only rather than broken.
18797
+ * The photograph a notification carries when the subject it is about is an
18798
+ * ABSENCE — a sound that owns no frame, an emptied occupancy scope that owns
18799
+ * no member. See `still-shelf.ts`. Null when this wiring has no snapshot cap,
18800
+ * which leaves those notifications text-only rather than broken.
18745
18801
  */
18746
- audioStills;
18802
+ stills;
18747
18803
  /** Per-device rate limit for the "matched NO rule" report — see `reportNoMatch`. */
18748
18804
  lastNoMatchReportAt = /* @__PURE__ */ new Map();
18749
18805
  noMatchSuppressed = /* @__PURE__ */ new Map();
@@ -19040,9 +19096,9 @@ var NotificationCenter = class NotificationCenter {
19040
19096
  this.logger = deps.logger;
19041
19097
  this.now = deps.now ?? (() => Date.now());
19042
19098
  const getSnapshot = deps.getSnapshot;
19043
- this.audioStills = getSnapshot !== void 0 ? new NcAudioStillShelf({
19099
+ this.stills = getSnapshot !== void 0 ? new NcStillShelf({
19044
19100
  getSnapshot,
19045
- logger: this.logger.child("audio-still"),
19101
+ logger: this.logger.child("still-shelf"),
19046
19102
  now: this.now
19047
19103
  }) : null;
19048
19104
  this.rules = new NcRuleStore({
@@ -19132,8 +19188,8 @@ var NotificationCenter = class NotificationCenter {
19132
19188
  getMediaForOwner: async (ownerKind, ownerId) => {
19133
19189
  const held = isSyntheticId(ownerId) ? this.syntheticMedia.get(ownerId) : void 0;
19134
19190
  if (held !== void 0) return held.files;
19135
- const audio = isAudioStillId(ownerId) ? this.audioStills?.get(ownerId) : void 0;
19136
- if (audio !== void 0) return audio;
19191
+ const shelved = isStillShelfId(ownerId) ? this.stills?.get(ownerId) : void 0;
19192
+ if (shelved !== void 0) return shelved;
19137
19193
  return deps.dispatcher.getMediaForOwner(ownerKind, ownerId);
19138
19194
  },
19139
19195
  ...deps.now !== void 0 ? { now: deps.now } : {},
@@ -19339,7 +19395,7 @@ var NotificationCenter = class NotificationCenter {
19339
19395
  }
19340
19396
  this.timelapseScheduler?.stop();
19341
19397
  this.summaryProducer?.stop();
19342
- this.audioStills?.clear();
19398
+ this.stills?.clear();
19343
19399
  this.addonUpdates.dispose();
19344
19400
  this.livenessHoldoff.dispose();
19345
19401
  this.evaluationActive = false;
@@ -19691,14 +19747,14 @@ var NotificationCenter = class NotificationCenter {
19691
19747
  * direction that DESTROYS the media.
19692
19748
  */
19693
19749
  audioStillOwner(deviceId, atMs, mode) {
19694
- const shelf = this.audioStills;
19750
+ const shelf = this.stills;
19695
19751
  if (shelf === null || !this.evaluationActive) return void 0;
19696
19752
  for (const rule of this.rules.listEnabled("immediate")) {
19697
19753
  if (rule.media.attach === "none") continue;
19698
19754
  if (!(mode === "condition" ? rule.conditions.audio !== void 0 : rule.conditions.audio === void 0 && referencesAudioClass(rule.conditions.classes))) continue;
19699
19755
  const devices = rule.conditions.devices;
19700
19756
  if (devices !== void 0 && devices.length > 0 && !devices.includes(deviceId)) continue;
19701
- return shelf.capture(deviceId, atMs);
19757
+ return shelf.capture(deviceId, atMs, "audio");
19702
19758
  }
19703
19759
  }
19704
19760
  /**
@@ -19757,7 +19813,48 @@ var NotificationCenter = class NotificationCenter {
19757
19813
  * Public so a test / a future out-of-band edge source can drive it directly.
19758
19814
  */
19759
19815
  onOccupancyEdge(edge) {
19760
- this.consumeEvent(incomingFromOccupancyEdge(edge));
19816
+ this.consumeEvent(incomingFromOccupancyEdge(edge, this.occupancyStillOwner(edge)));
19817
+ }
19818
+ /**
19819
+ * Start photographing this edge's camera NOW and return the owner id its
19820
+ * subject should name — or `undefined` when nothing would use it.
19821
+ *
19822
+ * **Only an edge that owns NOBODY.** `chooseOccupancyMediaOwner` names a
19823
+ * member track whenever the scope holds one, and that picture is of the
19824
+ * counted object itself; a photograph is strictly the stand-in for not having
19825
+ * one. The case this exists for is "posto libero" (camera 617): the vehicle
19826
+ * whose departure IS the notification has left, so the scope is empty, the
19827
+ * ladder resolves nothing and the delivery logged `no still could be resolved
19828
+ * … owners=[]`. A became-free edge has no owner BY CONSTRUCTION, and a
19829
+ * became-occupied edge can lack one too (every member parked, i.e.
19830
+ * `stationary:`) — so the gate is the ABSENCE itself, not the polarity. That
19831
+ * is the honest predicate: photograph exactly the edges whose media cannot be
19832
+ * resolved, and no others.
19833
+ *
19834
+ * **The capture is not awaited**, for the audio shelf's reason and one of its
19835
+ * own: a freed space is about to be taken by the next car, and a still fetched
19836
+ * after the outbox drained would show it occupied again.
19837
+ *
19838
+ * **The gate is the RULE's media policy AND the engine's own verdict.** The
19839
+ * watcher commits both polarities of every watched key, so a hub with only a
19840
+ * became-occupied rule still produces free edges — photographing those would
19841
+ * be a snapshot no notification can ever carry. `matchesOccupancy` is the same
19842
+ * predicate `evaluateRule` will apply, shared rather than restated. The rule
19843
+ * cache is an in-memory mirror, so this is not a fallible read (D49).
19844
+ */
19845
+ occupancyStillOwner(edge) {
19846
+ const shelf = this.stills;
19847
+ if (shelf === null || !this.evaluationActive) return void 0;
19848
+ if (edge.mediaTrackId !== void 0) return void 0;
19849
+ const subject = occupancySubjectFromEdge(edge);
19850
+ for (const rule of this.rules.listEnabled("device-event")) {
19851
+ if (rule.media.attach === "none") continue;
19852
+ const occ = rule.conditions.occupancy;
19853
+ if (occ === void 0 || !matchesOccupancy(occ, subject)) continue;
19854
+ const devices = rule.conditions.devices;
19855
+ if (devices !== void 0 && devices.length > 0 && !devices.includes(edge.deviceId)) continue;
19856
+ return shelf.capture(edge.deviceId, edge.timestamp, "occupancy");
19857
+ }
19761
19858
  }
19762
19859
  /**
19763
19860
  * Feed ONE audio inference frame into the sampling-window matcher.
@@ -20724,7 +20821,7 @@ var NotificationCenter = class NotificationCenter {
20724
20821
  kind,
20725
20822
  ruleId: rule.id,
20726
20823
  rule: rule.name,
20727
- snapshotWired: this.audioStills !== null
20824
+ snapshotWired: this.stills !== null
20728
20825
  }
20729
20826
  });
20730
20827
  }
@@ -20889,6 +20986,7 @@ var NotificationCenter = class NotificationCenter {
20889
20986
  if (subject.kind === "audio-window" || subject.kind === "audio-event") return { outcome: "disabled" };
20890
20987
  const trackId = subject.trackId;
20891
20988
  if (trackId === void 0) return { outcome: "disabled" };
20989
+ if (isStillShelfId(trackId)) return { outcome: "disabled" };
20892
20990
  const member = {
20893
20991
  trackId,
20894
20992
  className: subject.classNames[0] ?? "event",
@@ -30,7 +30,7 @@ async function d(e) {
30
30
  }
31
31
  }
32
32
  async function f() {
33
- return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-CeFPqm3h.mjs")).catch((e) => {
33
+ return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-BdX09HCw.mjs")).catch((e) => {
34
34
  throw l = void 0, e;
35
35
  }), l;
36
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-post-analysis",
3
- "version": "1.2.95",
3
+ "version": "1.2.96",
4
4
  "description": "Post-Analysis bundle — enrichment, embedding-encoder, pipeline-analytics. Multi-entry npm package shipping addons that consume pipeline output.",
5
5
  "keywords": [
6
6
  "camstack",