@camstack/addon-post-analysis 1.2.95 → 1.2.97

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { $ as 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: () => ({
@@ -14144,7 +14199,11 @@ function mosaicTextPlan(captions) {
14144
14199
  };
14145
14200
  }
14146
14201
  /** Grid shape for `n` tiles: as square as possible, rows never exceeding cols
14147
- * by more than one — a 3×3 for nine, a 3×2 for five, never a 1×9 strip. */
14202
+ * by more than one — a 3×3 for nine, a 3×2 for five, never a 1×9 strip.
14203
+ *
14204
+ * `rows` is the load-bearing half — it is how many rows {@link mosaicRowCounts}
14205
+ * distributes into. `cols` describes the WIDEST row, and is no longer a cell
14206
+ * count: see {@link mosaicRowCounts} for why a `cols × rows` grid was the bug. */
14148
14207
  function mosaicGrid(n) {
14149
14208
  const count = Math.max(1, Math.floor(n));
14150
14209
  const cols = Math.ceil(Math.sqrt(count));
@@ -14154,31 +14213,84 @@ function mosaicGrid(n) {
14154
14213
  };
14155
14214
  }
14156
14215
  /**
14216
+ * How many tiles each ROW carries, top to bottom — and the reason the mosaic
14217
+ * can no longer have a hole in it.
14218
+ *
14219
+ * The layout used to be a `cols × rows` GRID, and a grid holds more cells than
14220
+ * tiles for every count that is not a perfect fit: 3 into a 2×2, 5 into a 3×2,
14221
+ * 7 and 8 into a 3×3. The composite only ever wrote the tiles, so the spare
14222
+ * cells stayed the canvas — `MOSAIC_BACKGROUND`, rgb 17 — and the operator's
14223
+ * three-tile digest was a quarter black. Measured on the shipped renderer:
14224
+ * 24.8% of a three-tile mosaic was background. His words: *"se ci sono 3
14225
+ * elementi non dovrebbe renderizzare buchi neri"*.
14226
+ *
14227
+ * Counting ROWS instead removes the failure by construction rather than by
14228
+ * patching it. Every row spans the full width and divides it among the tiles it
14229
+ * actually has, so a row is full whatever the remainder and there is no such
14230
+ * thing as a spare cell. The shapes fall out of it, and they are the ones asked
14231
+ * for: 1 ⇒ full frame, 2 ⇒ side by side, 3 ⇒ one big over two small, 4 ⇒ 2×2.
14232
+ *
14233
+ * **The fewer-tiled row comes FIRST.** Tiles arrive in the selection's order,
14234
+ * so row 0 is the head of the ranking; putting the remainder at the top would
14235
+ * make the WORST tile the big one.
14236
+ *
14237
+ * Every cell keeps {@link TILE_ASPECT}, so rows differ in height — which is
14238
+ * also what stops the letterbox: a full-width hero tile is 16:9 exactly, not a
14239
+ * 16:9 picture centred in a double-height cell with bars over and under it.
14240
+ */
14241
+ function mosaicRowCounts(n) {
14242
+ const count = Math.max(1, Math.floor(n));
14243
+ const { rows } = mosaicGrid(count);
14244
+ const base = Math.floor(count / rows);
14245
+ const remainder = count % rows;
14246
+ const counts = [];
14247
+ for (let row = 0; row < rows; row += 1) counts.push(row < rows - remainder ? base : base + 1);
14248
+ return counts;
14249
+ }
14250
+ /**
14157
14251
  * Where every tile lands. Pure — no bytes, no sharp, no clock.
14158
14252
  *
14159
14253
  * Exported because the rectangles are DATA the artefact should carry: a viewer
14160
14254
  * that wants a tappable mosaic reads these rather than re-deriving a layout
14161
14255
  * that could drift from the one actually rendered.
14256
+ *
14257
+ * The rectangles COVER the picture area exactly — that is the property the
14258
+ * spec asserts, and it is the one the old grid broke.
14162
14259
  */
14163
14260
  function mosaicLayout(input) {
14164
- const { cols, rows } = mosaicGrid(input.tileCount);
14165
- const maxWidth = input.maxWidth ?? 1920;
14166
- const tileWidth = Math.max(160, Math.floor(maxWidth / cols));
14261
+ const rowCounts = mosaicRowCounts(input.tileCount);
14262
+ const width = Math.max(1, Math.floor(input.maxWidth ?? 1920));
14263
+ const firstRowCount = rowCounts[0] ?? 1;
14264
+ const tileWidth = Math.floor(width / firstRowCount);
14167
14265
  const tileHeight = Math.round(tileWidth * TILE_ASPECT);
14168
14266
  const titleHeight = input.withTitle === true ? Math.round(tileHeight * TITLE_BAND_FRACTION) : 0;
14169
14267
  const tiles = [];
14170
- for (let index = 0; index < Math.max(1, input.tileCount); index += 1) tiles.push({
14171
- index,
14172
- x: index % cols * tileWidth,
14173
- y: titleHeight + Math.floor(index / cols) * tileHeight,
14174
- w: tileWidth,
14175
- h: tileHeight
14176
- });
14268
+ let y = titleHeight;
14269
+ let index = 0;
14270
+ for (const count of rowCounts) {
14271
+ const nominalWidth = Math.floor(width / count);
14272
+ const rowHeight = Math.round(nominalWidth * TILE_ASPECT);
14273
+ let x = 0;
14274
+ for (let inRow = 0; inRow < count; inRow += 1) {
14275
+ const w = inRow === count - 1 ? width - x : nominalWidth;
14276
+ tiles.push({
14277
+ index,
14278
+ x,
14279
+ y,
14280
+ w,
14281
+ h: rowHeight
14282
+ });
14283
+ x += w;
14284
+ index += 1;
14285
+ }
14286
+ y += rowHeight;
14287
+ }
14177
14288
  return {
14178
- width: cols * tileWidth,
14179
- height: titleHeight + rows * tileHeight,
14180
- cols,
14181
- rows,
14289
+ width,
14290
+ height: y,
14291
+ cols: Math.max(...rowCounts),
14292
+ rows: rowCounts.length,
14293
+ rowCounts,
14182
14294
  tileWidth,
14183
14295
  tileHeight,
14184
14296
  titleHeight,
@@ -14242,36 +14354,20 @@ function titleSvg(width, height, caption) {
14242
14354
  *
14243
14355
  * A tile sharp cannot read is DROPPED and counted, never composited as a blank
14244
14356
  * cell — a black square in a digest reads as "the camera saw nothing", which is
14245
- * the opposite of what happened. The grid is recomputed from the survivors so
14246
- * the mosaic has no hole.
14357
+ * the opposite of what happened. The layout is computed from the SURVIVORS, and
14358
+ * because {@link mosaicRowCounts} counts rows rather than grid cells there is no
14359
+ * remainder to leave empty either.
14247
14360
  *
14248
- * @throws only if sharp cannot produce the final canvas at all; the caller
14249
- * treats that as "no mosaic" and still delivers the counts.
14361
+ * @throws when every tile was dropped, and when sharp cannot produce the final
14362
+ * canvas at all; the caller treats both as "no mosaic" and still
14363
+ * delivers the counts.
14250
14364
  */
14251
14365
  async function renderMosaic(request, logger) {
14252
14366
  const plan = mosaicTextPlan(request.captions);
14253
- const decoded = [];
14254
- const probeLayout = mosaicLayout({
14255
- tileCount: Math.max(1, request.tiles.length),
14256
- ...request.maxWidth !== void 0 ? { maxWidth: request.maxWidth } : {}
14257
- });
14367
+ const usable = [];
14258
14368
  for (const tile of request.tiles) try {
14259
- const label = plan.tileLabels ? tile.label?.trim() ?? "" : "";
14260
- const fitted = sharp(tile.jpeg).resize({
14261
- width: probeLayout.tileWidth,
14262
- height: probeLayout.tileHeight,
14263
- fit: "contain",
14264
- background: MOSAIC_BACKGROUND
14265
- });
14266
- const resized = await (label.length === 0 ? fitted : fitted.composite([{
14267
- input: tileLabelSvg(probeLayout.tileWidth, probeLayout.tileHeight, label),
14268
- top: 0,
14269
- left: 0
14270
- }])).jpeg({ quality: 82 }).toBuffer();
14271
- decoded.push({
14272
- tile,
14273
- resized
14274
- });
14369
+ await sharp(tile.jpeg).metadata();
14370
+ usable.push(tile);
14275
14371
  } catch (err) {
14276
14372
  logger.warn("summary mosaic: a tile could not be decoded and was dropped", {
14277
14373
  tags: { deviceId: tile.deviceId },
@@ -14281,23 +14377,34 @@ async function renderMosaic(request, logger) {
14281
14377
  }
14282
14378
  });
14283
14379
  }
14284
- const droppedTiles = request.tiles.length - decoded.length;
14380
+ const droppedTiles = request.tiles.length - usable.length;
14381
+ if (usable.length === 0) throw new Error(`summary mosaic: no tile could be decoded (${request.tiles.length} offered)`);
14285
14382
  const caption = plan.titleBand ? request.caption?.trim() ?? "" : "";
14286
14383
  const layout = mosaicLayout({
14287
- tileCount: Math.max(1, decoded.length),
14384
+ tileCount: usable.length,
14288
14385
  ...request.maxWidth !== void 0 ? { maxWidth: request.maxWidth } : {},
14289
14386
  withTitle: caption.length > 0
14290
14387
  });
14291
- const composites = await Promise.all(decoded.map(async ({ resized }, index) => ({
14292
- input: layout.tileWidth === probeLayout.tileWidth && layout.tileHeight === probeLayout.tileHeight ? resized : await sharp(resized).resize({
14293
- width: layout.tileWidth,
14294
- height: layout.tileHeight,
14388
+ const composites = await Promise.all(usable.map(async (tile, index) => {
14389
+ const rect = layout.tiles[index];
14390
+ if (rect === void 0) throw new Error("summary mosaic: layout lost a tile");
14391
+ const label = plan.tileLabels ? tile.label?.trim() ?? "" : "";
14392
+ const fitted = sharp(tile.jpeg).resize({
14393
+ width: rect.w,
14394
+ height: rect.h,
14295
14395
  fit: "contain",
14296
14396
  background: MOSAIC_BACKGROUND
14297
- }).jpeg({ quality: 82 }).toBuffer(),
14298
- top: layout.tiles[index]?.y ?? 0,
14299
- left: layout.tiles[index]?.x ?? 0
14300
- })));
14397
+ });
14398
+ return {
14399
+ input: await (label.length === 0 ? fitted : fitted.composite([{
14400
+ input: tileLabelSvg(rect.w, rect.h, label),
14401
+ top: 0,
14402
+ left: 0
14403
+ }])).jpeg({ quality: 82 }).toBuffer(),
14404
+ top: rect.y,
14405
+ left: rect.x
14406
+ };
14407
+ }));
14301
14408
  if (caption.length > 0 && layout.titleHeight > 0) composites.unshift({
14302
14409
  input: titleSvg(layout.width, layout.titleHeight, caption),
14303
14410
  top: 0,
@@ -14513,6 +14620,36 @@ function buildSummaryOutboxInputs(input) {
14513
14620
  * the quota degrades honestly: the best cameras get their one tile, the rest
14514
14621
  * are counted as dropped and the count rides the log line — never silently.
14515
14622
  *
14623
+ * ## Only tracks the pipeline PROMOTED
14624
+ *
14625
+ * A digest is retrospective. The operator's own framing, verbatim: *"ideale
14626
+ * sarebbe considerare solo tracce che vengono alla fine promosse a tracce, dato
14627
+ * che il summary non è atteso di essere realtime"* — the window is closed
14628
+ * before this function is called, so the selection may wait for the pipeline's
14629
+ * verdict instead of guessing at it.
14630
+ *
14631
+ * The verdict is `SortTracker`'s own promotion threshold: a candidate is not
14632
+ * emitted as a track until it has been matched on `minHits` frames
14633
+ * (`isConfirmedForEmit`, per-device setting, *"minimum consecutive detection
14634
+ * hits before a track is promoted to output"*). The gate here re-applies that
14635
+ * same threshold to what the CLOSED row actually left behind — the count of
14636
+ * recorded sightings on its trajectory — and the port that builds a candidate
14637
+ * is the only thing that knows the device's `minHits`.
14638
+ *
14639
+ * This is not a movement heuristic and deliberately so: `totalDistance` and
14640
+ * `state` were both measured against live traffic and both hide real passages.
14641
+ * What they cannot see is the shape this gate is for — a row whose whole life
14642
+ * is one or two frames. Measured on device 617 (2026-08-19 04:12): two rows,
14643
+ * `closedReason` NULL so the parked pass is blind to them, dwell 53 ms,
14644
+ * `totalDistance` 0, two recorded sightings each. And `minImportance` cannot
14645
+ * cover it either, structurally: the static-track gate that demotes a frozen
14646
+ * subject needs two centroids to measure anything
14647
+ * (`computeStaticTrackMetrics`), so a single-sighting row is exactly the shape
14648
+ * it never scores down — 33 of the live hub's 34 such rows sit at or above 0.2.
14649
+ *
14650
+ * There is no rule field to turn it off. A digest of things that were never
14651
+ * tracks has no audience, and this repo has shipped knobs nobody read.
14652
+ *
14516
14653
  * ## Zones are scoped PER CAMERA
14517
14654
  *
14518
14655
  * `filters.zones` is a flat id list on a rule that spans cameras, and zone ids
@@ -14536,15 +14673,20 @@ function dwellSeconds(candidate) {
14536
14673
  * branch that discards work silently reads as "it never happened".
14537
14674
  */
14538
14675
  function filterSummaryCandidates(candidates, filters, zoneOwner) {
14539
- const moving = filters?.includeStationary === true ? candidates : candidates.filter((candidate) => candidate.stationary !== true);
14540
- const droppedStationary = candidates.length - moving.length;
14676
+ const promoted = candidates.filter((candidate) => candidate.confirmed !== false);
14677
+ const droppedUnconfirmed = candidates.length - promoted.length;
14678
+ const unknownConfirmation = promoted.filter((candidate) => candidate.confirmed === void 0).length;
14679
+ const moving = filters?.includeStationary === true ? promoted : promoted.filter((candidate) => candidate.stationary !== true);
14680
+ const droppedStationary = promoted.length - moving.length;
14541
14681
  if (filters === void 0) return {
14542
14682
  kept: [...moving],
14543
14683
  droppedByClass: 0,
14544
14684
  droppedByZone: 0,
14545
14685
  droppedByImportance: 0,
14546
14686
  droppedByDwell: 0,
14547
- droppedStationary
14687
+ droppedStationary,
14688
+ droppedUnconfirmed,
14689
+ unknownConfirmation
14548
14690
  };
14549
14691
  const include = filters.classes;
14550
14692
  const exclude = new Set(filters.classesExclude ?? []);
@@ -14590,7 +14732,9 @@ function filterSummaryCandidates(candidates, filters, zoneOwner) {
14590
14732
  droppedByZone,
14591
14733
  droppedByImportance,
14592
14734
  droppedByDwell,
14593
- droppedStationary
14735
+ droppedStationary,
14736
+ droppedUnconfirmed,
14737
+ unknownConfirmation
14594
14738
  };
14595
14739
  }
14596
14740
  /**
@@ -14966,7 +15110,9 @@ var NcSummaryProducer = class {
14966
15110
  droppedByZone: filtered.droppedByZone,
14967
15111
  droppedByImportance: filtered.droppedByImportance,
14968
15112
  droppedByDwell: filtered.droppedByDwell,
14969
- droppedStationary: filtered.droppedStationary
15113
+ droppedStationary: filtered.droppedStationary,
15114
+ droppedUnconfirmed: filtered.droppedUnconfirmed,
15115
+ unknownConfirmation: filtered.unknownConfirmation
14970
15116
  } });
14971
15117
  if (opts.stamp) await this.deps.store.markGenerated(rule.id, window.endMs);
14972
15118
  return {
@@ -15030,6 +15176,8 @@ var NcSummaryProducer = class {
15030
15176
  scanned: candidates.length,
15031
15177
  matched: filtered.kept.length,
15032
15178
  droppedStationary: filtered.droppedStationary,
15179
+ droppedUnconfirmed: filtered.droppedUnconfirmed,
15180
+ unknownConfirmation: filtered.unknownConfirmation,
15033
15181
  droppedByClass: filtered.droppedByClass,
15034
15182
  tiles: loaded.length,
15035
15183
  cameras: new Set(loaded.map((t) => t.candidate.deviceId)).size,
@@ -16072,7 +16220,7 @@ function isSyntheticId(id) {
16072
16220
  * - and, since 2026-08-15, a `trackId` for both AUDIO kinds: a sound owns no
16073
16221
  * frame, so production photographs the camera at the match and holds the
16074
16222
  * 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
16223
+ * (`still-shelf.ts`). The tester's RAM-held frame stands in for exactly that
16076
16224
  * photograph, and — unlike every other kind here — it is taken the same way
16077
16225
  * production takes it, at the same moment, from the same cap.
16078
16226
  *
@@ -18739,11 +18887,12 @@ var NotificationCenter = class NotificationCenter {
18739
18887
  */
18740
18888
  syntheticMedia = /* @__PURE__ */ new Map();
18741
18889
  /**
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.
18890
+ * The photograph a notification carries when the subject it is about is an
18891
+ * ABSENCE — a sound that owns no frame, an emptied occupancy scope that owns
18892
+ * no member. See `still-shelf.ts`. Null when this wiring has no snapshot cap,
18893
+ * which leaves those notifications text-only rather than broken.
18745
18894
  */
18746
- audioStills;
18895
+ stills;
18747
18896
  /** Per-device rate limit for the "matched NO rule" report — see `reportNoMatch`. */
18748
18897
  lastNoMatchReportAt = /* @__PURE__ */ new Map();
18749
18898
  noMatchSuppressed = /* @__PURE__ */ new Map();
@@ -19040,9 +19189,9 @@ var NotificationCenter = class NotificationCenter {
19040
19189
  this.logger = deps.logger;
19041
19190
  this.now = deps.now ?? (() => Date.now());
19042
19191
  const getSnapshot = deps.getSnapshot;
19043
- this.audioStills = getSnapshot !== void 0 ? new NcAudioStillShelf({
19192
+ this.stills = getSnapshot !== void 0 ? new NcStillShelf({
19044
19193
  getSnapshot,
19045
- logger: this.logger.child("audio-still"),
19194
+ logger: this.logger.child("still-shelf"),
19046
19195
  now: this.now
19047
19196
  }) : null;
19048
19197
  this.rules = new NcRuleStore({
@@ -19132,8 +19281,8 @@ var NotificationCenter = class NotificationCenter {
19132
19281
  getMediaForOwner: async (ownerKind, ownerId) => {
19133
19282
  const held = isSyntheticId(ownerId) ? this.syntheticMedia.get(ownerId) : void 0;
19134
19283
  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;
19284
+ const shelved = isStillShelfId(ownerId) ? this.stills?.get(ownerId) : void 0;
19285
+ if (shelved !== void 0) return shelved;
19137
19286
  return deps.dispatcher.getMediaForOwner(ownerKind, ownerId);
19138
19287
  },
19139
19288
  ...deps.now !== void 0 ? { now: deps.now } : {},
@@ -19339,7 +19488,7 @@ var NotificationCenter = class NotificationCenter {
19339
19488
  }
19340
19489
  this.timelapseScheduler?.stop();
19341
19490
  this.summaryProducer?.stop();
19342
- this.audioStills?.clear();
19491
+ this.stills?.clear();
19343
19492
  this.addonUpdates.dispose();
19344
19493
  this.livenessHoldoff.dispose();
19345
19494
  this.evaluationActive = false;
@@ -19691,14 +19840,14 @@ var NotificationCenter = class NotificationCenter {
19691
19840
  * direction that DESTROYS the media.
19692
19841
  */
19693
19842
  audioStillOwner(deviceId, atMs, mode) {
19694
- const shelf = this.audioStills;
19843
+ const shelf = this.stills;
19695
19844
  if (shelf === null || !this.evaluationActive) return void 0;
19696
19845
  for (const rule of this.rules.listEnabled("immediate")) {
19697
19846
  if (rule.media.attach === "none") continue;
19698
19847
  if (!(mode === "condition" ? rule.conditions.audio !== void 0 : rule.conditions.audio === void 0 && referencesAudioClass(rule.conditions.classes))) continue;
19699
19848
  const devices = rule.conditions.devices;
19700
19849
  if (devices !== void 0 && devices.length > 0 && !devices.includes(deviceId)) continue;
19701
- return shelf.capture(deviceId, atMs);
19850
+ return shelf.capture(deviceId, atMs, "audio");
19702
19851
  }
19703
19852
  }
19704
19853
  /**
@@ -19757,7 +19906,48 @@ var NotificationCenter = class NotificationCenter {
19757
19906
  * Public so a test / a future out-of-band edge source can drive it directly.
19758
19907
  */
19759
19908
  onOccupancyEdge(edge) {
19760
- this.consumeEvent(incomingFromOccupancyEdge(edge));
19909
+ this.consumeEvent(incomingFromOccupancyEdge(edge, this.occupancyStillOwner(edge)));
19910
+ }
19911
+ /**
19912
+ * Start photographing this edge's camera NOW and return the owner id its
19913
+ * subject should name — or `undefined` when nothing would use it.
19914
+ *
19915
+ * **Only an edge that owns NOBODY.** `chooseOccupancyMediaOwner` names a
19916
+ * member track whenever the scope holds one, and that picture is of the
19917
+ * counted object itself; a photograph is strictly the stand-in for not having
19918
+ * one. The case this exists for is "posto libero" (camera 617): the vehicle
19919
+ * whose departure IS the notification has left, so the scope is empty, the
19920
+ * ladder resolves nothing and the delivery logged `no still could be resolved
19921
+ * … owners=[]`. A became-free edge has no owner BY CONSTRUCTION, and a
19922
+ * became-occupied edge can lack one too (every member parked, i.e.
19923
+ * `stationary:`) — so the gate is the ABSENCE itself, not the polarity. That
19924
+ * is the honest predicate: photograph exactly the edges whose media cannot be
19925
+ * resolved, and no others.
19926
+ *
19927
+ * **The capture is not awaited**, for the audio shelf's reason and one of its
19928
+ * own: a freed space is about to be taken by the next car, and a still fetched
19929
+ * after the outbox drained would show it occupied again.
19930
+ *
19931
+ * **The gate is the RULE's media policy AND the engine's own verdict.** The
19932
+ * watcher commits both polarities of every watched key, so a hub with only a
19933
+ * became-occupied rule still produces free edges — photographing those would
19934
+ * be a snapshot no notification can ever carry. `matchesOccupancy` is the same
19935
+ * predicate `evaluateRule` will apply, shared rather than restated. The rule
19936
+ * cache is an in-memory mirror, so this is not a fallible read (D49).
19937
+ */
19938
+ occupancyStillOwner(edge) {
19939
+ const shelf = this.stills;
19940
+ if (shelf === null || !this.evaluationActive) return void 0;
19941
+ if (edge.mediaTrackId !== void 0) return void 0;
19942
+ const subject = occupancySubjectFromEdge(edge);
19943
+ for (const rule of this.rules.listEnabled("device-event")) {
19944
+ if (rule.media.attach === "none") continue;
19945
+ const occ = rule.conditions.occupancy;
19946
+ if (occ === void 0 || !matchesOccupancy(occ, subject)) continue;
19947
+ const devices = rule.conditions.devices;
19948
+ if (devices !== void 0 && devices.length > 0 && !devices.includes(edge.deviceId)) continue;
19949
+ return shelf.capture(edge.deviceId, edge.timestamp, "occupancy");
19950
+ }
19761
19951
  }
19762
19952
  /**
19763
19953
  * Feed ONE audio inference frame into the sampling-window matcher.
@@ -20724,7 +20914,7 @@ var NotificationCenter = class NotificationCenter {
20724
20914
  kind,
20725
20915
  ruleId: rule.id,
20726
20916
  rule: rule.name,
20727
- snapshotWired: this.audioStills !== null
20917
+ snapshotWired: this.stills !== null
20728
20918
  }
20729
20919
  });
20730
20920
  }
@@ -20889,6 +21079,7 @@ var NotificationCenter = class NotificationCenter {
20889
21079
  if (subject.kind === "audio-window" || subject.kind === "audio-event") return { outcome: "disabled" };
20890
21080
  const trackId = subject.trackId;
20891
21081
  if (trackId === void 0) return { outcome: "disabled" };
21082
+ if (isStillShelfId(trackId)) return { outcome: "disabled" };
20892
21083
  const member = {
20893
21084
  trackId,
20894
21085
  className: subject.classNames[0] ?? "event",
@@ -22131,6 +22322,16 @@ function planRuleTest(input) {
22131
22322
  * never be re-owned through a patch.
22132
22323
  * - `nc.setRuleTargetEnabled` — a user opting HIS OWN target out of a rule
22133
22324
  * VISIBLE to him (his own personal rule OR a global/admin rule).
22325
+ * - the TIMELAPSE and DIGEST families (`nc.*TimelapseRule*`,
22326
+ * `nc.*SummaryRule*`) repeat that model exactly over their own stores. The
22327
+ * digest half was `auth: 'admin'` in v1 and was relaxed once the operator
22328
+ * closed the cost question; `nc.listSummaryRules` reports the current answer
22329
+ * as `canAuthor` so a client talking to an older hub degrades to a read-only
22330
+ * list instead of discovering the refusal as a 403 on a filled-in form.
22331
+ * - the three TEST actions (`nc.injectTestEvent`, `nc.testRule`,
22332
+ * `nc.testTimelapseRule`, `nc.testSummaryRule`) stay `auth: 'admin'`
22333
+ * regardless of who owns the rule: they DELIVER, to every target the rule
22334
+ * addresses, on other people's phones.
22134
22335
  *
22135
22336
  * Target-ownership limits (personal-rule delivery target list + the opt-out
22136
22337
  * target) are enforced against the notifiers target catalog; ADMINS bypass the
@@ -22349,7 +22550,25 @@ var ncActions = defineCustomActions({
22349
22550
  auth: "admin",
22350
22551
  caller: "required"
22351
22552
  }),
22352
- "nc.listSummaryRules": customAction(object({}), object({ rules: array(NcViewerSummaryRuleSchema) }), { caller: "required" }),
22553
+ "nc.listSummaryRules": customAction(object({}), object({
22554
+ rules: array(NcViewerSummaryRuleSchema),
22555
+ /**
22556
+ * DOES THIS HUB LET THE CALLER AUTHOR ONE? The capability probe, and the
22557
+ * only honest way a client can know.
22558
+ *
22559
+ * `nc.upsertSummaryRule` was `auth: 'admin'` until the operator closed
22560
+ * that decision, and a viewer talking to a hub that still refuses has to
22561
+ * find out BEFORE it offers a "New summary rule" button — otherwise the
22562
+ * only feedback is a 403 taken in front of the operator on a form they
22563
+ * had already filled in. ADDITIVE and therefore `.optional()` on every
22564
+ * client mirror: absent means an older hub, which the viewer reads as "no
22565
+ * authoring" and degrades to a read-only list with a note.
22566
+ *
22567
+ * Derived from the same predicate the handlers enforce — never a constant
22568
+ * `true` — so a hub that re-tightened the auth could not say otherwise.
22569
+ */
22570
+ canAuthor: boolean()
22571
+ }), { caller: "required" }),
22353
22572
  /**
22354
22573
  * ONE action for create AND update, like `nc.upsertTimelapseRule`: a digest
22355
22574
  * rule is authored as a whole (cameras + window + filters + targets) and the
@@ -22358,10 +22577,18 @@ var ncActions = defineCustomActions({
22358
22577
  *
22359
22578
  * `ruleId` absent ⇒ create from `rule`; present ⇒ patch.
22360
22579
  *
22361
- * `auth: 'admin'` in v1 (operator decision): a digest spans cameras and, once
22362
- * P2 lands, consumes the local vision model. A personal rule that fans vision
22363
- * calls across other people's cameras is a decision to take after the real
22364
- * cost has been watched for a few weeks, not before.
22580
+ * `caller: 'required'`, NOT `auth: 'admin'`. It was admin-only in v1 because
22581
+ * a digest spans cameras and consumes the local vision model, and that cost
22582
+ * had not been watched yet. The operator closed the decision, so this now
22583
+ * carries exactly the auth the two rule families beside it carry: ownership
22584
+ * is enforced in the handler, `ownerUserId` is STAMPED from the resolved
22585
+ * caller, a personal rule may deliver only to targets that caller OWNS, and an
22586
+ * admin still authors GLOBAL rules. No new ownership model was invented for
22587
+ * it — `NcSummaryStore` has had `ownerUserId` / `listForOwner` since it was
22588
+ * written and they were simply not reachable.
22589
+ *
22590
+ * `nc.testSummaryRule` stays admin: that one DELIVERS, on other people's
22591
+ * phones.
22365
22592
  */
22366
22593
  "nc.upsertSummaryRule": customAction(object({
22367
22594
  ruleId: string().optional(),
@@ -22369,12 +22596,10 @@ var ncActions = defineCustomActions({
22369
22596
  patch: NcSummaryRulePatchSchema.optional()
22370
22597
  }), object({ rule: NcSummaryRuleSchema }), {
22371
22598
  kind: "mutation",
22372
- auth: "admin",
22373
22599
  caller: "required"
22374
22600
  }),
22375
22601
  "nc.deleteSummaryRule": customAction(object({ ruleId: string() }), object({ success: literal(true) }), {
22376
22602
  kind: "mutation",
22377
- auth: "admin",
22378
22603
  caller: "required"
22379
22604
  }),
22380
22605
  /**
@@ -22468,6 +22693,17 @@ var ncActions = defineCustomActions({
22468
22693
  * a surface that distinguishes them reports what other people own. */
22469
22694
  "nc.getArtifact": customAction(object({ artifactId: string().min(1) }), object({ artifact: NcArtifactRecordSchema.nullable() }), { caller: "required" })
22470
22695
  });
22696
+ /**
22697
+ * Does THIS build let a non-admin author a digest rule?
22698
+ *
22699
+ * A named constant rather than a literal `true` at the call site, so the one
22700
+ * fact `nc.listSummaryRules.canAuthor` reports and the auth the upsert actually
22701
+ * carries sit beside each other and are changed together. If the operator ever
22702
+ * re-tightens the action, this goes to `false` in the same edit and the viewer
22703
+ * degrades on the next list — instead of the app discovering it by taking a 403
22704
+ * on a form the operator had already filled in.
22705
+ */
22706
+ var SUMMARY_AUTHORING_OPEN = true;
22471
22707
  /** Fail-closed caller resolution — an absent forwarded caller is NEVER admin. */
22472
22708
  function requireCaller$1(caller) {
22473
22709
  if (!caller || typeof caller.userId !== "string" || caller.userId.length === 0) throw new Error("forbidden: authenticated caller required");
@@ -22528,6 +22764,22 @@ function makeNcActionHandlers(deps) {
22528
22764
  if (editor === void 0) throw new Error("this node does not host the notification text catalog — use the hub");
22529
22765
  return editor;
22530
22766
  };
22767
+ /**
22768
+ * A digest rule the caller may EDIT. Same shape and same admin bypass as
22769
+ * {@link assertTimelapseEditable} — deliberately, because a second ownership
22770
+ * model for the third rule family is a second place to get it wrong.
22771
+ *
22772
+ * A GLOBAL rule (no `ownerUserId`, what an admin authors) is VISIBLE to
22773
+ * everyone and editable by admins only: `listForOwner` returns it, so a user
22774
+ * sees it in the list, and `readOnly` says so.
22775
+ */
22776
+ const assertSummaryEditable = (ruleId, caller, sum) => {
22777
+ const rule = sum.store.get(ruleId);
22778
+ if (rule === null) throw new Error(`forbidden: summary rule not found: ${ruleId}`);
22779
+ if (caller.isAdmin) return rule;
22780
+ if (rule.ownerUserId !== caller.userId) throw new Error(`forbidden: summary rule not owned: ${ruleId}`);
22781
+ return rule;
22782
+ };
22531
22783
  /** A timelapse rule the caller may EDIT. Same admin bypass as `NcRule`. */
22532
22784
  const assertTimelapseEditable = (ruleId, caller, tl) => {
22533
22785
  const rule = tl.store.get(ruleId);
@@ -22798,21 +23050,23 @@ function makeNcActionHandlers(deps) {
22798
23050
  "nc.listSummaryRules": async (_input, caller) => {
22799
23051
  const c = requireCaller$1(caller);
22800
23052
  const sum = requireSummary();
22801
- return { rules: (c.isAdmin ? sum.store.list() : sum.store.listForOwner(c.userId)).map((r) => ({
22802
- ...r,
22803
- readOnly: !c.isAdmin
22804
- })) };
23053
+ return {
23054
+ rules: (c.isAdmin ? sum.store.list() : sum.store.listForOwner(c.userId)).map((r) => ({
23055
+ ...r,
23056
+ readOnly: !c.isAdmin && r.ownerUserId !== c.userId
23057
+ })),
23058
+ canAuthor: SUMMARY_AUTHORING_OPEN
23059
+ };
22805
23060
  },
22806
23061
  "nc.upsertSummaryRule": async (input, caller) => {
22807
23062
  const c = requireCaller$1(caller);
22808
- if (!c.isAdmin) throw new Error("forbidden: admin required to author a summary rule");
22809
23063
  const sum = requireSummary();
22810
23064
  if (input.ruleId === void 0) {
22811
23065
  if (input.rule === void 0) throw new Error("upsertSummaryRule: rule or ruleId required");
22812
23066
  const parsed = NcSummaryRuleInputSchema.parse(input.rule);
22813
23067
  assertSummaryBudget(parsed);
22814
23068
  await assertTargetsOwned(parsed.targets.map((t) => t.targetId), c);
22815
- const rule = await sum.store.create(parsed, c.userId);
23069
+ const rule = await sum.store.create(parsed, c.userId, c.isAdmin ? void 0 : c.userId);
22816
23070
  deps.logger.info("summary rule created", { meta: {
22817
23071
  ruleId: rule.id,
22818
23072
  owner: c.userId,
@@ -22820,8 +23074,7 @@ function makeNcActionHandlers(deps) {
22820
23074
  } });
22821
23075
  return { rule };
22822
23076
  }
22823
- const existing = sum.store.get(input.ruleId);
22824
- if (existing === null) throw new Error(`forbidden: summary rule not found: ${input.ruleId}`);
23077
+ const existing = assertSummaryEditable(input.ruleId, c, sum);
22825
23078
  if (input.patch === void 0) throw new Error("upsertSummaryRule: patch required");
22826
23079
  const patch = NcSummaryRulePatchSchema.parse(input.patch);
22827
23080
  assertSummaryBudget({
@@ -22838,8 +23091,9 @@ function makeNcActionHandlers(deps) {
22838
23091
  },
22839
23092
  "nc.deleteSummaryRule": async (input, caller) => {
22840
23093
  const c = requireCaller$1(caller);
22841
- if (!c.isAdmin) throw new Error("forbidden: admin required to delete a summary rule");
22842
- await requireSummary().store.delete(input.ruleId);
23094
+ const sum = requireSummary();
23095
+ assertSummaryEditable(input.ruleId, c, sum);
23096
+ await sum.store.delete(input.ruleId);
22843
23097
  deps.logger.info("summary rule deleted", { meta: {
22844
23098
  ruleId: input.ruleId,
22845
23099
  owner: c.userId
@@ -44118,7 +44372,9 @@ var TrackStore = class {
44118
44372
  */
44119
44373
  rowToTrack(id, data, projection) {
44120
44374
  const slim = projection === "slim";
44121
- const positions = slim ? [] : data["positions"] ?? [];
44375
+ const storedPositions = data["positions"] ?? [];
44376
+ const observations = storedPositions.length;
44377
+ const positions = slim ? [] : storedPositions;
44122
44378
  const snapshots = slim ? [] : data["snapshots"] ?? [];
44123
44379
  const zones = data["zonesVisited"] ?? [];
44124
44380
  const classes = data["classes"];
@@ -44175,6 +44431,7 @@ var TrackStore = class {
44175
44431
  ...typeof hasFace === "boolean" ? { hasFace } : {},
44176
44432
  ...typeof hasRider === "boolean" ? { hasRider } : {},
44177
44433
  ...closedReason !== void 0 ? { closedReason } : {},
44434
+ observations,
44178
44435
  ...envelope !== null ? { envelope } : {}
44179
44436
  };
44180
44437
  }
@@ -52484,13 +52741,15 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
52484
52741
  buildSummaryPorts(stores) {
52485
52742
  return {
52486
52743
  listTracks: async ({ deviceId, sinceMs, untilMs, limit }) => {
52487
- return (await stores.trackStore.queryHistorical({
52744
+ const tracks = await stores.trackStore.queryHistorical({
52488
52745
  deviceId,
52489
52746
  since: sinceMs,
52490
52747
  until: untilMs,
52491
52748
  projection: "slim",
52492
52749
  limit
52493
- })).map((t) => ({
52750
+ });
52751
+ const { minHits } = await this.resolveDeviceDetectionSensitivitySettings(deviceId);
52752
+ return tracks.map((t) => ({
52494
52753
  trackId: t.trackId,
52495
52754
  deviceId: t.deviceId,
52496
52755
  className: t.className,
@@ -52499,7 +52758,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
52499
52758
  ...t.importance !== void 0 ? { importance: t.importance } : {},
52500
52759
  zones: [...t.zonesVisited],
52501
52760
  ...t.classes !== void 0 ? { classes: [...t.classes] } : {},
52502
- ...t.closedReason === "stationary-promotion" ? { stationary: true } : {}
52761
+ ...t.closedReason === "stationary-promotion" ? { stationary: true } : {},
52762
+ ...t.observations !== void 0 ? { confirmed: t.observations >= minHits } : {}
52503
52763
  }));
52504
52764
  },
52505
52765
  listMediaInfo: async (trackId) => {