@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.
@@ -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: () => ({
@@ -14151,7 +14206,11 @@ function mosaicTextPlan(captions) {
14151
14206
  };
14152
14207
  }
14153
14208
  /** Grid shape for `n` tiles: as square as possible, rows never exceeding cols
14154
- * by more than one — a 3×3 for nine, a 3×2 for five, never a 1×9 strip. */
14209
+ * by more than one — a 3×3 for nine, a 3×2 for five, never a 1×9 strip.
14210
+ *
14211
+ * `rows` is the load-bearing half — it is how many rows {@link mosaicRowCounts}
14212
+ * distributes into. `cols` describes the WIDEST row, and is no longer a cell
14213
+ * count: see {@link mosaicRowCounts} for why a `cols × rows` grid was the bug. */
14155
14214
  function mosaicGrid(n) {
14156
14215
  const count = Math.max(1, Math.floor(n));
14157
14216
  const cols = Math.ceil(Math.sqrt(count));
@@ -14161,31 +14220,84 @@ function mosaicGrid(n) {
14161
14220
  };
14162
14221
  }
14163
14222
  /**
14223
+ * How many tiles each ROW carries, top to bottom — and the reason the mosaic
14224
+ * can no longer have a hole in it.
14225
+ *
14226
+ * The layout used to be a `cols × rows` GRID, and a grid holds more cells than
14227
+ * tiles for every count that is not a perfect fit: 3 into a 2×2, 5 into a 3×2,
14228
+ * 7 and 8 into a 3×3. The composite only ever wrote the tiles, so the spare
14229
+ * cells stayed the canvas — `MOSAIC_BACKGROUND`, rgb 17 — and the operator's
14230
+ * three-tile digest was a quarter black. Measured on the shipped renderer:
14231
+ * 24.8% of a three-tile mosaic was background. His words: *"se ci sono 3
14232
+ * elementi non dovrebbe renderizzare buchi neri"*.
14233
+ *
14234
+ * Counting ROWS instead removes the failure by construction rather than by
14235
+ * patching it. Every row spans the full width and divides it among the tiles it
14236
+ * actually has, so a row is full whatever the remainder and there is no such
14237
+ * thing as a spare cell. The shapes fall out of it, and they are the ones asked
14238
+ * for: 1 ⇒ full frame, 2 ⇒ side by side, 3 ⇒ one big over two small, 4 ⇒ 2×2.
14239
+ *
14240
+ * **The fewer-tiled row comes FIRST.** Tiles arrive in the selection's order,
14241
+ * so row 0 is the head of the ranking; putting the remainder at the top would
14242
+ * make the WORST tile the big one.
14243
+ *
14244
+ * Every cell keeps {@link TILE_ASPECT}, so rows differ in height — which is
14245
+ * also what stops the letterbox: a full-width hero tile is 16:9 exactly, not a
14246
+ * 16:9 picture centred in a double-height cell with bars over and under it.
14247
+ */
14248
+ function mosaicRowCounts(n) {
14249
+ const count = Math.max(1, Math.floor(n));
14250
+ const { rows } = mosaicGrid(count);
14251
+ const base = Math.floor(count / rows);
14252
+ const remainder = count % rows;
14253
+ const counts = [];
14254
+ for (let row = 0; row < rows; row += 1) counts.push(row < rows - remainder ? base : base + 1);
14255
+ return counts;
14256
+ }
14257
+ /**
14164
14258
  * Where every tile lands. Pure — no bytes, no sharp, no clock.
14165
14259
  *
14166
14260
  * Exported because the rectangles are DATA the artefact should carry: a viewer
14167
14261
  * that wants a tappable mosaic reads these rather than re-deriving a layout
14168
14262
  * that could drift from the one actually rendered.
14263
+ *
14264
+ * The rectangles COVER the picture area exactly — that is the property the
14265
+ * spec asserts, and it is the one the old grid broke.
14169
14266
  */
14170
14267
  function mosaicLayout(input) {
14171
- const { cols, rows } = mosaicGrid(input.tileCount);
14172
- const maxWidth = input.maxWidth ?? 1920;
14173
- const tileWidth = Math.max(160, Math.floor(maxWidth / cols));
14268
+ const rowCounts = mosaicRowCounts(input.tileCount);
14269
+ const width = Math.max(1, Math.floor(input.maxWidth ?? 1920));
14270
+ const firstRowCount = rowCounts[0] ?? 1;
14271
+ const tileWidth = Math.floor(width / firstRowCount);
14174
14272
  const tileHeight = Math.round(tileWidth * TILE_ASPECT);
14175
14273
  const titleHeight = input.withTitle === true ? Math.round(tileHeight * TITLE_BAND_FRACTION) : 0;
14176
14274
  const tiles = [];
14177
- for (let index = 0; index < Math.max(1, input.tileCount); index += 1) tiles.push({
14178
- index,
14179
- x: index % cols * tileWidth,
14180
- y: titleHeight + Math.floor(index / cols) * tileHeight,
14181
- w: tileWidth,
14182
- h: tileHeight
14183
- });
14275
+ let y = titleHeight;
14276
+ let index = 0;
14277
+ for (const count of rowCounts) {
14278
+ const nominalWidth = Math.floor(width / count);
14279
+ const rowHeight = Math.round(nominalWidth * TILE_ASPECT);
14280
+ let x = 0;
14281
+ for (let inRow = 0; inRow < count; inRow += 1) {
14282
+ const w = inRow === count - 1 ? width - x : nominalWidth;
14283
+ tiles.push({
14284
+ index,
14285
+ x,
14286
+ y,
14287
+ w,
14288
+ h: rowHeight
14289
+ });
14290
+ x += w;
14291
+ index += 1;
14292
+ }
14293
+ y += rowHeight;
14294
+ }
14184
14295
  return {
14185
- width: cols * tileWidth,
14186
- height: titleHeight + rows * tileHeight,
14187
- cols,
14188
- rows,
14296
+ width,
14297
+ height: y,
14298
+ cols: Math.max(...rowCounts),
14299
+ rows: rowCounts.length,
14300
+ rowCounts,
14189
14301
  tileWidth,
14190
14302
  tileHeight,
14191
14303
  titleHeight,
@@ -14249,36 +14361,20 @@ function titleSvg(width, height, caption) {
14249
14361
  *
14250
14362
  * A tile sharp cannot read is DROPPED and counted, never composited as a blank
14251
14363
  * cell — a black square in a digest reads as "the camera saw nothing", which is
14252
- * the opposite of what happened. The grid is recomputed from the survivors so
14253
- * the mosaic has no hole.
14364
+ * the opposite of what happened. The layout is computed from the SURVIVORS, and
14365
+ * because {@link mosaicRowCounts} counts rows rather than grid cells there is no
14366
+ * remainder to leave empty either.
14254
14367
  *
14255
- * @throws only if sharp cannot produce the final canvas at all; the caller
14256
- * treats that as "no mosaic" and still delivers the counts.
14368
+ * @throws when every tile was dropped, and when sharp cannot produce the final
14369
+ * canvas at all; the caller treats both as "no mosaic" and still
14370
+ * delivers the counts.
14257
14371
  */
14258
14372
  async function renderMosaic(request, logger) {
14259
14373
  const plan = mosaicTextPlan(request.captions);
14260
- const decoded = [];
14261
- const probeLayout = mosaicLayout({
14262
- tileCount: Math.max(1, request.tiles.length),
14263
- ...request.maxWidth !== void 0 ? { maxWidth: request.maxWidth } : {}
14264
- });
14374
+ const usable = [];
14265
14375
  for (const tile of request.tiles) try {
14266
- const label = plan.tileLabels ? tile.label?.trim() ?? "" : "";
14267
- const fitted = (0, sharp.default)(tile.jpeg).resize({
14268
- width: probeLayout.tileWidth,
14269
- height: probeLayout.tileHeight,
14270
- fit: "contain",
14271
- background: MOSAIC_BACKGROUND
14272
- });
14273
- const resized = await (label.length === 0 ? fitted : fitted.composite([{
14274
- input: tileLabelSvg(probeLayout.tileWidth, probeLayout.tileHeight, label),
14275
- top: 0,
14276
- left: 0
14277
- }])).jpeg({ quality: 82 }).toBuffer();
14278
- decoded.push({
14279
- tile,
14280
- resized
14281
- });
14376
+ await (0, sharp.default)(tile.jpeg).metadata();
14377
+ usable.push(tile);
14282
14378
  } catch (err) {
14283
14379
  logger.warn("summary mosaic: a tile could not be decoded and was dropped", {
14284
14380
  tags: { deviceId: tile.deviceId },
@@ -14288,23 +14384,34 @@ async function renderMosaic(request, logger) {
14288
14384
  }
14289
14385
  });
14290
14386
  }
14291
- const droppedTiles = request.tiles.length - decoded.length;
14387
+ const droppedTiles = request.tiles.length - usable.length;
14388
+ if (usable.length === 0) throw new Error(`summary mosaic: no tile could be decoded (${request.tiles.length} offered)`);
14292
14389
  const caption = plan.titleBand ? request.caption?.trim() ?? "" : "";
14293
14390
  const layout = mosaicLayout({
14294
- tileCount: Math.max(1, decoded.length),
14391
+ tileCount: usable.length,
14295
14392
  ...request.maxWidth !== void 0 ? { maxWidth: request.maxWidth } : {},
14296
14393
  withTitle: caption.length > 0
14297
14394
  });
14298
- const composites = await Promise.all(decoded.map(async ({ resized }, index) => ({
14299
- input: layout.tileWidth === probeLayout.tileWidth && layout.tileHeight === probeLayout.tileHeight ? resized : await (0, sharp.default)(resized).resize({
14300
- width: layout.tileWidth,
14301
- height: layout.tileHeight,
14395
+ const composites = await Promise.all(usable.map(async (tile, index) => {
14396
+ const rect = layout.tiles[index];
14397
+ if (rect === void 0) throw new Error("summary mosaic: layout lost a tile");
14398
+ const label = plan.tileLabels ? tile.label?.trim() ?? "" : "";
14399
+ const fitted = (0, sharp.default)(tile.jpeg).resize({
14400
+ width: rect.w,
14401
+ height: rect.h,
14302
14402
  fit: "contain",
14303
14403
  background: MOSAIC_BACKGROUND
14304
- }).jpeg({ quality: 82 }).toBuffer(),
14305
- top: layout.tiles[index]?.y ?? 0,
14306
- left: layout.tiles[index]?.x ?? 0
14307
- })));
14404
+ });
14405
+ return {
14406
+ input: await (label.length === 0 ? fitted : fitted.composite([{
14407
+ input: tileLabelSvg(rect.w, rect.h, label),
14408
+ top: 0,
14409
+ left: 0
14410
+ }])).jpeg({ quality: 82 }).toBuffer(),
14411
+ top: rect.y,
14412
+ left: rect.x
14413
+ };
14414
+ }));
14308
14415
  if (caption.length > 0 && layout.titleHeight > 0) composites.unshift({
14309
14416
  input: titleSvg(layout.width, layout.titleHeight, caption),
14310
14417
  top: 0,
@@ -14520,6 +14627,36 @@ function buildSummaryOutboxInputs(input) {
14520
14627
  * the quota degrades honestly: the best cameras get their one tile, the rest
14521
14628
  * are counted as dropped and the count rides the log line — never silently.
14522
14629
  *
14630
+ * ## Only tracks the pipeline PROMOTED
14631
+ *
14632
+ * A digest is retrospective. The operator's own framing, verbatim: *"ideale
14633
+ * sarebbe considerare solo tracce che vengono alla fine promosse a tracce, dato
14634
+ * che il summary non è atteso di essere realtime"* — the window is closed
14635
+ * before this function is called, so the selection may wait for the pipeline's
14636
+ * verdict instead of guessing at it.
14637
+ *
14638
+ * The verdict is `SortTracker`'s own promotion threshold: a candidate is not
14639
+ * emitted as a track until it has been matched on `minHits` frames
14640
+ * (`isConfirmedForEmit`, per-device setting, *"minimum consecutive detection
14641
+ * hits before a track is promoted to output"*). The gate here re-applies that
14642
+ * same threshold to what the CLOSED row actually left behind — the count of
14643
+ * recorded sightings on its trajectory — and the port that builds a candidate
14644
+ * is the only thing that knows the device's `minHits`.
14645
+ *
14646
+ * This is not a movement heuristic and deliberately so: `totalDistance` and
14647
+ * `state` were both measured against live traffic and both hide real passages.
14648
+ * What they cannot see is the shape this gate is for — a row whose whole life
14649
+ * is one or two frames. Measured on device 617 (2026-08-19 04:12): two rows,
14650
+ * `closedReason` NULL so the parked pass is blind to them, dwell 53 ms,
14651
+ * `totalDistance` 0, two recorded sightings each. And `minImportance` cannot
14652
+ * cover it either, structurally: the static-track gate that demotes a frozen
14653
+ * subject needs two centroids to measure anything
14654
+ * (`computeStaticTrackMetrics`), so a single-sighting row is exactly the shape
14655
+ * it never scores down — 33 of the live hub's 34 such rows sit at or above 0.2.
14656
+ *
14657
+ * There is no rule field to turn it off. A digest of things that were never
14658
+ * tracks has no audience, and this repo has shipped knobs nobody read.
14659
+ *
14523
14660
  * ## Zones are scoped PER CAMERA
14524
14661
  *
14525
14662
  * `filters.zones` is a flat id list on a rule that spans cameras, and zone ids
@@ -14543,15 +14680,20 @@ function dwellSeconds(candidate) {
14543
14680
  * branch that discards work silently reads as "it never happened".
14544
14681
  */
14545
14682
  function filterSummaryCandidates(candidates, filters, zoneOwner) {
14546
- const moving = filters?.includeStationary === true ? candidates : candidates.filter((candidate) => candidate.stationary !== true);
14547
- const droppedStationary = candidates.length - moving.length;
14683
+ const promoted = candidates.filter((candidate) => candidate.confirmed !== false);
14684
+ const droppedUnconfirmed = candidates.length - promoted.length;
14685
+ const unknownConfirmation = promoted.filter((candidate) => candidate.confirmed === void 0).length;
14686
+ const moving = filters?.includeStationary === true ? promoted : promoted.filter((candidate) => candidate.stationary !== true);
14687
+ const droppedStationary = promoted.length - moving.length;
14548
14688
  if (filters === void 0) return {
14549
14689
  kept: [...moving],
14550
14690
  droppedByClass: 0,
14551
14691
  droppedByZone: 0,
14552
14692
  droppedByImportance: 0,
14553
14693
  droppedByDwell: 0,
14554
- droppedStationary
14694
+ droppedStationary,
14695
+ droppedUnconfirmed,
14696
+ unknownConfirmation
14555
14697
  };
14556
14698
  const include = filters.classes;
14557
14699
  const exclude = new Set(filters.classesExclude ?? []);
@@ -14597,7 +14739,9 @@ function filterSummaryCandidates(candidates, filters, zoneOwner) {
14597
14739
  droppedByZone,
14598
14740
  droppedByImportance,
14599
14741
  droppedByDwell,
14600
- droppedStationary
14742
+ droppedStationary,
14743
+ droppedUnconfirmed,
14744
+ unknownConfirmation
14601
14745
  };
14602
14746
  }
14603
14747
  /**
@@ -14973,7 +15117,9 @@ var NcSummaryProducer = class {
14973
15117
  droppedByZone: filtered.droppedByZone,
14974
15118
  droppedByImportance: filtered.droppedByImportance,
14975
15119
  droppedByDwell: filtered.droppedByDwell,
14976
- droppedStationary: filtered.droppedStationary
15120
+ droppedStationary: filtered.droppedStationary,
15121
+ droppedUnconfirmed: filtered.droppedUnconfirmed,
15122
+ unknownConfirmation: filtered.unknownConfirmation
14977
15123
  } });
14978
15124
  if (opts.stamp) await this.deps.store.markGenerated(rule.id, window.endMs);
14979
15125
  return {
@@ -15037,6 +15183,8 @@ var NcSummaryProducer = class {
15037
15183
  scanned: candidates.length,
15038
15184
  matched: filtered.kept.length,
15039
15185
  droppedStationary: filtered.droppedStationary,
15186
+ droppedUnconfirmed: filtered.droppedUnconfirmed,
15187
+ unknownConfirmation: filtered.unknownConfirmation,
15040
15188
  droppedByClass: filtered.droppedByClass,
15041
15189
  tiles: loaded.length,
15042
15190
  cameras: new Set(loaded.map((t) => t.candidate.deviceId)).size,
@@ -16094,7 +16242,7 @@ function isSyntheticId(id) {
16094
16242
  * - and, since 2026-08-15, a `trackId` for both AUDIO kinds: a sound owns no
16095
16243
  * frame, so production photographs the camera at the match and holds the
16096
16244
  * 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
16245
+ * (`still-shelf.ts`). The tester's RAM-held frame stands in for exactly that
16098
16246
  * photograph, and — unlike every other kind here — it is taken the same way
16099
16247
  * production takes it, at the same moment, from the same cap.
16100
16248
  *
@@ -18761,11 +18909,12 @@ var NotificationCenter = class NotificationCenter {
18761
18909
  */
18762
18910
  syntheticMedia = /* @__PURE__ */ new Map();
18763
18911
  /**
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.
18912
+ * The photograph a notification carries when the subject it is about is an
18913
+ * ABSENCE — a sound that owns no frame, an emptied occupancy scope that owns
18914
+ * no member. See `still-shelf.ts`. Null when this wiring has no snapshot cap,
18915
+ * which leaves those notifications text-only rather than broken.
18767
18916
  */
18768
- audioStills;
18917
+ stills;
18769
18918
  /** Per-device rate limit for the "matched NO rule" report — see `reportNoMatch`. */
18770
18919
  lastNoMatchReportAt = /* @__PURE__ */ new Map();
18771
18920
  noMatchSuppressed = /* @__PURE__ */ new Map();
@@ -19062,9 +19211,9 @@ var NotificationCenter = class NotificationCenter {
19062
19211
  this.logger = deps.logger;
19063
19212
  this.now = deps.now ?? (() => Date.now());
19064
19213
  const getSnapshot = deps.getSnapshot;
19065
- this.audioStills = getSnapshot !== void 0 ? new NcAudioStillShelf({
19214
+ this.stills = getSnapshot !== void 0 ? new NcStillShelf({
19066
19215
  getSnapshot,
19067
- logger: this.logger.child("audio-still"),
19216
+ logger: this.logger.child("still-shelf"),
19068
19217
  now: this.now
19069
19218
  }) : null;
19070
19219
  this.rules = new NcRuleStore({
@@ -19154,8 +19303,8 @@ var NotificationCenter = class NotificationCenter {
19154
19303
  getMediaForOwner: async (ownerKind, ownerId) => {
19155
19304
  const held = isSyntheticId(ownerId) ? this.syntheticMedia.get(ownerId) : void 0;
19156
19305
  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;
19306
+ const shelved = isStillShelfId(ownerId) ? this.stills?.get(ownerId) : void 0;
19307
+ if (shelved !== void 0) return shelved;
19159
19308
  return deps.dispatcher.getMediaForOwner(ownerKind, ownerId);
19160
19309
  },
19161
19310
  ...deps.now !== void 0 ? { now: deps.now } : {},
@@ -19361,7 +19510,7 @@ var NotificationCenter = class NotificationCenter {
19361
19510
  }
19362
19511
  this.timelapseScheduler?.stop();
19363
19512
  this.summaryProducer?.stop();
19364
- this.audioStills?.clear();
19513
+ this.stills?.clear();
19365
19514
  this.addonUpdates.dispose();
19366
19515
  this.livenessHoldoff.dispose();
19367
19516
  this.evaluationActive = false;
@@ -19713,14 +19862,14 @@ var NotificationCenter = class NotificationCenter {
19713
19862
  * direction that DESTROYS the media.
19714
19863
  */
19715
19864
  audioStillOwner(deviceId, atMs, mode) {
19716
- const shelf = this.audioStills;
19865
+ const shelf = this.stills;
19717
19866
  if (shelf === null || !this.evaluationActive) return void 0;
19718
19867
  for (const rule of this.rules.listEnabled("immediate")) {
19719
19868
  if (rule.media.attach === "none") continue;
19720
19869
  if (!(mode === "condition" ? rule.conditions.audio !== void 0 : rule.conditions.audio === void 0 && referencesAudioClass(rule.conditions.classes))) continue;
19721
19870
  const devices = rule.conditions.devices;
19722
19871
  if (devices !== void 0 && devices.length > 0 && !devices.includes(deviceId)) continue;
19723
- return shelf.capture(deviceId, atMs);
19872
+ return shelf.capture(deviceId, atMs, "audio");
19724
19873
  }
19725
19874
  }
19726
19875
  /**
@@ -19779,7 +19928,48 @@ var NotificationCenter = class NotificationCenter {
19779
19928
  * Public so a test / a future out-of-band edge source can drive it directly.
19780
19929
  */
19781
19930
  onOccupancyEdge(edge) {
19782
- this.consumeEvent(incomingFromOccupancyEdge(edge));
19931
+ this.consumeEvent(incomingFromOccupancyEdge(edge, this.occupancyStillOwner(edge)));
19932
+ }
19933
+ /**
19934
+ * Start photographing this edge's camera NOW and return the owner id its
19935
+ * subject should name — or `undefined` when nothing would use it.
19936
+ *
19937
+ * **Only an edge that owns NOBODY.** `chooseOccupancyMediaOwner` names a
19938
+ * member track whenever the scope holds one, and that picture is of the
19939
+ * counted object itself; a photograph is strictly the stand-in for not having
19940
+ * one. The case this exists for is "posto libero" (camera 617): the vehicle
19941
+ * whose departure IS the notification has left, so the scope is empty, the
19942
+ * ladder resolves nothing and the delivery logged `no still could be resolved
19943
+ * … owners=[]`. A became-free edge has no owner BY CONSTRUCTION, and a
19944
+ * became-occupied edge can lack one too (every member parked, i.e.
19945
+ * `stationary:`) — so the gate is the ABSENCE itself, not the polarity. That
19946
+ * is the honest predicate: photograph exactly the edges whose media cannot be
19947
+ * resolved, and no others.
19948
+ *
19949
+ * **The capture is not awaited**, for the audio shelf's reason and one of its
19950
+ * own: a freed space is about to be taken by the next car, and a still fetched
19951
+ * after the outbox drained would show it occupied again.
19952
+ *
19953
+ * **The gate is the RULE's media policy AND the engine's own verdict.** The
19954
+ * watcher commits both polarities of every watched key, so a hub with only a
19955
+ * became-occupied rule still produces free edges — photographing those would
19956
+ * be a snapshot no notification can ever carry. `matchesOccupancy` is the same
19957
+ * predicate `evaluateRule` will apply, shared rather than restated. The rule
19958
+ * cache is an in-memory mirror, so this is not a fallible read (D49).
19959
+ */
19960
+ occupancyStillOwner(edge) {
19961
+ const shelf = this.stills;
19962
+ if (shelf === null || !this.evaluationActive) return void 0;
19963
+ if (edge.mediaTrackId !== void 0) return void 0;
19964
+ const subject = occupancySubjectFromEdge(edge);
19965
+ for (const rule of this.rules.listEnabled("device-event")) {
19966
+ if (rule.media.attach === "none") continue;
19967
+ const occ = rule.conditions.occupancy;
19968
+ if (occ === void 0 || !matchesOccupancy(occ, subject)) continue;
19969
+ const devices = rule.conditions.devices;
19970
+ if (devices !== void 0 && devices.length > 0 && !devices.includes(edge.deviceId)) continue;
19971
+ return shelf.capture(edge.deviceId, edge.timestamp, "occupancy");
19972
+ }
19783
19973
  }
19784
19974
  /**
19785
19975
  * Feed ONE audio inference frame into the sampling-window matcher.
@@ -20746,7 +20936,7 @@ var NotificationCenter = class NotificationCenter {
20746
20936
  kind,
20747
20937
  ruleId: rule.id,
20748
20938
  rule: rule.name,
20749
- snapshotWired: this.audioStills !== null
20939
+ snapshotWired: this.stills !== null
20750
20940
  }
20751
20941
  });
20752
20942
  }
@@ -20911,6 +21101,7 @@ var NotificationCenter = class NotificationCenter {
20911
21101
  if (subject.kind === "audio-window" || subject.kind === "audio-event") return { outcome: "disabled" };
20912
21102
  const trackId = subject.trackId;
20913
21103
  if (trackId === void 0) return { outcome: "disabled" };
21104
+ if (isStillShelfId(trackId)) return { outcome: "disabled" };
20914
21105
  const member = {
20915
21106
  trackId,
20916
21107
  className: subject.classNames[0] ?? "event",
@@ -22153,6 +22344,16 @@ function planRuleTest(input) {
22153
22344
  * never be re-owned through a patch.
22154
22345
  * - `nc.setRuleTargetEnabled` — a user opting HIS OWN target out of a rule
22155
22346
  * VISIBLE to him (his own personal rule OR a global/admin rule).
22347
+ * - the TIMELAPSE and DIGEST families (`nc.*TimelapseRule*`,
22348
+ * `nc.*SummaryRule*`) repeat that model exactly over their own stores. The
22349
+ * digest half was `auth: 'admin'` in v1 and was relaxed once the operator
22350
+ * closed the cost question; `nc.listSummaryRules` reports the current answer
22351
+ * as `canAuthor` so a client talking to an older hub degrades to a read-only
22352
+ * list instead of discovering the refusal as a 403 on a filled-in form.
22353
+ * - the three TEST actions (`nc.injectTestEvent`, `nc.testRule`,
22354
+ * `nc.testTimelapseRule`, `nc.testSummaryRule`) stay `auth: 'admin'`
22355
+ * regardless of who owns the rule: they DELIVER, to every target the rule
22356
+ * addresses, on other people's phones.
22156
22357
  *
22157
22358
  * Target-ownership limits (personal-rule delivery target list + the opt-out
22158
22359
  * target) are enforced against the notifiers target catalog; ADMINS bypass the
@@ -22371,7 +22572,25 @@ var ncActions = require_dist.defineCustomActions({
22371
22572
  auth: "admin",
22372
22573
  caller: "required"
22373
22574
  }),
22374
- "nc.listSummaryRules": require_dist.customAction(require_dist.object({}), require_dist.object({ rules: require_dist.array(NcViewerSummaryRuleSchema) }), { caller: "required" }),
22575
+ "nc.listSummaryRules": require_dist.customAction(require_dist.object({}), require_dist.object({
22576
+ rules: require_dist.array(NcViewerSummaryRuleSchema),
22577
+ /**
22578
+ * DOES THIS HUB LET THE CALLER AUTHOR ONE? The capability probe, and the
22579
+ * only honest way a client can know.
22580
+ *
22581
+ * `nc.upsertSummaryRule` was `auth: 'admin'` until the operator closed
22582
+ * that decision, and a viewer talking to a hub that still refuses has to
22583
+ * find out BEFORE it offers a "New summary rule" button — otherwise the
22584
+ * only feedback is a 403 taken in front of the operator on a form they
22585
+ * had already filled in. ADDITIVE and therefore `.optional()` on every
22586
+ * client mirror: absent means an older hub, which the viewer reads as "no
22587
+ * authoring" and degrades to a read-only list with a note.
22588
+ *
22589
+ * Derived from the same predicate the handlers enforce — never a constant
22590
+ * `true` — so a hub that re-tightened the auth could not say otherwise.
22591
+ */
22592
+ canAuthor: require_dist.boolean()
22593
+ }), { caller: "required" }),
22375
22594
  /**
22376
22595
  * ONE action for create AND update, like `nc.upsertTimelapseRule`: a digest
22377
22596
  * rule is authored as a whole (cameras + window + filters + targets) and the
@@ -22380,10 +22599,18 @@ var ncActions = require_dist.defineCustomActions({
22380
22599
  *
22381
22600
  * `ruleId` absent ⇒ create from `rule`; present ⇒ patch.
22382
22601
  *
22383
- * `auth: 'admin'` in v1 (operator decision): a digest spans cameras and, once
22384
- * P2 lands, consumes the local vision model. A personal rule that fans vision
22385
- * calls across other people's cameras is a decision to take after the real
22386
- * cost has been watched for a few weeks, not before.
22602
+ * `caller: 'required'`, NOT `auth: 'admin'`. It was admin-only in v1 because
22603
+ * a digest spans cameras and consumes the local vision model, and that cost
22604
+ * had not been watched yet. The operator closed the decision, so this now
22605
+ * carries exactly the auth the two rule families beside it carry: ownership
22606
+ * is enforced in the handler, `ownerUserId` is STAMPED from the resolved
22607
+ * caller, a personal rule may deliver only to targets that caller OWNS, and an
22608
+ * admin still authors GLOBAL rules. No new ownership model was invented for
22609
+ * it — `NcSummaryStore` has had `ownerUserId` / `listForOwner` since it was
22610
+ * written and they were simply not reachable.
22611
+ *
22612
+ * `nc.testSummaryRule` stays admin: that one DELIVERS, on other people's
22613
+ * phones.
22387
22614
  */
22388
22615
  "nc.upsertSummaryRule": require_dist.customAction(require_dist.object({
22389
22616
  ruleId: require_dist.string().optional(),
@@ -22391,12 +22618,10 @@ var ncActions = require_dist.defineCustomActions({
22391
22618
  patch: NcSummaryRulePatchSchema.optional()
22392
22619
  }), require_dist.object({ rule: NcSummaryRuleSchema }), {
22393
22620
  kind: "mutation",
22394
- auth: "admin",
22395
22621
  caller: "required"
22396
22622
  }),
22397
22623
  "nc.deleteSummaryRule": require_dist.customAction(require_dist.object({ ruleId: require_dist.string() }), require_dist.object({ success: require_dist.literal(true) }), {
22398
22624
  kind: "mutation",
22399
- auth: "admin",
22400
22625
  caller: "required"
22401
22626
  }),
22402
22627
  /**
@@ -22490,6 +22715,17 @@ var ncActions = require_dist.defineCustomActions({
22490
22715
  * a surface that distinguishes them reports what other people own. */
22491
22716
  "nc.getArtifact": require_dist.customAction(require_dist.object({ artifactId: require_dist.string().min(1) }), require_dist.object({ artifact: NcArtifactRecordSchema.nullable() }), { caller: "required" })
22492
22717
  });
22718
+ /**
22719
+ * Does THIS build let a non-admin author a digest rule?
22720
+ *
22721
+ * A named constant rather than a literal `true` at the call site, so the one
22722
+ * fact `nc.listSummaryRules.canAuthor` reports and the auth the upsert actually
22723
+ * carries sit beside each other and are changed together. If the operator ever
22724
+ * re-tightens the action, this goes to `false` in the same edit and the viewer
22725
+ * degrades on the next list — instead of the app discovering it by taking a 403
22726
+ * on a form the operator had already filled in.
22727
+ */
22728
+ var SUMMARY_AUTHORING_OPEN = true;
22493
22729
  /** Fail-closed caller resolution — an absent forwarded caller is NEVER admin. */
22494
22730
  function requireCaller$1(caller) {
22495
22731
  if (!caller || typeof caller.userId !== "string" || caller.userId.length === 0) throw new Error("forbidden: authenticated caller required");
@@ -22550,6 +22786,22 @@ function makeNcActionHandlers(deps) {
22550
22786
  if (editor === void 0) throw new Error("this node does not host the notification text catalog — use the hub");
22551
22787
  return editor;
22552
22788
  };
22789
+ /**
22790
+ * A digest rule the caller may EDIT. Same shape and same admin bypass as
22791
+ * {@link assertTimelapseEditable} — deliberately, because a second ownership
22792
+ * model for the third rule family is a second place to get it wrong.
22793
+ *
22794
+ * A GLOBAL rule (no `ownerUserId`, what an admin authors) is VISIBLE to
22795
+ * everyone and editable by admins only: `listForOwner` returns it, so a user
22796
+ * sees it in the list, and `readOnly` says so.
22797
+ */
22798
+ const assertSummaryEditable = (ruleId, caller, sum) => {
22799
+ const rule = sum.store.get(ruleId);
22800
+ if (rule === null) throw new Error(`forbidden: summary rule not found: ${ruleId}`);
22801
+ if (caller.isAdmin) return rule;
22802
+ if (rule.ownerUserId !== caller.userId) throw new Error(`forbidden: summary rule not owned: ${ruleId}`);
22803
+ return rule;
22804
+ };
22553
22805
  /** A timelapse rule the caller may EDIT. Same admin bypass as `NcRule`. */
22554
22806
  const assertTimelapseEditable = (ruleId, caller, tl) => {
22555
22807
  const rule = tl.store.get(ruleId);
@@ -22820,21 +23072,23 @@ function makeNcActionHandlers(deps) {
22820
23072
  "nc.listSummaryRules": async (_input, caller) => {
22821
23073
  const c = requireCaller$1(caller);
22822
23074
  const sum = requireSummary();
22823
- return { rules: (c.isAdmin ? sum.store.list() : sum.store.listForOwner(c.userId)).map((r) => ({
22824
- ...r,
22825
- readOnly: !c.isAdmin
22826
- })) };
23075
+ return {
23076
+ rules: (c.isAdmin ? sum.store.list() : sum.store.listForOwner(c.userId)).map((r) => ({
23077
+ ...r,
23078
+ readOnly: !c.isAdmin && r.ownerUserId !== c.userId
23079
+ })),
23080
+ canAuthor: SUMMARY_AUTHORING_OPEN
23081
+ };
22827
23082
  },
22828
23083
  "nc.upsertSummaryRule": async (input, caller) => {
22829
23084
  const c = requireCaller$1(caller);
22830
- if (!c.isAdmin) throw new Error("forbidden: admin required to author a summary rule");
22831
23085
  const sum = requireSummary();
22832
23086
  if (input.ruleId === void 0) {
22833
23087
  if (input.rule === void 0) throw new Error("upsertSummaryRule: rule or ruleId required");
22834
23088
  const parsed = NcSummaryRuleInputSchema.parse(input.rule);
22835
23089
  assertSummaryBudget(parsed);
22836
23090
  await assertTargetsOwned(parsed.targets.map((t) => t.targetId), c);
22837
- const rule = await sum.store.create(parsed, c.userId);
23091
+ const rule = await sum.store.create(parsed, c.userId, c.isAdmin ? void 0 : c.userId);
22838
23092
  deps.logger.info("summary rule created", { meta: {
22839
23093
  ruleId: rule.id,
22840
23094
  owner: c.userId,
@@ -22842,8 +23096,7 @@ function makeNcActionHandlers(deps) {
22842
23096
  } });
22843
23097
  return { rule };
22844
23098
  }
22845
- const existing = sum.store.get(input.ruleId);
22846
- if (existing === null) throw new Error(`forbidden: summary rule not found: ${input.ruleId}`);
23099
+ const existing = assertSummaryEditable(input.ruleId, c, sum);
22847
23100
  if (input.patch === void 0) throw new Error("upsertSummaryRule: patch required");
22848
23101
  const patch = NcSummaryRulePatchSchema.parse(input.patch);
22849
23102
  assertSummaryBudget({
@@ -22860,8 +23113,9 @@ function makeNcActionHandlers(deps) {
22860
23113
  },
22861
23114
  "nc.deleteSummaryRule": async (input, caller) => {
22862
23115
  const c = requireCaller$1(caller);
22863
- if (!c.isAdmin) throw new Error("forbidden: admin required to delete a summary rule");
22864
- await requireSummary().store.delete(input.ruleId);
23116
+ const sum = requireSummary();
23117
+ assertSummaryEditable(input.ruleId, c, sum);
23118
+ await sum.store.delete(input.ruleId);
22865
23119
  deps.logger.info("summary rule deleted", { meta: {
22866
23120
  ruleId: input.ruleId,
22867
23121
  owner: c.userId
@@ -44178,7 +44432,9 @@ var TrackStore = class {
44178
44432
  */
44179
44433
  rowToTrack(id, data, projection) {
44180
44434
  const slim = projection === "slim";
44181
- const positions = slim ? [] : data["positions"] ?? [];
44435
+ const storedPositions = data["positions"] ?? [];
44436
+ const observations = storedPositions.length;
44437
+ const positions = slim ? [] : storedPositions;
44182
44438
  const snapshots = slim ? [] : data["snapshots"] ?? [];
44183
44439
  const zones = data["zonesVisited"] ?? [];
44184
44440
  const classes = data["classes"];
@@ -44235,6 +44491,7 @@ var TrackStore = class {
44235
44491
  ...typeof hasFace === "boolean" ? { hasFace } : {},
44236
44492
  ...typeof hasRider === "boolean" ? { hasRider } : {},
44237
44493
  ...closedReason !== void 0 ? { closedReason } : {},
44494
+ observations,
44238
44495
  ...envelope !== null ? { envelope } : {}
44239
44496
  };
44240
44497
  }
@@ -52544,13 +52801,15 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
52544
52801
  buildSummaryPorts(stores) {
52545
52802
  return {
52546
52803
  listTracks: async ({ deviceId, sinceMs, untilMs, limit }) => {
52547
- return (await stores.trackStore.queryHistorical({
52804
+ const tracks = await stores.trackStore.queryHistorical({
52548
52805
  deviceId,
52549
52806
  since: sinceMs,
52550
52807
  until: untilMs,
52551
52808
  projection: "slim",
52552
52809
  limit
52553
- })).map((t) => ({
52810
+ });
52811
+ const { minHits } = await this.resolveDeviceDetectionSensitivitySettings(deviceId);
52812
+ return tracks.map((t) => ({
52554
52813
  trackId: t.trackId,
52555
52814
  deviceId: t.deviceId,
52556
52815
  className: t.className,
@@ -52559,7 +52818,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
52559
52818
  ...t.importance !== void 0 ? { importance: t.importance } : {},
52560
52819
  zones: [...t.zonesVisited],
52561
52820
  ...t.classes !== void 0 ? { classes: [...t.classes] } : {},
52562
- ...t.closedReason === "stationary-promotion" ? { stationary: true } : {}
52821
+ ...t.closedReason === "stationary-promotion" ? { stationary: true } : {},
52822
+ ...t.observations !== void 0 ? { confirmed: t.observations >= minHits } : {}
52563
52823
  }));
52564
52824
  },
52565
52825
  listMediaInfo: async (trackId) => {